Reproduced Exploit
OMNI404 (O404) — ERC-404 `transfer(id≤50)` + whitelist rounding drain
O404 is an ERC-404: one contract, ERC-20 balances in 1e18 units and up to 50 ERC-721 ids. transfer(to, valueOrId) guesses intent from the magnitude of the second argument:
Loss
~2.4–3.02 WETH. Main tx +2.427 WETH; three drain() follow-ups in the same block. Teaching PoC 1.342 WETH (out…
Chain
Ethereum
Category
input-validation
Date
Sep 2026
Source
Crypto Training
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Crypto Training original detection and analysis (live Twitter/X security-alert intake — not from DeFiHackLabs). Standalone Foundry PoC, offline
anvil_state.json, and full write-up: 2026-09-OMNI404Erc404Rounding_exp in theevm-hack-registrymirror.
Vulnerability classes: vuln/input-validation/wrong-type · vuln/arithmetic/rounding · vuln/logic/incorrect-state-transition · vuln/defi/fee-manipulation
Reproduction: Foundry fork at Ethereum block 25951647 (one before the four-tx drain). Balancer flash of 5 WETH, UniV3 flash of O404 to mint NFTs from the bank, seed ids onto the (whitelisted) pool via
transfer(pool, id), then exact-output swaps of 1..21 wei that deliver 1e18 O404 each. Offlineanvil_state.json+_shared/run_poc.sh[PASS] (~1.342 WETH). Alerts: SlowMist · ExVul.
Key info#
| Loss | ~2.4–3.02 WETH. Main tx +2.427 WETH; three drain() follow-ups in the same block. Teaching PoC 1.342 WETH (output.txt) |
| Chain | Ethereum (chainId 1) |
| Protocol | OMNI404 / O404 (ERC-404 + LayerZero OFT) |
| Date | 2026-09-11 04:04 UTC |
| Attacker EOA | 0xFB26db4EAb18Cb50d29Ff431888dD643A7e9C9f8 |
| Attack contract | 0x505B2EBea0EC6e30D02768f1de8DdE8Dd9122aD4 (run() / drain()) |
| Vulnerable token | 0xd5C02bB3e40494D4674778306Da43a56138A383E OMNI404 / O404 |
| Victim pool | UniV3 WETH/O404 1% 0xB3f613b9Bc84ddB29D78fA4685b01d98412BBa0b |
| Flash lender | Balancer Vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8 |
| Main attack tx | 0x4cbc3d8db832eb5442ce1c11d79fda05cafabfe7906933ed25881d60bf41d6f3 @ 25951648 |
| Follow-ups | 0x8fb27ec5… · 0xaf873efb… · 0xc38dd1a7… (same block, drain()) |
| Compiler | Solidity v0.8.23+commit.f704f362 (paris, optimizer 1000) |
| Bug class | Overloaded transfer(to, valueOrId) treats valueOrId ≤ 50 as an NFT id and moves 1e18 ERC-20. UniV3 exact-output of 1..21 wei therefore delivers a whole unit. Coupled with whitelist skip of mint/burn on the pool, buy/sell leaks rounding into the NFT bank |
TL;DR#
O404 is an ERC-404: one contract, ERC-20 balances in 1e18 units and up to 50 ERC-721 ids. transfer(to, valueOrId) guesses intent from the magnitude of the second argument:
1 ≤ valueOrId ≤ 50→ treat as token id, transfer that NFT andunits(1e18) ERC-20.- otherwise → treat as an ERC-20 amount and run
_transfer(floor-based mint/burn).
Uniswap V3 pays token-out with token.transfer(recipient, amount). Exact-output swaps of 1, 2, …, 21 wei therefore hit the NFT branch. If the pool owns that id (the attacker first flashes O404, mints NFTs from the bank because the pool is whitelisted, then transfer(pool, id) seeds the id + 1e18 onto the pool), the pool sends 1e18 O404 + the NFT while the AMM accounts i wei and charges dust WETH.
The attacker then sells the whole units into the still-high AMM and extracts WETH. ExVul: pool O404 21.0349 unchanged in ERC-20 terms after a full cycle, +3.02 WETH stolen, NFT bank 15 → 21. SlowMist: same transfer(id≤50) / 1e18 confusion.
Background#
maxTotalSupplyERC721 = 50, units = 1e18. The UniV3 pool is whitelist[pool] = true so _transfer skips mint/burn on the pool side (gas "optimisation" for pairs). NFTs live in a bank queue (_storedERC721Ids) on the token contract itself — pre-attack ownerOf(1..15) == O404.
The vulnerable code#
1. transfer overload (SlowMist)#
sources/OMNI404_d5c02b/src_O404.sol:
function transfer(address to_, uint256 valueOrId_) public virtual returns (bool) {
if (to_ == address(0)) revert InvalidRecipient();
if (valueOrId_ <= maxTotalSupplyERC721) {
uint256 id = valueOrId_;
if (msg.sender != _ownerOf[id]) revert Unauthorized();
_transferERC20(msg.sender, to_, units); // always 1e18
_transferERC721(msg.sender, to_, id);
} else {
_transfer(msg.sender, to_, valueOrId_);
}
return true;
}
UniV3 TransferHelper.safeTransfer(token1, recipient, uint256(-amount1)) with amount1 = 1..21 is exactly this branch.
2. Whitelist skips mint/burn (ExVul)#
function _transfer(address from, address to, uint256 amount) internal returns (bool) {
uint256 erc20BalanceOfSenderBefore = erc20BalanceOf(from);
uint256 erc20BalanceOfReceiverBefore = erc20BalanceOf(to);
_transferERC20(from, to, amount);
if (!whitelist[from]) {
uint256 num721ToBurn = (erc20BalanceOfSenderBefore / units) - (balanceOf[from] / units);
for (uint256 i = 0; i < num721ToBurn; i++) _burnERC721(from);
}
if (!whitelist[to]) {
uint256 num721ToMint = (balanceOf[to] / units) - (erc20BalanceOfReceiverBefore / units);
for (uint256 i = 0; i < num721ToMint; i++) _mintERC721(to);
}
return true;
}
A flash of N whole O404 from the pool (whitelist) mints N NFTs to the attacker and does not burn on the pool. Those ids can then be parked on the pool with transfer(pool, id) (NFT path — whitelist is irrelevant here).
Root cause#
- One
uint256for two types. Amount vs id is inferred from≤ 50, not frommsg.sig/ a dedicatedtransferFromNFT. - NFT path always moves
units. A 1-wei AMM transfer becomes a 1e18 ERC-20 transfer. - Whitelist asymmetry. Pool skips mint/burn; the attacker does not. Flashing ERC-20 from the pool mints bank NFTs the attacker can seed back.
- UniV3 does not re-read
balanceOffor the swapped amount. It trusts thetransferit issued. Extra 1e18 leaving the pool is invisible to tick math until the next real swap.
Preconditions#
- Pool is whitelisted; attacker is not.
- Bank holds ids (or
mintedcan increase up to 50). - UniV3 pool has WETH (pre-attack 3.295 WETH, 21.035 O404).
- Balancer has WETH to flash (fee 0).
Attack walkthrough#
Block 25951648, four txs (deploy · run() +2.427 · drain() ×3):
- Flash 5 WETH from Balancer.
- Buy 0.2 O404, then flash ~15–21 O404 from the pool. Pool skip-burn; attacker mints NFTs from the bank.
transfer(pool, id)for each owned id — parks the NFT and 1e18 on the pool (returns flash principal). UniV3 is locked (LOK) during flash, so no swaps yet.- After flash returns: exact-output 1..21 wei O404. Each successful call delivers 1e18 (NFT path) for dust WETH.
- Sell ~19.8 O404 for WETH. Repeat
drain(). - Repay Balancer; unwrap. +3.018 ETH across the block.
Teaching PoC (one attack()): 1.342 WETH to the attacker EOA; pool WETH 3.295 → 1.953; minted 15 → 16.
Diagrams#
Remediation#
- Split ERC-20 and ERC-721 entrypoints.
transfer(address,uint256)must be ERC-20 only. UsetransferFrom(address,address,uint256)/safeTransferFromfor ids, and never infer type fromvalue <= maxSupply. - If a combined function remains, treat
valueOrId <= maxTotalSupplyERC721as NFT only whenownerOfmatches and the caller requested an NFT, and do not moveunitsof ERC-20 on that path unless the caller also holds those units as a native NFT sale. - Do not whitelist the UniV3 pool (or any AMM) unless mint/burn is conserved some other way (DN-404-style skip amounts, or a dedicated pair hook).
- AMMs integrating ERC-404 should use
transferFromwith amounts always > max id, or a wrapper that only exposes ERC-20.
How to reproduce#
cd audits/evm-hack-registry
_shared/run_poc.sh 2026-09-OMNI404Erc404Rounding_exp -vvvvv
# expect [PASS] testExploit — ~1.342 WETH to the attacker EOA
Fork: Ethereum 25951647. Online: https://eth.drpc.org.
Reference: https://x.com/SlowMist_Team/status/2098300936720695573 · https://x.com/exvulsec/status/2098273797753405607
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-09-OMNI404Erc404Rounding_exp (evm-hack-registry mirror).
- Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.