Pickle Finance pDAI Evil Jar / Fake-Jar Controller Code-Injection Drain (2020-11-21)
- Name: Pickle Finance pDAI Evil Jar / fake-jar controller code-injection drain
- Date: 2020-11-21
- Protocol: Pickle Finance
- Chain: Ethereum
- Category: contract-identity confusion / fake integration endpoint / delegatecall-assisted code injection / vault drain
- Loss: 19,759,355 DAI
- Summary: Pickle’s
ControllerV4.swapExactJarForJar()let the caller assemble the whole trusted swap route out of attacker-chosen pieces instead of proving one canonical Pickle jar-to-jar path. The function accepted arbitrary_fromJar/_toJaraddresses, derived_fromJarTokenand_toJarTokenfrom those caller-supplied contracts, and thendelegatecalled an approved converter with attacker-shaped calldata inside controller context. In the first phase, EvilJar endpoints were enough to make the controller pull roughly19.76M DAIout ofStrategyCmpdDaiV2and back toward pDAI; afterpDAI.earn()redeposited that value into Compound ascDAI, a second crafted swap usedCurveProxyLogic.add_liquidity()plus a fake underlying token contract to induceStrategyCmpdDaiV2.withdraw(address)and move the strategy’scDAIinto the controller. The controller then approved the attacker-controlled_toJarfor the controller-heldcDAIbalance and called_toJar.deposit(), which simplytransferFrom’d the tokens to the attacker. The attacker redeemed the stolencDAIback into DAI. - Trigger / entry point:
ControllerV4.swapExactJarForJar()accepted arbitrary_fromJarand_toJaraddresses and only interacted with them through theIJarinterface.- The controller did not prove that those jar addresses were canonical Pickle jars tied to the controller’s own registry / strategy graph.
- The same function also let callers provide arbitrary approved converter targets and calldata; approved converters were executed through
delegatecallin controller context. - One approved converter,
CurveProxyLogic, exposed anadd_liquidity()path that could be shaped into arbitrary downstream calls because attacker-chosencurve,curveFunctionSig, andunderlyingparameters controlled what the controller ultimately called.
- Root cause:
- Pickle authenticated the wrong thing. It treated any contract that looked enough like a jar as if it were a canonical jar in the controller’s own jar / token / strategy graph.
swapExactJarForJar()never proved that_fromJar,_toJar,_fromJarToken,_toJarToken, approved converter target, and final deposit recipient all belonged to one protocol-owned swap route. The helper let the caller author those trust anchors piecemeal.delegatecallinto approved converter logic then widened the blast radius: once the controller had accepted attacker-chosen jars, it also ran attacker-shaped conversion logic inside its own storage, approval, and downstream call context.StrategyCmpdDaiV2also treated non-wantassets such ascDAIas withdrawable dust, which made the second-stage cash-out possible once the attacker could induce the controller to callwithdraw(address)against that strategy-held side asset.
- Exploit path:
- The attacker deployed malicious
EvilJarcontracts implementing the minimum interface needed forswapExactJarForJar(). - They called
ControllerV4.swapExactJarForJar()with EvilJar as both_fromJarand_toJar. - Because the controller never verified canonical jar identity, it interacted with the fake jars as if they were legitimate swap endpoints.
- In the first phase, the crafted swap path triggered withdrawals from
StrategyCmpdDaiV2, bringing about19.76M DAIback toward the pDAI jar. - The attacker called
pDAI.earn()three times so the jar re-deposited the DAI into Compound, leaving the strategy holding the equivalent value ascDAI. - The attacker deployed a fake underlying contract and more EvilJar contracts for the second phase.
- They called
swapExactJarForJar()again, this time supplying approved converterCurveProxyLogicplus crafted calldata. - The controller
delegatecalled intoCurveProxyLogic.add_liquidity()in its own context. - Inside that path, attacker-chosen
curve,curveFunctionSig, and fakeunderlying.balanceOf()output shaped a call toStrategyCmpdDaiV2.withdraw(address), which transferred the strategy’scDAI“dust” into the controller. - The controller then approved the attacker-controlled
_toJarfor the controller-held token balance and calledIJar(_toJar).deposit(_toBal). - EvilJar’s
deposit()implementation simply pulled the approvedcDAIfrom the controller into attacker control. - The attacker redeemed
cDAIfor DAI through Compound and exited with19,759,355 DAI.
- The attacker deployed malicious
- What the attacker needed to be true:
- The controller had to accept arbitrary jar addresses without checking that they were canonical Pickle jars.
CurveProxyLogichad to remain an approved jar converter.- The controller had to
delegatecallconverter logic with attacker-supplied parameters rather than tightly constraining the call graph. StrategyCmpdDaiV2had to expose a callable path that would treatcDAIas withdrawable dust instead of protected principal.- The pDAI jar and controller had to still hold / route enough capital for the attacker to cycle the strategy state into the exploitable
cDAIposition.
- Technical anatomy worth remembering:
- This was not “just” a bad external-call list. The exploit worked because contract identity itself was caller-controlled: the controller never proved
_fromJar/_toJarwere the canonical jars its own registry and strategy plumbing meant to trust. - The sharper healthy-side boundary was one controller-owned route-and-execution object. In a safe design,
which canonical jar am I exiting,which token / ratio describes that jar,which converter family and call primitive may run,which downstream strategy call may happen, andwhich recipient jar may receive the final approval-backed depositshould have stayed fused. Pickle let the caller supply each piece separately. - The approved-converter model made the fake-jar bug much more dangerous. Once the controller trusted attacker-supplied jar endpoints,
delegatecall-based converter execution let the attacker repurpose controller context into arbitrary strategy interaction. The exploit therefore lived at two joined boundaries at once: counterfeit root-object admission on the way in, and an under-pinned privileged execution envelope once the controller started running converter code in its own context. CurveProxyLogic.add_liquidity()was supposed to be a narrow conversion primitive, but itscurve,curveFunctionSig, andunderlyingparameters let the attacker inject a call envelope the controller would execute as if it were normal conversion logic, including the call to fakeunderlying.balanceOf()that shaped the laterwithdraw(address)target.- The final theft path was an approval-and-callback pattern: once the controller held the
cDAI, it executedsafeApprove(_toJar, _toBal); IJar(_toJar).deposit(_toBal). Because_toJaritself was the counterfeit trust root, the last approval and the last recipient callback were owned by the attacker too. - The exploit preserved a useful distinction between interface validation and canonical identity validation. Pickle checked the former and skipped the latter.
- This was not “just” a bad external-call list. The exploit worked because contract identity itself was caller-controlled: the controller never proved
- Representative transactions and contracts:
- Main exploit transaction:
0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0 - Vulnerable controller path:
ControllerV4.swapExactJarForJar() - Vulnerable approved converter:
CurveProxyLogic.add_liquidity() - Drained strategy module:
StrategyCmpdDaiV2 - Attacker-deployed EvilJar contracts highlighted in technical postmortems included
0x75aa95508f019997aeee7b721180c80085abe0f9and0x02c8364546ec849e1726fb6cae5228702b111ee6 - Second-stage attacker contracts highlighted in the same reconstruction included
0x8739c55df8ca529dce060ed43279ea2f2e122122,0xa2da08093a083c78c21aeca77d6fc89f3d545aed, and0xa445e12d69e8bd60290f6935d49ff39ba31c6115
- Main exploit transaction:
- Response / mitigation:
- Pickle first disabled further pDAI deposits by calling
setMin(0)on the jar. - Governance was shifted from the timelock to the multisig so the team could react immediately rather than waiting out the delay.
- The team then revoked
CurveProxyLogicas an approved converter. - The postmortem made clear that removing one converter reduced immediate exploitability but did not erase the deeper trust-boundary problems in jar identity and controller swap design.
- Pickle first disabled further pDAI deposits by calling
Pattern tags
- Counterfeit root-object admission:
swapExactJarForJar()accepted attacker-supplied_fromJar/_toJarcontracts that only needed to satisfy theIJarinterface, so controller trust began from fake jars rather than from a controller-owned registry graph. - Approved-helper execution-envelope drift:
approvedJarConverters[_target] == truelooked like a safety check, butdelegatecallplus attacker-shaped calldata letCurveProxyLogic.add_liquidity()become an arbitrary controller-context execution envelope instead of a narrowly pinned conversion primitive. - Side-asset dust reclassification: the exploit only cashed out because
StrategyCmpdDaiV2could be induced to treat strategy-heldcDAIas withdrawable non-wantdust after the attacker had pushed the first-stage DAI back throughpDAI.earn(). - Approval-backed counterfeit recipient exit: after the forged route pulled
cDAIinto the controller, the finalsafeApprove(_toJar, _toBal)plusIJar(_toJar).deposit(_toBal)step turned the counterfeit destination jar itself into the authorized recipient of controller-held funds.
Why the exploit worked
ControllerV4.swapExactJarForJar()never fused jar identity, jar-reported token semantics, approved converter family, and final deposit recipient into one controller-owned route object. The attacker could choose each trust anchor separately and still look locally valid at every step.- Pickle authenticated converter addresses but not converter meaning. Because
CurveProxyLogicran throughdelegatecall, attacker-chosencurve,curveFunctionSig, and fakeunderlying.balanceOf()output defined what controller-context execution actually did after the approved-helper check had already passed. - The first exploit stage mattered because it republished pDAI-controlled DAI back into Compound as
cDAI, creating the exact side asset that the second forged route could later withdraw fromStrategyCmpdDaiV2under the strategy’s too-broadwithdraw(address)/ dust assumptions. - The last hop was an approval drain inside protocol plumbing, not just a weird converter bug. Once controller-held
cDAIwas approved to the counterfeit_toJar, EvilJar’sdeposit()only needed an ordinarytransferFrom()to finish the theft. - Operationally, the exploit shows why
implements the expected interfaceis not a security property. Pickle kept proving local shape while skipping provenance, so the controller never knew whether it was executing a real jar swap or an attacker-authored jar theater.
Detection and audit pivots
- For every helper that accepts contract endpoints, ask whether the supplied address is proven against a canonical registry / strategy graph or merely assumed safe because it implements the expected interface.
- Treat every
delegatecall-based approved-helper system as an execution-envelope review problem. For each approved target, enumerate which caller-supplied fields still decide downstream call targets, selectors, argument values, and approval recipients after the address check passes. - Inventory strategy side assets and
dustwithdrawal paths, then test whether a first-stage state transition can reclassify economically real principal into a later withdrawable non-wanttoken. - Fuzz multi-phase routes, not only one-shot swaps: can an attacker first reshape where value sits (
want→ side asset) and then use a second counterfeit route to drain the new asset location through a different helper path? - Review every
approve(destination); destination.deposit(...)pattern inside routers or controllers. If the destination contract is caller-supplied or only interface-validated, that approval edge is already a theft surface even before the callback runs.
Failure-prevention checklist
- Recompute the canonical jar route from protocol-owned state before any swap starts.
swapExactJarForJar()should have loaded one controller-pinned_fromJar/_toJarpair, their expected underlying tokens, and the exact strategy path those jars were allowed to touch instead of inheriting jar identity from the caller. - Treat jar identity and converter identity as one joined trust object. If a controller approves only specific jar-to-jar lanes, then approved converter targets, allowed selectors, and allowed downstream token / strategy interactions should be pinned per lane rather than accepted as free-form calldata behind a generic
approvedJarConverters[target]check. - Remove or heavily constrain
delegatecall-based helper execution on value-bearing controller paths. If helper code must run in controller context, pin one exact calldata schema, one exact downstream call graph, and one exact asset domain instead of letting attacker-shaped arguments decide what the approved helper really means at runtime. - Make counterfeit-endpoint regression tests first-class: fake
_fromJar, fake_toJar, fake underlying token, fake converter payload, and fake final deposit recipient should all fail before any controller-held approval or withdrawal can occur. - Narrow strategy side-asset escape hatches.
withdraw(address)/ dust recovery paths should prove that the requested token is genuinely non-core surplus and that no controller helper can reclassify principal into that side-asset bucket first. - Refuse
approve(destination); destination.deposit(...)flows unlessdestinationis protocol-pinned for the exact route being executed. If the final recipient is still caller-shaped, the approval edge is already a withdrawal surface. - Preserve route-local blast-radius controls even if one counterfeit root slips through once: route-specific caps, delayed settlement, narrower helper permissions, or isolated controller inventories should stop a single bad route from turning into treasury-scale loss.
Audit prompts
- What exact controller-owned object proves
_fromJar,_toJar,_fromJarToken,_toJarToken, and the permitted converter family belong to one canonical swap route before any user-supplied contract is queried? - If the attacker deploys fresh contracts that perfectly implement
IJarand helper interfaces, which single check still fails because the controller recomputes route identity instead of trusting interface shape? - After
approvedJarConverters[target] == true, which caller-controlled fields still decide downstream call targets, selectors, recipients, or token identities inside controller context? - Can a first-stage helper call move value into a token bucket that a later
withdraw(address)/ dust-recovery path will treat as harmless surplus? If yes, why is that side-asset lane not already part of the same solvency review boundary? - Where does the protocol bind the final approval recipient to the same canonical route object as the initial jar identity, and what stops
_toJarfrom being counterfeit even if earlier steps look locally valid? - Which registry, governance, or config mutation path can redefine trusted jars, trusted converters, or their pairing rules, and is that change control governed with the same seriousness as downstream withdrawal authority?
- If one counterfeit route executed once, what concrete cap, pause, or inventory segregation would have limited controller-context loss before operators reacted?
Lessons that should already be standard
- Interface conformance is not provenance. A counterfeit jar can satisfy every expected method and still be the wrong trust root.
Approved helper addressis not the same thing asapproved execution meaning. Oncedelegatecalland attacker-shaped calldata enter the flow, the real security question is what exact controller-context envelope was pinned.- Route identity needs to stay fused end to end. The same controller-owned object should determine source jar, destination jar, converter family, downstream strategy touch set, and final recipient of any approval-backed deposit.
- Side-asset rescue paths are part of the attack surface whenever earlier helpers can move real principal into the bucket later treated as dust.
- Approval edges inside protocol plumbing deserve the same suspicion as explicit user-facing withdrawals.
approve(fakeDestination); fakeDestination.deposit()is already a payout rail if destination provenance is weak. - Counterfeit-root bugs and privileged-helper bugs often compose into one exploit, not two independent findings. Pickle only became this dangerous because fake jar identity and under-pinned controller-context execution were allowed to reinforce each other.
Why this incident matters
- It is one of the better EVM examples of contract-identity confusion: the protocol reasoned over attacker-supplied contracts that merely looked like the expected integration endpoints.
- It also shows how convenience helpers need one protocol-owned route object, not separate trust in
jar shape now,converter semantics later, andapproved deposit recipient at the end. Pickle let the counterfeit jar own the first and last trust decisions whiledelegatecalllet the attacker script the middle. - The preserved healthy-side boundary is sharper than
validate the jar better: the controller should have pinned one canonical jar graph and one exact privileged converter envelope, instead of letting caller-supplied jars and caller-shaped converter calldata decide what controller-context code execution and final approval-backed deposit still meant. - The exploit is a reminder that interface conformance is not provenance. A contract can implement every expected function and still be the wrong thing to trust.
Internal linkages
- Core recurring pattern: account-confusion-root-of-trust-substitution.
- Broad root-authenticity companion: root-object-authenticity-and-trust-anchor-recomputation-discipline — the healthier first question is what exact controller-owned jar / route root Pickle should have recomputed before any caller-supplied jar, token, or helper endpoint was allowed to define swap reality.
- Positive-design route companion: approval-funded-route-authority-truth-security-object — the healthier lesson is that jar identity, token / strategy meaning, converter family, and final approval-backed deposit recipient needed to stay one controller-owned route object.
- Positive-design execution companion: execution-envelope-identity-pinning — useful when the sharper question is why an approved converter family plus
delegatecallinto controller context should have been pinned as one exact executable object rather than one attacker-shaped envelope behind a merely approved helper label. - Adjacent privileged-execution surface: approval-by-address-mutable-code-execution — a useful cousin because Pickle did not merely trust the wrong jar root; it also treated
approved converteras if that alone safely fixed what controller-context execution envelope would later mean.
Control surface
-
The leverage sat in one broken helper authority object: canonical jar identity, the jar-reported token / ratio metadata, approved converter selection plus calldata meaning, the exact controller-context
delegatecallenvelope, and the final_toJarapproval / deposit recipient. -
Pickle did not mainly fail because Compound was unsafe or because Curve math was wrong. It failed because the controller let the caller decide which contracts counted as trusted jars, what controller-context executable envelope the approved converter label really resolved to, and who owned the last approval-backed token exit.
-
Sources:
- https://github.com/banteg/evil-jar
- https://github.com/pickle-finance/protocol/blob/4d7ecfa766536622848a29407c0464283d11fb67/src/controller-v4.sol#L250-L370
- https://github.com/pickle-finance/protocol/blob/4d7ecfa766536622848a29407c0464283d11fb67/src/proxy-logic/curve.sol#L27-L54
- https://ethtx.info/mainnet/0xe72d4e7ba9b5af0cf2a8cfb1e30fd9f388df0ab3da79790be842bfbed11087b0
- https://www.halborn.com/blog/post/explained-the-defi-protocol-pickle-finance-hack-nov-2020
-
Last reviewed: 2026-07-21 UTC