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.

Packet capture file opened with Wireshark

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-Agent HTTP header is Mozilla/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 Authorization header bearing the value Bearer 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.

Start of the main function in IDA view

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:

IDA highlights occurrences of whatever is under the cursor

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.

guitar_sax_flag.jpg

Flag

C4N7_ST4R7_A_FLAR3_WITHOUT_4_$PARK@FLARE-ON.COM