All incidents

WECOStaking Reward-Debt Mismatch

Share
Nov 16, 2023 18:16 UTCAttackLoss: 888,001,419.44 WECOPending manual check2 exploit txWindow: 0s
Estimated Impact
888,001,419.44 WECO
Label
Attack
Exploit Tx
2
Addresses
1
Attack Window
0s
Nov 16, 2023 18:16 UTC → Nov 16, 2023 18:16 UTC

Exploit Transactions

TX 1BSC
0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051
Nov 16, 2023 18:16 UTCExplorer
TX 2BSC
0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8
Nov 16, 2023 18:16 UTCExplorer

Victim Addresses

0xd672b766d66662f5c6fd798a999e1193a7945451BSC

Loss Breakdown

888,001,419.44WECO

Similar Incidents

Root Cause Analysis

WECOStaking Reward-Debt Mismatch

1. Incident Overview TL;DR

On BNB Smart Chain block 33549938, adversary EOA 0xf5f21746ff9351f16a42fa272d7707cc35760e4b first funded helper contract 0x76c8a674e814f5bd806fe6dd9975446a76056c1a with 25000001000000000000000000 WECO in transaction 0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051, then called the helper's attack(uint256) entrypoint in transaction 0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8. Inside that second transaction the helper deposited 25000000000000000000000000 WECO into WECOStaking at 0xd672b766d66662f5c6fd798a999e1193a7945451, then executed 1461 same-block redeposits of 1 WECO each. The trace and balance diff show that the sequence drained 888001419435523636722346264 WECO from WECOStaking, leaving the helper with 913001420435523636722346264 WECO.

The root cause is a deterministic accounting bug in WECOStaking. The contract writes user.offsetPoints in demagnified token units inside deposit(), but later consumes that field as if it were still magnified inside _claimAndLock(). Because _updateAccumulator() returns early when block.timestamp <= prevTimestamp, every redeposit in the same block reuses the same accumulator snapshot and pays nearly the user's full cumulative rewards again.

2. Key Background

WECOStaking is a reward-distribution contract for the WECO token at 0x5d37abafd5498b0e7af753a2e83bd4f0335aa89f. Reward accounting is based on an accumulator accumulatedRewardsPerStakingPower that is scaled by MAGNIFIER = 1e20. The intended invariant is simple: the reward-debt field user.offsetPoints must stay in the same magnified units as userStakingPower * accumulatedRewardsPerStakingPower until the final division by MAGNIFIER.

The exploit matters because once the accumulator has become non-zero, an unprivileged user can create a bogus debt snapshot with one deposit and then immediately reclaim almost all cumulative rewards again with another deposit in the same block. No privileged key, governance role, or attacker-specific contract is required; any account can deploy a fresh helper and call the public deposit() and withdraw() functions.

Two public state variables are critical to the exploit:

  • prevTimestamp, which gates whether _updateAccumulator() advances the accumulator.
  • accumulatedRewardsPerStakingPower, which holds the magnified cumulative reward index used by both deposit() and _claimAndLock().

3. Vulnerability Analysis & Root Cause Summary

The vulnerability class is an on-chain accounting bug, not MEV or an access-control issue. The verified WECOStaking source shows that deposit(uint256,uint256) updates user.offsetPoints with an extra division by MAGNIFIER, even though the later claim path expects a magnified debt value. _claimAndLock(address) then computes claimableRewards = (accumulatedRewards - user.offsetPoints) / MAGNIFIER, so the subtraction uses a value that is 1e20 too small. As a result, the claim path pays out almost the attacker's full cumulative entitlement instead of only the delta since the previous action.

The bug becomes dramatically exploitable because _updateAccumulator() exits immediately when the current block.timestamp is not greater than prevTimestamp. Once the first deposit in a block updates prevTimestamp, every same-block redeposit observes the same accumulator value and therefore repeats the same broken payout logic. The exploit trace confirms exactly that pattern: one large deposit writes the bad debt snapshot, then 1461 same-block 1 WECO redeposits each emit a large ClaimReward. This is a permissionless ACT opportunity because any unprivileged actor can source WECO, deploy a helper, and reproduce the public call sequence against the live contract state.

4. Detailed Root Cause Analysis

The verified victim code contains all three ingredients of the exploit.

From the verified WECOStaking source on BscScan:

function deposit(uint _amount, uint256 _weeksLocked) external {
    ...
    _claimAndLock(msg.sender);
    ...
    user.offsetPoints =
        ((fullAmount + bonusStakingPower) *
            accumulatedRewardsPerStakingPower) /
        MAGNIFIER;
    WECOIN.transferFrom(msg.sender, address(this), _amount);
}
function _updateAccumulator() internal {
    if (block.timestamp <= prevTimestamp) return;
    ...
    accumulatedRewardsPerStakingPower += diff;
    ...
    prevTimestamp = block.timestamp;
}
function _claimAndLock(address _user) private {
    _updateAccumulator();
    ...
    accumulatedRewards =
        userStakingPower *
        accumulatedRewardsPerStakingPower;
    claimableRewards =
        (accumulatedRewards - user.offsetPoints) /
        MAGNIFIER;
    user.offsetPoints = accumulatedRewards;
    ...
    WECOIN.transfer(_user, claimableRewards);
}

The invariant violation is that user.offsetPoints is written in demagnified units in deposit() but later subtracted from a magnified accumulatedRewards value in _claimAndLock(). A correct implementation would keep both values in magnified units until the final division by MAGNIFIER.

The exploit transaction proves the bug with concrete values. In transaction 0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8, the helper first calls WECOStaking::deposit(25000000000000000000000000, 0). The trace shows the contract updating the accumulator to 2499592666748405106 and storing users[helper].offsetPoints = 624898166687101276500000. Because deposit() already divided by MAGNIFIER, that stored debt is far smaller than the magnified value _claimAndLock() expects.

The next same-block 1 WECO redeposit immediately realizes the mismatch:

WECOStaking::deposit(1000000000000000000, 0)
  WECOIN::transfer(helper, 624898166687101276493751)
  emit ClaimReward(user: helper, amount: 624898166687101276493751)

The payout exactly matches the broken formula:

((25000000000000000000000000 * 2499592666748405106) - 624898166687101276500000) / 100000000000000000000
= 624898166687101276493751

Nothing advances the accumulator between these redeposits because _updateAccumulator() exits as soon as block.timestamp <= prevTimestamp. The trace contains 1461 ClaimReward emissions and 1461 same-block deposit(1000000000000000000, 0) calls, confirming that the attacker repeatedly re-used the same accumulator snapshot. This is why the exploit does not require any privileged role or timing secret beyond submitting a single transaction with repeated public calls.

The balance diff quantifies the final drain:

{
  "token": "0x5d37abafd5498b0e7af753a2e83bd4f0335aa89f",
  "holder": "0x76c8a674e814f5bd806fe6dd9975446a76056c1a",
  "before": "25000001000000000000000000",
  "after": "913001420435523636722346264",
  "delta": "888001419435523636722346264"
}
{
  "token": "0x5d37abafd5498b0e7af753a2e83bd4f0335aa89f",
  "holder": "0xd672b766d66662f5c6fd798a999e1193a7945451",
  "before": "888437874486073990651517073",
  "after": "436455050550353929170809",
  "delta": "-888001419435523636722346264"
}

These numbers are consistent with the code-level breakpoint: once the bad debt snapshot is installed, repeated same-block redeposits transfer reward inventory directly out of WECOStaking.

5. Adversary Flow Analysis

The adversary flow is fully observable on-chain and consists of two crafted transactions in the same block.

  1. Funding transaction 0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051

    • Sender: 0xf5f21746ff9351f16a42fa272d7707cc35760e4b
    • Calldata: WECOIN.transfer(0x76c8a674e814f5bd806fe6dd9975446a76056c1a, 25000001000000000000000000)
    • Effect: the helper contract receives 25000001000000000000000000 WECO and is ready to interact with the victim contract.
  2. Exploit transaction 0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8

    • Sender: 0xf5f21746ff9351f16a42fa272d7707cc35760e4b
    • Target: helper contract 0x76c8a674e814f5bd806fe6dd9975446a76056c1a
    • Calldata: attack(25000000000000000000000000)
    • Effect:
      • the helper deposits 25000000000000000000000000 WECO into WECOStaking;
      • the first deposit updates prevTimestamp and writes the broken debt snapshot;
      • the helper retains 1 WECO liquid balance, then performs 1461 same-block redeposits of 1 WECO each;
      • every redeposit emits a large ClaimReward because the accumulator stays unchanged while the stored debt remains demagnified.

The trace excerpt below captures the pivot from setup to exploitation:

0x76C8A674e814f5BD806fe6Dd9975446a76056C1a::attack(25000000000000000000000000)
  WECOStaking::deposit(25000000000000000000000000, 0)
  WECOIN::balanceOf(helper) -> 1000000000000000000
  WECOStaking::deposit(1000000000000000000, 0)
    WECOIN::transfer(helper, 624898166687101276493751)
    emit ClaimReward(user: helper, amount: 624898166687101276493751)

The helper contract is not a source of privilege. Its only role is operational convenience: it batches a public deposit/redeposit sequence that any unprivileged actor could reproduce with freshly deployed code. That is why the incident remains ACT despite using an attacker-controlled helper.

6. Impact & Losses

The victim is WECOStaking at 0xd672b766d66662f5c6fd798a999e1193a7945451. The direct measured loss in the exploit transaction is:

  • WECO: 888001419435523636722346264 raw units (888001419.435523636722346264 WECO with 18 decimals)

The staking contract's liquid WECO balance dropped from 888437874486073990651517073 to 436455050550353929170809. The attacker helper ended the transaction with 913001420435523636722346264 WECO, which already includes its seeded capital and the drained profit. Because the helper also interacted through the standard deposit path, the exploit shows that the contract can be drained while continuing to honor the attacker's staked principal bookkeeping.

7. References

  • Exploit transaction: 0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8
  • Funding transaction: 0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051
  • Victim contract: WECOStaking at 0xd672b766d66662f5c6fd798a999e1193a7945451
  • Token contract: WECOIN at 0x5d37abafd5498b0e7af753a2e83bd4f0335aa89f
  • Verified victim source: https://bscscan.com/address/0xd672b766d66662f5c6fd798a999e1193a7945451#code
  • Collector exploit trace: /workspace/session/artifacts/collector/seed/56/0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8/trace.cast.log
  • Collector exploit balance diff: /workspace/session/artifacts/collector/seed/56/0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8/balance_diff.json
  • Collector funding balance diff: /workspace/session/artifacts/collector/seed/56/0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051/balance_diff.json