# OMNI404: ERC-404 ID/amount confusion drains Uniswap V3 pool

OMNI404 ERC-404 token's transfer() treats small values as NFT IDs, so Uniswap V3 exact-output swaps of ≤50 wei paid out 1e18 tokens, draining 2.4 WETH from the pool.

- Payout: Real-world hack
- Severity: high
- Program: OMNI404
- Disclosed: 2026-09-09
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2026-09/OMNI404_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20260909-omni404
- Tags: domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:high tech:evm tech:solidity technique:chained technique:source-review vuln:business-logic vuln:flash-loan

**Target:** ERC-404 token contract + Uniswap V3 pool
**Root cause:** O404.transfer(to, valueOrId) branches on valueOrId <= maxTotalSupplyERC721 (50), interpreting small amounts as an NFT id and transferring a full 1e18 ERC-20 units plus the NFT, while the caller only intended to send that many wei.
**Steps:**
1. Flash-borrow 5 WETH from Balancer Vault (zero fee).
2. Seed 0.2 O404 via exact-output swap to cover V3 flash fee.
3. Call `POOL.flash(0, 20e18)` to borrow 20 O404; the whitelisted pool doesn't burn, and the non-whitelisted attacker contract's floor(balance/units) reconciliation mints ~20 NFTs (ids 1..21) to it.
4. In `uniswapV3FlashCallback`, repay via `O404.transfer(pool, id)` for each owned id — each ID-path transfer sends 1e18 O404 + NFT, leaving the pool owning low ids 1..21.
5. Buy-loop: for each id the pool owns, `POOL.swap(this, true, -int256(id), MIN_SQRT, '')` — exact-output of `id` wei triggers pool's `transfer(this, id)` which hits the ID-path and pays out 1e18 O404 + NFT for ~2 wei WETH.
6. Sell reclaimed ~19.8 O404 back to pool via `POOL.swap(this, false, 19.8e18, MAX_SQRT, '')` for real WETH.
7. Repeat 3 cycles, sell leftover 0.2 O404, repay Balancer loan, unwrap WETH.
**How found:** Source review of O404.transfer() revealed the `valueOrId_ <= maxTotalSupplyERC721` branch treating small amounts as NFT ids; combined with knowledge that Uniswap V3 exact-output swaps call token.transfer(recipient, amountOut), the researcher realized a ≤50 wei amountOut would trigger the ID-path and pay out 1e18 units. The `require(msg.sender == _ownerOf[id])` constraint was bypassed by first arranging pool ownership of low ids via the V3 flash + ID-path repayment.
**Exploitation:** Balancer flash loan of 5 WETH funds the attack; Uniswap V3 flash borrow of 20 O404 mints NFTs to the attacker contract via ERC-404 reconciliation; ID-path repayments leave the pool owning low NFT ids; micro exact-output swaps of ≤50 wei each extract 1e18 O404 for ~2 wei WETH; reclaimed tokens are dumped for WETH, draining the pool reserve. run() nets 2.427 WETH; full incident ~3.02 ETH across 4 txs.
**Escalation:** Repeated the cycle 3x in run() plus three additional drain() calls in the same block, scaling 2.427 WETH to ~3.02 ETH total.
**Insight:** When a token overloads transfer() to disambiguate ERC-20 amounts vs ERC-721 ids by magnitude, any external protocol that calls transfer() with attacker-influenced small amounts (Uniswap V3 exact-output amountOut, DEX aggregators, refunds) becomes a free-mint primitive — check every external transfer() call site for values that can fall into the ID branch.
