We do not have a reliable USD price for the recorded assets yet.
0xd672b766d66662f5c6fd798a999e1193a7945451BSCOn 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 , but later consumes that field as if it were still magnified inside . Because returns early when , every redeposit in the same block reuses the same accumulator snapshot and pays nearly the user's full cumulative rewards again.
deposit()_claimAndLock()_updateAccumulator()block.timestamp <= prevTimestampWECOStaking 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().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.
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.
The adversary flow is fully observable on-chain and consists of two crafted transactions in the same block.
Funding transaction 0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051
0xf5f21746ff9351f16a42fa272d7707cc35760e4bWECOIN.transfer(0x76c8a674e814f5bd806fe6dd9975446a76056c1a, 25000001000000000000000000)25000001000000000000000000 WECO and is ready to interact with the victim contract.Exploit transaction 0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8
0xf5f21746ff9351f16a42fa272d7707cc35760e4b0x76c8a674e814f5bd806fe6dd9975446a76056c1aattack(25000000000000000000000000)25000000000000000000000000 WECO into WECOStaking;prevTimestamp and writes the broken debt snapshot;1 WECO liquid balance, then performs 1461 same-block redeposits of 1 WECO each;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.
The victim is WECOStaking at 0xd672b766d66662f5c6fd798a999e1193a7945451. The direct measured loss in the exploit transaction is:
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.
0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e80x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051WECOStaking at 0xd672b766d66662f5c6fd798a999e1193a7945451WECOIN at 0x5d37abafd5498b0e7af753a2e83bd4f0335aa89fhttps://bscscan.com/address/0xd672b766d66662f5c6fd798a999e1193a7945451#code/workspace/session/artifacts/collector/seed/56/0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8/trace.cast.log/workspace/session/artifacts/collector/seed/56/0x2040a481c933b50ee31aba257c2041c48bb7a0b4bf4b4fad1ac165f19c4269e8/balance_diff.json/workspace/session/artifacts/collector/seed/56/0x6129e18fdba3b4d3f1e6c3c9c448cafcbee5b5c82e4bbb69a404360f0e579051/balance_diff.json