[Post-Mortem] OTSea Staking - Lost 26k Due to Logic Flaw

·3 min read
This analysis is written for educational purposes

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.rewardReferenceEpoch from memory (unchanged) to pick an epoch’s totalStake for 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 withdraw with a preceding claim (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:

  1. Read and verify storage fields (user has sufficient staked balance).
  2. 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.
  3. 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