All incidents

Fake cUSDC Stake Drain

Share
Jul 20, 2025 02:50 UTCAttackLoss: 1,286,577.59 cUSDCPending manual check1 exploit txWindow: Atomic
Estimated Impact
1,286,577.59 cUSDC
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Jul 20, 2025 02:50 UTC → Jul 20, 2025 02:50 UTC

Exploit Transactions

TX 1Ethereum
0xa02b159fb438c8f0fb2a8d90bc70d8b2273d06b55920b26f637cab072b7a0e3e
Jul 20, 2025 02:50 UTCExplorer

Victim Addresses

0x245a551ee0f55005e510b239c917fa34b41b3461Ethereum

Loss Breakdown

1,286,577.59cUSDC

Similar Incidents

Root Cause Analysis

Fake cUSDC Stake Drain

1. Incident Overview TL;DR

On Ethereum mainnet transaction 0xa02b159fb438c8f0fb2a8d90bc70d8b2273d06b55920b26f637cab072b7a0e3e, an unprivileged attacker drained 128657759164064 cUSDC from Staking at 0x245a551ee0f55005e510b239c917fa34b41b3461. The attacker first initialized the cUSDC epoch chain, then created a fake internal cUSDC stake with deposit, and finally withdrew the protocol's pre-existing cUSDC with emergencyWithdraw.

The root cause is an unchecked ERC20 return value in the non-stable deposit path. Staking.deposit calls IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount) for non-stable tokens and then unconditionally increments internal accounting even if the token returned false. Compound cUSDC at 0x39aa39c021dfbae8fac545936693ac917d5e7563 uses that exact failure mode, so the attacker could mint a fake stake without transferring any real cUSDC.

2. Key Background

Staking treats only USDC, USDT, and DAI as stablecoins. Any other token address is handled by the non-stable branches in deposit and emergencyWithdraw. cUSDC is therefore handled as a non-stable token.

Staking also uses epoch accounting. If a token has not yet been initialized for a given epoch, anyone can initialize it through manualEpochInit as long as the previous epoch is initialized. At the attack pre-state, getCurrentEpoch() was 54, so the attacker could publicly build the cUSDC epoch history from 0 through 54.

Compound cTokens are not strict ERC20s in the revert-on-failure sense. CErc20.transferFrom returns false when token transfer checks fail, including insufficient token balance, instead of reverting the whole caller transaction. That behavior is critical because Staking.deposit never inspects the returned bool in the non-stable path.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is unsafe accounting over an unchecked token transfer. In Staking.deposit, the non-stable branch performs a raw transferFrom and immediately updates balances[msg.sender][tokenAddress] and epoch pool state. There is no require on the returned bool and no post-transfer balance check. As a result, the contract assumes the asset arrived even when no asset moved.

The key safety invariant is: an increase in balances[user][tokenAddress] must correspond to the same amount of tokens actually entering Staking. The code breaks that invariant at the deposit branch for non-stable tokens. cUSDC is a viable exploit token because it is not classified as stable and its transferFrom can fail softly. Once the fake balance exists, emergencyWithdraw trusts the internal balance and transfers real tokens already held by the contract.

4. Detailed Root Cause Analysis

Independent validation confirms the vulnerable victim code:

function deposit(address tokenAddress, uint256 amount, address referrer) public nonReentrant {
    bool isStableCoin = checkStableCoin(tokenAddress);
    require(IERC20(tokenAddress).allowance(msg.sender, address(this)) >= amount, "Staking: Token allowance too small");

    if (isStableCoin) {
        ...
    } else {
        IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount);
    }

    balances[msg.sender][tokenAddress] = balances[msg.sender][tokenAddress].add(amount);
    ...
    emit Deposit(msg.sender, tokenAddress, amount);
}

The public recovery path is also directly exploitable for non-stable tokens:

function emergencyWithdraw(address tokenAddress) public {
    bool isStableCoin = checkStableCoin(tokenAddress);
    require(!isStableCoin, "Cant withdraw stable coins");
    require((getCurrentEpoch() - lastWithdrawEpochId[tokenAddress]) >= 10, "At least 10 epochs must pass without success");

    uint256 totalUserBalance = balances[msg.sender][tokenAddress];
    require(totalUserBalance > 0, "Amount must be > 0");

    balances[msg.sender][tokenAddress] = 0;
    IERC20(tokenAddress).transfer(msg.sender, totalUserBalance);
}

The epoch bootstrap is permissionless:

function manualEpochInit(address[] memory tokens, uint128 epochId) public {
    require(epochId <= getCurrentEpoch(), "can't init a future epoch");
    ...
    emit ManualEpochInit(msg.sender, epochId, tokens);
}

The token-side behavior is equally clear:

function transferFrom(address src, address dst, uint256 amount) external nonReentrant returns (bool) {
    return transferTokens(msg.sender, src, dst, amount) == uint(Error.NO_ERROR);
}

Inside transferTokens, cUSDC returns a nonzero failure code when the sender lacks enough tokens, which causes transferFrom to return false rather than revert.

The on-chain trace of the exploit transaction shows the complete realization path:

Staking::manualEpochInit(..., 54)
Staking::deposit(cUSDC, 128657759164064, 0x0)
  CErc20::transferFrom(helper, Staking, 128657759164064)
    emit Failure(error: 9, info: 76, detail: 0)
  emit Deposit(user: helper, tokenAddress: cUSDC, amount: 128657759164064)
Staking::emergencyWithdraw(cUSDC)
  CErc20::transfer(helper, 128657759164064)
  emit EmergencyWithdraw(user: helper, tokenAddress: cUSDC, amount: 128657759164064)
CErc20::transfer(attackerEOA, 128657759164064)

Pre-state checks also match the report. At block 22957532, Staking.getCurrentEpoch() returned 54, Staking.balanceOf(0x657a2b6fe37ced2f31fd7513095dbfb126a53601, cUSDC) returned 0, and cUSDC.balanceOf(Staking) returned 128657759164064. That means the attacker started with no internal cUSDC position while the victim contract already held a fully drainable cUSDC balance.

5. Adversary Flow Analysis

The sender EOA was 0x657a2b6fe37ced2f31fd7513095dbfb126a53601. During the exploit transaction it created helper contract 0x7f1f536223d6a84ad4897a675f04886ce1c3b7a1, which executed the attack path on-chain.

Step 1: the helper initialized epochs 0 through 54 for cUSDC by calling manualEpochInit. This was possible because the function is public and only requires sequential initialization.

Step 2: the helper approved Staking to spend cUSDC and called deposit(cUSDC, 128657759164064, address(0)). The approval satisfied Staking's allowance check, but the helper still owned no cUSDC. cUSDC therefore emitted Failure(error: 9, info: 76) inside transferFrom.

Step 3: despite the failed transfer, Staking emitted Deposit and credited the helper with an internal cUSDC balance equal to the full stranded amount.

Step 4: the helper immediately called emergencyWithdraw(cUSDC). Because cUSDC is treated as non-stable and lastWithdrawEpochId[cUSDC] was still the default zero value, the epoch-gap condition passed at current epoch 54. Staking then transferred the real cUSDC it already held to the helper.

Step 5: the helper forwarded the drained cUSDC to the sender EOA, completing the profit realization in the same transaction.

6. Impact & Losses

The exploit drained the victim contract's entire observed cUSDC balance at the targeted pre-state. The measured loss was:

  • cUSDC: 128657759164064 raw units (8 decimals)

This was an ACT opportunity. Any unprivileged account observing the same public pre-state could have initialized the epochs, created the fake balance, and withdrawn the stranded cUSDC without any privileged role, signature, or attacker-private artifact.

7. References

  • Exploit transaction: 0xa02b159fb438c8f0fb2a8d90bc70d8b2273d06b55920b26f637cab072b7a0e3e
  • Victim contract: Staking at 0x245a551ee0f55005e510b239c917fa34b41b3461
  • Token contract: cUSDC / CErc20 at 0x39aa39c021dfbae8fac545936693ac917d5e7563
  • Seed trace: /workspace/session/artifacts/collector/seed/1/0xa02b159fb438c8f0fb2a8d90bc70d8b2273d06b55920b26f637cab072b7a0e3e/trace.cast.log
  • Seed metadata: /workspace/session/artifacts/collector/seed/1/0xa02b159fb438c8f0fb2a8d90bc70d8b2273d06b55920b26f637cab072b7a0e3e/metadata.json
  • Verified cUSDC source artifact: /workspace/session/artifacts/collector/seed/1/0x39aa39c021dfbae8fac545936693ac917d5e7563/src/Contract.sol
  • Verified Staking source: Etherscan verified source for 0x245a551ee0f55005e510b239c917fa34b41b3461