← ret / Huntress CTF
← ret

Huntress CTF: 2025 - Reverse Engineering Challenge Writeups

Huntress CTF 2025 banner

I try my best to explain how I solve the 4 RE challenges from this years CTF, some of it relied on trial and error / recognising patterns and therefore may not be as technically accurate as i’d like

NimCrackMe1

SHA 256: 47d7fa30cfeeba6cc42e75e97382ab05002a6cd0ebb4d622156a6af84fda7d5e

Execution flow: Main > NimMain > NimMainInner > NimMainModule > main__crackme_u20

_buildEncodedFlag__crackme_u18_ gets called at 140012c02, followed by _xorStrings__crackme_u3_at 140012c6b

Dynamic Approach: Set a breakpoint at 140012c6b, step over the function call, review the data in the R11 register x64dbg breakpoint at 140012c6b showing R11 register after xorStrings call in NimCrackMe1

x64dbg R11 register value pointing to the decrypted NimCrackMe1 flag string

x64dbg dump view showing decrypted NimCrackMe1 flag bytes in R11

Static Approach: The _buildEncodedFlag__crackme_u18_ function builds a Nim String of 0x26 (38) bytes in length.

IDA Pro decompiler showing _buildEncodedFlag function allocating 38-byte Nim string

The, one byte is written at a time, resulting in the hexadecimal code: 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

This result is stored in var_28

IDA Pro showing byte-by-byte construction of the encoded flag stored in var_28

And soon, var_98, which is passed to _xorStrings__crackme_u3_ as the 2nd argument.

IDA Pro showing var_98 passed as 2nd argument to _xorStrings__crackme_u3_ function

The first argument is the result, the second is the encoded flag, and the third is the XOR key (length (var_a8) and key (var0_1))

var_a0_1 = &TM__cGo7QGde1ZstH4i7xlaOag_4 TM__cGo7QGde1ZstH4i7xlaOag_4 is a global variable for Nim is not for malware!

IDA Pro showing global variable TM__cGo7QGde1ZstH4i7xlaOag_4 resolving to XOR key string

Rust Tickler

SHA 256: df95140548732f34d8cf11b6b9dd7addb31480fab871b7004c7c1e09acfd920b

Finding main: Entry point > FUN_140005424 > FUN_140001790 > FUN_1400011f0

Towards the end of this large function is an undefined function sub_140001740.

IDA Pro showing call to undefined sub_140001740 at end of FUN_1400011f0 in Rust Tickler

IDA Pro decompiler showing sub_140001740 function body with XOR operation

IDA Pro disassembly showing string bytes being XORd with 0x51 in Rust Tickler

We can see a string being XOR’d with 0x51

Replicating this will result in the flag

CyberChef XOR with 0x51 revealing the Rust Tickler flag

Rust Tickler flag output after XOR decryption with key 0x51

Rust Tickler 2

SHA 256: 47d7fa30cfeeba6cc42e75e97382ab05002a6cd0ebb4d622156a6af84fda7d5e

Main > 0x140001350

IDA Pro showing main function entry point at 0x140001350 for Rust Tickler 2

Set a breakpoint for this address in x64dbg:

x64dbg breakpoint set at 0x140001350 with disassembly visible

A few instructions into this function, data gets moved to RDX, and the length of data is moved into RAX

x64dbg disassembly showing data moved into RDX and length into RAX

Jump over this instruction in a debugger and right click the RDX register > Follow in dump to see the data

x64dbg RDX register value shown before following in dump

x64dbg dump view showing encrypted HNTS data in RDX before XOR decryption

At 1400013ae, an XOR key is moved into XMM0

x64dbg disassembly showing XOR key being loaded into XMM0 at 1400013ae

An XOR operation then occurs using this key towards i_3 (The data in RDX)

x64dbg disassembly showing XOR operation applied to i_3 data using XMM0 key

This XOR operation is performed in the following loop:

x64dbg disassembly showing XOR decryption loop iterating over the data buffer

Partial decrypted data in RDX after the first iteration shows a HNTS header:

x64dbg dump view showing HNTS magic bytes appearing in RDX after first XOR loop pass

Set a breakpoint at 1400013E5 and hit it to complete the decryption loop, revealing the decrypted data structure in RDX

x64dbg dump at 1400013E5 showing fully decrypted HNTS data structure in RDX

This decrypted data structure gets passed to function 140003ea0

x64dbg disassembly showing decrypted RDX passed to HNTS parser function 140003ea0

This function is essentially the HNTS data parser, it checks that the data is as expected by checking the magic bytes and creates an indexed array for later lookup

The parsed data structure is moved into the RDX prior to the call to function 140003de0

x64dbg disassembly showing parsed HNTS structure moved into RDX before call to 140003de0

x64dbg dump showing parsed HNTS indexed structure in RDX ready for lookup function

0xAAAAAAAA is then moved into the R8 register

x64dbg disassembly showing 0xAAAAAAAA moved into R8 as ID for HNTS lookup

These values are used as arguments for the call to function 140003de0

x64dbg disassembly showing call to 140003de0 with HNTS structure and 0xAAAAAAAA ID

After passing this function in a debugger, a string is returned:

x64dbg showing string returned by 140003de0 for ID 0xAAAAAAAA

A similar set up occurs later, where 0xAAAA is moved into R8 and returns another string after a call to 140003de0

x64dbg showing 0xAAAA loaded into R8 for second HNTS lookup call

Which returns the string Bingus

x64dbg showing string Bingus returned by 140003de0 for ID 0xAAAA

So the values being moved to R8 prior to the function call return different strings. They are acting as IDs within the HNTS data structure and return different output depending on the ID provided.

Looking at the HNTS data structure, the IDs are formatted in a pretty recognisable way:

hex view of decrypted HNTS structure showing ID fields and their offsets

We already know that AAAAAAAA and AAAA are valid IDs, AAAAA is also later called in the code. The rest of the IDs highlighted in this zone are also valid due to their offsets within the structure.

Modifying the R8 register to one of these IDs prior to the function call changes the result

x64dbg R8 register modified to various HNTS IDs showing different strings returned

The 7F structure ID will return the flag:

x64dbg showing flag string returned when R8 is set to the 7F HNTS ID

Rust Tickler 3

SHA 256: a4a5b64d72540552c691293f9e988e189674275f6e4743b8d61f299bd6f31fc7

Main function of interest: 1400011f0

This challenge initially follows a similar format to Rust Tickler 2 where an ID is moved into R8 prior to a function call which results in a different string being returned, for example:

x64dbg disassembly showing HNTS ID being moved into R8 for lookup in Rust Tickler 3

This results in:

x64dbg showing string returned from HNTS lookup for the given ID

When we get to ​​1400013C2, there is a conditional jump, where either ID 1338 or 1339 is used

x64dbg disassembly at 1400013C2 showing conditional jump choosing ID 1338 or 1339

x64dbg disassembly showing the alternative branch using ID 1339 as the success path

Trial and error tells us that 1338 is the failure, and 1339 is the success, so we’ll set our RIP / patch the ZF / binary to follow that execution path.

Failure / success is determined based on if the provided input is equal to the result of ID 133A

x64dbg disassembly showing memcmp comparing user input against result of HNTS ID 133A

Modifying one of the IDs in R8 prior to the 1423ED7D0 function call will reveal the answer:

x64dbg showing answer string returned after changing R8 to ID 133A before the lookup call

Continuing to follow execution, we see a path being built: (ID 1348 = Exodus)

x64dbg dump showing Exodus directory path being built using HNTS ID 1348 lookup

x64dbg showing constructed Exodus directory path string used for existence check

The binary will terminate shortly after this is seen if this directory doesn’t exist.

If the correct answer is provided and the above path exists, a file (filename created from ID 1369)

So all we need to do is create this directory and supply the hash when prompted by the executable.

Rust Tickler 3 binary accepting correct input and proceeding past stage 1 check

Stage 2 performs a memcmp to compare supplied input to a known value and if that value matches, the success path is executed.

However, patching the binary to get a success just reveals the message:

Rust Tickler 3 terminal output after patching stage 2 memcmp showing partial progress message

And there is not much change in execution flow as seen:

x64dbg execution flow after patching stage 2 showing no new code path reached

The answer lies between 140001354 and 1400013c3. This is AES ciphertext, AES key, and AES IV.

IDA Pro showing AES ciphertext, key, and IV data variables between 140001354 and 1400013c3

IDA Pro showing AES 256 ciphertext, 32-byte key, and 16-byte IV data values in Rust Tickler 3

I believe this data is then passed through further cryptographic functions prior to being used by the memcmp function.

Data_14037d0f8 = cb584b62035d138f77bc9810f00f1a2020700f8fbf0d75dca3fd71085f1467cde9d05f1f83bbc76b7d9beb42f7510095

Data_14037d128 = d4c39486fdf04283f5d96436ba68ea1c4f4194796af82d0f8eed7c12f53fa07c

Data_14037d148 = 539fb31e1cc13442420d039397e91777

These data variables follow that of AES 256, where the cipher is 48 bytes, followed by a 32 byte key and 16 byte IV.

CyberChef AES-256 decryption of the Rust Tickler 3 ciphertext using extracted key and IV