Calculated from recorded token losses using historical USD prices at the incident time.
0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a0x951d51baefb72319d9fbe941e1615938d89abfe2Ethereum0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2EthereumOpyn's ETH-underlying oToken at 0x951d51baefb72319d9fbe941e1615938d89abfe2 was exploited in Ethereum block 10592517 through transaction 0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a. The attacker exercised 60.0 oTokens across two vaults while sending only one 30 ETH payment. Because the contract reused the same msg.value across multiple _exercise() loop iterations, the second tranche released 9,900 USDC from victim vault 0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2 without a second matching ETH payment.
The root cause is an accounting bug in the ETH-underlying exercise path. exercise() iterates across a caller-supplied vault list, but _exercise() validates msg.value independently on each iteration instead of enforcing that cumulative ETH received across the whole external call equals cumulative ETH owed. The attacker routed the first exercise tranche through a self-owned vault, then called removeUnderlying() to withdraw the same 30 ETH, leaving the victim-vault tranche as pure 9,900 USDC profit.
Opyn oTokens are ERC20 option tokens backed by per-owner vaults stored inside the same oToken contract. A vault tracks , , , and ownership state. Holders can exercise during the exercise window, burning oTokens and paying the option underlying in exchange for vault collateral.
collateraloTokensIssuedunderlyingThe crucial protocol behavior is split between two functions. exercise(uint256,address[]) iterates over a caller-chosen list of vault owners and calls _exercise() one vault at a time until the requested oToken amount is exhausted. For ETH-underlying markets, _exercise() does not call transferFrom(); it instead checks that msg.value equals the underlying required for that tranche.
This distinction matters because msg.value is fixed for the entire external call. If the contract does not explicitly track how much of that ETH budget has already been consumed, a single payment can be treated as fresh capital by multiple loop iterations.
The vulnerability is a payment-accounting bug in Opyn's ETH-underlying exercise path. The contract correctly computes how much ETH is required for a single tranche, and it correctly computes how much USDC collateral to release for that tranche. The failure is that this validation is scoped to each _exercise() invocation instead of the full exercise() loop.
The relevant control flow is:
function exercise(uint256 oTokensToExercise, address payable[] memory vaultsToExerciseFrom) public payable {
for (uint256 i = 0; i < vaultsToExerciseFrom.length; i++) {
...
_exercise(..., vaultOwner);
}
}
if (isETH(underlying)) {
require(msg.value == amtUnderlyingToPay, "Incorrect msg.value");
}
The correct invariant is: for ETH-underlying options, the total ETH received during one exercise() call must equal the sum of underlyingRequiredToExercise() across every _exercise() iteration that burns oTokens and pays collateral. Opyn violates that invariant because _exercise() re-checks the same immutable msg.value for every vault without decrementing any remaining ETH budget.
In this incident, the attacker used two 30.0 oToken tranches. The first tranche exercised against the attacker's own vault and merely parked 30 ETH as withdrawable underlying inside that vault. The second tranche exercised against the victim vault and paid out 9,900 USDC. Since the contract had only received one 30 ETH payment total, the second collateral payout was underfunded.
The seed transaction first prepared an attacker-controlled tranche, then used it to turn the accounting bug into profit. Before the transaction, the helper contract already held 30.5167052 oTokens acquired from the public OptionsExchange market. During the transaction it added 9,900 USDC collateral to its own vault and minted 30.0 additional oTokens, giving it enough balance to burn 60.0 total.
The source-level breakpoint sits in the interaction between the outer loop and the ETH branch:
else if (vault.oTokensIssued >= oTokensToExercise) {
_exercise(oTokensToExercise, vaultOwner);
return;
} else {
oTokensToExercise = oTokensToExercise.sub(vault.oTokensIssued);
_exercise(vault.oTokensIssued, vaultOwner);
}
uint256 amtUnderlyingToPay = underlyingRequiredToExercise(oTokensToExercise);
vault.underlying = vault.underlying.add(amtUnderlyingToPay);
...
vault.collateral = vault.collateral.sub(totalCollateralToPay);
vault.oTokensIssued = vault.oTokensIssued.sub(oTokensToExercise);
if (isETH(underlying)) {
require(msg.value == amtUnderlyingToPay, "Incorrect msg.value");
}
_burn(msg.sender, oTokensToExercise);
transferCollateral(msg.sender, amtCollateralToPay);
The trace from the observed transaction shows the exact exploit pattern. The attacker called:
oToken::exercise{value: 30000000000000000000}(600000000, [attacker, victim])
That one call produced two separate exercise outcomes:
emit Exercise(... amtUnderlyingToPay: 30000000000000000000, amtCollateralToPay: 9900000000, vaultExercisedFrom: attacker)
emit Exercise(... amtUnderlyingToPay: 30000000000000000000, amtCollateralToPay: 9900000000, vaultExercisedFrom: 0x01BDb7Ada61C82E951b9eD9F0d312DC9Af0ba0f2)
After those two exercises, the attacker recovered the parked ETH through:
emit RemoveUnderlying(amountUnderlying: 30000000000000000000, vaultOwner: attacker)
The economic result is confirmed by the balance diff artifact. The helper contract's USDC balance increased from 68504683582 to 78404683582, a net gain of 9900000000 raw units. The victim vault's recorded accounting after the exploit was collateral = 5100000000, oTokensIssued = 154545454, and underlying = 30000000000000000000, proving that one 9,900 USDC tranche was drained while only one 30 ETH payment ever entered the call.
The adversary cluster consisted of EOA 0x915c2d6f571d3d47a182dd59d5f41e87d4c3fb8e and helper contract 0xe7870231992ab4b1a01814fa0a599115fe94203f. The EOA paid gas and triggered the helper. The helper held the purchased oTokens, owned the self-funded vault, received the drained USDC, and withdrew the recovered ETH.
The exploit flow was:
30.5167052 market-bought oTokens before block 10592517.9,900 USDC collateral to an attacker-owned vault and mint 30.0 fresh oTokens.exercise(60.0 oTokens, [attacker vault, victim vault]) with only 30 ETH.30 ETH into the attacker vault's underlying field.9,900 USDC from victim vault 0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2.removeUnderlying() and withdraw the same 30 ETH from the attacker vault.This is ACT because every step is permissionless. The oTokens were publicly available through the OptionsExchange/Uniswap path, the vault operations were public, exercise() accepted an arbitrary vault list, and removeUnderlying() was callable by the attacker as owner of the self-funded vault.
The direct measurable loss is 9,900 USDC from victim vault 0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2. In raw smallest units that is "9900000000" with decimal = 6.
The post-state also shows secondary accounting distortion: the victim vault's oTokensIssued decreased by 30.0 and its underlying increased by 30 ETH, even though the contract only received one 30 ETH payment for two exercised tranches. Economically, the attacker retained the victim's USDC while recovering the same ETH used to satisfy the first tranche.
0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a0x951d51baefb72319d9fbe941e1615938d89abfe2exercise{value: 30 ETH} call, two Exercise events, and RemoveUnderlying0xe7870231992ab4b1a01814fa0a599115fe94203f gained 9900000000 USDC0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2, confirmed as an EOA/no-code account at the exploit block