All incidents

NOON Pool Drain via Public transfer

Share
May 29, 2023 20:21 UTCAttackLoss: 1.14 WETHPending manual check1 exploit txWindow: Atomic
Estimated Impact
1.14 WETH
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
May 29, 2023 20:21 UTC → May 29, 2023 20:21 UTC

Exploit Transactions

TX 1Ethereum
0x23fb7f093e827ed061aafb574cfd420eab879621c7f78cb341292e106a3a88c0
May 29, 2023 20:21 UTCExplorer

Victim Addresses

0x421a5671306cb5f66ff580573c1c8d536e266c93Ethereum
0x6feac5f3792065b21f85bc118d891b33e0673bd8Ethereum

Loss Breakdown

1.14WETH

Similar Incidents

Root Cause Analysis

NOON Pool Drain via Public _transfer

1. Incident Overview TL;DR

In Ethereum mainnet transaction 0x23fb7f093e827ed061aafb574cfd420eab879621c7f78cb341292e106a3a88c0 at block 17366980, attacker EOA 0x9748c8540a5f752ba747f1203ac13dae789033de called its helper contract 0xe9458215bef3aa61a039eb59ffc283bf82b81c84 and drained nearly the entire WETH side of the NO/WETH Uniswap V2 pair 0x421a5671306cb5f66ff580573c1c8d536e266c93. The exploit extracted 1136449063099364001 wei WETH and left the pair with only 1 wei WETH.

The root cause is a broken authorization invariant in the NO token 0x6feac5f3792065b21f85bc118d891b33e0673bd8. Its runtime exposes _transfer(address,address,uint256) as an unrestricted external entry point, so any caller can move NO balances out of arbitrary holders, including the AMM pair. Once the pair's NO balance was temporarily reduced to 1, sync() recorded a poisoned reserve state. Restoring the NO balance before swap() let the attacker trade against stale reserves and withdraw almost all WETH.

2. Key Background

The incident depends on two public protocol components:

  • The NO token at 0x6feac5f3792065b21f85bc118d891b33e0673bd8.
  • The NO/WETH Uniswap V2 pair at 0x421a5671306cb5f66ff580573c1c8d536e266c93.

At the independently validated pre-state immediately before block 17366980:

  • pair.token0() = 0x6fEAc5F3792065b21f85BC118D891b33e0673bD8 and pair.token1() = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.
  • NO.balanceOf(pair) = 88104775387217598184058610577.
  • WETH.balanceOf(pair) = 1136449063099364002.
  • WETH.balanceOf(0xe9458215bef3aa61a039eb59ffc283bf82b81c84) = 0.

Uniswap V2 assumes token balances can only move according to normal ERC-20 authorization rules. sync() snapshots the pair's live token balances into reserves, and swap() prices output against those stored reserves. If a token lets any third party arbitrarily decrease the pair's balance, an attacker can make sync() record a fake reserve state and then restore the balance before swapping.

That is exactly what happened here. The NO side is token0, so forcing the stored reserve to 1 NO makes the pair price the restored NO balance as fresh input against the full WETH reserve.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK case and an ACT opportunity, not a privileged compromise. The vulnerable primitive is NO token selector 0x30e0789e, resolved to _transfer(address,address,uint256). Independent bytecode inspection confirms that this path debits balance[from] and credits balance[to] for arbitrary addresses, but does not authenticate msg.sender against from and does not consult allowance. That violates the core ERC-20 safety invariant that only the holder or an approved spender may reduce a holder's balance.

The broken token invariant is sufficient to break Uniswap V2 reserve accounting. By calling _transfer(pair, helper, pairBalance - 1), the attacker reduced the pair's live NO balance to 1 while leaving its WETH balance unchanged. pair.sync() then stored reserves of (1 NO, 1136449063099364002 WETH). After sending the NO back to the pair, the attacker called swap(); Uniswap interpreted the restored NO as massive fresh input against reserveIn = 1, so getAmountOut() returned 1136449063099364001 wei WETH and the pair paid it out.

The exploit preconditions were minimal and fully public: the vulnerable token had to expose arbitrary-balance transfer functionality, the pair had to custody a valuable second asset, and the attacker had to be able to call sync() before restoring the NO balance and executing swap(). The violated security principles are equally direct: internal transfer helpers must not be exposed as unrestricted external entry points, AMM integrations depend on standard ERC-20 authorization semantics, and pools should not rely on assets whose balances can be arbitrarily rewritten by third parties.

4. Detailed Root Cause Analysis

The ACT pre-state sigma_B is Ethereum mainnet at block 17366979, where all required information was public: the NO token bytecode, the NO/WETH pair balances, and the helper deployment by the same sender in transaction 0x37cc59c61a1fc5bc5af7be530bb4969f08895c3c58a03a8f7f8581bfb3aeb339.

Independent runtime inspection of the NO token shows the dangerous selector and the unauthorized balance rewrite path. The critical branch begins at runtime offset 0x054d:

NO token runtime disassembly
0000054d: JUMPDEST
00000579: SLOAD
0000059e: SLOAD
000005c9: SLOAD
000005d4: SLOAD
000005da: SSTORE
000005e0: SLOAD
000005e5: SSTORE
0000061d: LOG3
00000649: JUMPDEST
0000064e: JUMP
00000659: CALLER

The function rewrites the balance mapping and emits a Transfer event before execution reaches any CALLER-dependent logic. That matches the observed exploit behavior: an unprivileged external caller can move balances out of the pair.

The seed transaction trace then shows the full exploit sequence:

Seed transaction trace
0x6fEA...::_transfer(0x421A..., 0xE945..., 88104775387217598184058610576)
emit Transfer(src: 0x421A..., dst: 0xE945..., wad: 88104775387217598184058610576)
0x421A...::sync()
emit Sync(: 1, : 1136449063099364002)
0x6fEA...::_transfer(0xE945..., 0x421A..., 88104775387217598184058610576)
0x421A...::swap(0, 1136449063099364001, 0xE945..., 0x)
WETH9::transfer(0xE945..., 1136449063099364001)
emit Sync(: 88104775387217598184058610577, : 1)

The exploit is deterministic:

  1. The helper contract calls NO._transfer(pair, helper, pairNoBalance - 1), leaving the pair with exactly 1 NO.
  2. The helper calls pair.sync(), which records reserves (1, 1136449063099364002).
  3. The helper returns the NO to the pair, so the live NO balance is restored but the stored reserve remains poisoned until the next AMM update.
  4. Using the same public state, UniswapV2Router.getAmountOut(88104775387217598184058610576, 1, 1136449063099364002) yields 1136449063099364001.
  5. pair.swap(0, 1136449063099364001, helper, "") transfers that WETH to the attacker helper and leaves the pair with only 1 wei WETH.

This satisfies the stated invariant breakpoint from the root cause analysis: a third party was able to decrease the pair's NO balance without authorization, and that single violation poisoned Uniswap's reserve accounting.

5. Adversary Flow Analysis

The adversary cluster contains:

  • EOA 0x9748c8540a5f752ba747f1203ac13dae789033de, which deployed the helper and sent the exploit transaction.
  • Helper contract 0xe9458215bef3aa61a039eb59ffc283bf82b81c84, which executed the exploit logic and received the drained WETH.

The victim-side components are:

  • NO/WETH Uniswap V2 pair 0x421a5671306cb5f66ff580573c1c8d536e266c93.
  • NO token 0x6feac5f3792065b21f85bc118d891b33e0673bd8.

The on-chain execution flow is:

  1. In block 17366973, the attacker EOA deployed helper contract 0xe9458215bef3aa61a039eb59ffc283bf82b81c84 in transaction 0x37cc59c61a1fc5bc5af7be530bb4969f08895c3c58a03a8f7f8581bfb3aeb339.
  2. In block 17366980, the attacker EOA called the helper in transaction 0x23fb7f093e827ed061aafb574cfd420eab879621c7f78cb341292e106a3a88c0.
  3. The helper moved 88104775387217598184058610576 NO from the pair to itself via the public _transfer primitive.
  4. The helper called sync() so the pair stored the false reserve state (1 NO, 1136449063099364002 WETH).
  5. The helper transferred the NO back to the pair and immediately called swap() to withdraw 1136449063099364001 wei WETH.
  6. The exploit completed with the pair's NO balance restored, the pair's WETH reserve reduced to 1, and the helper holding the extracted WETH.

Two additional related transactions were identified in the incident set: 0xcf77be5591f4aa955d55b26861951fd869fcd02a448dd8e5f025b779d1843dd3 and 0x94f9878df62988dae69939328c3365550223a423a5b143dcff7b59af7709880d. They are related context, but the deterministic ACT realization is fully captured by the helper deployment transaction and the exploit transaction above.

6. Impact & Losses

The measurable pool loss is:

  • WETH: 1136449063099364001 wei (1.136449063099364001 WETH).

After the exploit, the pair still held 88104775387217598184058610577 NO but only 1 wei WETH. Liquidity providers in the NO/WETH pair absorbed the WETH loss.

The attack was also clearly profitable. The helper finished with 1136449063099364001 wei WETH, while the combined execution cost was 4652911844773156 wei ETH-equivalent (4652910844773156 wei sender gas plus a 1000000000 wei native payment from the helper), leaving a net gain of 1131796151254590845 wei ETH-equivalent.

7. References

  • Exploit transaction: 0x23fb7f093e827ed061aafb574cfd420eab879621c7f78cb341292e106a3a88c0
  • Helper deployment transaction: 0x37cc59c61a1fc5bc5af7be530bb4969f08895c3c58a03a8f7f8581bfb3aeb339
  • Related transactions: 0xcf77be5591f4aa955d55b26861951fd869fcd02a448dd8e5f025b779d1843dd3, 0x94f9878df62988dae69939328c3365550223a423a5b143dcff7b59af7709880d
  • NO token: 0x6feac5f3792065b21f85bc118d891b33e0673bd8
  • NO/WETH Uniswap V2 pair: 0x421a5671306cb5f66ff580573c1c8d536e266c93
  • WETH: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
  • Independent evidence used for validation: seed transaction metadata, seed trace, seed balance diff, on-chain receipts, and direct runtime bytecode inspection of the NO token