Incident Overview
Date: September 13, 2024
Loss: 26k USD
Vulnerability Type: Memory-vs-Storage Logic Flaw
Contract: OTSeaStaking.sol
What Happened
The OTSeaStaking contract was exploited by a hacker who discovered a critical logic flaw in the withdrawal mechanism. The attacker was able to call the "withdraw" function multiple times and receive significantly more tokens than they had originally staked, resulting in a loss of 26k USD.
More specifically, the attacker successively called claim(...) and withdraw(...) with crafted index arrays, exploiting an ordering bug in _withdrawMultiple. This allowed repeated principal withdrawals without actually removing stake from future epochs, draining ~26k USD.
Root Cause Analysis
Memory vs. Storage Inconsistency
Around line 396 of OTSeaStaking.sol
The vulnerable _withdrawMultiple implementation had this sequence:
// 1. Copy Deposit struct from storage into memory
Deposit memory deposit = _deposits[msg.sender][index];
// 2. Clear rewardReferenceEpoch in storage (set to 0)
_deposits[msg.sender][index].rewardReferenceEpoch = 0;
// 3. Use the old memory copy’s rewardReferenceEpoch to decide from which epoch to deduct stake
_epochs[
currentEpoch < deposit.rewardReferenceEpoch
? deposit.rewardReferenceEpoch
: currentEpoch
].totalStake -= deposit.amount;
// 4. Accumulate principal for transfer
totalAmount += deposit.amount;
- Stale memory value: After clearing in storage, the code still uses
deposit.rewardReferenceEpochfrom memory (unchanged) to pick an epoch’stotalStakefor deduction. - Incorrect stake removal: This removes the principal from an earlier or current epoch, but does not remove it from the next epoch’s
totalStake, effectively leaving the stake intact for subsequent reward cycles. - Attack loop: By pairing each
withdrawwith a precedingclaim(which repopulates memory with a non-zero epoch), the attacker could bypass the zero-check and withdraw the same principal repeatedly.
In short, withdrawals don’t deduct the stake (deposit.amount) from future epochs’ totalStake, so the same deposit.amount can be withdrawn multiple times. Therefore, a user can deposit tokens once and call the withdraw function multiple times (paired with claim(), each claim repopulates a nonzero epoch into memory so the subsequent withdraw can again deduct from the wrong epoch.)
Correct Pattern
The vulnerability stems from improper state management in the withdrawal logic. When a user withdraws their staked tokens, the contract should follow the Checks-Effects-Interactions pattern. Therefore, a secure fix follows Checks–Effects–Interactions strictly:
- Read and verify storage fields (user has sufficient staked balance).
- Update storage state using up-to-date storage values (clear epoch, deduct stake correctly), that is , update state first and make sure the update work as expected.
- Perform transfers (external calls last).
The original contract has logic flaws in step2, leading to incorrect updates.
Proof of Concept
I've written a proof of concept demonstrating this vulnerability, which can be found here.
You can find more incidents and their proof of concepts in the DeFiHackLabs repository
References
- Transaction Hash: 0x90b4fcf583444d44efb8625e6f253cfcb786d2f4eda7198bdab67a54108cd5f4
- External Analysis: Nick Franklin's Analysis --> taken down, see https://rekt.news/the-impersonator
- OTSeaStaking Contract Address: 0xF2c8e860ca12Cde3F3195423eCf54427A4f30916
- OTSea Token Contract Address: 0x5da151b95657e788076d04d56234bd93e409cb09