Opyn oETH Put exercise() msg.value Reuse / Double-Exercise Collateral Drain (2020-08-04)

  • Name: Opyn oETH Put exercise() msg.value reuse / double-exercise collateral drain
  • Date: 2020-08-04
  • Protocol: Opyn (Convexity Protocol v1 ETH puts)
  • Chain: Ethereum
  • Category: ambient payment-context reuse / msg.value loop replay / options exercise accounting failure
  • Loss: 371,260 USDC stolen; 943,425.13 USDC total identified as at risk before whitehat recovery
  • Summary: Opyn’s ETH put exercise() path looped across a caller-supplied list of vaults and invoked _exercise() once per vault. For ERC-20 underlyings, each iteration pulled fresh tokens with transferFrom(). For ETH puts, the code instead trusted the outer transaction’s msg.value and checked it against the per-iteration amtUnderlyingToPay. Because msg.value is ambient call context rather than per-iteration payment state, the same ETH sent once to the external exercise() call could satisfy multiple internal _exercise() checks in the loop. The attacker used one batch of ETH to exercise against more than one vault, collecting USDC collateral from victim vaults without supplying matching ETH for each vault-specific payout.
  • Trigger / entry point:
    • Opyn’s external exercise() function accepted a list of vaults to exercise from and iterated through them until the requested oTokensToExercise amount was consumed.
    • Each loop iteration called internal _exercise() logic for one vault slice.
    • In the ETH-put branch, _exercise() validated payment with msg.value == amtUnderlyingToPay instead of maintaining a decremented local ETH budget across iterations.
    • The same outer-call msg.value therefore remained visible to every internal _exercise() call in that loop.
  • Root cause:
    • Opyn treated ambient transaction context (msg.value) as if it proved fresh payment for each logical exercise sub-operation.
    • The contract’s multi-vault batch design made exercise() a looped payment surface, but the ETH path still behaved like a single-call payable function.
    • The ETH and ERC-20 branches were not economically equivalent: ERC-20 exercise pulled fresh underlying per iteration, while ETH exercise only re-checked the same global variable.
    • Because exercising a victim vault releases that vault’s USDC collateral immediately, the payment-context mistake turned directly into collateral theft rather than a mere accounting discrepancy.
  • Exploit path:
    1. The attacker acquired or minted oETH put tokens needed to exercise target vaults.
    2. They created an attacker-controlled vault sized so one legitimate ETH payment would also satisfy exercise conditions for a victim vault of the same tranche size.
    3. They called the external exercise() function with a vault list containing both their own vault and victim vaults.
    4. On the first _exercise() iteration, the contract consumed the real ETH sent with the outer transaction and released the corresponding USDC collateral.
    5. On the next iteration, _exercise() again checked msg.value against that vault’s amtUnderlyingToPay and accepted the same outer ETH as if fresh payment had arrived.
    6. The contract therefore paid out collateral from the victim vault even though no second ETH payment was made.
    7. Opyn’s postmortem showed one common wrapper-contract flow: create vault, mint oTokens, call exercise() against both attacker and victim vaults in one transaction, then redeem the underlying from the attacker’s own vault.
    8. PeckShield illustrated the same primitive with two vault exercises using 150 oETH and only 75 ETH, where two separate 24,750 USDC collateral payouts were unlocked by one batch of ETH.
  • What the attacker needed to be true:
    • The options contract had to expose a looped or batched exercise surface over multiple vaults.
    • The ETH-underlying path had to reuse msg.value as a per-iteration payment check rather than decrementing a locally consumed amount.
    • Victim vaults had to hold enough USDC collateral to make repeated exercise economically worthwhile.
    • The attacker needed access to enough oETH put tokens to drive the desired exercise amount, whether by buying them or minting them.
    • The protocol needed to remain live long enough that outstanding oETH liquidity and vault collateral could still be reached after the first suspicious transactions.
  • Technical anatomy worth remembering:
    • This was not a generic reentrancy bug and not a pricing-oracle failure. The preserved lesson is narrower: msg.value proves only that the outer call carried ETH, not that each internal loop iteration brought in new ETH.
    • The missing state object was a consumed native-asset budget. exercise() had become a multi-vault settlement machine, but the ETH branch never carried a monotonic remainingValue, per-vault receipt row, or WETH-style explicit transfer object through the loop.
    • The exploit cleanly contrasts ETH and ERC-20 payment paths. ERC-20 exercise used explicit transferFrom() per iteration and therefore naturally proved fresh payment. ETH exercise relied on ambient context and silently lost that property.
    • Samczsun later used this Opyn incident as the mental model for recognizing the same bug class in Sushi’s MISO auction batching: safe-looking components can still compose into a repeated-payment illusion.
    • The exploit surface was especially dangerous because exercising options directly releases collateral; once payment provenance failed, value left the system immediately.

Three payment-authority promotions this incident collapsed

  1. An outer ETH payment existed at all
    • The attacker sent real ETH with the top-level exercise() call, so there really was one ambient payment object in the transaction context.
    • At this stage the strongest true fact was only this call frame carried ETH once, not each vault-specific exercise slice was independently paid for.
  2. That one ambient payment was promoted into fresh canonical payment truth for each internal _exercise() iteration
    • This was the decisive failure. The ETH-underlying branch kept re-reading msg.value inside the loop and treated the same outer-call context as if it proved a new payment for each vault in the list.
    • Opyn’s own postmortem made the shape explicit: ETH puts were not sound for multi-vault exercise, because one external exercise(oTokensToExercise, [vaultAttacker, vaultVictim]) call could make the internal function think 2 ETH had been supplied to two vault-side exercises when only 1 ETH had actually arrived.
  3. That accepted per-iteration payment truth still deserved immediate vault-collateral release authority
    • Once _exercise() accepted the reused payment context, the contract did not merely update an internal counter; it released real USDC collateral from each exercised vault.
    • That is why one payment-context mistake immediately became theft instead of staying a bookkeeping inconsistency: the same ambient ETH was allowed to authorize several economically separate collateral payouts.

Healthy options review keeps those three promotions separate instead of flattening them into one vague exercise received ETH story.

  • Representative transactions and contracts:
    • Opyn ETH Put / OptionsContract: 0x951d51baefb72319d9fbe941e1615938d89abfe2
    • PeckShield-highlighted exploit transaction: 0xd06378b73536e7718895069a5219855774d362db47312dc304dfd4b6e39ef000
    • Opyn postmortem attack transaction: 0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a
    • Vulnerable ETH-payment check discussed in Opyn’s postmortem: OptionsContract.sol line 809 in the cited Convexity Protocol source
  • Response / mitigation:
    • Opyn removed Uniswap liquidity for affected oETH puts, blocked ETH-put interactions on the frontend, and began buying back outstanding oETH puts above market to reduce the attack surface.
    • The team raised the exercise fee to the maximum 10% to reduce attacker profitability while emergency response proceeded.
    • Because the contracts lacked a pause path, Opyn and outside responders designed whitehat liquidations that temporarily changed collateralization parameters, liquidated vaults, and moved collateral into safer custody.
    • Opyn reported 572,165.13 USDC recovered via two whitehat operations and later reimbursed ETH put sellers in full.

Pattern tags

  • ambient-payment-context-reuse
  • Direct-loop native-payment replay / one outer exercise() call reused the same msg.value across several internal _exercise() iterations over different vaults
  • ETH-versus-ERC-20 path divergence / the ERC-20 branch pulled fresh underlying with transferFrom() per iteration while the ETH branch only re-read ambient transaction context
  • Collateral-release-on-payment-proof failure / once the payment check was fooled, the vault’s USDC collateral left immediately instead of merely corrupting bookkeeping

Why the exploit worked

  • Opyn’s external exercise() surface had already become a multi-step settlement machine, but the ETH-underlying branch still behaved as if one call frame meant one payment. The contract iterated through a list of vaults and called _exercise() repeatedly, yet each iteration re-validated the same outer msg.value instead of debiting an internal remaining-ETH budget.
  • The security boundary diverged across asset types. In the ERC-20 branch, each loop iteration proved fresh payment by executing transferFrom() for that vault-specific exercise slice. In the ETH branch, the protocol silently substituted ambient context for actual per-iteration payment provenance, so the same ETH could satisfy several vault payouts.
  • The payout path turned the accounting mistake directly into theft. Exercising a put did not just update an internal score; it released real USDC collateral from each exercised vault. Once the contract accepted one reused payment as if it were several, the victim vault’s collateral became immediately withdrawable by the attacker.
  • The vault-list design also made same exercise size across vaults the exploitable shape. Opyn’s own writeup shows the attacker pairing an attacker-controlled vault with victim vaults of matching tranche size so one legitimate ETH payment would satisfy the equality check in each internal call.

Detection and audit pivots

  • Search every payable function that references msg.value, then ask whether it can be reached more than once inside one logical action through loops, batching, helper self-calls, or multi-vault / multi-market settlement paths. Opyn shows that an internal loop is enough; you do not need an explicit multicall helper to get repeated ambient payment reuse.
  • Compare native-asset and ERC-20 branches line by line. If one path proves payment with transferFrom() per sub-operation while the other path merely checks msg.value, the native branch should be treated as the higher-risk semantics boundary until proven otherwise.
  • Ask the design question in object form: where does the protocol store value already consumed by prior sub-operations in this transaction? If the answer is effectively nowhere; each inner call just rereads msg.value, the payment proof is already too ambient.
  • Treat payout happens immediately after payment validation as an escalation flag. When the same branch both accepts payment proof and releases collateral, refunds ETH, mints claims, or transfers reserves, a payment-context mistake already has line-of-sight to loss.
  • Ask whether one transaction can cause more than one vault, market, tranche, or beneficiary to rely on the same outer payment context. If the system cannot point to a monotonic remainingValue or equivalent consumed-budget variable, it is probably still trusting ambient context.
  • Test the worst composition, not the happy path: same-sized vault slices, mixed attacker-and-victim vault lists, and one outer ETH payment covering several internal exercises. Opyn is the reminder that a contract may behave correctly for one vault at a time while still failing as soon as several economically equivalent inner actions share one call frame.

Failure-prevention checklist

  • Treat native-asset exercise value as a consumable internal budget, not as ambient transaction context. If one exercise() call can touch multiple vaults, the contract should debit a remainingValue or per-vault paid amount exactly once per vault slice instead of re-reading raw msg.value inside _exercise().
  • Keep ETH and ERC-20 exercise branches economically equivalent. If the token path proves payment with a fresh transferFrom() on every iteration, the native-asset path should prove the same thing with explicit wrapped-asset accounting or a monotonic consumed-value journal.
  • Prefer normalizing native assets into one explicit receipt object before fanout. Wrapping ETH into WETH or otherwise internalizing payment once before iterating leaves the loop consuming a balance it actually owns instead of inherited ambient call context.
  • Make multi-vault settlement semantics explicit. If batching several vault exercises is allowed, the contract should pass and consume per-vault payment amounts; if it is not meant to be supported, enforce one-vault-per-call or another one-shot rule onchain rather than leaving the unsupported combination reachable.
  • Test mixed attacker-and-victim vault lists, especially equal-sized vault slices. Opyn’s exploit only needed one real ETH payment plus matching tranche sizes, so happy-path tests over one vault at a time are not enough.
  • Treat payment check -> immediate collateral release branches as critical loss surfaces. When the same branch both validates payment and transfers vault collateral, any payment-provenance bug should be assumed to have direct line-of-sight to theft.
  • Expose an emergency stop or scoped disablement for value-sensitive exercise paths. Opyn had to improvise with front-end blocks, fee hikes, and whitehat liquidations because the vulnerable contract lacked a pause rail.

Audit prompts

  • Can one outer exercise() call cause several vaults, tranches, or beneficiaries to rely on the same msg.value or other ambient payment context?
  • Where is native-asset payment actually consumed? If the answer is the code checks msg.value again, rather than an internal remaining budget was debited, the contract is probably still trusting ambient context.
  • Do ETH and ERC-20 exercise paths prove fresh payment in the same way, or is one branch materially weaker because it relies on inherited call-frame state?
  • Does the contract let an attacker combine an attacker-controlled vault with victim vaults of matching size in one list so that a legitimate first payment makes later victim-side equality checks pass?
  • After payment validation succeeds, does the same code path immediately transfer collateral, settle reserves, or mint redeemable rights? If yes, a payment-context bug is already a cash-out bug.
  • If the team says a multi-vault or batched exercise shape is not really supported, what exact onchain rule makes that unsupported combination impossible?

Lessons that should already be standard

  • msg.value inside an internal call is still outer-call context. It does not become fresh payment proof just because the business logic moved into a helper like _exercise().
  • A loop over vaults is already a settlement machine. Once one payable entrypoint can satisfy several inner exercise branches, native-asset payment provenance has to be tracked per inner action.
  • Asset-type parity matters. If ERC-20 settlement pulls fresh funds per iteration but ETH settlement only re-checks inherited context, the ETH path is carrying a different and weaker trust model.
  • Immediate collateral release makes payment-accounting mistakes unforgiving. If one reused payment can unlock another vault’s USDC right away, the bug is not merely accounting drift.
  • Frontend won't batch this and users usually exercise one vault at a time are not controls. If the contract leaves the path callable, attackers can compose it.

Why this incident matters

  • It is the clean direct-loop predecessor to later msg.value replay incidents such as Sushi MISO: the same payment-context bug appeared before the batch-helper / delegatecall flavor made it more famous.
  • The incident preserves a very specific invariant: every logical exercise, mint, bid, or purchase step that claims to consume native assets needs its own proven payment budget, not just access to outer-call context.
  • It is also a useful design contrast case because Opyn’s ERC-20 exercise path was materially safer for a very mundane reason: it used explicit per-iteration transfer semantics instead of ambient ETH state.

Internal linkages

Control surface