October 20, 2026
|
Exploit Postmortem
Cover image for "OMNI404's $7.4K Loss and How Olympix Would Have Prevented It"

OMNI404's $7.4K Loss and How Olympix Would Have Prevented It

Date: September 11, 2026

Chain: Ethereum

Loss: about 3.02 ETH of pool liquidity, roughly $7.4K

Class: a transfer amount read as an NFT ID, paying out a full token

OMNI404 launched on February 10, 2024, at the height of the ERC-404 experiment, when a wave of tokens tried to make one contract behave as both a fungible coin and an NFT collection. Its Uniswap V3 pool against WETH went live about an hour later. Two and a half years on, the pool was still there, still holding a few ETH, and the token still decided on every transfer whether the number it was handed meant an amount or an NFT ID.

At 04:04:35 UTC on September 11, 2026, one wallet deployed a contract and ran it four times inside the same Ethereum block, pulling about 3.02 ETH out of the pool, roughly $7.4K. The attacker had found that when the pool sent out a tiny number of O404 wei, the token read that number as an NFT ID and sent a whole O404 token instead, while the pool's books recorded only the wei.

One Number, Two Meanings

An ERC-404 token keeps two ledgers in a single contract. There's an ERC-20 balance, measured in units of 10^18, and there's an NFT collection that tracks it, so that every whole token a wallet holds comes with one NFT. OMNI404's collection was capped at 50 NFTs, and the contract reconciles the two ledgers whenever tokens move, minting NFTs to a receiver whose whole-token balance goes up and burning them from a sender whose whole-token balance goes down. Addresses on a whitelist, which included the Uniswap V3 pool, skip that reconciliation.

The ERC-20 standard and the ERC-721 standard both use a function whose last argument is a uint256, and OMNI404's transfer() had to decide which one the caller meant. The verified O404 source on Etherscan names the argument valueOrId and settles it with a single comparison: any value less than or equal to maxTotalSupplyERC721, which is 50 here, is treated as an NFT ID. When that branch runs, the contract checks that the caller owns that NFT, then moves the NFT along with exactly one full token, 10^18 units of the ERC-20 balance, no matter what number was passed in.

For a person sending an NFT, that's convenient. For a contract that sends tokens by amount, it's a trap, because asking to send 7 wei of O404 sends one whole O404 if the sender happens to own NFT number 7. The one rule this contract needed and didn't enforce is that the ERC-20 balance a transfer moves has to match the amount the caller asked to move. Think of a cashier who reads a customer's request for "3" as "item number 3" whenever the number is small enough, and hands over the whole item while ringing up three cents.

Exact-Output Swaps Ask for Tiny Amounts

A Uniswap V3 pool pays out a trade by calling transfer(recipient, amountOut) on the token it's selling, and its accounting assumes that exactly amountOut left. In an exact-output swap, the buyer chooses amountOut, so the buyer can ask for 1 wei, 2 wei, or any number up to 50. If the pool owns the NFT with that ID, OMNI404 sends the buyer a full token and the NFT, and the pool charges the buyer only for the handful of wei it thinks it sold.

The pool didn't own low-numbered NFTs on its own, since it's whitelisted and the reconciliation that mints them skips it. According to the DeFiHackLabs analysis of the attack, the attacker solved that with the pool's own flash() function. Borrowing 20 O404 from the pool minted NFTs with IDs 1 through 21 to the attacker's contract, which isn't whitelisted. Repaying the loan with ID-path transfers, one per owned ID, returned the principal and left the pool owning those low IDs.

Four Transactions in One Block

The attacker's wallet, 0xFB26db4EAb18Cb50d29Ff431888dD643A7e9C9f8, received about 0.1 ETH at 03:30 UTC. Thirty-four minutes later, in block 25951648, it deployed the attack contract and called it four times, so every step happened at the same timestamp.

The first call, run(), took a 5 WETH flash loan from the Balancer Vault and bought a small amount of O404 for about 0.032 WETH. It then went through three cycles of the same routine. Each cycle borrowed 20 O404 through the pool's flash(), repaid it through the NFT branch so the pool ended up holding IDs 1 to 21, and then made a run of exact-output swaps for amounts of 1 to 21 wei, paying the pool 2 to 5 wei of WETH each time while receiving a full O404 token per swap. Blockscout's record of the transaction shows dozens of those WETH payments of a few wei each. After each run of cheap buys, the contract sold the reclaimed O404 back to the pool for real WETH, and the three sales returned 1.613, 0.560 and 0.284 WETH.

When run() finished, it repaid Balancer's 5 WETH and unwrapped the remaining 2.427 WETH into ETH. The other three calls, to a function DeFiHackLabs identifies as drain(), repeated the trick against what was left in the pool and unwrapped 0.368, 0.149 and 0.081 WETH. Across the four transactions the pool lost about 3.02 ETH, and the attacker's ETH balance went up by 3.018 ETH after gas.

SlowMist's alert reported a loss of 2.4 WETH, which matches the first transaction alone. ExVul reported the full 3.02 ETH across all four. ExVul's alert also described OMNI404 as unaudited and framed the leak as rounding in the floor-based NFT bookkeeping, while DeFiHackLabs, working from the verified source and the trace, concluded that the value comes from the ID branch in transfer(), the mechanism SlowMist described.

Mixed Out Four Weeks Later

The attacker's wallet held the ETH for almost a month. On October 8, 2026, it made four deposits into the Tornado Cash router, three of 1 ETH and one of 0.1 ETH, between 11:23 and 11:58 UTC. As of October 20, 2026, none of the security alerts or news coverage of the exploit cited a statement from the OMNI404 team.

How Olympix Finds an Amount That Isn't an Amount

Olympix reads a contract the way an attacker does, as states, transitions and entry points, and takes in the context around the code, including the standards it claims to implement, to infer the rules the system depends on without stating them. For OMNI404, the states that matter are the two ledgers and the balance of every address that can call transfer(), and the entry points are every function that moves tokens. The engine maps every path through them and uses signals to decide which ones are worth attacking. Olympix's static analysis surfaces the signal here: a public transfer function that branches on the magnitude of its amount argument and, on one branch, moves a fixed quantity of balance that has nothing to do with the number it was given.

The invariant fits in one sentence: a transfer of x units must reduce the sender's ERC-20 balance by exactly x. Every caller that integrates with an ERC-20 token, from wallets to AMM pools, relies on that rule, and OMNI404's ID branch breaks it for every x from 1 to 50 whenever the sender owns NFT number x. From all paths, the engine narrows to feasible ones, where the sender really does hold that NFT, and then to the ones where the gap between what was asked and what moved changes someone else's balance.

BugPoCer takes that invariant and runs Counterexample-Guided Invariant Refutation from the contract outward, executing feasible paths until one breaks it. The counterexample is short: an address that owns NFT 7 calls transfer(recipient, 7), and the recipient's balance rises by 10^18 instead of 7. BugPoCer returns that sequence with the minimal conditions that trigger it and a generated Forge test that replays it, and because the pool's whitelisting and flash() are part of the state the engine reads, the same counterexample extends to the path the attacker used, where a pool that owns low IDs pays out whole tokens for wei.

That finding would have surfaced the first time transfer() was written with its valueOrId branch, in development, at the merge that added it, and before the token and its pool went live in February 2024. Olympix runs automatically at each of those points, so a team sees where its whole codebase stands against rules like this one before release.

Two Standards, One Argument

The ERC-404 design tried to fit two token standards into one set of function signatures, and OMNI404 resolved the overlap by guessing from the size of a number. Any integration that sent small amounts by value, which is exactly what an exact-output swap does, was exposed to that guess for as long as the pool existed. A rule as basic as "a transfer moves the amount it was asked to move" is one an engine can state and test for every path of a hybrid token, and to watch Olympix produce that counterexample against your own token contracts, book a technical walkthrough.

On-Chain References

Protocol: OMNI404 (O404), ERC-404 token

Chain: Ethereum

Date: September 11, 2026, 04:04:35 UTC

Loss: about 3.02 ETH from the O404/WETH Uniswap V3 pool (2.427 WETH in the first transaction)

Attacker EOA: 0xFB26...C9f8

Attack contract: 0x505B...2aD4

Vulnerable contract (OMNI404 token): 0xd5C0...383E

Victim pool (Uniswap V3 O404/WETH): 0xB3f6...Ba0b

Attack contract deployment tx: 0x795e...3caa

Exploit tx (run): 0x4cbc...d6f3

Follow-up tx (drain): 0x8fb2...40d8

Follow-up tx (drain): 0xaf87...cc9f

Follow-up tx (drain): 0xc38d...6d96

Block: 25951648

First Tornado Cash deposit: 0x8374...b324

Sources

SlowMist: OMNI404 TI Alert

ExVul: OMNI404 Exploit Alert

DeFiHackLabs: OMNI404 Proof of Concept

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.