Reproduced Exploit

NOON (NO) Exploit — Public `_transfer()` Lets Anyone Drain the AMM Pool For Free

The NO token exposes its internal balance-moving primitive as a public function with the raw ERC-20-internal signature _transfer(address sender, address recipient, uint256 amount). There is no access control and no allowance check on sender. Consequently anyone can move tokens out of any address —…

May 2023Ethereumaccess-control12 min read

Loss

1.13645 WETH (~$2K) drained from the NO/WETH Uniswap-V2 pair

Chain

Ethereum

Category

access-control

Date

May 2023

Source

DeFiHackLabs

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

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.

Loading fork state…

Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-05-NOON_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/NOON_exp.sol.


Vulnerability classes: vuln/access-control/missing-auth · vuln/logic/missing-allowance · vuln/oracle/price-manipulation

Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. The NO token is closed-source on-chain; the only verified source available is the UniswapV2Pair the attacker drained.


Key info#

Loss1.13645 WETH (~$2K) drained from the NO/WETH Uniswap-V2 pair
Vulnerable contractNO token (closed source) — 0x6fEAc5F3792065b21f85BC118D891b33e0673bD8
Victim poolNO/WETH Uniswap-V2 pair — 0x421A5671306CB5f66FF580573C1c8D536E266c93
Attacker (this PoC)ContractTest test harness 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
Reference txHexagate report, "second tx" — twitter.com/hexagate_/status/1663501545105702912
Chain / block / dateEthereum mainnet / 17,366,979 / ~May 29, 2023 (block ts 1685391671)
Pair compilerUniswapV2Pair v0.5.16+commit.9c3226ce, optimizer 999999 runs
Bug classWrong function visibility — _transfer(from,to,amount) is publicly callable, allowing an un-owned, un-approved transfer of any holder's balance

TL;DR#

The NO token exposes its internal balance-moving primitive as a public function with the raw ERC-20-internal signature _transfer(address sender, address recipient, uint256 amount). There is no access control and no allowance check on sender. Consequently anyone can move tokens out of any address — including the AMM pair — into their own wallet, for free.

The attacker:

  1. Calls NO._transfer(Pair, attacker, balanceOf(Pair) - 1) — yanking essentially the pair's entire NO balance (88,104,775,387,217,598,184,058,610,576 base units, i.e. all but 1 wei) into the attacker's account. No ETH/WETH paid, no swap.
  2. Calls Pair.sync() — forcing the pair to record its now-gutted NO balance (1 wei) as reserve0, while reserve1 (WETH) stays untouched at 1.13645 WETH. The constant product k collapses; the marginal price of NO explodes.
  3. Sends the stolen NO back to the pair and (without re-syncing) calls Pair.swap() so the pool's still-stale reserve0 = 1 treats the giant NO inflow as a swap input, paying out almost the entire WETH reserve.

Net result: the attacker walks away with 1.13645 WETH — the pool's entire honest WETH liquidity — having spent nothing but gas.


Background — what's in play#

There are two contracts involved:

  • NO token (0x6fEAc…3bD8) — a non-standard ERC-20 whose source was never verified on Etherscan. The PoC's own header notes this and hypothesizes the bug:

    "Closed source contract. Probable vulnerabilities: Wrong function (_transfer) visibility / Non-standard ERC20 implementation."

    The trace confirms the hypothesis: the test calls a function with the four-argument-style internal signature _transfer(address,address,uint256) directly on the token and it succeeds, moving another account's balance without authorization.

  • The NO/WETH Uniswap-V2 pair (0x421A…6c93) — a perfectly standard Uniswap-V2 pair (token0 = NO, token1 = WETH). It is not the buggy contract; it is the victim. Its sync()/swap() accounting trusts that token balances only move through mechanisms it can reason about. The NO bug breaks that assumption.

On-chain state at the fork block, read from the trace:

ParameterValueSource
Pair token0NO (reserve0)Sync(reserve0=NO, reserve1=WETH)output.txt:42
Pair token1WETH (reserve1)same
Pool NO balance88,104,775,387,217,598,184,058,610,577 (≈ 8.81e28)output.txt:30
Pool WETH balance (the prize)1,136,449,063,099,364,002 wei = 1.13645 WETHoutput.txt:41
Attacker WETH before0output.txt:24

The vulnerable code#

The NO token is closed-source, so we cannot quote its _transfer body. But the PoC interface and the execution trace fully characterize the flaw.

What the attacker declared (PoC interface)#

SOLIDITY
interface INO {
    function _transfer(address sender, address recipient, uint256 amount) external; // ⚠️ PUBLIC internal-style fn
    function transfer(address to, uint256 value) external;
    function balanceOf(address account) external returns (uint256);
}

test/NOON_exp.sol:17-25

_transfer(sender, recipient, amount) is the canonical internal OpenZeppelin signature for moving tokens between two arbitrary accounts. In a correct ERC-20 it is internal and is only reached via transfer() (which forces sender == msg.sender) or transferFrom() (which checks allowance). Here it is exposed external with neither guard, so the caller may name any sender.

What the trace proves#

CODE
[28433] NO::_transfer(Pair: [0x421A…6c93], ContractTest: [0x7FA9…1496], 88104775387217598184058610576)
  ├─ emit Transfer(from: Pair, to: ContractTest, value: 88104775387217598184058610576)
  └─ ← [Stop]

output.txt:31-36msg.sender (the attacker) is neither the Pair nor approved by it, yet the call moves the pair's NO balance to the attacker and emits a normal Transfer. That is the entire vulnerability.


Root cause — why it was possible#

A privileged internal balance-moving routine (_transfer(from,to,amount)) was given external visibility with no msg.sender == from check and no allowance deduction. It is the smart-contract equivalent of leaving the bank vault's internal "move money between any two accounts" lever on the public lobby wall.

Standard ERC-20 enforces exactly two ways to debit an account:

PathGuard
transfer(to, amount)implicitly sets from = msg.sender — you can only spend your own tokens
transferFrom(from, to, amount)requires allowance[from][msg.sender] >= amount

The NO token added (or left) a third, unguarded path — a public _transfer(from, to, amount) — which bypasses both guards. Once such a primitive exists, any token balance in the system becomes the caller's to take, and the most lucrative balance is the one sitting inside the AMM pool.

The Uniswap-V2 pair amplifies this from "steal NO" into "steal WETH":

  • The pair prices NO purely from reserve0/reserve1. After the attacker empties the pair's NO and sync()s, reserve0 = 1 wei while reserve1 = 1.13645 WETH is untouched — k collapses and NO becomes "infinitely valuable" against WETH.
  • swap() only checks the K-invariant against the stored reserves (require(balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000²)UniswapV2Pair.sol:477). By feeding NO back into the pair without a fresh sync(), the attacker leaves reserve0 at the stale 1, so the K check is trivially satisfied for an almost-total WETH withdrawal.

Preconditions#

  • The NO token exposes a public, unauthorized _transfer(from, to, amount) (the core bug — always true for this token).
  • A live NO/WETH AMM pair holds real WETH liquidity (the pool held 1.13645 WETH).
  • No capital is required beyond gas: the theft is a direct balance move, not a trade. Fully permissionless and free — no flash loan, no approvals, no ownership.

Step-by-step attack walkthrough (with on-chain numbers from the trace)#

Pair token0 = NOreserve0 = NO; token1 = WETHreserve1 = WETH. All figures are taken directly from the Transfer/Sync/Swap events and getReserves/getAmountOut returns in output.txt. NO amounts are in base units (token decimals ≈ 18; the headline figure is ~8.81e28 ≈ 88.1 billion NO).

#Step (PoC line)Pool NO balancePair reserve0 (NO)Pair reserve1 (WETH)Effect
0Initial8.81045e28(stale)1.13645 WETHHonest pool.
1Free drainNO._transfer(Pair, attacker, bal(Pair)-1) (:50)1 wei(still stale)1.13645 WETHPair's entire NO balance (minus 1 wei) moved to attacker; no payment.
2Pair.sync() (:52)1 wei11.13645 WETHReserves forced to match: Sync(reserve0=1, reserve1=1136449063099364002). k collapses.
3Send NO backNO.transfer(Pair, bal(attacker)) (:54)8.81045e281 (NOT re-synced)1.13645 WETHPair NO balance restored, but reserve0 still reads 1.
4Read reservesPair.getReserves() (:56)8.81045e2811.13645 WETHgetReserves() → (1, 1136449063099364002, …).
5QuoteRouter.getAmountOut(bal(Pair)-1, reserve0=1, reserve1=1.13645e18) (:60)Returns 1136449063099364001 (≈ all WETH minus 1 wei).
6Pair.swap(0, 1136449063099364001, attacker, "") (:62)8.81045e288.81045e281 weiK check passes against stale reserves; pays out 1.13645 WETH to attacker. Sync(reserve0=8.81e28, reserve1=1).

A subtle but important detail in step 6: the swap's K invariant compares the new balances (NO already returned in step 3) against the stale reserves (reserve0 = 1). Because the pool's actual NO balance is now ~8.81e28 while the stored reserve0 is 1, the input side amount0In is enormous relative to the recorded reserve, so getAmountOut(in, reserveIn=1, reserveOut=1.13645e18) returns essentially the whole WETH reserve.

The "1 wei" trick#

The attacker leaves 1 wei of NO in the pair (transfers balanceOf(Pair) - 1, not the full balance) so that the post-sync() reserve0 = 1 rather than 0. A reserve0 of 0 would make the pair's swap math divide-by-zero / revert (getAmountOut requires a non-zero input reserve), and would make reserve0 * reserve1 = 0 degenerate. With reserve0 = 1, the swap is well-formed but priced as if NO is essentially infinitely scarce — letting a flood of returned NO buy out the WETH side.


Profit / loss accounting (WETH)#

DirectionAmount (wei)WETH
Attacker WETH before00
WETH paid in (capital)00
WETH received from swap()1,136,449,063,099,364,0011.13645
Net profit1,136,449,063,099,364,001+1.13645

output.txt:82-86 — attacker WETH balance goes 0 → 1136449063099364001 (≈ 1.13645 WETH). The pool started with 1,136,449,063,099,364,002 wei of WETH and ends with 1 wei — the attacker took the entire honest WETH liquidity to the wei (≈ $2K at ~May 2023 ETH prices), spending zero capital.


Diagrams#

Sequence of the attack#

sequenceDiagram autonumber actor A as "Attacker (ContractTest)" participant T as "NO token (closed source)" participant P as "NO/WETH Pair" participant R as "UniswapV2 Router" Note over P: "Initial: NO ~8.81e28 / WETH 1.13645<br/>reserve0=NO, reserve1=WETH" rect rgb(255,235,238) Note over A,P: "Step 1 - free drain (the bug)" A->>T: "_transfer(Pair, attacker, bal(Pair)-1)" T-->>A: "emit Transfer(Pair -> attacker, 8.81e28-1)" Note over P: "Pair NO balance = 1 wei (no WETH paid)" end rect rgb(255,243,224) Note over A,P: "Step 2 - force the broken reserve" A->>P: "sync()" Note over P: "Sync(reserve0=1, reserve1=1.13645 WETH)<br/>k collapses" end rect rgb(232,245,233) Note over A,P: "Step 3 - return NO, do NOT re-sync" A->>T: "transfer(Pair, bal(attacker))" Note over P: "Pair NO balance restored ~8.81e28<br/>but reserve0 still = 1 (stale)" end rect rgb(227,242,253) Note over A,R: "Steps 4-6 - cash out" A->>P: "getReserves() -> (1, 1.13645e18)" A->>R: "getAmountOut(bal(Pair)-1, 1, 1.13645e18) -> 1.13645e18-1" A->>P: "swap(0, 1136449063099364001, attacker)" P-->>A: "1.13645 WETH out (K-check vs stale reserves passes)" Note over P: "Sync(reserve0=8.81e28, reserve1=1 wei) - WETH drained" end Note over A: "Net +1.13645 WETH (all honest liquidity), spent 0"

Pool state evolution#

flowchart TD S0["Stage 0 - Initial<br/>NO bal ~8.81e28 | WETH 1.13645<br/>reserves honest"] S1["Stage 1 - After free _transfer drain<br/>NO bal 1 wei | WETH 1.13645<br/>(NO stolen, 0 WETH paid)"] S2["Stage 2 - After sync()<br/>reserve0=1 | reserve1=1.13645 WETH<br/>k collapses"] S3["Stage 3 - After returning NO (no re-sync)<br/>NO bal ~8.81e28 | reserve0 still = 1<br/>balance != reserve (stale)"] S4["Stage 4 - After swap()<br/>NO bal ~8.81e28 | WETH 1 wei<br/>WETH side drained"] S0 -->|"public _transfer(Pair, attacker, bal-1)"| S1 S1 -->|"sync() forces reserve0=1"| S2 S2 -->|"transfer NO back, skip sync"| S3 S3 -->|"swap(0, ~allWETH) - K check vs stale reserve0=1"| S4 style S1 fill:#ffe0b2,stroke:#ef6c00,stroke-width:2px style S2 fill:#ffcdd2,stroke:#c62828,stroke-width:2px style S4 fill:#c8e6c9,stroke:#2e7d32

The flaw: authorized vs. unauthorized debit paths#

flowchart TD Start(["Caller wants to move tokens out of address X"]) --> Path{"Which entry point?"} Path -->|"transfer(to, amt)"| G1["from = msg.sender<br/>(can only spend own balance)"] Path -->|"transferFrom(X, to, amt)"| G2{"allowance[X][msg.sender] >= amt?"} G2 -- no --> Rev["revert - safe"] G2 -- yes --> OK1["debit X - authorized"] G1 --> OK2["debit caller - authorized"] Path -->|"⚠️ public _transfer(X, to, amt)"| Bug["NO access control<br/>NO allowance check<br/>X = ANY address"] Bug --> Steal(["Debit the AMM pair's NO balance for free<br/>then sync + swap to extract its WETH"]) style Bug fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Steal fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Path fill:#fff3e0,stroke:#ef6c00

Remediation#

  1. Make _transfer internal. The balance-moving primitive _transfer(from, to, amount) must be internal (or private), never external/public. This single change closes the hole entirely.
  2. If an external mover is genuinely required, guard from. Any externally callable function that debits a named from MUST enforce either from == msg.sender (like transfer) or allowance[from][msg.sender] >= amount with a decrement (like transferFrom). Never let an arbitrary caller name an arbitrary sender.
  3. Use a vetted ERC-20 base. Inherit OpenZeppelin's ERC20, where _transfer is internal by construction, instead of hand-rolling a "non-standard" implementation. Most of these closed-source, custom-token rugs share this exact mistake.
  4. Verify source & audit before listing liquidity. A closed-source token paired with real WETH on a public AMM is an open invitation; LPs and front-ends should treat unverified tokens as untrusted. (Hexagate flagged this on-chain.)
  5. (Defense-in-depth on the AMM side, not a fix for this token): the fact that swap() can be driven by stale reserves after an external balance manipulation is intrinsic to Uniswap V2 and is why arbitrary token-balance writes are catastrophic — there is no pair-side mitigation; the token must not allow unauthorized balance moves in the first place.

How to reproduce#

The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many unrelated PoCs that fail the whole-project forge build):

BASH
_shared/run_poc.sh 2023-05-NOON_exp --mt testTransfer -vvvvv
  • RPC: an Ethereum mainnet archive endpoint is required (fork block 17,366,979). foundry.toml uses an Infura mainnet endpoint; any archive provider serving historical state at that block works.
  • Result: [PASS] testTransfer() with attacker WETH going from 0 to 1.136449063099364001.

Expected tail (output.txt:3-8):

CODE
Ran 1 test for test/NOON_exp.sol:ContractTest
[PASS] testTransfer() (gas: 130425)
Logs:
  Attacker amount of WETH before exploitation of vulnerability: 0.000000000000000000
  Attacker amount of WETH after exploitation of vulnerability: 1.136449063099364001

References: Hexagate (https://twitter.com/hexagate_/status/1663501545105702912, second tx); DeFiHackLabs. NO token, Ethereum mainnet, ~$2K.


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

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.