All incidents

Nowswap V1 WETH Mis-Scaled KLOSS Invariant Exploit

Share
Sep 15, 2021 07:43 UTCAttackLoss: 158.29 WETH, 535,706.72 USDTManually checked1 exploit txWindow: Atomic
Estimated Impact
158.29 WETH, 535,706.72 USDT
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Sep 15, 2021 07:43 UTC → Sep 15, 2021 07:43 UTC

Exploit Transactions

TX 1Ethereum
0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713
Sep 15, 2021 07:43 UTCExplorer

Victim Addresses

0x9536a78440f72f5e9612949f1848fe5e9d4934ccEthereum

Loss Breakdown

158.29WETH
535,706.72USDT

Similar Incidents

Root Cause Analysis

Nowswap V1 WETH Mis-Scaled KLOSS Invariant Exploit

1. Incident Overview TL;DR

On Ethereum mainnet at block 13229001, an unprivileged attacker drained almost all WETH and USDT liquidity from the Nowswap V1 WETH/USDT pool at 0x9536a78440f72f5e9612949f1848fe5e9d4934cc. The attacker’s externally owned account (EOA) 0x5676e585bf16387bc159fd4f82416434cda5f1a3 deployed a bespoke router 0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca and, in a single transaction 0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713, executed a sequence of alternating WETH and USDT swap legs against the pool.

Each swap leg exploited a mis-scaled invariant check in the pool’s swap(0x022c0d9f) implementation. The pool mixed packed 112‑bit reserves, raw ERC20 balances, and the stored kLast value using inconsistent 2^112-style scaling, so the “K loss” guard effectively enforced K_post ≥ 0.7 × K_pre instead of the intended K_post ≥ K_pre. This allowed trades where the pool paid out roughly 10× more of WETH or USDT than it received (same-asset in/out), while the true constant product reserve0 * reserve1 shrank by ~30% per leg but the transaction still did not revert.

Before the attack, the pool held 158292004512062229651 WETH wei (~158.29 WETH) and 535718798195 USDT units (~535,718.80 USDT). After the attack transaction, the pool held only 3567797354993498 WETH wei (~0.0036 WETH) and 12075082 USDT units (~12,075.082 USDT). The attacker EOA ended with 158288436714707236153 WETH (~158.29 WETH) and 535706723113 USDT (~535,706.723113 USDT), yielding approximately 316.29 WETH-equivalent profit after paying 0.079428083724520515 ETH in gas.

2. Key Background

Nowswap V1 is a Uniswap-V2-style constant-product automated market maker (AMM). For each pool, it stores reserves as uint112 reserve0 and uint112 reserve1 packed into a single storage slot alongside a timestamp, and tracks a K-like invariant through a stored kLast. The implementation uses DS-math-like helpers for multiplication and subtraction that revert on overflow or underflow, and K-related helpers that enforce per-operation accounting constraints and a “K loss” check.

The victim pool in this incident is the Nowswap V1 WETH/USDT pair at:

  • Pool: 0x9536a78440f72f5e9612949f1848fe5e9d4934cc (Ethereum mainnet chainid = 1)
  • Token0: WETH9 at 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (18 decimals)
  • Token1: USDT at 0xdac17f958d2ee523a2206206994597c13d831ec7 (6 decimals)

The pool contract is unverified on Etherscan, so the analysis is based on opcode-level disassembly, Heimdall decompilation, and traces. The disassembled runtime bytecode in artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7_disassembly.txt shows a dispatcher that routes selector 0x022c0d9f to the main swap implementation:

00000166: JUMPDEST
00000167: DUP1
00000168: PUSH4 0x022c0d9f
0000016d: EQ
0000016e: PUSH2 0x01ae
00000171: JUMPI
...
000005bb: JUMPDEST   ; entry into core swap path

The adversary-related cluster consists of:

  • EOA 0x5676e585bf16387bc159fd4f82416434cda5f1a3 (attacker): deploys the router and receives the stolen assets.
  • Router 0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca: an adversary-deployed helper contract that orchestrates repeated calls to the Nowswap pool’s swap(0x022c0d9f) and getReserves(0x0902f1ac) and forwards all accumulated tokens back to the EOA.

The router is deployed in transaction 0xe8a689ba79028e28e0289af956ee6970bb8e9bdef453875c252f9e01bfed3f68 at block 13228994 by the same EOA, as recorded in artifacts/root_cause/data_collector/iter_1/address/1/0xa14660a3…/txlist.json. The decompiled router code in artifacts/root_cause/data_collector/iter_1/contract/1/0xa14660a3…/decompile/0xa14660a3…-decompiled.sol confirms that the attacker controls a bespoke helper that receives the pool address and token addresses and can perform arbitrary call sequences. Critically, the Nowswap swap entrypoint itself is fully permissionless: there are no owner-only checks in the disassembly or trace, and any unprivileged EOA with sufficient ETH for gas can call it either directly or via their own router.

3. Vulnerability Analysis & Root Cause Summary

The core vulnerability lies in Nowswap V1’s implementation of the K-tracking invariant on the swap(0x022c0d9f) path for this pool. Conceptually, the pool intends to maintain a non-decreasing constant product K = reserve0 * reserve1 (up to fees), enforced via DS-math helpers and a K_LOSS check that compares a K-like quantity derived from the reserves to a stored kLast. However, the swap path combines:

  • packed 112‑bit reserves returned by getReserves, scaled via 2^112 shifts,
  • raw ERC20 balances from balanceOf for WETH (18 decimals) and USDT (6 decimals),
  • and the stored kLast,

using inconsistent scaling factors and divisions. The result is that the “K loss” inequality on the swap path does not enforce K_post ≥ K_pre; empirically and from the arithmetic derivation, it permits trades where:

  • amount_out / amount_in ≈ 10 for WETH→WETH and USDT→USDT legs, and
  • K_post / K_pre ≈ 0.7 (a ~30% decrease),

without triggering a revert.

This mis-scaled K_LOSS check is the root cause: it allows invariant-breaking same-asset trades that drastically reduce the true constant product while appearing acceptable under the implemented guard. The attacker’s router simply exploits this by alternating WETH and USDT legs to compound the K reduction until the pool is effectively drained.

4. Detailed Root Cause Analysis

4.1 Pre-state and invariant

Immediately before block 13229001 and transaction 0xf3158a7e…0713, the system is in pre-state σ_B defined by:

  • Pool reserves from getReserves:
    • reserve0_pre = 158292004512062229651 WETH wei
    • reserve1_pre = 535718798195 USDT units
  • Adversary cluster balances from erc20_state_diff_balanceOf.json:
    • EOA and router both hold 0 WETH and 0 USDT.
  • The router has already been deployed at block 13228994 by the EOA.

The intended invariant (as reconstructed from the disassembly and invariant analysis) is a constant product K based on reserve0 * reserve1, tracked and checked using DS-math helpers and a stored kLast. The helpers identified in nowswap_swap_formula_and_invariant_iter3.md and the disassembly include:

  • A DS-math mul helper (e.g., at PC 0x1973) that computes a * b and reverts if (a * b) / b != a.
  • A DS-math sub helper (e.g., at PC 0x1b70) that computes x - y and reverts on underflow.
  • Packing/unpacking helpers (e.g., at PCs 0x219d and 0x21af) that pack/unpack reserve0 and reserve1 into the combined storage slot using 2^112 shifts.
  • K-related helpers (e.g., at PCs 0x1bc0, 0x1ea1, 0x1fff, 0x2051) that validate reserve sizes, compute K-like quantities, and perform the K_LOSS check, reverting with "NowswapV1: K_LOSS" if the inequality fails.

These helpers are used consistently in mint/burn paths to ensure K does not decrease unexpectedly. However, on the swap(0x022c0d9f) path for this pool, the way these helpers are wired to actual ERC20 balances and kLast introduces a unit mismatch.

4.2 Swap path behavior for selector 0x022c0d9f

From the disassembly and invariant analysis, the swap path for selector 0x022c0d9f behaves roughly as follows:

  1. Dispatch (at PC ~0x0166) compares msg.sig to 0x022c0d9f and jumps into the swap implementation at PC ~0x01ae.
  2. The function decodes four calldata parameters and sets a reentrancy/lock flag.
  3. It reads the packed reserves from storage slot 0x08 via a helper, unpacking them into reserve0 and reserve1.
  4. It loads token addresses from storage (WETH9 and USDT) and determines the direction of the leg (WETH or USDT).
  5. It performs ERC20 transfer calls to move tokens between the pool and msg.sender (the router).
  6. It calls balanceOf(address(this)) for both tokens to obtain the actual post-transfer balances b0 and b1.
  7. It computes deltas and K-like terms using DS-math mul and sub helpers, combining:
    • unpacked reserves (reserve0, reserve1) that are logically 112‑bit,
    • actual balances (b0, b1) with 18/6 decimals,
    • and kLast, with several SHL/DIV operations by 2^112 and 2^224.
  8. It performs a K_LOSS comparison and, if it passes, updates reserves and kLast and emits Swap/Sync events.

In the victim pool’s configuration, this computation is mis-scaled. Instead of comparing a consistently scaled version of reserve0 * reserve1 before and after the swap, it compares a value that effectively behaves like:

  • K_check_post >= c * K_check_pre for some c ≈ 0.7, where K_check_pre and K_check_post are built from a mix of scaled and unscaled quantities. This is confirmed empirically by inspecting the actual Swap and Sync events.

4.3 Concrete invariant breakpoints from on-chain logs

The file artifacts/root_cause/data_collector/iter_3/analysis/nowswap_leg_arithmetic.json reconstructs each swap leg from the pool’s Sync and Swap events. Two representative legs are:

{
  "logIndex": 161,
  "direction": "WETH",
  "amount0In": "5276400150402074421",
  "amount0Out": "52764001504020743217",
  "pre_reserve0": "110804403158443560855",
  "pre_reserve1": "535718798195",
  "post_reserve0": "110804403158443560855",
  "post_reserve1": "375003158837",
  "K_pre": "59360001694755646587976946656725",
  "K_post": "41552001197464795129007440525635",
  "K_ratio_post_over_pre": 0.7000000001875984,
  "amount_ratio_out_over_in": 10.0
},
{
  "logIndex": 165,
  "direction": "USDT",
  "amount1In": "17857293373",
  "amount1Out": "178572932731",
  "pre_reserve0": "110804403158443560855",
  "pre_reserve1": "375003158837",
  "post_reserve0": "77563082210910492698",
  "post_reserve1": "375003158837",
  "K_pre": "41552001197464795129007440525635",
  "K_post": "29086400838225356627618022672226",
  "K_ratio_post_over_pre": 0.7,
  "amount_ratio_out_over_in": 9.999999944056471
}

Snippet origin: Nowswap V1 WETH/USDT leg arithmetic, derived from Swap and Sync events for tx 0xf3158a7e…0713.

Observations:

  • In the WETH leg (logIndex 161), the pool’s recorded reserve0 (WETH) remains unchanged, while reserve1 (USDT) decreases. However, the Swap event shows a same-asset trade: the pool receives amount0In WETH and sends out ~10× that amount as amount0Out WETH. The true constant product K falls by ~30% (K_post / K_pre ≈ 0.7).
  • In the USDT leg (logIndex 165), reserve1 (USDT) remains unchanged while reserve0 (WETH) decreases. The pool receives USDT and pays out ~10× more USDT, while K again falls by ~30%.

Despite these large K reductions, the transaction does not revert with "NowswapV1: K_LOSS". The same pattern repeats across subsequent legs, each time reducing K by approximately 30% and paying out ~10× more of the in-token than the pool receives, as documented for all legs in nowswap_leg_arithmetic.json and corroborated by the Sync events in summary_13228800_13229050.json.

4.4 End-state and consistency with ERC20 balances

The ERC20 state diff in artifacts/root_cause/data_collector/iter_2/tx/1/0xf3158a7…/erc20_state_diff_balanceOf.json confirms:

{
  "tokens": {
    "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {
      "0x5676e585bf16387bc159fd4f82416434cda5f1a3": { "pre": 0, "post": 158288436714707236153 },
      "0x9536a78440f72f5e9612949f1848fe5e9d4934cc": { "pre": 158292004512062229651, "post": 3567797354993498 }
    },
    "0xdac17f958d2ee523a2206206994597c13d831ec7": {
      "0x5676e585bf16387bc159fd4f82416434cda5f1a3": { "pre": 0, "post": 535706723113 },
      "0x9536a78440f72f5e9612949f1848fe5e9d4934cc": { "pre": 535718798195, "post": 12075082 }
    }
  }
}

Snippet origin: ERC20 balance and reserve diff for attack transaction 0xf3158a7e…0713.

These balances match the leg-by-leg arithmetic and the final getReserves state, confirming that:

  • The pool’s WETH balance decreased by exactly the EOA’s WETH gain.
  • The pool’s USDT balance decreased by exactly the EOA’s USDT gain.
  • The router 0xa1466… finishes with zero WETH and zero USDT.

Taken together, the code-level reconstruction, invariant analysis, per-leg K ratios, and ERC20 state diff converge on the same conclusion: the mis-scaled K_LOSS invariant on the swap path allows the constant product to shrink by ~30% per leg while still passing the guard, enabling a chain of same-asset trades that drains the pool.

5. Adversary Flow Analysis

5.1 Adversary-related cluster and setup

The adversary-related cluster consists of:

  • EOA (attacker): 0x5676e585bf16387bc159fd4f82416434cda5f1a3
  • Router (orchestrator): 0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca

txlist.json for the router address shows the following key transactions:

  1. Deployment – tx 0xe8a689ba79028e28e0289af956ee6970bb8e9bdef453875c252f9e01bfed3f68 at block 13228994:
    • from: attacker EOA 0x5676…f1a3
    • to: empty (contract creation)
    • contractAddress: 0xa14660a3…f4ca
    • value: 0
    • methodId: consistent with contract deployment.
  2. Exploit transaction – tx 0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713 at block 13229001:
    • from: attacker EOA
    • to: router 0xa1466…f4ca
    • value: 0
    • input: function selector 0x4f4c7231 with arguments including the pool address and token addresses.

The Etherscan-style metadata in artifacts/root_cause/seed/1/0xf3158a7…/metadata.json confirms that this transaction is EIP-1559, pays 0.079428083724520515 ETH in gas, and has no ETH value transferred to the router.

5.2 Exploit execution in tx 0xf3158a7e…0713

The internal trace in trace.cast.log shows the router calling the pool’s swap(0x022c0d9f) multiple times in a single transaction, with intervening calls to getReserves and ERC20 transfers. Combined with Swap and Sync logs, the leg arithmetic file reconstructs the sequence:

  • The router starts with zero WETH and USDT; all liquidity is in the pool.
  • It calls swap to execute a WETH leg where WETH is both sent in and paid out (same-token in/out), while the recorded WETH reserve stays constant and the USDT reserve drops.
  • It then executes a USDT leg where USDT is both sent in and paid out, while the USDT reserve stays constant and the WETH reserve drops.
  • This alternation repeats several times, each time:
    • Paying out roughly 10× more of the in-token than the router sends in.
    • Reducing the true constant product K by about 30%.
    • Keeping one of the reserves unchanged across a leg while dramatically reducing the other.

The per-leg details are captured in nowswap_leg_arithmetic.json and the Sync event summary summary_13228800_13229050.json, which together show K ratios of approximately 0.7 for each leg and amount_out/amount_in ratios around 10.

At the end of the sequence:

  • The router transfers all accumulated WETH and USDT to the EOA.
  • The router’s balances return to zero.
  • The pool’s reserves collapse to near zero in both assets.

5.3 ACT opportunity characterization

This incident is an ACT (anyone-can-take) opportunity:

  • The exploited function swap(0x022c0d9f) on the Nowswap V1 pool is fully permissionless; there are no owner-only checks in the disassembly or traces.
  • The router is an adversary-deployed helper, but any unprivileged account could deploy an equivalent router that issues the same call pattern with the same calldata from the same pre-state σ_B.
  • The success predicate (net increase in WETH-equivalent value for the adversary cluster) depends only on:
    • the pool’s on-chain code and state,
    • publicly observable token balances and reserves,
    • and the ability to submit the same sequence of transactions.

Thus, any unprivileged actor with access to the same public information and sufficient gas budget can reproduce the exploit against the same vulnerable Nowswap pool.

6. Impact & Losses

6.1 Pool-level impact

Using the ERC20 state diffs and getReserves snapshots:

  • Before the attack tx:
    • Pool WETH balance: 158292004512062229651 wei (~158.292004512062229651 WETH).
    • Pool USDT balance: 535718798195 units (~535,718.798195 USDT).
  • After the attack tx:
    • Pool WETH balance: 3567797354993498 wei (~0.003567797354993498 WETH).
    • Pool USDT balance: 12075082 units (~12,075.082 USDT).

Effectively, the Nowswap V1 WETH/USDT pool lost:

  • WETH: 158.288436714707236153 (difference between pre and post WETH balances).
  • USDT: 535706.723113 (difference between pre and post USDT balances).

The constant product invariant collapsed by many orders of magnitude, and the pool’s liquidity became unusable at any reasonable price. LPs in this pair effectively lost their deposited WETH and USDT to the attacker.

6.2 Attacker profit

The attacker portfolio analysis in attacker_portfolio_pnl_weth.json shows:

  • Attacker EOA final balances:
    • WETH: 158288436714707236153 wei (~158.288436714707236153 WETH).
    • USDT: 535706723113 units (~535,706.723113 USDT).
  • Conversion rate used (from a Uniswap V2 WETH/USDT pool at block 13229001):
    • price_usdt_in_weth ≈ 0.0002950805850088843312255761868.
  • Total attacker portfolio value in WETH:
    • Before tx: ≈ 0 WETH-equivalent.
    • After tx: 316.3650899640836932259039222 WETH-equivalent.
  • Gas cost:
    • fee_paid_eth = 0.079428083724520515 ETH.

Net profit in reference asset (WETH):

  • 316.3650899640836932259039222 - 0.079428083724520515 ≈ 316.2856618803591727109039222 WETH.

There is no evidence, from the traces or cross-protocol checks in the artifacts, of secondary impacts such as price oracle manipulation, cascading liquidations, or governance side effects. The damage appears localized to the Nowswap V1 WETH/USDT pool and its LPs.

7. References

  • Seed transaction trace and metadata

    • Transaction: 0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713 (Ethereum mainnet, block 13229001).
    • Sources: trace.cast.log and metadata.json under artifacts/root_cause/seed/1/0xf3158a7….
  • Victim pool code and invariant analysis

    • Pool address: 0x9536a78440f72f5e9612949f1848fe5e9d4934cc.
    • Disassembly: artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7…/disassemble/0x9536a7_disassembly.txt.
    • Swap formula and K_LOSS derivation: artifacts/root_cause/data_collector/iter_3/analysis/nowswap_swap_formula_and_invariant_iter3.md.
  • On-chain logs and per-leg arithmetic

    • Sync/Swap event summary: artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7…/logs/summary_13228800_13229050.json.
    • Leg-by-leg arithmetic for tx 0xf3158a7e…0713: artifacts/root_cause/data_collector/iter_3/analysis/nowswap_leg_arithmetic.json.
  • ERC20 balance diffs and reserves

    • ERC20 state diff for WETH and USDT in the attack tx: artifacts/root_cause/data_collector/iter_2/tx/1/0xf3158a7…/erc20_state_diff_balanceOf.json.
    • Pre/post getReserves snapshots embedded in the same file.
  • Attacker cluster and router

    • Attacker EOA: 0x5676e585bf16387bc159fd4f82416434cda5f1a3.
    • Router: 0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca.
    • Router deployment and exploit tx history: artifacts/root_cause/data_collector/iter_1/address/1/0xa14660a3…/txlist.json.
    • Router decompiled code: artifacts/root_cause/data_collector/iter_1/contract/1/0xa14660a3…/decompile/0xa14660a3…-decompiled.sol.
  • Attacker profit computation

    • Attacker portfolio PnL in WETH: artifacts/root_cause/data_collector/iter_3/analysis/attacker_portfolio_pnl_weth.json.
    • Underlying WETH/USDT price snapshot from a Uniswap V2 pool at block 13229001 used for valuation.