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 / _toJar addresses, derived _fromJarToken and _toJarToken from those caller-supplied contracts, and then delegatecalled an approved converter with attacker-shaped calldata inside controller context. In the first phase, EvilJar endpoints were enough to make the controller pull roughly 19.76M DAI out of StrategyCmpdDaiV2 and back toward pDAI; after pDAI.earn() redeposited that value into Compound as cDAI, a second crafted swap used CurveProxyLogic.add_liquidity() plus a fake underlying token contract to induce StrategyCmpdDaiV2.withdraw(address) and move the strategy’s cDAI into the controller. The controller then approved the attacker-controlled _toJar for the controller-held cDAI balance and called _toJar.deposit(), which simply transferFrom’d the tokens to the attacker. The attacker redeemed the stolen cDAI back into DAI.
  • Trigger / entry point:
    • ControllerV4.swapExactJarForJar() accepted arbitrary _fromJar and _toJar addresses and only interacted with them through the IJar interface.
    • 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 delegatecall in controller context.
    • One approved converter, CurveProxyLogic, exposed an add_liquidity() path that could be shaped into arbitrary downstream calls because attacker-chosen curve, curveFunctionSig, and underlying parameters 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.
    • delegatecall into 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.
    • StrategyCmpdDaiV2 also treated non-want assets such as cDAI as withdrawable dust, which made the second-stage cash-out possible once the attacker could induce the controller to call withdraw(address) against that strategy-held side asset.
  • Exploit path:
    1. The attacker deployed malicious EvilJar contracts implementing the minimum interface needed for swapExactJarForJar().
    2. They called ControllerV4.swapExactJarForJar() with EvilJar as both _fromJar and _toJar.
    3. Because the controller never verified canonical jar identity, it interacted with the fake jars as if they were legitimate swap endpoints.
    4. In the first phase, the crafted swap path triggered withdrawals from StrategyCmpdDaiV2, bringing about 19.76M DAI back toward the pDAI jar.
    5. The attacker called pDAI.earn() three times so the jar re-deposited the DAI into Compound, leaving the strategy holding the equivalent value as cDAI.
    6. The attacker deployed a fake underlying contract and more EvilJar contracts for the second phase.
    7. They called swapExactJarForJar() again, this time supplying approved converter CurveProxyLogic plus crafted calldata.
    8. The controller delegatecalled into CurveProxyLogic.add_liquidity() in its own context.
    9. Inside that path, attacker-chosen curve, curveFunctionSig, and fake underlying.balanceOf() output shaped a call to StrategyCmpdDaiV2.withdraw(address), which transferred the strategy’s cDAI “dust” into the controller.
    10. The controller then approved the attacker-controlled _toJar for the controller-held token balance and called IJar(_toJar).deposit(_toBal).
    11. EvilJar’s deposit() implementation simply pulled the approved cDAI from the controller into attacker control.
    12. The attacker redeemed cDAI for DAI through Compound and exited with 19,759,355 DAI.
  • What the attacker needed to be true:
    • The controller had to accept arbitrary jar addresses without checking that they were canonical Pickle jars.
    • CurveProxyLogic had to remain an approved jar converter.
    • The controller had to delegatecall converter logic with attacker-supplied parameters rather than tightly constraining the call graph.
    • StrategyCmpdDaiV2 had to expose a callable path that would treat cDAI as 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 cDAI position.
  • 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 / _toJar were 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, and which recipient jar may receive the final approval-backed deposit should 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 its curve, curveFunctionSig, and underlying parameters let the attacker inject a call envelope the controller would execute as if it were normal conversion logic, including the call to fake underlying.balanceOf() that shaped the later withdraw(address) target.
    • The final theft path was an approval-and-callback pattern: once the controller held the cDAI, it executed safeApprove(_toJar, _toBal); IJar(_toJar).deposit(_toBal). Because _toJar itself 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.
  • 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 0x75aa95508f019997aeee7b721180c80085abe0f9 and 0x02c8364546ec849e1726fb6cae5228702b111ee6
    • Second-stage attacker contracts highlighted in the same reconstruction included 0x8739c55df8ca529dce060ed43279ea2f2e122122, 0xa2da08093a083c78c21aeca77d6fc89f3d545aed, and 0xa445e12d69e8bd60290f6935d49ff39ba31c6115
  • 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 CurveProxyLogic as 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.

Pattern tags

  • Counterfeit root-object admission: swapExactJarForJar() accepted attacker-supplied _fromJar / _toJar contracts that only needed to satisfy the IJar interface, so controller trust began from fake jars rather than from a controller-owned registry graph.
  • Approved-helper execution-envelope drift: approvedJarConverters[_target] == true looked like a safety check, but delegatecall plus attacker-shaped calldata let CurveProxyLogic.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 StrategyCmpdDaiV2 could be induced to treat strategy-held cDAI as withdrawable non-want dust after the attacker had pushed the first-stage DAI back through pDAI.earn().
  • Approval-backed counterfeit recipient exit: after the forged route pulled cDAI into the controller, the final safeApprove(_toJar, _toBal) plus IJar(_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 CurveProxyLogic ran through delegatecall, attacker-chosen curve, curveFunctionSig, and fake underlying.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 from StrategyCmpdDaiV2 under the strategy’s too-broad withdraw(address) / dust assumptions.
  • The last hop was an approval drain inside protocol plumbing, not just a weird converter bug. Once controller-held cDAI was approved to the counterfeit _toJar, EvilJar’s deposit() only needed an ordinary transferFrom() to finish the theft.
  • Operationally, the exploit shows why implements the expected interface is 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 dust withdrawal paths, then test whether a first-stage state transition can reclassify economically real principal into a later withdrawable non-want token.
  • 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 / _toJar pair, 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 unless destination is 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 IJar and 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 _toJar from 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 address is not the same thing as approved execution meaning. Once delegatecall and 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, and approved deposit recipient at the end. Pickle let the counterfeit jar own the first and last trust decisions while delegatecall let 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 delegatecall into 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 converter as if that alone safely fixed what controller-context execution envelope would later mean.

Control surface