Glomma River Trading

A perp market that reads its mark price straight off the thin AMM pool you are free to trade against.

2026.09.05 NNS CTF 2026 207 pts Blockchain
FLAG NNS{a_thoU54Nd_d0l14Rs_oF_sPot_liqu1DitY_5H0uld_pRoB4B1y_Not_Be_al10WeD_70_PR1ce_4_tW0_HUNdreD_7Hou54nD_Do11ar_peRP}

Challenge: Glomma River Trading (Blockchain, NNS CTF 2026) Author: hoover Points: 207 (dynamic) Flag: NNS{a_thoU54Nd_d0l14Rs_oF_sPot_liqu1DitY_5H0uld_pRoB4B1y_Not_Be_al10WeD_70_PR1ce_4_tW0_HUNdreD_7Hou54nD_Do11ar_peRP}

Overview

A simplified re-implementation of a Hyperliquid-style “HyperCore” system: one contract (HyperCore) tracks a single player’s cross-margin collateral, a shared perp vault (vaultEquity), a constant-product spot AMM pool (spotBaseReserve / spotQuoteReserve) for “asset 10001”, and a single perp market (“asset 1”) whose oracle price is read directly off that same spot AMM pool:

function oraclePx(uint32 asset) public view returns (uint64) {
    require(asset == 1, "unknown asset");
    return uint64(spotQuoteReserve * PRICE_SCALE / spotBaseReserve);
}

Initial state: collateral = 10,000e6, vaultEquity = 5,000,000e6, spotBaseReserve = spotQuoteReserve = 1,000e6 (oracle price = 1.0).

Win condition (Setup.isSolved()): CORE.vaultEquity() <= 100,000e6 && CORE.collateral() >= 4,900,000e6

i.e. drain ~4.9M+ units from the shared perp vault into the player’s own collateral.

All interaction goes through a single entry point:

function sendRawAction(bytes calldata data) external {
    require(msg.sender == player, "only player");
    require(data.length == 228 && bytes4(data[:4]) == hex"01000001", "bad action");
    (uint32 asset, bool isBuy, , uint64 size, bool reduceOnly, uint8 tif, ) =
        abi.decode(data[4:], (uint32, bool, uint64, uint64, bool, uint8, uint128));
    require(tif == 3, "IOC only");
    ...
}

which dispatches to _openPerp, _closePerp (asset 1) or _buySpot (asset 10001).

Vulnerability

The perp’s mark price used for PnL settlement is read live from the same AMM pool that the player can freely trade against (oraclePx() == spot pool price, no TWAP, no external oracle, no price-impact cap). This is a classic oracle manipulation via a thinly-liquid AMM bug:

  1. _openPerp only checks initial margin against the current (pre-manipulation) oracle price: size * px / CORE_SCALE <= collateral * MAX_LEVERAGE (20x).
  2. _buySpot lets the player buy the AMM’s base asset with their own collateral, pushing spotQuoteReserve up and spotBaseReserve down along x*y=k, which directly inflates oraclePx(). The only real constraint is quoteIn <= collateral.
  3. _closePerp then pays PnL computed against this now-inflated price: profit = size * (oraclePx() - entryPx) / CORE_SCALE, moving that amount from vaultEquity into collateral, capped only by require(profit <= vaultEquity).

So the player can: open the largest perp position their initial collateral allows, pump the oracle price by buying the illiquid spot pool with that same collateral, then close the position and pocket the “profit” straight out of the vault — self-dealing against an oracle that has zero resistance to manipulation.

Exploit math

With collateral0 = 10,000e6, MAX_LEVERAGE = 20, px0 = 1e6 (CORE_SCALE = 1e8), the maximum perp size openable at the initial price is:

S_max = collateral0 * MAX_LEVERAGE * CORE_SCALE / px0 = 2e13

Chose S = 2e13 (the max). Needed profit window to satisfy both solve conditions without reverting (profit <= vaultEquity):

profit >= 5,000,000e6 - 100,000e6 = 4,900,000e6   (vaultEquity <= 100,000e6)
profit <= 5,000,000e6                              (no "HLP insolvent" revert)

=> required oracle price after the pump:

finalPx = px0 + profit * CORE_SCALE / S  ∈ [25,500,000, 26,000,000]

Solved the constant-product AMM (k = 1e9 * 1e9 = 1e18) for a baseOut (spot buy size) landing finalPx inside that window, well within the collateral budget (quoteIn <= collateral0 = 1e10):

baseOut = 802,940,000  (spotSize = baseOut * 100 = 80,294,000,000)
-> newBase = 197,060,000, newQuote = 5,074,596,570
-> finalPx = 25,751,530  (comfortably inside the target window)
-> profit  = 4,950,306,000,000

giving final vaultEquity = 49,694,000,000 (<=100,000e6) and final collateral = 4,956,231,403,430 (>=4,900,000e6) — both conditions met.

Exploit steps

  1. Parsed the sendRawAction calldata format: 4-byte selector 0x01000001 followed by abi.encode(uint32 asset, bool isBuy, uint64 _, uint64 size, bool reduceOnly, uint8 tif, uint128 _) (7 * 32 bytes = 224, + 4-byte selector = 228 bytes total, matching the required data.length == 228). Built each action’s calldata with cast abi-encode and prepended the selector.

  2. Tx 1 — open perp long: asset=1, isBuy=true, size=20000000000000, reduceOnly=false, tif=3.

  3. Tx 2 — pump the oracle: asset=10001, isBuy=true, size=80294000000, reduceOnly=false, tif=3. Confirmed on-chain via oraclePx(1) that the price moved to exactly 25,751,530 as predicted.

  4. Tx 3 — close perp, realizing the manipulated profit: asset=1, isBuy=false, size=20000000000000, reduceOnly=true, tif=3.

  5. Verified collateral() = 4,956,231,403,430 and vaultEquity() = 49,694,000,000, then Setup.isSolved() returned true.

  6. Retrieved the flag from the challenge’s TLS launcher (ncat --ssl ... 1337, option “2 - get flag”).

Fix

Never source a perp’s mark/settlement price from a pool the trader can themselves move in the same transaction/session. Use an external price feed, a manipulation-resistant TWAP over a long window, and/or cap price impact per trade and per block; also consider capping PnL extraction relative to available margin rather than trusting a single instantaneous oracle read.

#solidity#oracle-manipulation#defi