0x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f40xf2c8e860ca12cde3f3195423ecf54427a4f30916Ethereum0x5da151b95657e788076d04d56234bd93e409cb09Ethereum0x34bccf4af03870265fe99cec262524f343cca7ffEthereumAn unprivileged Ethereum mainnet adversary exploited OTSea staking in transaction 0x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f4 at block 20738191. The attacker used public OTSea liquidity, opened staking deposits, advanced the public reward epoch through OTSeaRevenueDistributor.distribute(), then reused already-withdrawn deposit indexes inside claim() so the staking contract treated those deposits as active again. A later withdraw() released the same principal a second time, draining OTSea from the staking contract.
The root cause is a broken deposit lifecycle in OTSeaStaking. withdraw() marks a deposit as withdrawn by setting rewardReferenceEpoch to 0, but claim() calls _claimMultiple() and unconditionally writes currentEpoch back into every listed deposit. That allows a withdrawn deposit to leave its terminal state and become withdrawable again. The incident is an ACT opportunity because every step used public contracts and public state only.
OTSea staking stores each stake as Deposit { uint32 rewardReferenceEpoch, uint88 amount }. A deposit is considered withdrawn when rewardReferenceEpoch == 0. New deposits are created by with , so they only begin participating once a later epoch is finalized.
_createDeposit()rewardReferenceEpoch = currentEpoch + 1Epoch advancement is exposed through the public revenue distributor. OTSeaRevenueDistributor.distribute() reads the current epoch from the staking contract and, after the initial owner-only epoch, any caller may trigger the next epoch once minInterval has elapsed. If the distributor holds enough ETH and staking total stake is above the configured minimum, it forwards its ETH into OTSeaStaking.distribute(); otherwise it calls skipEpoch(). This means an unprivileged attacker can make rewards claimable whenever the public timing condition is met.
The relevant public protocol components in this incident are:
0xF2c8e860ca12Cde3F3195423eCf54427A4f309160x5dA151B95657e788076D04d56234Bd93e409CB090x34BCcF4aF03870265Fe99cEc262524F343Cca7ff0x75eabb7cb05a5057e092fdbbd86fb801b4d02b1fThe vulnerability is an accounting and lifecycle bug in OTSeaStaking, not a pricing anomaly or privileged backdoor. The intended safety invariant is that once a deposit has been withdrawn, it is terminal and must never again earn rewards or release principal. OTSeaStaking::_withdrawMultiple() enforces the first half of that lifecycle by reverting if rewardReferenceEpoch == 0, then setting rewardReferenceEpoch to 0 and returning deposit.amount to the caller.
The bug is in OTSeaStaking::_claimMultiple(). That function loops over each requested deposit index, adds the rewards from _calculateRewards, and then writes _deposits[_msgSender()][_indexes[i]].rewardReferenceEpoch = currentEpoch for every listed deposit. There is no check that the deposit was still active. If at least one index in the claim list has positive rewards, totalRewards is non-zero, NoRewards() is avoided, and previously withdrawn deposits are revived along with the live deposit.
Once revived, the deposit still retains its original amount. A later withdraw() call will load that same amount and transfer it again, producing duplicate principal release. The public distributor is what makes the sequence practical for any attacker: it lets the adversary create a reward-bearing deposit in the same public environment and then pair it with a previously withdrawn index during claim().
The key victim-side code path is the interaction between withdraw() and claim() in OTSeaStaking:
function _withdrawMultiple(uint256[] calldata _indexes)
private
returns (uint88 totalAmount, uint256 totalRewards)
{
...
Deposit memory deposit = _deposits[_msgSender()][_indexes[i]];
if (deposit.rewardReferenceEpoch == 0) revert OTSeaErrors.NotAvailable();
_deposits[_msgSender()][_indexes[i]].rewardReferenceEpoch = 0;
...
totalAmount += deposit.amount;
}
function _claimMultiple(uint256[] calldata _indexes) private returns (uint256 totalRewards) {
...
totalRewards += _calculateRewards(_msgSender(), _indexes[i]);
_deposits[_msgSender()][_indexes[i]].rewardReferenceEpoch = currentEpoch;
...
if (totalRewards == 0) revert NoRewards();
}
_withdrawMultiple() treats rewardReferenceEpoch == 0 as the terminal withdrawn state. That matches the submitted invariant. But _claimMultiple() does not preserve that terminal state. It recalculates rewards and overwrites the epoch for every index in the claim list, even if that deposit was already withdrawn.
The seed transaction and the validator fork both show the same exploit chain:
0 and 1.OTSeaRevenueDistributor.distribute() once using the distributor's existing ETH balance and again after funding the distributor and waiting the public interval, advancing staking to a reward-bearing epoch.0, causing rewardReferenceEpoch for deposit 0 to become 0.claim([0,1], attacker). Deposit 1 contributes positive rewards, so the call succeeds. During the same loop, deposit 0 is rewritten from 0 to the active epoch.withdraw([0,1], attacker) and receives principal for deposit 0 again.The validator fork trace reproduces the exact breakpoint:
OTSeaStaking::withdraw([0], attacker)
...
OTSeaStaking::getDeposit(attacker, 0) -> Deposit({ rewardReferenceEpoch: 0, amount: 1609078533632580711145 })
OTSeaStaking::claim([0, 1], attacker)
...
OTSeaStaking::getDeposit(attacker, 0) -> Deposit({ rewardReferenceEpoch: 33, amount: 1609078533632580711145 })
OTSeaStaking::withdraw([0, 1], attacker)
...
OTSeaERC20::transfer(attacker, 3218157067265161422290)
The seed balance diff is consistent with the same bug at incident scale. OTSeaStaking lost 43944445168159512442507586 raw OTSea units, and the adversary profit-taking contract 0x5aec8469414332d62bf5058fb91f2f8457e5c5cb gained 37944445168159512442507586 raw OTSea units while another 6000000000000000000000000 raw OTSea units were sold into the public Uniswap pool during the same transaction. That state transition is consistent with duplicate release of principal from staking followed by partial monetization.
The ACT conditions described in root_cause.json are also supported by code and traces:
claim().The seed transaction sender was EOA 0x000000003704bc4ffb86000046721f44ef3dbabe, which called helper contract 0xd11ee5a6a9ebd9327360d7a82e40d2f8c314e985. The submitted root cause also identifies 0x5aec8469414332d62bf5058fb91f2f8457e5c5cb as the contract that ended the transaction with the drained OTSea balance reflected in the balance diff. Those account assignments are consistent with the seed metadata and balance deltas.
The on-chain execution has three stages:
Epoch Trigger
The helper path calls the public distributor. The seed trace records OTSeaRevenueDistributor::distribute() followed by OTSeaStaking::distribute{value: 295647903437594148}(), proving the attacker relied on a public epoch transition.
Deposit Reactivation
Earlier in the sequence, a deposit is withdrawn and its rewardReferenceEpoch is zeroed. Later, claim() is invoked with a mixed list that includes a withdrawn index and a still-live reward-bearing index. Because _claimMultiple() rewrites all listed indexes, the withdrawn deposit becomes active again.
Duplicate Withdrawal And Monetization
The attacker withdraws the revived indexes again, receives duplicate OTSea principal and ETH rewards, then routes part of the OTSea through the public Uniswap market. The trace also contains the large 6000000000000000000000000 OTSea sale size cited in the root cause.
The exploit is single-transaction and permissionless. No privileged role, leaked key, attacker-side ABI, or off-chain secret is required. The only requirements are public contracts, public balances, and a reward-bearing deposit to avoid NoRewards().
The measurable loss in the submitted analysis is:
43944445168159512442507586 raw units (decimal = 18)The seed balance diff shows that OTSeaStaking lost exactly that amount during the transaction. The same artifact shows the adversary profit-taking contract ending with 37944445168159512442507586 raw OTSea units and the remaining 6000000000000000000000000 raw OTSea units being sold during the same transaction. The incident therefore drained principal directly from the staking contract rather than merely extracting rewards.
The affected parties are OTSea stakers and the staking pool itself. The exploit breaks the protocol's expected conservation of staked principal, so any remaining users are exposed to a direct depletion of the staking contract's token reserves.
0x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f40x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f40x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f4OTSeaStaking.sol source bundle containing withdraw() and _claimMultiple()OTSeaRevenueDistributor.sol source bundle containing the public distribute() gateforge test