WIFStaking claimEarned bug enables repeated WIF reward extraction
Exploit Transactions
0xda8f6a4bed7e5689a343d111632d37480c0316f1d20b732803c4bd482823e2840x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e090x362e092c5d7d0a30706a171133667d7297c753a8e5eedf21e6eb0c738c09808e0x6cbe3aae3bd106c42b7007b650619b85011014be64646b4d5835f937e52e3bf9Victim Addresses
0xa1ce40702e15d0417a6c74d0bab96772f36f4e99EthereumLoss Breakdown
Similar Incidents
SorraV2 staking withdraw bug enables repeated SOR reward drain
43%AaveBoost zero-amount reward extraction drains brAAVE incentives
35%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
33%CVG staking supply drain from reward-mint inflation bug
33%Indexed Finance DEFI5 gulp/reindex bug enables SUSHI flash-swap drain
32%JokInTheBoxStaking unstake replay bug drains staked JOK repeatedly
32%Root Cause Analysis
WIFStaking claimEarned bug enables repeated WIF reward extraction
1. Incident Overview TL;DR
An unprivileged EOA 0x394ba273315240510b61ca22ba152e3478a45892 used an owner-gated helper contract 0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada to stake WIF into WIFStaking plan 3, call the buggy claimEarned function once to mint a large APR-based WIF reward without any time- or claim-based limits, and then unwind the WIF reward back into ETH via two UniswapV2 swaps. Across the four transactions in sequence b, the adversary’s net ETH balance increased by exactly 3.609928334830556174 ETH after all gas and fees, while the staked WIF principal remained locked in WIFStaking.
The root cause is a faulty reward calculation and accounting design in WIFStaking.claimEarned: for plan-3 stakes, it computes a reward proportional only to stake size and APR, ignores elapsed time, and does not track previously claimed amounts, then transfers the full computed reward from WIFStaking’s WIF balance on every call. This implementation breaks the intended staking reward invariant and allows unbounded WIF extraction by addresses that control plan-3 stakes while their principal remains staked.
2. Key Background
- WIF is an ERC-20 token at 0xbfae33128ecf041856378b57adf0449181fffde7 with a liquid WIF/WETH UniswapV2 pair at 0x64571ea88c809abeea5ddbeb7427ef37f87946d0, which enables direct swaps between WIF and ETH on Ethereum mainnet.
- WIFStaking 0xa1ce40702e15d0417a6c74d0bab96772f36f4e99 is a staking contract that defines interest-bearing plans, including plan 3, and tracks per-user stakes, APRs, and reward claims in storage; its verified source is available at artifacts/root_cause/data_collector/iter_1/contract/1/0xa1ce40702e15d0417a6c74d0bab96772f36f4e99/source/src/WIFStaking.sol.
- Helper contract 0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada is an owner-gated orchestrator that wires together WIF, WIFStaking, and the UniswapV2 router, exposing functions that stake ETH into WIFStaking, call WIFStaking.claimEarned, and swap WIF rewards back into ETH for its owner EOA 0x394ba2…. Its behavior is confirmed from decompiled bytecode at artifacts/root_cause/data_collector/iter_1/contract/1/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada/decompile/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada-decompiled.sol.
- The candidate ACT opportunity is evaluated from pre-state sigma_B at block 20103190, where the helper, WIFStaking, WIF token, and WIF/WETH liquidity already exist, and where the adversary can submit the four adversary-crafted transactions in sequence b without requiring any privileged role.
3. Vulnerability Analysis & Root Cause Summary
The root cause is a faulty reward calculation and accounting design in WIFStaking.claimEarned: for plan-3 stakes, it computes a reward proportional only to stake size and APR, ignores elapsed time, and does not track previously claimed amounts, then transfers the full computed reward from WIFStaking’s WIF balance on every call. This implementation breaks the intended staking reward invariant and allows unbounded WIF extraction by addresses that control plan-3 stakes while their principal remains staked.
Invariant: For each staking position, the total rewards that a user can claim over time must be bounded by principal_amount * effective_rate * elapsed_time and cannot exceed the protocol’s intended issuance schedule; in particular, reward claims must depend on elapsed time since stake or last claim and must not allow the same APR-based reward to be recomputed and transferred repeatedly without accounting for prior claims.
Breakpoint: In WIFStaking.claimEarned, the code iterates over the caller’s stakes in the specified plan and computes _earned = amount * plan.apr / 10000 for each stake, then sums these values into a variable (e.g., reward) and transfers reward * 10% to 0x000000000000000000000000000000000000dEaD and reward * 90% to msg.sender using WIF.transferFrom(WIFStaking, recipient, amount) without considering block.timestamp or any claimed-amount field. This loop and transfer sequence is the first concrete operation that violates the invariant, because it allows the same plan-3 principal to generate the same APR-based reward on every call as long as WIFStaking still holds enough WIF to cover the transfer.
4. Detailed Root Cause Analysis
Verified WIFStaking.sol shows that plan parameters, including APRs, are stored in a plans mapping, and that claimEarned accepts a planId and an integer count parameter used to limit the number of stakes processed in a single call. Inside claimEarned, the contract loads plan = plans[planId], obtains the caller’s stakes array for that plan, and iterates up to count entries, reading each stake’s amount and computing _earned = amount * plan.apr / 10000. The implementation sums all _earned values into a reward accumulator, and then immediately transfers reward * 10% WIF to burn address 0x000000000000000000000000000000000000dEaD and reward * 90% WIF to msg.sender by calling the WIF token’s transferFrom function with WIFStaking as the source. At no point does claimEarned read or update a field that tracks how much reward was previously claimed for a given stake, and it does not use block.timestamp or any time-based notion when computing _earned. As a result, a caller that controls an address with at least one active plan-3 stake can invoke claimEarned(3, count) whenever WIFStaking holds enough WIF, and each invocation recomputes the same APR-based reward _earned for each processed stake and transfers it again, independent of how much time has passed since the stake or since a prior claim. This behavior deterministically transfers WIF from WIFStaking’s WIF balance to the caller and the burn address on every call and is fully reachable by any address that holds plan-3 stakes, with no owner-only or admin-only guards on claimEarned.
Key vulnerable implementation in WIFStaking.claimEarned (verified source for 0xa1ce40702e15d0417a6c74d0bab96772f36f4e99):
function claimEarned(uint256 _stakingId, uint256 _burnRate) public override {
require(_burnRate == 10 || _burnRate == 25 || _burnRate == 40, "Invalid burn rate");
uint256 _earned = 0;
Plan storage plan = plans[_stakingId];
require(stakes[_stakingId][msg.sender].length > 0, "No stakes found");
for (uint256 i = 0; i < stakes[_stakingId][msg.sender].length; i++) {
Staking storage _staking = stakes[_stakingId][msg.sender][i];
_earned = _earned.add(
_staking
.amount
.mul(plan.apr)
.div(10000)
);
totalRewards = totalRewards.add(_earned);
totalRewardsPerPlan[_stakingId] = totalRewardsPerPlan[_stakingId].add(_earned);
totalRewardsPerWalletPerPlan[_stakingId][msg.sender] = totalRewardsPerWalletPerPlan[_stakingId][msg.sender].add(_earned);
totalEarnedRewardsPerWallet[msg.sender] += _earned;
_staking.stakeAt = block.timestamp;
}
require(_earned > 0, "There is no amount to claim");
uint256 burnAmount = _earned.mul(_burnRate).div(100);
IERC20(stakingToken).transfer(BURN_ADDRESS, burnAmount);
IERC20(stakingToken).transfer(msg.sender, _earned.sub(burnAmount));
}
5. Adversary Flow Analysis
Adversary-crafted transaction sequence b on Ethereum mainnet (chainid 1) from pre-state sigma_B at block 20103190:
- Tx 1: 0xda8f6a4bed7e5689a343d111632d37480c0316f1d20b732803c4bd482823e284 — Stake tx: helper uses UniswapV2 router 0x7a250d5630b4cf539739df2c5dacb4c659f2488d and WIF/WETH pair 0x64571ea88c809abeea5ddbeb7427ef37f87946d0 to swap exactly 0.3 ETH for 48,265,619,511,955,219 WIF, then calls WIFStaking.stake(3, amount) so that WIFStaking records a new plan-3 stake for the helper. Evidence: raw.json, artifacts/root_cause/seed/1/0xda8f6a4bed7e5689a343d111632d37480c0316f1d20b732803c4bd482823e284/trace.cast.log, artifacts/root_cause/seed/1/0xda8f6a4bed7e5689a343d111632d37480c0316f1d20b732803c4bd482823e284/balance_diff.json, artifacts/root_cause/data_collector/iter_1/contract/1/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada/decompile/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada-decompiled.sol, artifacts/root_cause/data_collector/iter_1/contract/1/0xa1ce40702e15d0417a6c74d0bab96772f36f4e99/source/src/WIFStaking.sol.
- Tx 2: 0x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e09 — Buggy reward claim tx: WIFStaking.claimEarned(3, 10) computes a reward for each plan-3 stake owned by the caller as _earned = amount * plan.apr / 10000, without using elapsed time or any claimed-amount tracking, sums _earned across all stakes, and transfers 10% of the total _earned to 0x000000000000000000000000000000000000dEaD and 90% to the helper. In this tx WIFStaking sends 1,135,207,370,921,186,696 WIF in total: 113,520,737,092,118,552 WIF to the burn address and 1,021,686,633,829,068,144 WIF to helper 0x93d4…, reducing WIFStaking’s WIF balance by exactly this amount. Evidence: artifacts/root_cause/seed/1/0x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e09/trace.cast.log, artifacts/root_cause/seed/1/0x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e09/balance_diff.json, artifacts/root_cause/data_collector/iter_1/contract/1/0xa1ce40702e15d0417a6c74d0bab96772f36f4e99/source/src/WIFStaking.sol.
- Tx 3: 0x362e092c5d7d0a30706a171133667d7297c753a8e5eedf21e6eb0c738c09808e — First reward-unwind swap: helper approves 500,000,000,000,000,000 WIF to router 0x7a250d…, and router swaps this WIF through pair 0x64571e… into WETH/ETH. balance_diff.json for this tx shows an ETH increase of 2.324805600118599760 for the adversary EOA after gas. Evidence: artifacts/root_cause/data_collector/iter_2/tx/1/0x362e092c5d7d0a30706a171133667d7297c753a8e5eedf21e6eb0c738c09808e/receipt.json, artifacts/root_cause/data_collector/iter_2/tx/1/0x362e092c5d7d0a30706a171133667d7297c753a8e5eedf21e6eb0c738c09808e/balance_diff.json.
- Tx 4: 0x6cbe3aae3bd106c42b7007b650619b85011014be64646b4d5835f937e52e3bf9 — Second reward-unwind swap: helper approves 521,686,633,829,068,144 WIF to router 0x7a250d…, and router swaps this WIF through pair 0x64571e… into WETH/ETH. balance_diff.json for this tx shows an ETH increase of 1.625127509711956414 for the adversary EOA after gas. Together with the first swap, the approved WIF amounts (500,000,000,000,000,000 + 521,686,633,829,068,144) sum to 1,021,686,633,829,068,144 WIF, exactly matching the helper’s reward balance from the claim tx, and no WIF remains in the helper afterward. Evidence: artifacts/root_cause/data_collector/iter_2/tx/1/0x6cbe3aae3bd106c42b7007b650619b85011014be64646b4d5835f937e52e3bf9/receipt.json, artifacts/root_cause/data_collector/iter_2/tx/1/0x6cbe3aae3bd106c42b7007b650619b85011014be64646b4d5835f937e52e3bf9/balance_diff.json.
Representative trace excerpt for the buggy reward claim (seed trace for tx 0x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e09):
EOA 0x394ba2… -> helper 0x93d4… selector 0x71563727
helper 0x93d4… -> WIFStaking 0xa1ce40… :: claimEarned(3, 10)
WIFStaking 0xa1ce40… -> WIF token 0xbfae33… :: transfer(0x000000000000000000000000000000000000dEaD, 113520737092118552)
WIFStaking 0xa1ce40… -> WIF token 0xbfae33… :: transfer(0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada, 1021686633829068144)
6. Impact & Losses
In the observed sequence b executed from pre-state sigma_B, WIFStaking transfers 1,135,207,370,921,186,696 WIF out of its balance in a single claimEarned call: 113,520,737,092,118,552 WIF to the burn address 0x000000000000000000000000000000000000dEaD and 1,021,686,633,829,068,144 WIF to helper 0x93d4…, which then swaps the entire 1,021,686,633,829,068,144 WIF to WETH/ETH through UniswapV2 in two transactions. The adversary EOA’s net ETH holdings increase by exactly 3.609928334830556174 ETH across the four transactions in b, while its plan-3 WIF principal remains staked in WIFStaking. These effects are directly confirmed by the victim contract source code, transaction traces, and balance_diff.json artifacts and describe the concrete on-chain loss and value transfer realized in this incident.
7. References
- [1] Seed transaction metadata and traces for stake and claim: artifacts/root_cause/seed/1/
- [2] Data collector receipts and balance_diffs for helper swap transactions: artifacts/root_cause/data_collector/iter_2/tx/1/
- [3] WIFStaking verified source: artifacts/root_cause/data_collector/iter_1/contract/1/0xa1ce40702e15d0417a6c74d0bab96772f36f4e99/source/src/WIFStaking.sol
- [4] Helper contract decompiled code: artifacts/root_cause/data_collector/iter_1/contract/1/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada/decompile/0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada-decompiled.sol