blockchainfreemedium

fruit-market

kitctf

Task: a no-fee constant-product DEX (3 tokens, 3 tiny pools) where bot traders continuously dump 50% of their largest holding, and the player is the MEV coordinator who relays their signed swaps; goal is to push MANAGER's APL balance to >=500/1000. Solution: relay trader txs first (latency-safe to avoid deadline reverts that brick the instance), then run triangular cross-pool arbitrage (APL->BAN->CHY->APL) to exploit the huge trader-induced price imbalances, multiplying ~24 APL into 468 in one cycle.

$ ls tags/ techniques/
triangular_arbitragecross_pool_price_imbalance_exploitno_fee_amm_value_extractionlatency_safe_tx_relaymev_coordinator_abuseeip1559_rawtx_decode

fruit-market — KITCTF / GPN24 CTF

Summary

A "fruit market" is a no-fee constant-product AMM with three ERC20 tokens (APPLE/BANANA/CHERRY) and three tiny liquidity pools. Bot "traders" continuously dump 50% of their largest holding into these tiny pools, creating massive cross-pool price imbalances. The player is hired as the "trade transaction coordinator" — the relayer that broadcasts the traders' already-signed swaps. The win condition is simply balanceOf(APL, MANAGER) >= 500 (out of a total APL supply of 1000), starting from only 10 APL.

The intended exploit is triangular (cross-pool) arbitrage: cycle a balance APL→BAN→CHY→APL. Because the three independent no-fee pools drift to wildly different relative prices under the traders' constant dumping, a single triangular cycle multiplies APL dramatically (one winning cycle: ~24 APL → 468 APL). The critical operational trick is to relay the trader's tx first, before doing your own swaps, so you never blow the trader's short deadline (which would brick the instance).

Challenge Overview

We are hiring a trade transaction coordinator (m/f/x) for our bleeding-edge fruit market — our blockchain market place enables the fastest and safest way for trading fruits in town! As our trade transaction coordinator, you take trades from our customers and execute them on-chain before they expire. Your neutrality towards all customers makes our market the ultimate choice for all professional fruit dealers.

The "neutrality towards all customers" line is ironic: as the coordinator you sit in a privileged MEV position — you see and relay every customer trade and can interleave your own transactions around them.

Setup / Architecture

Provided tarball: Dockerfile, start.sh, eth/ (MiniDex.sol+.json, ERC20Fixed.sol+.json), html/ (static client), python/ (main.py Flask app, trader.py, utils.py).

Backend:

  • Anvil devnet (anvil --accounts 0, Foundry v1.7.1) + Flask (web3==7.16.0, flask==3.1.3, flask-socketio==5.6.1).
  • 3 ERC20 "ERC20Fixed" tokens, each _mint(msg.sender, 1000) at deploy: APPLE (APL), BANANA (BAN), CHERRY (CHY). Total APL supply = 1000.
  • 3 MiniDex constant-product AMM pools, NO trading fee:
    • APL-BAN seeded 100 APL / 200 BAN
    • APL-CHY seeded 100 APL / 400 CHY
    • BAN-CHY seeded 100 BAN / 200 CHY
  • 3 "trader" bot accounts seeded with tokens. A background worker calls Trader.next() each second. Trader strategy: take the token you have the MOST of, swap 50% of it for a random other token, with deadline = build_time + random.randint(2,10) seconds.
  • Crucial design: the worker only EMITS the trader's signed approve+swap raw transactions (+ swap hash) over Socket.IO (new_trade); it does not submit them. Submission is expected from the connected client (the "coordinator" = us).

Endpoints:

  • GET /setup — token/dex/account addresses.
  • GET /open — starts the trader background worker.
  • POST /submit {rawtx}send_raw_transaction of ANY signed rawtx (the relay; anyone can submit anything).
  • POST /fruit-basket {address}single-use globally; sets MANAGER = address, gives it lots of ETH (anvil_setBalance) + 10 APL. Rejects setup/dex/erc addresses.
  • GET /flag — returns flag iff balanceOf(APL, MANAGER) >= 500.
  • GET /balance[/pubkey] — balances helper.
  • POST / — JSON-RPC proxy that only allows safe read-only methods (eth_call, eth_getLogs, eth_getStorageAt, …) — used for reading chain state. State-changing calls must go through /submit.

Analysis

The win condition is balance-only

GET /flag checks only balanceOf(APL, MANAGER) >= 500. You do not need MANAGER's private key to be the trading account — anyone can transfer APL to MANAGER. In practice we made MANAGER our own key-controlled account and accumulated APL directly into it.

MiniDex math (no fee)

// getAmountOut, integer math, NO fee outputAmount = outputReserve - (inputReserve*outputReserve) / (inputReserve + inputAmount);
amount_out(in, inRes, outRes) = outRes - (inRes*outRes) // (inRes + in)

Integer division rounds down, so the subtraction rounds the output slightly up — round-trips on a single static pool are loss-free up to rounding. There is no slippage tax beyond the curve itself; value moves freely between tokens.

Why front-running / sandwiching is the wrong tool

The obvious idea is to sandwich the trader's swap on a single pool. But:

  • A single-pool round-trip on a no-fee AMM extracts almost nothing — the price you push out is the price you buy back at; profit ≈ rounding dust.
  • Same-direction "ammo stocking" sandwiches require extra round-trips to the remote relay before the trader's tx, which kills the trader's short deadline (see Pitfalls).

Why triangular cross-pool arbitrage wins

There are three independent no-fee pools sharing three tokens. The traders constantly dump 50% of their largest holding into these tiny pools (reserves 100–400), causing enormous price swings and large cross-pool imbalances. When the product of the three exchange rates around the cycle APL→BAN→CHY→APL exceeds 1, a single triangular cycle mints APL out of thin air.

Illustrative pre-trade reserves from the winning run:

  • APL-BAN: 30 APL / 553 BAN (APL very expensive here)
  • APL-CHY: 545 APL / 66 CHY (APL very cheap here)
  • BAN-CHY: 27 BAN / 574 CHY

One cycle: 24 APL → 79 BAN → 428 CHY → 473 APL — a ~20x multiplication of the input. The per-cycle gain is capped by the depth of the closing pool (APL-CHY held ~103 APL), so when pools are near-balanced, small input fractions are most profitable.

Exploitation

Step 1 — claim the basket, persist the key, open the market

POST /fruit-basket with our freshly generated address, immediately persisting the private key to manager.key, then GET /open to start the traders, then connect the Socket.IO client.

Step 2 — relay-first reaction loop (keep the traders alive)

On every new_trade event, the very first action is to relay the trader's approve+swap rawtx. Only after that do we run our own light "rebalance toward APL" reaction. This keeps the trader's deadline intact and keeps the pools imbalanced for us to exploit.

Step 3 — triangular arbitrage engine

A second process (arb.py) reads live reserves via the RPC proxy, evaluates both triangular directions (APL→BAN→CHY→APL and APL→CHY→BAN→APL) over a range of input fractions/absolute amounts, picks the (path, amount) that maximizes APL profit, and executes it hop-by-hop (approve+swap per hop) via POST /submit with the MANAGER key. Loop until balanceOf(APL, MANAGER) >= 500, then GET /flag.

  • One big cycle: 24 APL → 79 BAN → 428 CHY → 473 APL.
  • Final small cycle to cross 500: spend ~4 APL → ~39 APL out (+35), output capped by APL-CHY pool depth.

Solve scripts

exploit.py — relay-first new_trade handler

# Decode broadcast trader rawtx (EIP-1559 typed or legacy) to learn (token, amount, dex) def parse_tx(rawtx_hex): from hexbytes import HexBytes raw = HexBytes(rawtx_hex) if raw[0] <= 0x7f: # typed tx (0x02 = EIP-1559) from eth_account.typed_transactions import TypedTransaction d = TypedTransaction.from_bytes(raw).as_dict() return {'to': d.get('to'), 'data': d.get('data')} import rlp from eth_account._utils.legacy_transactions import Transaction txo = rlp.decode(bytes(raw), Transaction) return {'to': txo.to, 'data': txo.data} def handle_trade(data): swap_raw, approve_raw = data['swap'], data['approve'] # ---- RELAY TRADER TX FIRST, IMMEDIATELY (latency-critical: short deadlines) ---- submit_raw(approve_raw) submit_raw(swap_raw) p = parse_tx(swap_raw) # swap(uint256 amount, address token, uint256 deadline) body = to_hexdata(p['data'])[10:] # strip 0x + selector 0x43264349 amount = int(body[0:64], 16) token = Web3.to_checksum_address('0x' + body[64+24:128]) dex, sym = to_addr(p['to']), ADDR2SYM.get(token) toks = pool_name_of(dex).split('-') if 'APL' not in toks: return other = [t for t in toks if t != 'APL'][0] mybal = my_bal() # latency-safe BACK-RUN only (we already relayed the trader's tx): if sym == 'APL': # trader SOLD APL -> APL cheap here -> buy it if mybal.get(other, 0) > 0: do_swap(dex, ERCS[other], mybal[other]) else: # trader BOUGHT APL -> stock ammo (but keep progress) if 0 < mybal.get('APL', 0) < 450: do_swap(dex, ERCS['APL'], mybal['APL'] // 2 or mybal['APL'])

arb.py — best triangular cycle + execute

def amount_out(in_amt, in_res, out_res): return out_res - (in_res * out_res) // (in_res + in_amt) # no fee def best_cycle(): res = reserves() # {'APL-BAN': {'APL':.., 'BAN':..}, ...} have = bal('APL') def simulate(path, amt): for i in range(len(path) - 1): tin, tout = path[i], path[i+1] pool = res[f"{min(tin,tout)}-{max(tin,tout)}"] if amt <= 0: return 0 amt = amount_out(amt, pool[tin], pool[tout]) return amt best = (['APL','BAN','CHY','APL'], 1.0, 0) fracs = [1,.75,.5,.35,.25,.15,.1,.07,.05,.03,.02,.015,.01,.007,.005] for path in (['APL','BAN','CHY','APL'], ['APL','CHY','BAN','APL']): amts = set(int(have*f) for f in fracs) | {2,3,4,5,6,8,10,12,15,20,25,30,40,50} for amt in amts: if not (0 < amt <= have): continue profit = simulate(path, amt) - amt if profit > best[2]: best = (path, amt/have if have else 0, profit) return best # (path, fraction, est_apl_profit) def main(): for _ in range(400): check_flag() # GET /flag once balanceOf(APL,MANAGER) >= 500 for t in ('BAN','CHY'): # collapse leftovers back to APL if bal(t) > 0: do_swap(t, 'APL', bal(t)); time.sleep(0.4) path, frac, profit = best_cycle() if profit <= 0: time.sleep(2); continue # wait for traders to re-imbalance the pools amt0 = max(int(bal('APL') * frac), 1) first = True for i in range(len(path) - 1): tin, tout = path[i], path[i+1] do_swap(tin, tout, amt0 if first else bal(tin)); first = False time.sleep(0.5) reset_nonce()

We validated the whole strategy locally first: anvil + a copy of main.py (run on port 5055 to dodge macOS AirPlay on 5000) with a dummy FLAG env, confirming the arb engine reaches the flag before touching the live instance.

AMM math

For a constant-product pool with reserves (inRes, outRes) and no fee:

amount_out(in, inRes, outRes) = outRes - (inRes * outRes) // (inRes + in)

Integer division rounds down → output rounds slightly up; single-pool round-trips are loss-free up to dust. A triangular cycle A→B→C→A yields profit exactly when the product of the three effective exchange rates around the cycle exceeds 1. The trader bots, by repeatedly dumping 50% of their largest holding into the tiny pools, force the three pools to drift to inconsistent relative prices, opening this >1 window again and again. Gain per cycle is bounded by the depth of the pool that closes the loop (here APL-CHY, ~100 APL), so once pools are near-balanced, small input fractions extract the most.

Operational lessons / pitfalls

These cost us 3 burned instances before the clean solve — they are the real difficulty of the challenge:

  1. Relay the trader tx FIRST, always. The trader swap deadline is build_time + randint(2,10)s. We (the client) are the relayer of trader txs. Any latency added before relaying (e.g. doing our own front-run swaps first = extra HTTP round-trips to a remote server) pushes the trader tx past its deadline → require(block.timestamp <= deadline) reverts → Trader.missed_tx increments. After missed_tx >= 3 the trader stops forever and the worker stops emitting trades — permanently bricking the instance. Fix: relay approve+swap as the very first action on every new_trade, then do your own arbitrage (which doesn't affect the trader's deadline). Keeping traders alive is what produces the imbalances we exploit.
  2. Persist the MANAGER key immediately. /fruit-basket is single-use per instance and permanently assigns MANAGER. If you lose the generated private key (e.g. a run is killed mid-execution before the key is written), MANAGER is locked to an address you can't sign for and the instance is dead. Fix: write the freshly generated key to manager.key before calling /fruit-basket, so kills/restarts can resume.
  3. One clean pass per instance. Instance #1 lost the MANAGER key to a killed run; instances #2 and #3 stalled the traders by adding latency before relaying / by an aggressive same-direction sandwich. Be deliberate.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups