Evil Jar Technical Post-mortem
Authors
@banteg @emilianobonassi @lehnberg @samczsun @vasa-develop @bneiluj
Summary
- On Saturday November 21 2020, an attacker drained 19 million DAI from pickle.finance's pDAI Jar.
- Taking advantage of multiple flaws in the system, including issues with the Jar swap and Jar convert logic, the attacker was able to craft a sophisticated exploit to carry out the heist.
- Dubbed "Evil Jar", the attack has been reverse-engineered successfully with details shared below.
Background
Pickle Jars are forked versions of Yearn Vaults v1 with modifications. The Jars are controlled by a Controller contract, its latest version enabling direct swaps between Jars. It was this added swap functionality that was leveraged together with multiple design flaws in order to execute the attack.
Details of vulnerability
Pickle ControllerV4's swapExactJarForJar()
function can be used to drain non-Jar tokens from Strategies or tokens that end up in the Controller.
// Function to swap between jars
function swapExactJarForJar(
address _fromJar, // From which Jar
address _toJar, // To which Jar
uint256 _fromJarAmount, // How much jar tokens to swap
uint256 _toJarMinAmount, // How much jar tokens you'd like at a minimum
address payable[] calldata _targets,
bytes[] calldata _data
) external returns (uint256)
An attacker can craft an EvilJar contract with a part of the exploit payload, as well as a FakeUnderlying contract, which holds another part of the payload.
EvilJar is passed as both _fromJar
and _toJar
arguments. Since Controller doesn't verify if the Jar contract is legit, it's enough to implement token
, getRatio
, decimals
, transfer
, transferFrom
, approve
, allowance
, balanceOf
, withdraw
and deposit
functions to make it work with the Jar.
The deposit()
function holds the payload and transfers the tokens from the Controller to the attacker. It could look like this:
@external
def deposit(amt: uint256): # payload
self.token.transferFrom(msg.sender, self.owner, amt)
It would be called here, with _toJar
being the EvilJar and _toBal
being the token balance in the Controller:
// Deposit into new Jar
uint256 _toBal = IERC20(_toJarToken).balanceOf(address(this));
IERC20(_toJarToken).safeApprove(_toJar, 0);
IERC20(_toJarToken).safeApprove(_toJar, _toBal);
IJar(_toJar).deposit(_toBal); // call to EvilJar
The above makes it possible to drain funds from the Controller. For this to have effect, there needs to be funds there to drain.
A user can provide a list of arbitrary _targets
and _data
. Each target needs to be marked as approvedJarConverter
:
for (uint256 i = 0; i < _targets.length; i++) {
require(_targets[i] != address(0), "!converter");
require(approvedJarConverters[_targets[i]], "!converter");
}
At the time of this writing, there are two Jar Converters approved:
- UniswapV2ProxyLogic
- CurveProxyLogic, this one is vulnerable and allows code injection
After withdrawing funds from the fabricated EvilJar, the controller delegate calls the provided Converters with the data provided by the attacker. This means an attacker can call the code from their contract in the context of the Controller.
CurveProxyLogic has the following function which can be used to inject an arbitrary call:
function add_liquidity(
address curve,
bytes4 curveFunctionSig,
uint256 curvePoolSize,
uint256 curveUnderlyingIndex,
address underlying
) public {
uint256 underlyingAmount = IERC20(underlying).balanceOf(address(this)); // call to FakeUnderlying
uint256[] memory liquidity = new uint256[](curvePoolSize);
liquidity[curveUnderlyingIndex] = underlyingAmount;
bytes memory callData = abi.encodePacked(
curveFunctionSig,
liquidity, // our injected value
uint256(0)
);
...
(bool success, ) = curve.call(callData);
}
By supplying a target address as curve
, a function 4-byte identifier as curveFunctionSig
and FakeUnderlying as underlying
, an attacker can make the Controller call a function in the target Strategy, with a limitation of that function requiring zero or one argument.
A FakeUnderlying contract needs to implement balanceOf
, allowance
and approve
to work. The exploit payload goes into the balanceOf
, which sets the first argument of the function to be called:
@view
@external
def balanceOf(src: address) -> (address):
return self.target
In this case, calling Strategy.withdraw(address)
, seizes dust that is sent to the Controller.
As a preventive measure, the function disallows withdrawing the want
token, which is the deposit token of the Jar. So in the case of the pDAI Jar, it would not allow stealing DAI this way. However, the pDAI Jar's Strategy holds cDAI, a tokenized Compound deposit, and considers this incorrectly to be dust.
It was the combination of the above flaws that allowed the attacker to proceed with the exploit.
Details of exploit
Reverse-engineered Exploit
Our simplified and more efficient reproduction of the exploit is published at https://github.com/banteg/evil-jar. Note that it is different from what was actually used in the attack.
- banteg's version
- samczsun's version
Original Exploit
Exploit transaction trace.
-
Deploy two Evil Jars
-
Get the amount available to withdraw from StrategyCmpdDaiV2
StrategyCmpdDaiV2.getSuppliedUnleveraged()
=> 19728769153362174946836922 -
Invoke
ControllerV4.swapExactJarForJar()
passing the Evil Jars and the amount retrieved in the previous step. -
ControllerV4.swapExactJarForJar()
doesn't check the Jars and calls them, withdrawing fromStrategyCmpDAIV2
usingStrategyCmpDAIV2.withdrawForSwap()
which ultimately usesStrategyCmpDAIV2.deleverageToMin()
. This transfers 19M DAI to pDAI. We are still in Pickle Contracts, in this part of the attack Evil Jars were used just to put the funds to pDAI. -
Call
pDAI.earn()
3 times. This invokes a Compound deposit viaStrategyCmpDAIV2.deposit()
, leading to the contract receiving cDAI.StrategyCmpdDAIV2
now has an equivalent of 19M in cDAI. -
Deploy 3 more evil contracts, the first one being the equivalent of FakeUnderlying in our replicated exploit and the other two Evil Jars.
-
Invoke
ControllerV4.swapExactJarForJar()
passing the Evil Jars, no amount and aCurveProxyLogic
as target with a crafted data which allowed an injection to call the equivalentFakeUnderlying
. -
ControllerV4
delegate callsCurveProxyLogic.add_liquidity()
passingStrategyCmpDAIV2
and a crafted signature which leads to withdrawal of cDAI and transferring them toControllerV4
. -
The funds (in cDAI) are now in the Controller, it calls the
EvilJar.deposit()
which transfer the funds to the attacker smart contract. -
The attacker smart contract redeems cDAI for DAI from Compound and transfers DAI to the attacker EOA.
Details of fix
The first part of mitigating the problem was to prevent further deposits into the PickleJar. For this, the Pickle Finance team called setMin(0)
on the DAI PickleJar via the governance multisig.
As described above, the offending logic that allows arbitrary code injection is located in CurveProxyLogic
, which is a Converter approved for use within the Controller. In order to revoke this Converter, this function in the Controller must be called:
function revokeJarConverter(address _converter) public {
require(msg.sender == governance, "!governance");
approvedJarConverters[_converter] = false;
}
At the time, the governance
role was set to a 12-hour Timelock so it was decided that the governance multisig address would be set as the new governance
role so that the Pickle Finance team could invoke this function without waiting for a Timelock. This transaction was queued and executed by 2020-11-22 03:15 PM (UTC), granting the Pickle Finance team the ability to revoke CurveProxyLogic
from use by calling revokeJarConverter()
.
While this removes a key piece of the exploit, there are still further issues of concern as explained in the sections above. The Pickle Finance team will continue to work in the coming days and weeks to fix these vulnerabilities.
Timeline of events
- 2020-11-21 18:37 (UTC) An attacker drains 19,759,355 DAI from pDAI Jar.
- 22:49 Artem, Emiliano, Sam, Julen, Vaibhav and Pickle devs start a war room call to replicate the exploit.
- 2020-11-22 01:15 A governance switch from Timelock to multisig is initiated
ControllerV4.setGovernance(multisig_addr)
. - 01:32 Exploit is successfully replicated and narrowed down to the actually vulnerable parts.
- 02:05 Sam publishes his version of the exploit.
- 03:58 pDAI Jar deposits are disabled by calling
setMin(0)
- 11:43 22.6 COMP withdrawn from Strategy by
Controller.strategist
- 15:16 CurveProxyLogic has been revoked.