Opyn oETH Put exercise() msg.value Reuse / Double-Exercise Collateral Drain (2020-08-04)
- Name: Opyn oETH Put
exercise()msg.valuereuse / double-exercise collateral drain - Date: 2020-08-04
- Protocol: Opyn (Convexity Protocol v1 ETH puts)
- Chain: Ethereum
- Category: ambient payment-context reuse /
msg.valueloop 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 withtransferFrom(). For ETH puts, the code instead trusted the outer transaction’smsg.valueand checked it against the per-iterationamtUnderlyingToPay. Becausemsg.valueis ambient call context rather than per-iteration payment state, the same ETH sent once to the externalexercise()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 requestedoTokensToExerciseamount was consumed. - Each loop iteration called internal
_exercise()logic for one vault slice. - In the ETH-put branch,
_exercise()validated payment withmsg.value == amtUnderlyingToPayinstead of maintaining a decremented local ETH budget across iterations. - The same outer-call
msg.valuetherefore remained visible to every internal_exercise()call in that loop.
- Opyn’s external
- 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.
- Opyn treated ambient transaction context (
- Exploit path:
- The attacker acquired or minted oETH put tokens needed to exercise target vaults.
- 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.
- They called the external
exercise()function with a vault list containing both their own vault and victim vaults. - On the first
_exercise()iteration, the contract consumed the real ETH sent with the outer transaction and released the corresponding USDC collateral. - On the next iteration,
_exercise()again checkedmsg.valueagainst that vault’samtUnderlyingToPayand accepted the same outer ETH as if fresh payment had arrived. - The contract therefore paid out collateral from the victim vault even though no second ETH payment was made.
- 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. - PeckShield illustrated the same primitive with two vault exercises using 150 oETH and only 75 ETH, where two separate
24,750 USDCcollateral 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.valueas 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.valueproves 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 monotonicremainingValue, 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.
- This was not a generic reentrancy bug and not a pricing-oracle failure. The preserved lesson is narrower:
Three payment-authority promotions this incident collapsed
- 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, noteach vault-specific exercise slice was independently paid for.
- The attacker sent real ETH with the top-level
- 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.valueinside 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 think2 ETHhad been supplied to two vault-side exercises when only1 ETHhad actually arrived.
- This was the decisive failure. The ETH-underlying branch kept re-reading
- 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.
- Once
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.solline809in the cited Convexity Protocol source
- Opyn ETH Put /
- 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 samemsg.valueacross 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 outermsg.valueinstead 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 vaultsthe 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 explicitmulticallhelper 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 checksmsg.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 effectivelynowhere; each inner call just rereads msg.value, the payment proof is already too ambient. - Treat
payout happens immediately after payment validationas 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
remainingValueor 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 aremainingValueor per-vault paid amount exactly once per vault slice instead of re-reading rawmsg.valueinside_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 releasebranches 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 samemsg.valueor other ambient payment context? - Where is native-asset payment actually consumed? If the answer is
the code checks msg.value again, rather thanan 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.valueinside 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 thisandusers usually exercise one vault at a timeare 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.valuereplay incidents such as Sushi MISO: the same payment-context bug appeared before the batch-helper /delegatecallflavor 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
- Core recurring pattern: ambient-payment-context-reuse.
- Positive-design companion: canonical-payer-identity-and-receipt-binding-discipline — the sharper healthy-side question is what exact payer-bound receipt object should have replaced raw outer-call
msg.valuebefore each vault exercise was allowed to unlock USDC collateral. - Positive-design companion: actual-receipt-accounting-and-token-semantics-admission-discipline — useful when the follow-up is how native-asset exercise should have been turned into one explicit received-and-consumed payment object rather than ambient call-frame context.
- Positive-design companion: entrypoint-equivalence-under-shared-standards — useful when the sharper question is why Opyn’s ETH and ERC-20 exercise paths should have preserved one payment-proof invariant instead of silently diverging on the native branch.
- Adjacent comparison: sushiswap-miso-dutchauction-batchable-msg-value-refund-drain-near-miss-2021-08-17 — MISO preserves the
delegatecallbatch/refund flavor of the same family, while Opyn is the cleaner direct-loop example where one real ETH payment unlocked several vault-side collateral releases. - Adjacent pattern: balance-delta-provenance-failure — both families mistake environmental state for proof that one specific economic action delivered value, but Opyn’s failure was ambient ETH context rather than ambiguous token-balance deltas.
Control surface
-
The decisive leverage sat in the multi-vault
exercise()loop, the ETH-specific_exercise()branch, and Opyn’s decision to treat outer-callmsg.valueas reusable proof of per-vault payment. -
The contract did not mainly fail because options are complex. It failed because one real ETH payment was allowed to impersonate several distinct exercise payments inside one call frame.
-
Sources:
- https://medium.com/opyn/opyn-eth-put-exploit-c5565c528ad2
- https://medium.com/opyn/opyn-eth-put-exploit-post-mortem-1a009e3347a8
- https://peckshield.medium.com/opyn-hacks-root-cause-analysis-c65f3fe249db
- https://samczsun.com/two-rights-might-make-a-wrong/
- https://bloxy.info/tx/0xd06378b73536e7718895069a5219855774d362db47312dc304dfd4b6e39ef000
- https://bloxy.info/tx/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a
- https://github.com/opynfinance/Convexity-Protocol/blob/master/contracts/OptionsContract.sol#L805-L809
-
Last reviewed: 2026-08-04 UTC