Glomma River Trading
A perp market that reads its mark price straight off the thin AMM pool you are free to trade against.
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:
_openPerponly checks initial margin against the current (pre-manipulation) oracle price:size * px / CORE_SCALE <= collateral * MAX_LEVERAGE(20x)._buySpotlets the player buy the AMM’s base asset with their own collateral, pushingspotQuoteReserveup andspotBaseReservedown alongx*y=k, which directly inflatesoraclePx(). The only real constraint isquoteIn <= collateral._closePerpthen pays PnL computed against this now-inflated price:profit = size * (oraclePx() - entryPx) / CORE_SCALE, moving that amount fromvaultEquityintocollateral, capped only byrequire(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
-
Parsed the
sendRawActioncalldata format: 4-byte selector0x01000001followed byabi.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 requireddata.length == 228). Built each action’s calldata withcast abi-encodeand prepended the selector. -
Tx 1 — open perp long:
asset=1, isBuy=true, size=20000000000000, reduceOnly=false, tif=3. -
Tx 2 — pump the oracle:
asset=10001, isBuy=true, size=80294000000, reduceOnly=false, tif=3. Confirmed on-chain viaoraclePx(1)that the price moved to exactly 25,751,530 as predicted. -
Tx 3 — close perp, realizing the manipulated profit:
asset=1, isBuy=false, size=20000000000000, reduceOnly=true, tif=3. -
Verified
collateral() = 4,956,231,403,430andvaultEquity() = 49,694,000,000, thenSetup.isSolved()returnedtrue. -
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.