Challenge 7 ("The Boss Needs Help")
Description
Wow you are extremely good at internet!
Maybe you can help us. We just got a call from the management of a true rock-and-roll legend. This artist, famous for his blue-collar anthems and marathon live shows, fears his home studio machine in New Jersey has been compromised.
Our client is a master of the six-string, not the command line. We've isolated a suspicious binary from his machine, hopeanddreams.exe, that appears to be phoning home. We've also collected suspicious HTTP traffic and are passing that along.
Can you uncover what happened?
Writeup
Another Saturday, another solved challenge. 😎 (So far, I have completed all but one challenge on a Saturday.)
This time, we're given a file named hopeanddreams.exe along with packets.pcapng. As their names suggest, they are an executable PE file and a packet capture file respectively.
First, let's open the capture file in Wireshark and inspect what kind of network traffic was captured.

The file seems to contain exclusively HTTP traffic between a client and two separate hosts, twelve[.]flare-on[.]com:8000 (which I will refer to as the flare-on domain for short) and theannualtraditionofstaringatdisassemblyforweeks[.]torealizetheflagwasjustxoredwiththefilenamethewholetime[.]com:8080 (henceforth the disassembly domain), over the course of some 7 minutes, totaling 274 captured packets (including TCP).
While it's nice to see some reflection from the challenge authors, I (correctly) discarded the disassembly domain name as having to do anything with the challenge and proceeded to look at the HTTP traffic closer. The first HTTP request, GET /good (am I overinterpreting this?), went to the flare-on domain and already had some interesting characteristics:
- The
User-AgentHTTP header isMozilla/5.0 (Avocado OS; 1-Core Toaster) AppleWebKit/537.36 (XML, like Gecko) FLARE/1.0— always a great sign seeing a toaster connect to the internet; - There is an
Authorizationheader bearing the valueBearer e4b8058f06f7061e8f0f8ed15d23865ba2427b23a695d9b27bc308a26d— likely included to guarantee unbreakable security of the session sent over unencrypted HTTP.
The server responded with an 200 OK response and some JSON-encoded data in the response body:
{"d": "085d8ea282da6cf76bb2765bc3b26549a1f6bdf08d8da2a62e05ad96ea645c685da48d66ed505e2e28b968d15dabed15ab1500901eb9da4606468650f72550483f1e8c58ca13136bb8028f976bedd36757f705ea5f74ace7bd8af941746b961c45bcac1eaf589773cecf6f1c620e0e37ac1dfc9611aa8ae6e6714bb79a186f47896f18203eddce97f496b71a630779b136d7bf0c82d560"}
Next, similarly encoded data was sent from the client to the disassembly domain in a POST / request, except this time, the JSON object also contains another key-value pair, "msg": "sysi":
{"d":"3b001a3d06733da13984c89fe99ffc936128497575d01feab284eba5da0bd909d11be82b443705dc61cd307635ff27998d65e911837716ed7c190504472831cc78c19f578ff339cfa7e046695a98fcb4bfaf4a586294a86c72113d06733e3542c14dc0451af3fc79b1f1a2e9b26e4723a21a5b632b1d434e51ab070cb53373fcff024ba26f9cfd673284fc47bd768e2262a394559ff0194b9b4103951f14bcb8","msg":"sysi"}
Likewise, this server (actually located at the same IP address), responds with a 200 OK and this succinct JSON:
{"d": "5134c8a46686f2950972712f2cd84174"}
From this point forward, it seems that the conversation consists of repeated requests to GET /get, as well as the occasional POST /re, all with data encoded in a similar way as before, that is, using JSON and hex strings.
Having grasped the basic idea of the HTTP traffic from the packet capture file, let's open up the executable and see how it relates to the network comms.

Although the executable is looks like a fairly standard C++ program at first, even the first look upon the main function tells a different tale: as you begin to scroll through the disassembly, you quickly realize that the code is full of useless arithmetic instructions added artificially in order to make the code harder to navigate and reason about. As a byproduct of this obfuscation technique, the Hex-Rays decompiler even refuses to process the function due to its size, and you must increase a default limit if you want to see the disassembly rendered as a graph.
There are several ways to deal with such an obfuscation, ranging from ignoring it altogether and accepting to step through the useless instructions, all the way to algorithmically transforming the program into an equivalent, but more readable form, where these superfluous instructions are removed (however, for this transformation to leave the semantics unchanged, certain assumptions must be made). This is a topic I could write a lot about, but for the challenge, I chose a much more grounded approach and simply wrote (or, well, made my LLM of choice write) a Python function that uses the IDA API to find and jump to the next function call, unconditional jump, or return instruction (function calls fortunately seemed to be neither obfuscated nor inlined in any way).
import idaapi
import idc
import ida_ua
def find_next_jump():
"""
Finds and jumps (in the GUI) to the next jmp, call or ret instruction from
the current cursor position. Returns the address if found and BADADDR otherwise.
"""
# Get current effective address under cursor
ea = idc.get_screen_ea()
if ea == idc.BADADDR:
print("No valid address under cursor")
return idc.BADADDR
# Start searching from the next instruction
ea = idc.next_head(ea)
# Search until we reach the end of the segment
while ea != idc.BADADDR:
# Get the mnemonic of the instruction
mnem = idc.print_insn_mnem(ea)
# Check if it's a jmp or call instruction
if mnem.startswith('jmp') or mnem.startswith('call') or mnem.startswith('ret'):
print(f"Found {mnem} at: 0x{ea:X}")
print(f"Full instruction: {idc.GetDisasm(ea)}")
# Jump to the found instruction in IDA
idc.jumpto(ea)
return ea
# Move to next instruction
ea = idc.next_head(ea)
print("No jmp or call instruction found")
return idc.BADADDR
After this script is run in IDA, you can call find_next_jump() in the Python console at any time to skip the "junk" and see the next potentially interesting piece of disassembly.
From the beginning of main, the first such piece is at preferred virtual address (PVA) 0x140210EC3. The function being called is a rather simple one, as it is only used to zero-initialize a structure on the stack:
void *__fastcall zero_memory_0x140_bytes(void *a1)
{
return memset(a1, 0, 0x140u);
}
The same cannot be said about the next two functions, called at 0x140210ED7 and 0x140210EE5. Both are passed the same pointer to the local structure of size 0x140, with the former also receiving a pointer into some memory in the .data section. Their semantics are not clear from a single initial look, but when run under a debugger without any extra setup, the program exits, never returning from the latter function, so it's probably worth analyzing closer.
Once again, the function at 0x140081590 is too long for the decompiler or even the graph view, but it's not a big deal with our handy IDAPython snippet.
The first couple of stops were already interesting, as they were library calls. In short, the next three function calls retrieve the current time and convert it to a string using the format string "%Y%m%d%H".
.text:0000000140087965 xor ecx, ecx ; Time
.text:0000000140087967 call _time64
.text:000000014008796C mov [rsp+4FE8h+Time], rax
...
.text:000000014008E1A9 lea rdx, [rsp+4FE8h+Time] ; Time
.text:000000014008E1B1 lea rcx, [rsp+4FE8h+Tm] ; Tm
.text:000000014008E1B9 call _gmtime64_s
...
.text:00000001400915CF lea rcx, [rsp+4FE8h+var_30E4]
.text:00000001400915D7 call sub_1400D3850
.text:00000001400915DC mov rcx, rax
.text:00000001400915DF call sub_140299E30 ; Returns "%Y%m%d%H"
.text:00000001400915E4 lea r9, [rsp+4FE8h+Tm] ; Tm
.text:00000001400915EC mov r8, rax ; Format
.text:00000001400915EF mov edx, 0Bh ; SizeInBytes
.text:00000001400915F4 lea rcx, [rsp+4FE8h+Buffer] ; Buffer
.text:00000001400915FC call cs:strftime
I should quickly touch on the two functions called right before strftime. They are a repeating pattern throughout the whole binary — put simply, it is some mechanism for decrypting, decoding or finding strings at runtime. I frankly didn't reverse the mechanism any further, because there was no point. The point is that they are deterministic (as far as I could tell) and the latter returns a char * to the decoded null-terminated string (in this case the format specifier string).
Before going through the next couple of function calls, it is worth discussing the binary representation of one key class from the C++ standard library, which is std::string.
The std::string (also known by its full name, std::basic_string<char, std::char_traits<char>, std::allocator<char>>) class is essentially a growable buffer of bytes mostly used to store UTF8-encoded strings. A naive implementation could look like this:
class std::string
{
std::unique_ptr<char[]> _data;
size_t _data_length;
size_t _buffer_capacity;
};
Such an implementation would have to allocate heap memory every time any string is created, whether it's the entire contents of the C++ standard (assuming it could fit into physical memory), or just the string "hello". You could notice that as long as our buffer only consumes 8 bytes in total (including the terminating zero for the c_str method), we could simply use the space for the 8-byte pointer (_data) to store the string without any heap memory allocation, which is relatively slow.
This is precisely the idea behind short-string optimization (SSO, no not that SSO). One difference between our example and the MSVC implementation is that Microsoft uses up to 16 bytes for a short string, which is a very reasonable tradeoff. The binary representation of a std::string in MSVC is thus more akin to this (compare with Microsoft devblogs):
class std::string
{
union {
char *ptr;
char buf[16];
} _data_storage;
size_t _data_size;
size_t _buffer_capacity;
};
Whenever the _buffer_capacity is equal to 15 (it may never be less than that by contract), the buf union member is active and the string bytes are stored directly in the string object itself. Whenever the string grows above 15 bytes, the buffer is reallocated on the heap and the pointer is stored into the ptr union member, which becomes active for the rest of the lifetime of that string object.
With this MSVC internals interlude out of the way, we can continue with the analysis. In the disassembly snippet below, I identified most of the functions involving std::strings purely by inspecting their arguments before the call and their result after the call. Furthermore, I could tell if a function was from the MSVC stdlib implementation by noticing the specific way how these functions check that a string will not exceed a maximum length, e.g. in std_string_concat:
if ( 0x7FFFFFFFFFFFFFFFLL - Size < v3 )
sub_7FF71C1B1570();
// where sub_7FF71C1B1570 is just
std::_Xlength_error("string too long");
(I don't know about you, I have yet to see a string of length (0x7FFFFFFFFFFFFFFF), but that's besides the point.)
Let's carry on, still using the IDAPython trick:
.text:0000000140094805 lea rdx, [rsp+4FE8h+Buffer]
.text:000000014009480D lea rcx, [rsp+4FE8h+var_148]
.text:0000000140094815 call std_string_from_cstring
...
.text:000000014009B8B0 lea r8, [rsp+4FE8h+var_168] ; s"<username>@<computername>"
.text:000000014009B8B8 lea rdx, [rsp+4FE8h+var_148] ; s"<formatted datetime>"
.text:000000014009B8C0 lea rcx, [rsp+4FE8h+stdstring_src] ; std::string &
.text:000000014009B8C8 call std_string_concat ; produced s"<datetime><user>@<computer>"
...
.text:000000014009EC3C lea rcx, [rsp+4FE8h+stdstring_src]
.text:000000014009EC44 call std_string_length
.text:000000014009EC49 mov [rsp+4FE8h+len], rax
...
.text:00000001400A5498 lea rcx, [rsp+4FE8h+ciphertext]
.text:00000001400A54A0 call return_arg_deref
.text:00000001400A54A5 mov r8, [rsp+4FE8h+len] ; len
.text:00000001400A54AD mov rdx, rax ; buffer_dst
.text:00000001400A54B0 lea rcx, [rsp+4FE8h+stdstring_src] ; stdstring_src
.text:00000001400A54B8 call encrypt_string
For the time being, I chose the name for this last function based on its observed behaviour and didn't dig deeper until I was sure it would be helpful or necessary. This didn't take long:
.text:00000001400A8D3A mov r8, [rsp+4FE8h+var_848] ; s"Mozilla/5.0 (Avocado OS; 1-Core Toaster) AppleWebKit/537.36 (XML, like Gecko) FLARE/1.0"
.text:00000001400A8D42 mov rdx, [rsp+4FE8h+var_840] ; s"User-Agent"
.text:00000001400A8D4A lea rcx, [rsp+4FE8h+user_agent_stdpair_stdstring]
.text:00000001400A8D52 call construct_pair ; just a guess, not sure if actually std::pair
...
.text:00000001400A8DCD lea rcx, [rsp+4FE8h+ciphertext]
.text:00000001400A8DD5 call return_arg_deref
.text:00000001400A8DDA mov r8, [rsp+4FE8h+len] ; length
.text:00000001400A8DE2 mov rdx, rax ; bytes
.text:00000001400A8DE5 lea rcx, [rsp+4FE8h+hex_string_ciphertext] ; out_stdstring
.text:00000001400A8DED call bytearray_to_hex_stdstring
...
.text:00000001400A8E0A lea rcx, [rsp+4FE8h+var_30FA]
.text:00000001400A8E12 call sub_7FF71C283A90
.text:00000001400A8E17 mov rcx, rax
.text:00000001400A8E1A call sub_7FF71C442330 ; returns "Bearer "
.text:00000001400A8E1F mov rdx, rax
.text:00000001400A8E22 lea rcx, [rsp+4FE8h+var_4C8]
.text:00000001400A8E2A call std_string_from_cstring
.text:00000001400A8E2F mov [rsp+4FE8h+bearer_stds], rax
Clearly, HTTP headers are being assembled here, and they are those same HTTP headers that we've seen in the first captured request (User-Agent and Authorization with value Bearer ...). The bearer token from the captured traffic was hex encoded, so already we can assume that the hex-encoded ciphertext is used as this token in the HTTP traffic that will follow.
This also means if my instinct was correct and the function above is really a symmetric encryption routine (and not e.g. a one-way hash function), then we can decrypt the token from the captured request to obtain the username and computer name of the user that partook in this innocent HTTP conversation, as well as the date and time.
Examining the encryption routine more closely using the IDAPython approach from earlier, we can notice that there is only one function call:
.text:000000014005A946 call std_string_data
Keeping the SSO in mind, the purpose and name of the callee is clear even without correct types:
__int64 __fastcall std_string_data(void *a1)
{
if ( *((_QWORD *)a1 + 3) <= 0xFu )
return (__int64)a1;
else
return *(_QWORD *)a1;
}
Since our IDAPython function only tracks calls, jmps and rets, you may think that it fails here, since there is no other call after this one. But it is enough to track what happens to the return value, i.e. rax, after std_string_data returns, to understand how the encryption works.
Only a couple instructions after the call, rax is read from at index rbx into r11.
.text:000000014005A95D movzx r11d, byte ptr [rax+rbx]
Next, we can track how r11 is used further. Luckily, IDA provides some great ways to do this. One of them is highlighting other occurrences of the register under the cursor in the disassembly:

A less known feature is that you can navigate between occurrences of a register, mnemonic, and probably other things, with the keyboard shortcuts Alt + Up arrow and Alt + Down arrow, although this does not seem to take different "parts" of a register into consideration, like r11b vs r11d.
Using these tricks, we can isolate the crucial instructions:
.text:000000014005A95D movzx r11d, byte ptr [rax+rbx] ; c = plaintext[i]
.text:000000014005A968 xor r11b, 5Ah ; c ^= 0x5a
.text:000000014005CB2A lea eax, [rbx+1]
.text:000000014005CB33 add r11b, al ; c += (i + 1)
.text:000000014005E952 movzx eax, r11b
.text:000000014005E956 movzx eax, byte ptr [rax+r12]
.text:000000014005E95B mov [rsi+rbx], al ; rsi[i] = r12[c]
.text:000000014005E95E inc rbx ; i += 1
.text:000000014005E961 cmp rbx, rdi ; i < rdi ?
.text:000000014005E964 jb loc_7FF71C208A42 ; if so, continue the loop
This looks like a basic substitution cipher with r12 pointing to the S-box and rsi to the destination buffer. Naturally, rdi would be the length of the plaintext. This intuition was confirmed, as rsi, rdi and r12 can be last seen written to shortly after the function prologue,
.text:0000000140058A32 lea r12, byte_14046A540
.text:0000000140058A39 mov rdi, r8
.text:0000000140058A3C mov rsi, rdx
and byte_14046A540 is an array of 256 bytes in the read-only data section of the executable.
Dumping the S-box (Shift+E) and writing a decryption routine in Python was easy work:
sbox = "52 09 6A D5 30 36 A5 38 BF 40 A3 9E 81 F3 D7 FB 7C E3 39 82 9B 2F FF 87 34 8E 43 44 C4 DE E9 CB 54 7B 94 32 A6 C2 23 3D EE 4C 95 0B 42 FA C3 4E 08 2E A1 66 28 D9 24 B2 76 5B A2 49 6D 8B D1 25 72 F8 F6 64 86 68 98 16 D4 A4 5C CC 5D 65 B6 92 6C 70 48 50 FD ED B9 DA 5E 15 46 57 A7 8D 9D 84 90 D8 AB 00 8C BC D3 0A F7 E4 58 05 B8 B3 45 06 D0 2C 1E 8F CA 3F 0F 02 C1 AF BD 03 01 13 8A 6B 3A 91 11 41 4F 67 DC EA 97 F2 CF CE F0 B4 E6 73 96 AC 74 22 E7 AD 35 85 E2 F9 37 E8 1C 75 DF 6E 47 F1 1A 71 1D 29 C5 89 6F B7 62 0E AA 18 BE 1B FC 56 3E 4B C6 D2 79 20 9A DB C0 FE 78 CD 5A F4 1F DD A8 33 88 07 C7 31 B1 12 10 59 27 80 EC 5F 60 51 7F A9 19 B5 4A 0D 2D E5 7A 9F 93 C9 9C EF A0 E0 3B 4D AE 2A F5 B0 C8 EB BB 3C 83 53 99 61 17 2B 04 7E BA 77 D6 26 E1 69 14 63 55 21 0C 7D"
sbox = list(int(x, 16) for x in sbox.split(" "))
inverse_sbox = { x: i for i, x in enumerate(sbox) }
bearer = bytes.fromhex("e4b8058f06f7061e8f0f8ed15d23865ba2427b23a695d9b27bc308a26d")
plain = bytearray()
for i in range(len(bearer)):
c = inverse_sbox[bearer[i]]
c -= i + 1
c ^= 0x5a
plain.append(c)
print(plain.decode()) # Output: 2025082006TheBoss@THUNDERNODE
I must admit that naming your computer THUNDERNODE is kinda badass. But let's continue the analysis. Skipping some uninteresting destructor calls and then some more string decoding and copying (including "/good" and "twelve.flare-on.com"), we get to this call:
.text:00000001400AF507 lea r9, [rsp+4FE8h+obj_headers]
.text:00000001400AF50F mov r8, [rsp+4FE8h+str_path_good] ; "/good"
.text:00000001400AF517 lea rdx, [rsp+4FE8h+obj_response]
.text:00000001400AF51F lea rcx, [rsp+4FE8h+obj_containing_hostname_and_port]
.text:00000001400AF527 call make_request_http_get
As this writeup is already getting lengthy, I will not elaborate on the internals of this function, but I think it's fairly easy to arrive at the conclusion that it indeed does send a GET request and write the response object onto the stack location at rdx. Another hint towards this can be that a couple lines further, the response status code is checked against 200.
.text:00000001400AF587 cmp dword ptr [rax], 200
Now, since the twelve[.]flare-on[.]com domain does not have a DNS record (unlike the other one, which someone registered 3 days after the start of the CTF and redirected to a rickroll), and even if it did, who knows what might be there, it would be nice to spoof this "C2" server ourselves. I did this by adding a rule into System32\drivers\etc\hosts that resolves both these domains to localhost and running a simple Python HTTP server on ports 8000 and 8080.
For the flare-on domain, I just replayed the first response exactly as it appeared in the packet capture, which allowed me to continue dynamically analyzing the code following the first HTTP request.
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/good":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"d": "085d8ea282da6cf76bb2765bc3b26549a1f6bdf08d8da2a62e05ad96ea645c685da48d66ed505e2e28b968d15dabed15ab1500901eb9da4606468650f72550483f1e8c58ca13136bb8028f976bedd36757f705ea5f74ace7bd8af941746b961c45bcac1eaf589773cecf6f1c620e0e37ac1dfc9611aa8ae6e6714bb79a186f47896f18203eddce97f496b71a630779b136d7bf0c82d560"}')
# ...
server = HTTPServer(("localhost", 8000), Handler)
print("Serving on http://localhost:8000")
server.serve_forever()
After receiving this response, the binary decrypts it using an algorithm I have not inspected closer, yielding the following JSON.
{
"sta": "excellent",
"ack": "peanut@theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080"
}
The program then parses the username and hostname from the ack field and stores them in the a structure passed as an argument from main, before returning to the caller, where the return value is checked for success.
; (continuation of main function)
.text:0000000140210EDD lea rcx, [rsp+488h+string_struct]
.text:0000000140210EE5 call send_first_request_parse_ack_url
.text:0000000140210EEA test al, al
.text:0000000140210EEC jnz loc_140213B75
Following the code in the success branch using the IDAPython trick, we observe this behaviour:
.text:000000014021920D lea rdx, [rsp+488h+Buf1]
.text:0000000140219215 lea rcx, [rsp+488h+string_struct]
.text:000000014021921D call sub_140036CF0 ; reads ack domain:port to rdx buf
...
.text:0000000140219239 mov rcx, rax
.text:000000014021923C call sub_14022C510 ; returns "N/A"
.text:0000000140219241 mov rdx, rax
.text:0000000140219244 lea rcx, [rsp+488h+var_218]
.text:000000014021924C call std_string_from_cstring
.text:0000000140219251 mov rdx, rax
.text:0000000140219254 lea rcx, [rsp+488h+Buf1]
.text:000000014021925C call std_string_operator_eq
.text:0000000140219261 movzx ebx, al
...
.text:0000000140219271 test bl, bl
.text:0000000140219273 jz loc_14021BF02
The program checks if the parsed host from the ack field is the string "N/A", which we know it isn't in our case, so we follow the "success" branch (the other branch probably only decrypts two strings and then exits, based on a short look, but it is not important).
The next interesting call in the execution of main is the one below:
.text:000000014021E7BB call send_second_request
.text:000000014021E7C0 test al, al
.text:000000014021E7C2 jz loc_14022116E
Of course, this is my name for a function I've already analyzed. Let's see the inside.
.text:00000001400DA758 xor ecx, ecx
.text:00000001400DA75A call _time64
.text:00000001400DA75F mov [rsp+5458h+Time], rax
...
.text:00000001400E0FF7 lea rdx, [rsp+5458h+Time] ; Time
.text:00000001400E0FFF lea rcx, [rsp+5458h+Tm] ; Tm
.text:00000001400E1007 call _gmtime64_s
...
.text:00000001400E438F lea rcx, [rsp+5458h+var_34E0]
.text:00000001400E4397 call sub_14012C040
.text:00000001400E439C mov rcx, rax
.text:00000001400E439F call sub_14027EFB0
.text:00000001400E43A4 lea r9, [rsp+5458h+Tm] ; Tm
.text:00000001400E43AC mov r8, rax ; Format
.text:00000001400E43AF mov edx, 3 ; SizeInBytes
.text:00000001400E43B4 lea rcx, [rsp+5458h+Buffer] ; Buffer
.text:00000001400E43BC call cs:strftime
...
.text:00000001400EA8A9 lea r9, [rsp+5458h+hour] ; hour
.text:00000001400EA8B1 mov r8, [rsp+5458h+peanut_2] ; peanut_ack
.text:00000001400EA8B9 mov rdx, [rsp+5458h+userandhostname_2] ; user_host_name
.text:00000001400EA8C1 lea rcx, [rsp+5458h+aes_key_stdvector] ; key_vec_out
.text:00000001400EA8C9 call derive_aes_key_from_host_response_and_time
...
.text:00000001400F413B lea rdx, [rsp+5458h+sysinfo]
.text:00000001400F4143 lea rcx, [rsp+5458h+var_370]
.text:00000001400F414B call json_to_string_M
...
.text:00000001400F79A8 lea rdx, [rsp+5458h+sysinfo]
.text:00000001400F79B0 lea rcx, [rsp+5458h+plaintext_vec]
.text:00000001400F79B8 call sub_140076E40 ; copies
...
.text:00000001400FE1CD lea rcx, [rsp+5458h+aes_key_stdvector]
.text:00000001400FE1D5 call return_arg_deref
.text:00000001400FE1DA lea r8, [rsp+5458h+var_58]
.text:00000001400FE1E2 mov rdx, rax
.text:00000001400FE1E5 lea rcx, [rsp+5458h+keystream]
.text:00000001400FE1ED call aes256_expand_key
...
.text:00000001401015EF lea rcx, [rsp+5458h+plaintext_vec]
.text:00000001401015F7 call sub_1402B4A20
.text:00000001401015FC mov [rsp+5458h+plaintext_len], rax
.text:0000000140101604 lea rcx, [rsp+5458h+plaintext_vec]
.text:000000014010160C call return_arg_deref
.text:0000000140101611 mov rcx, [rsp+5458h+plaintext_len]
.text:0000000140101619 mov r8, rcx ; length
.text:000000014010161C mov rdx, rax ; plaintext
.text:000000014010161F lea rcx, [rsp+5458h+keystream] ; keystream
.text:0000000140101627 call aes_encrypt ; sys info encrypted here
...
.text:00000001401017B1 lea rcx, [rsp+5458h+plaintext_vec] ; now ciphertext
.text:00000001401017B9 call sub_1402B4A20
.text:00000001401017BE mov [rsp+5458h+length], rax
.text:00000001401017C6 lea rcx, [rsp+5458h+plaintext_vec]
.text:00000001401017CE call return_arg_deref
.text:00000001401017D3 mov rcx, [rsp+5458h+length]
.text:00000001401017DB mov r8, rcx ; length
.text:00000001401017DE mov rdx, rax ; bytes
.text:00000001401017E1 lea rcx, [rsp+5458h+out_stdstring] ; out_stdstring
.text:00000001401017E9 call bytearray_to_hex_stdstring
...
.text:0000000140104E71 mov rax, [rsp+5458h+var_998]
.text:0000000140104E79 mov [rsp+5458h+var_5430], rax
.text:0000000140104E7E mov rax, [rsp+5458h+var_990]
.text:0000000140104E86 mov [rsp+5458h+var_5438], rax
.text:0000000140104E8B mov r9, [rsp+5458h+var_988]
.text:0000000140104E93 mov r8, [rsp+5458h+var_980]
.text:0000000140104E9B lea rdx, [rsp+5458h+var_2D8]
.text:0000000140104EA3 mov rcx, [rsp+5458h+arg_0]
.text:0000000140104EAB call make_request_http_post
...
.text:0000000140104F04 cmp dword ptr [rax], 200
The beginning of the function is very similar to the one that sent the first request. A key difference is in how the data (in this request, some hardware and system info) is encrypted. While I could guess the semantics for most function calls just by comparing the inputs and outputs, I had to look closer at what I would later name derive_aes_key_from_host_response_and_time, aes256_expand_key and aes_encrypt to understand that this was in fact AES with a key derived from various known information.
Identifying AES was not hard — it is a ubiquitous cipher that most of us know by heart (maybe save the S-box and RCs); the function I've named aes_encrypt shows a typical structure of a cipher-block-chain operation mode, internally calling sub_140050560, which is the actual AES encryption routine (look at the only global memory access in this function and you should recognize the 256-byte AES S-box (well, maybe not the whole S-box, but at least the beginning bytes)).
void __fastcall aes_encrypt(uint8_t *keystream, uint8_t *plaintext, size_t length)
{
uint8_t *iv; // r9
size_t n_16b_blocks; // rdi
uint8_t *buffer; // rax
signed __int64 buffer_to_key_diff; // r9
__int64 j; // rdx
iv = keystream + 240; // The key schedule is 240 bytes, the IV is stored after it.
if ( length )
{
n_16b_blocks = ((length - 1) >> 4) + 1;
do
{
buffer = plaintext;
buffer_to_key_diff = iv - plaintext;
j = 16;
do
{
*buffer ^= buffer[buffer_to_key_diff]; // *buffer ^= *key[j]
++buffer;
--j;
}
while ( j );
sub_140050560(plaintext, keystream); // AES block encryption routine
iv = plaintext;
plaintext += 16;
--n_16b_blocks;
}
while ( n_16b_blocks );
}
*((_OWORD *)keystream + 15) = *(_OWORD *)iv;
}
This revelation made the semantics of the previous two functions (key derivation & expansion) clear. What needed to be answered is how the 32-byte AES key was derived. As I've already hinted in the disassembly comments, the arguments passed to derive_aes_key_from_host_response_and_time were observed to be the current hour (obtained likely from the formatted timestamp returned by strftime), the username part of the URL received from the first server (in this case, "peanut"), and the username@computername string.
One of the first functions called during the derivation is sub_140439850. The IDA decompiler shows this function reading some constant from read-only memory:
*(_OWORD *)&v24[24] = xmmword_14046A640;
*(_OWORD *)&v24[40] = xmmword_14046A650;
Inspecting this constant reveals the likely employed algorithm to any cryptography nerd; it is the 512-bit initialization vector of the SHA-256 one-way function.
.rdata:000000014046A640 xmmword_14046A640 xmmword 0A54FF53A3C6EF372BB67AE856A09E667h
.rdata:000000014046A650 xmmword_14046A650 xmmword 5BE0CD191F83D9AB9B05688C510E527Fh
This is not surprising, as the cryptographic security and 256-bit output of SHA-256 is ideal for derivation of a 256-bit key; though we still need to figure out what its input is exactly. This is not that hard to figure out based on IDA's decompiler output:
...
sha256_probably(user_host_name, user_host_name_end, p_sha_of_name, p_sha_of_name + 32);
...
sub_1404354A0(concatenated_peanut_hour, hour_len, v11, peanut_ack, peanut_len, hour, hour_len);
...
sha256_probably(concatenated_peanut_hour_ptr, concatenated_peanut_hour_end, p_sha_of_concat, p_sha_of_concat + 32);
...
if ( (*(_QWORD *)key_vec_out > (unsigned __int64)(p_sha_of_name + 31) || v20 < (unsigned __int64)p_sha_of_name)
&& (v19 > (__m128 *)(p_sha_of_concat + 31) || v20 < (unsigned __int64)p_sha_of_concat)
&& (v19 > key_vec_out || v20 < (unsigned __int64)key_vec_out) )
{
*v19 = _mm_xor_ps(
(__m128)_mm_loadu_si128((const __m128i *)p_sha_of_concat),
(__m128)_mm_loadu_si128((const __m128i *)p_sha_of_name));
v19[1] = _mm_xor_ps(
(__m128)_mm_loadu_si128((const __m128i *)p_sha_of_concat + 1),
(__m128)_mm_loadu_si128((const __m128i *)p_sha_of_name + 1));
}
else
{
do
{
*(_BYTE *)(v8 + *(_QWORD *)key_vec_out) = p_sha_of_name[v8] ^ p_sha_of_concat[v8];
++v8;
}
while ( v8 < 0x20 );
}
Without understanding all of the output above, we can make a reasonable assumption that the AES key is obtained as SHA-256("TheBoss@THUNDERNODE") ^ SHA-256(concat("peanut", "06")) (remember that we know from the decrypted bearer token that the conversation took place on 2025-08-20 at 6 AM).
I wrote a Python function (using cryptography.io for AES as usual) to verify this against the key stored in the debugged program's memory:
# pip install cryptography
from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher
from cryptography.hazmat.primitives.padding import PKCS7
import hashlib
def derive_aes_key(host, ack, hour):
def bytesxor(a, b):
return bytes([x ^ y for x, y in zip(a, b)])
s_host = hashlib.sha256(host.encode()).digest()
s_rest = hashlib.sha256((ack + hour).encode()).digest()
return bytesxor(s_host, s_rest)
assert derive_aes_key("TheBoss@THUNDERNODE", "peanut", "23") \
== bytes.fromhex("fe0831f34dc845dac9fecf52b640d5469f3258457c4fae531bf202c5996261f6")
The assertion ran without raising an error, so the derivation function was likely correct. I manually copied some responses from the second "C2" server and tried decrypting them with this key and the IV, which is always set to a constant 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f. Somewhat surprisingly, I got back valid plaintexts, meaning both the key and IV were reused without any changes (for now). Motivated by this discovery, I used the pyshark library to parse all JSONs exchanged in the conversation and try to decrypt them.
import base64
import json
import pyshark
def decrypt(hex_ct):
ct = bytes.fromhex(hex_ct)
cipher = Cipher(algorithms.AES256(aes_key), modes.CBC(aes_iv)).decryptor()
pt_raw = cipher.update(ct) + cipher.finalize()
unpadder = PKCS7(algorithms.AES256.block_size).unpadder()
pt = unpadder.update(pt_raw) + unpadder.finalize()
return pt.decode()
cap = pyshark.FileCapture('packets.pcapng', display_filter='http && http.content_type contains "json"')
for pkt in cap:
try:
data = pkt.http.file_data.binary_value.decode('utf-8', errors='ignore')
json_data = json.loads(data)
if "d" in json_data:
try:
pt = decrypt(json_data["d"])
print("decrypted:", pt)
except Exception as e:
print("not decrypted:", e)
else:
print(f"unencrypted: {json_data}")
except (AttributeError, json.JSONDecodeError):
pass
This was the output:
not decrypted: The length of the provided data is not a multiple of the block length.
decrypted: {"ci":"Architecture: x64, Cores: 2","cn":"THUNDERNODE","hi":"TheBoss@THUNDERNODE","mI":"6143 MB","ov":"Windows 6.2 (Build 9200)","un":"TheBoss"}
decrypted: {"sta": "ok"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "whoiam"}}
decrypted: {"op":""}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "whoami"}}
decrypted: {"op":"thundernode\\theboss\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\""}}
decrypted: {"op":"OS Name: Microsoft Windows 10 Pro\nOS Version: 10.0.19045 N/A Build 19045\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b C:\\Users\\%USERNAME%\\"}}
decrypted: {"op":"3D Objects\nContacts\nDesktop\nDocuments\nDownloads\nFavorites\nLinks\nMusic\nOneDrive\nPictures\nSaved Games\nSearches\nVideos\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "arp -a"}}
decrypted: {"op":"\nInterface: 1.1.1.1 --- 0x1\n Internet Address Physical Address Type\n 224.0.0.22 static \n 224.0.0.251 static \n 224.0.0.252 static \n 239.255.255.250 static \n\nInterface: 192.168.56.103 --- 0x7\n Internet Address Physical Address Type\n 192.168.56.100 08-00-27-ab-e1-14 dynamic \n 192.168.56.117 08-00-27-93-a7-cc dynamic \n 192.168.56.255 ff-ff-ff-ff-ff-ff static \n 224.0.0.22 01-00-5e-00-00-16 static \n 224.0.0.251 01-00-5e-00-00-fb static \n 224.0.0.252 01-00-5e-00-00-fc static \n 239.255.255.250 01-00-5e-7f-ff-fa static \n 255.255.255.255 ff-ff-ff-ff-ff-ff static \n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "query user"}}
decrypted: {"op":" USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\n>theboss console 2 Active none 8/18/2025 8:30 AM\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b C:\\Users\\%USERNAME%\\Desktop"}}
decrypted: {"op":"Google Chrome.lnk\nLyrics.lnk\nnotes.txt\nStudio_Masters_Vault.lnk\n_DELETED_STUFF\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b C:\\Users\\%USERNAME%\\Documents"}}
decrypted: {"op":"boss_tech_notes.txt\nE_Street_Band_Contacts.xlsx\nLyrics\nPersonal_Stuff\nStudio_Masters_Vault\nSweetScape\nTour_Rider_2024.docx\nVisual Studio 2022\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\%USERNAME%\\Documents\\boss_tech_notes.txt"}}
decrypted: {"fc":"","sta":"error cnof"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\boss_tech_notes.txt"}}
decrypted: {"fc":"WWVhaCwgSSBnZXQgaXQuIFNvbWUgZ3V5cywgdGhleSdyZSBoYXBweSBqdXN0IHRvIHR1cm4gdGhlIGtleSBhbmQgZHJpdmUuIEJ1dCB5b3UuLi4geW91IGdvdHRhIHBvcCB0aGUgaG9vZC4gWW91IGdvdHRhIHRyYWNlIHRoZSB3aXJlcywgZmVlbCB0aGUgaGVhdCBjb21pbicgb2ZmIHRoZSBibG9jay4gWW91J3JlIG5vdCBsb29raW5nIHRvIHN0ZWFsIHRoZSBjYXIuLi4geW91J3JlIGp1c3QgdHJ5aW5nIHRvIHVuZGVyc3RhbmQgdGhlIHNvdWwgb2YgdGhlIGVuZ2luZS4gVGhhdCdzIGFuIGhvbmVzdCBuaWdodCdzIHdvcmsgcmlnaHQgdGhlcmUu","sta":"success"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}
not decrypted: Invalid padding bytes.
not decrypted: Invalid padding bytes.
unencrypted: {'sta': 'received'}
not decrypted: Invalid padding bytes.
not decrypted: Invalid padding bytes.
not decrypted: Invalid padding bytes.
not decrypted: Invalid padding bytes.
unencrypted: {'sta': 'received'}
(Many more invalid messages followed, stripped here for obvious reasons.)
From these decrypted messages, it appears our rock-n-roll hero has unfortunately been infected with some kind of a remote access trojan (RAT) capable of executing commands from the attacker and exfiltrating data back to the C2. In this case, the attacker gathered some basic info about the OS, listed some directories, and found boss_tech_notes.txt, which obviously contains the flag. Let's decode the Base64 data:
> [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("WWVhaCwgSSBnZXQgaXQuIFNvbWUgZ3V5cywgdGhleSdyZSBoYXBweSBqdXN0IHRvIHR1cm4gdGhlIGtleSBhbmQgZHJpdmUuIEJ1dCB5b3UuLi4geW91IGdvdHRhIHBvcCB0aGUgaG9vZC4gWW91IGdvdHRhIHRyYWNlIHRoZSB3aXJlcywgZmVlbCB0aGUgaGVhdCBjb21pbicgb2ZmIHRoZSBibG9jay4gWW91J3JlIG5vdCBsb29raW5nIHRvIHN0ZWFsIHRoZSBjYXIuLi4geW91J3JlIGp1c3QgdHJ5aW5nIHRvIHVuZGVyc3RhbmQgdGhlIHNvdWwgb2YgdGhlIGVuZ2luZS4gVGhhdCdzIGFuIGhvbmVzdCBuaWdodCdzIHdvcmsgcmlnaHQgdGhlcmUu"))
"Yeah, I get it. Some guys, they're happy just to turn the key and drive. But you... you gotta pop the hood. You gotta trace the wires, feel the heat comin' off the block. You're not looking to steal the car... you're just trying to understand the soul of the engine. That's an honest night's work right there."
Oh.
I guess we have to decrypt the rest of the messages too. Ok. Let's look at the last message we could successfully decrypt.
{"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}
From this and the previous messages, it seems that when msg = "cmd", d.cid determines the type of command to run. This was indeed the first time a command with ID 6 was issued, so it may be worth reversing what it does.
For this purpose, I wrote a mock Python HTTP server for the second C2, which sent this exact command.
# ...
class Handler(BaseHTTPRequestHandler):
def encrypt_and_send(self, msg):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
aes_key = derive_aes_key("TheBoss@THUNDERNODE", "peanut", "06")
aes_iv = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
pt = msg
padder = PKCS7(algorithms.AES256.block_size).padder()
pt_padded = padder.update(pt) + padder.finalize()
cipher = Cipher(algorithms.AES256(aes_key), modes.CBC(aes_iv)).encryptor()
ct = cipher.update(pt_padded) + cipher.finalize()
self.wfile.write(json.dumps({
"d": ct.hex()
}).encode())
def do_POST(self): # Receive initial fingerprint request
if self.path == "/":
self.encrypt_and_send(b'{"sta": "ok"}')
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not Found")
def do_GET(self):
if self.path == "/get":
self.encrypt_and_send(b'{"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}')
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not Found")
server = HTTPServer(("localhost", 8080), Handler)
print("Serving on http://localhost:8080")
server.serve_forever()
With both servers running, I restarted the debugged binary and made sure the right username, computer name and time would be written to the application context structure. Then I let the code run until it received the command. This lead me to the function shown next:
.text:0000000140221168 call do_rat_stuff
; do_rat_stuff:
.text:000000014012C6F0 loop_begin:
...
.text:000000014012F344 mov ecx, 5000 ; dwMilliseconds
.text:000000014012F349 call cs:__imp_Sleep
...
.text:0000000140131E1E lea rcx, [rsp+3A8h+var_190]
.text:0000000140131E26 call sub_140137840
.text:0000000140131E2B mov rcx, rax
.text:0000000140131E2E call sub_1402640F0 ; "/get"
.text:0000000140131E33 mov rdx, rax
.text:0000000140131E36 lea rcx, [rsp+3A8h+var_168]
.text:0000000140131E3E call std_string_from_cstring
.text:0000000140131E43 nop
.text:0000000140131E44 lea r9, [rsp+3A8h+var_108]
.text:0000000140131E4C mov r8, rax
.text:0000000140131E4F lea rdx, [rsp+3A8h+var_78]
.text:0000000140131E57 mov rcx, r14
.text:0000000140131E5A call send_get_cmd_request
...
.text:0000000140131E6D cmp qword ptr [rsp+3A8h+var_78], 0
.text:0000000140131E76 jz loc_140137824
...
.text:0000000140131E89 cmp dword ptr [rax+20h], 200
.text:0000000140131E90 jnz loc_140137824 ; response code == 200?
...
.text:0000000140134C7D call json_parse
...
.text:00000001401377E7 lea r8, [rsp+3A8h+var_88]
.text:00000001401377EF mov rdx, r15
.text:00000001401377F2 mov rcx, r14
.text:00000001401377F5 call decrypt_and_execute
...
.text:0000000140137824 lea rcx, [rsp+3A8h+var_78]
.text:000000014013782C call response_dtor_M
.text:0000000140137831 jmp loop_begin
Inside decrypt_and_execute, the payload is decrypted as usual using AES and the JSON is parsed.
.text:000000014013B002 lea rdx, [rsp+0B778h+var_CE0]
.text:000000014013B00A mov rcx, rax
.text:000000014013B00D call json_value_to_stdstring
...
.text:000000014013AFBD mov rcx, rax
.text:000000014013AFC0 call sub_140260370 ; Returns "d"
.text:000000014013AFC5 mov rdx, rax
.text:000000014013AFC8 lea rcx, [rsp+0B778h+var_D00]
.text:000000014013AFD0 call std_string_from_cstring
...
.text:000000014013B013 lea rdx, [rsp+0B778h+var_CE0]
.text:000000014013B01B lea rcx, [rsp+0B778h+var_5F0]
.text:000000014013B023 call decode_hex_bytestring
...
.text:000000014013E3B5 call _time64
...
.text:0000000140144F1E call _gmtime64_s
...
.text:000000014014BC80 call cs:strftime
...
.text:000000014015216D lea r9, [rsp+0B778h+hour] ; hour
.text:0000000140152175 mov r8, [rsp+0B778h+peanut_ack] ; peanut_ack
.text:000000014015217D mov rdx, [rsp+0B778h+user_host_name] ; user_host_name
.text:0000000140152185 lea rcx, [rsp+0B778h+key_vec_out] ; key_vec_out
.text:000000014015218D call derive_aes_key_from_host_response_and_time
...
.text:000000014015F1F4 mov rdx, rax
.text:000000014015F1F7 lea rcx, [rsp+0B778h+var_358]
.text:000000014015F1FF call aes256_expand_key
...
.text:000000014016262B mov r8, rcx ; length
.text:000000014016262E mov rdx, rax ; ciphertext
.text:0000000140162631 lea rcx, [rsp+0B778h+var_358] ; keystream
.text:0000000140162639 call aes_decrypt
...
.text:000000014016588E lea rdx, [rsp+0B778h+var_5F0] ; const std::vector &plaintext
.text:0000000140165896 lea rcx, [rsp+0B778h+var_4F8] ; std::string &dst
.text:000000014016589E call std_vector_byte_to_std_string
...
.text:0000000140173BB3 call sub_140258870 ; Returns "cid"
.text:0000000140173BB8 mov rdx, rax
.text:0000000140173BBB mov rcx, [rsp+0B778h+var_12B8]
.text:0000000140173BC3 call std_string_from_cstring
.text:0000000140173BC8 mov [rsp+0B778h+var_1200], rax
.text:0000000140173BD0 mov rdx, [rsp+0B778h+var_1200]
.text:0000000140173BD8 lea rcx, [rsp+0B778h+var_658]
.text:0000000140173BE0 call json_get_value_by_key
.text:0000000140173BE5 mov rcx, rax
.text:0000000140173BE8 call json_get_number_as_int
.text:0000000140173BED mov [rsp+0B778h+cid], eax
.text:0000000140173BF4 mov eax, [rsp+0B778h+cid]
Then, if the payload is a command, the cid is switched to different handlers:
.text:0000000140173BFB mov [rsp+0B778h+cid_2], eax
.text:0000000140173C02 movsxd rax, [rsp+0B778h+cid_2]
.text:0000000140173C0A mov eax, [rsp+0B778h+cid_2]
.text:0000000140173C11 sub eax, 2
.text:0000000140173C14 test eax, eax
.text:0000000140173C16 jz short handle_cid_2
.text:0000000140173C18 dec eax
.text:0000000140173C1A test eax, eax
.text:0000000140173C1C jz handle_cid_3
.text:0000000140173C22 sub eax, 2
.text:0000000140173C25 test eax, eax
.text:0000000140173C27 jz handle_cid_5
.text:0000000140173C2D dec eax
.text:0000000140173C2F test eax, eax
.text:0000000140173C31 jz handle_cid_6
We are of course interested in cid = 6, which is handled thus:
.text:00000001401E4468 call sub_140237DB0 ; Returns "dt"
.text:00000001401E446D mov rdx, rax
.text:00000001401E4470 mov rcx, [rsp+0B778h+var_1040]
.text:00000001401E4478 call std_string_from_cstring
.text:00000001401E447D mov [rsp+0B778h+var_1038], rax
.text:00000001401E4485 mov rdx, [rsp+0B778h+var_1038]
.text:00000001401E448D lea rcx, [rsp+0B778h+var_658]
.text:00000001401E4495 call json_get_value_by_key
.text:00000001401E449A mov rcx, rax
.text:00000001401E449D call json_get_number_as_int
.text:00000001401E44A2 mov [rsp+0B778h+param_dt], eax
...
.text:00000001401EAC79 imul eax, [rsp+0B778h+param_dt], 1000
.text:00000001401EAC84 mov ecx, eax ; dwMilliseconds
.text:00000001401EAC86 call cs:__imp_Sleep
...
.text:00000001401EDEBD call sub_140235EF0 ; Returns "np"
.text:00000001401EDEC2 mov rdx, rax
.text:00000001401EDEC5 mov rcx, [rsp+0B778h+var_12A0]
.text:00000001401EDECD call std_string_from_cstring
.text:00000001401EDED2 mov [rsp+0B778h+var_1158], rax
.text:00000001401EDEDA mov rdx, [rsp+0B778h+var_1158]
.text:00000001401EDEE2 lea rcx, [rsp+0B778h+var_658]
.text:00000001401EDEEA call json_get_value_by_key
.text:00000001401EDEEF lea rdx, [rsp+0B778h+cid_6_param_np]
.text:00000001401EDEF7 mov rcx, rax
.text:00000001401EDEFA call json_value_to_stdstring
...
.text:00000001401F12FC lea rdx, [rsp+0B778h+cid_6_param_np]
.text:00000001401F1304 mov rcx, [rsp+0B778h+struct_from_main]
.text:00000001401F130C call sub_140032650 ; sets "peanut" to received np
...
.text:00000001401FE48F retn
This clarifies the meaning of the other parameters: dt is likely short for delay time, as it governs the number of seconds to sleep before execution, and np obviously has to mean new peanut, because the function of command ID 6, like my comment above kindly reminds me, is to change what was initially the "peanut" string to a new value. In cryptological terms, this parameter is a kind of salt.
The finish line
With this new knowledge in mind, let's augment the traffic decryptor to change the salted peanuts as needed. (I'm also decoding exfiltrated files and saving them to disk, otherwise they would clutter the output.)
i = 0
for pkt in cap:
try:
data = pkt.http.file_data.binary_value.decode('utf-8', errors='ignore')
json_data = json.loads(data)
if "d" in json_data:
try:
pt = decrypt(json_data["d"])
ptj = json.loads(pt)
if "msg" in ptj and ptj["msg"] == "cmd" and ptj["d"]["cid"] == 6:
aes_key = derive_aes_key("TheBoss@THUNDERNODE", ptj["d"]["np"], "06")
print("peanut changed")
elif "fc" in ptj:
i += 1
with open(f"file_{i}.dat", "wb") as f:
f.write(base64.b64decode(ptj["fc"]))
print(f"exfiltrated file written to file_{i}.dat")
continue
print("decrypted:", pt)
except Exception as e:
print("not decrypted:", e)
else:
print(f"unencrypted: {json_data}")
except (AttributeError, json.JSONDecodeError):
pass
This time, the output is more cheerful, with no more unsuccessful decryptions.
// ...
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\boss_tech_notes.txt"}}
exfiltrated file written to file_2.dat
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
peanut changed
decrypted: {"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b /s C:\\Users\\%USERNAME%\\Documents\\Studio_Masters_Vault\\"}}
decrypted: {"op":"..."} // truncated
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b C:\\Users\\%USERNAME%\\Documents\\Studio_Masters_Vault\\The_Vault"}}
decrypted: {"op":"Darkness_Acoustic.mp3\nrocknroll.zip\nThe_River_Outtakes.zip\n"}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\Studio_Masters_Vault\\The_Vault\\rocknroll.zip"}}
exfiltrated file written to file_3.dat
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
peanut changed
decrypted: {"msg": "cmd", "d": {"cid": 6, "dt": 25, "np": "miami"}}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "dir /b /s C:\\Users\\%USERNAME%\\Documents\\Personal_Stuff"}}
decrypted: {"op":"..."} // truncated
unencrypted: {'sta': 'received'}
peanut changed
decrypted: {"msg": "cmd", "d": {"cid": 6, "dt": 1, "np": "miami"}}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\Personal_Stuff\\passwords.txt"}}
exfiltrated file written to file_4.dat
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 2, "line": "echo \"BRUUUUUUUUUUUUUUUUUUUCCCCCEEEEEEEEEEEEEEEEEEEEEEEEE\" > C:\\Users\\%USERNAME%\\Desktop\\thanks.txt"}}
decrypted: {"op":""}
unencrypted: {'sta': 'received'}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "no_op"}
decrypted: {"msg": "cmd", "d": {"cid": 3}}
In total, 3 valid files were saved to disk: C:\Users\TheBoss\Documents\boss_tech_notes.txt as file_2.dat, C:\Users\TheBoss\Documents\Studio_Masters_Vault\The_Vault\rocknroll.zip as file_3.dat and C:\Users\TheBoss\Documents\Personal_Stuff\passwords.txt as file_4.dat.
We have already seen boss_tech_notes.txt, let's look at passwords.txt:
Email: BornToRun!75
Bank: TheRiver##1980
ComputerLogin: TheBossMan
Other: TheBigM@n1942!
There seems to be no flag in this masterclass on bad passwords, so it must be hidden in rocknroll.zip. Indeed, file_3.dat is a valid password-protected ZIP. Because rocknroll dot zip is neither email, nor a bank, nor is it a computer, we decrypt it using the "other" password, TheBigM@n1942!.
The zip contains a single file — the following picture.

Flag
C4N7_ST4R7_A_FLAR3_WITHOUT_4_$PARK@FLARE-ON.COM