Crypto exploits

X7Finance exploit analysis

X7Pioneer's reward pool drained for ~6.78 ETH on Ethereum when `claimRewards([])` re-bumped the totalRewards against an unaccounted balance delta that the matching `lastETHBalance` refresh never recorded.

David

Security ·

Reported loss
~6.78 ETH to the attacker on Ethereum, per DeFiHackLabs
Occurred
Logged

Logged as “2026-10-08: X7Pioneers Claimrewards Adds The Unaccounted Eth Balance Delta Into Totalrewards On Every Call But Only Refreshes Lastethbalance When Claimable Is Non Zero So Calling It With An Empty Tokenids Array Re Counts The Same Donated Delta Each Time And Inflates The Per Token Reward Until A Single Owned Nft Drains The Whole Pool The 2696 Eth Donation Supplied By A Flash Loan And Repaid From The Drain on X7Finance”.

Analysis2 min read

X7Pioneer distributes ETH rewards to holders of its own NFT, with the per-token reward rate computed from totalRewards / totalSupply(). Two pieces of claimRewards() interact badly. The accumulator bump totalRewards += (address(this).balance - lastETHBalance) runs on every entry, gated only by lastETHBalance < address(this).balance. The matching refresh lastETHBalance = address(this).balance - claimable only runs inside the claimable > 0 branch. tokenIds is caller-supplied. An empty array makes the loop a no-op and leaves claimable == 0, so the bump runs while the refresh does not. The same unaccounted balance delta is re-counted into totalRewards on each empty call.

Repeated empty calls inflate totalRewards without bound. Per-token reward is totalRewards / totalSupply(), so a single owned NFT can then claim far more than its real share, up to the entire reward pool balance.

The attacker is a plain EOA that owns one X7Pioneer NFT (id 300). The mechanics are three steps, all permissionless. First, the attacker donates ETH to the contract through its plain receive(); at the parent block address(this).balance == lastETHBalance, so this donation is what creates the unaccounted delta the bug is waiting on. Second, the attacker calls claimRewards([]) repeatedly, with no tokenIds, so each one re-runs the accumulator bump against the same delta while the loop body computes claimable = 0. One call is enough to expose the gap, and a small handful more than enough to drive the per-token share up to the whole pool. Third, the attacker calls claimRewards([300]), which pays the inflated share and drains the pool. The donation capital was raised with an Aave v3 WETH flash loan and repaid in the same transaction, so the net profit is the pool's pre-existing genuine reward balance.

DeFiHackLabs records a loss of approximately 6.78 ETH to the attacker on Ethereum (the attacker EOA balance rose by 6.777538 ETH; the reconstruction nets 6.793749214542107991 ETH, the difference being the on-chain Aave flash-loan premium and gas, neither modeled). The single draining claimRewards([300]) pays 33.756919822234785023 ETH, matching the on-chain RewardsClaimed to the wei, with the donation capital returned. The on-chain attack transaction is 0xc98056d6ab55ef898189da5fa1fb08b968cb84ca99528d171f8240836b21766d, mined on 2026-10-08.

One change would have stopped this. lastETHBalance should be refreshed on every entry to the function, not only when claimable > 0, so that an empty-array call cannot count the same delta twice.

The shape sits against the older FHToken and CashCowCoin reward-accounting entries in the corpus: each trusted a stored "last seen" balance that an attacker could keep upper and re-entering an unexpected call path. The protocols are unrelated, but the assumption is the same, and each one fails on the same line.

Sources read (3)

Other exploits

Query every incident yourself — free, no signup

Updated 11 Nov 2026. Figures are as reported by the sources listed above and are not independently verified.