$ cat writeup.md…
$ cat writeup.md…
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.
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).
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.
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 --accounts 0, Foundry v1.7.1) + Flask (web3==7.16.0, flask==3.1.3, flask-socketio==5.6.1)._mint(msg.sender, 1000) at deploy: APPLE (APL), BANANA (BAN), CHERRY (CHY). Total APL supply = 1000.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.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.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.
// 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.
The obvious idea is to sandwich the trader's swap on a single pool. But:
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:
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.
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.
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.
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.
24 APL → 79 BAN → 428 CHY → 473 APL.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'])
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.
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.
These cost us 3 burned instances before the clean solve — they are the real difficulty of the challenge:
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./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.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar