Skip to content

Malware Disguised as a "Coding Challenge" - Part 2

This is part 2, see here for part 1: Malware Disguised as a "Coding Challenge" - Part 1

Diving Deeper

After attending Lenny Zeltser's FOR610 SANS course on Malware Analysis, I decided to dive back into this sample from 2024.

Eventually, I was able to observe the complete execution chain and unpack the payload (spoiler: it was LummaC2, as predicted)

image

Multi-Stage Loader: Stage 1

In part 1, the execution flow of the multi-stage loader complicated analysis. I didn't have a solid method for catching child processes with a debugger before they executed, so my dynamic approach was limited to the initial config.exe and the first process-hollowed pla.dll (which downloaded the payload). Taking a closer look at Windows API calls by setting breakpoints on LoadLibraryW and CreateProcessW revealed a 2nd stage via a child process: netsh.exe.

After config.exe process hollows pla.dll and downloads the .png payload, it spawns netsh.exe by calling directly into a CreateProcessW API call inside of shdocvw.dll.

image execution flow moving into shdocvw.dll by calling an address in edi directly

image spawning netsh.exe from within shdocvw.dll via CreateProcessW

It was difficult to debug netsh.exe due to the way it is spawned - I tried changing the flags for "CreateProcessW" to create the process in a suspended state so that I could attach a second x32dbg instance before it executes, but this broke the execution flow of the loader.

Conveniently, stepping through the first process-hollowed pla.dll reveals a 5 second sleep call after it spawns netsh.exe (but before it starts the 2nd stage of the loader). This gives me enough time to suspend the process tree, attach a 2nd debugger instance to netsh.exe, then resume execution.

image pla.dll sleeps for 5 seconds to wait for netsh.exe to initialize

image this gives me 5 seconds to suspend the netsh.exe tree via System Informer, then attach a debugger before resuming

Once netsh.exe is initialized after the 5 second delay, the parent process finishes scheduling future execution that netsh.exe will handle, then exits, leaving netsh.exe running in its own process tree.

Multi-Stage Loader: Stage 2

Behavioral analysis reveals that WerFault.exe is spawned very briefly by netsh.exe:

image Interestingly, no network connections or malicious actions are logged by Process Monitor or fakedns at this stage, which could be an indication that it has detected it's in a VM and exits

Attempting to use hollows_hunter to dump the spawned WerFault.exe has inconsistent results (perhaps due to how quickly the process exits), and the WerFault.exe dump it generates seems to be only partially unpacked or incomplete, so it wasn't usable for static/dynamic analysis.

hollows_hunter.exe /loop /pname WerFault.exe;netsh.exe;pla.dll;conhost.exe

To dump the unpacked payload (most likely the process hollowed WerFault.exe), I set breakpoints on CreateProcessW, ReadProcessMemory, WriteProcessMemory, VirtualAllocEx, VirtualAlloc, and ResumeThread in the x32dbg instance attached to netsh.exe.

Monitoring those breakpoints reveals the spawning of WerFault.exe via rpcrt4.dll (similarly to how the 1st stage used shdocvw.dll to spawn netsh.exe), and eventually writing of the unpacked payload into that new process.

image WerFault.exe is spawned here

Interestingly, the memory region that is copied to WerFault.exe has "MZ" (and the PE header) near the beginning, but not right at the beginning. Dumping this memory region requires some manual cleanup before it can be loaded and analyzed. First, the bytes before "MZ" must be deleted:

image

Then, the bytes at the end (which show up as an "Overlay" in PE-Bear) must be deleted as well.

image "Overlay" seen in PE-Bear

Unpacked Payload

Examining the strings of the dumped PE reveal an interesting phrase. Further research reveals this is most likely random junk text added to LummaC2 payloads, potentially to confuse EDR and ensure hashes of each payload are different:

How to Talk to Your Cat About Gun Safety. Do you love your cat? Well, no self-respecting cat mom or dad would let their baby grow up without a solid grounding in gun safety

image

As a quick check, running FLOSS on the PE reveals many stack and tight strings, and likely uncovers the version of LummaC2 we're dealing with as LummaC2 Build: Mar 27 2024:

$ floss netsh_werfualt-fixedHeader.bin > werfault_floss_out.txt
extracting stackstrings: 100%|███████████████████████████| 121/121 [00:00<00:00, 237.03 functions/s]
INFO: floss.tightstrings: extracting tightstrings from 66 functions...
...
INFO: floss.results: --be85de5ipdocierre1
INFO: floss.results: Content-Disposition: form-data; name="
INFO: floss.results: Content-Type: attachment/x-object
INFO: floss.results: --be85de5ipdocierre1
INFO: floss.results: Content-Disposition: form-data; name="file"; filename="
INFO: floss.results: hwid
INFO: floss.results: send_message
INFO: floss.results: file
INFO: floss.results: Content-Type: multipart/form-data; boundary=be85de5ipdocierre1
INFO: floss.results: 0api
INFO: floss.results: act=life
INFO: floss.results: *+,-./0123456789:;<=>?@ABC
INFO: floss.results: Content-Type: application/x-www-form-urlencoded
INFO: floss.results: name="atok" value="
INFO: floss.results: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
INFO: floss.results: act=recive_message&ver=4.0&lid=
INFO: floss.results: \Last Version
INFO: floss.results: \Local Extension Settings\
INFO: floss.results: /Extensions/
INFO: floss.results: key4.db
INFO: floss.results: cert9.db
INFO: floss.results: formhistory.sqlite
INFO: floss.results: cookies.sqlite
INFO: floss.results: logins.json
INFO: floss.results: places.sqlite
INFO: floss.results: %LocalAppData%
INFO: floss.results: \Packages
INFO: floss.results: %AppData%\Thunderbird\Profiles
INFO: floss.results: `abcdefghij
INFO: floss.results: Thunderbird
INFO: floss.results: \LocalState\Indexed\LiveComm\
INFO: floss.results: .eml
INFO: floss.results: Mails/Windows10Mail
INFO: floss.results: Processes.txt
INFO: floss.results: %AppData%
INFO: floss.results: Discord
INFO: floss.results: DiscordCanary
INFO: floss.results: DiscordPTB
INFO: floss.results: - Path:
INFO: floss.results: - Configuration:
INFO: floss.results: # Buy now: TG @lummanowork
INFO: floss.results: - LummaC2 Build: Mar 27 2024
INFO: floss.results: - LID:
INFO: floss.results: Clipboard.txt
...
extracting tightstrings from function 0x437590: 100%|███████| 66/66 [00:03<00:00, 21.52 functions/s]
INFO: floss.string_decoder: decoding strings
...
INFO: floss.results: Content-Type: application/x-www-form-urlencoded
INFO: floss.results: act=life
INFO: floss.results: --be85de5ipdocierre1
INFO: floss.results: sition: form-data; name="file"; filename="
INFO: floss.results: ent-Type: attachment/x-object
...
INFO: floss.results: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
INFO: floss.results: %SystemDrive%
...

floss output (excerpt)

Static analysis of the PE proves to be difficult and time-consuming due to control flow flattening. OALabs has a great video on scripting in IDA Pro (utilizing unicorn emulator) to patch these dynamic encrypted jumps to make static analysis more user-friendly (available to the OALabs Patreon community).

Instead of static analysis, I decided to take the dynamic approach and let the malware do the work for me.

Dynamic Analysis of LummaC2

Running the dumped malware .exe directly prompts the user with this warning dialog box (which was first seen in CAPE sandbox in part 1):

image this dialog seems to discourage customers of Lumma from leaking the unpacked LummaC2 build

If the user clicks "Yes", the malware first checks if any of the C2 servers are reachable by sending a request with act=life as the command (seen in FLOSS output above). Since the Lumma C2 servers are no longer active, further analysis on the communication protocol wasn't performed, but the C2 server decryption algorithm was reversed.

The C2 server domains are stored in encrypted strings following this format:

DoZexj3m+CDHWYhAIjYmbIZ1JKRDf+ZkuOlpttpBQjh+7jGyUoGKQbcx/ChXUU4bqAZMyzM=

image

Examining the decryption function reveals the following repeating-key XOR decryption algorithm (after decoding the stored base64 string):

  1. First 32 bytes are the key
  2. Remaining bytes are the encrypted string
  3. XOR encrypted string with repeating key to decrypt

The following python script implements the decryption and reveals the C2 domains:

import base64

def decrypt_string(encoded_str):
    """Decrypt LummaC2 encoded string"""
    # Normalize base64: replace custom chars with standard ones
    normalized = encoded_str.replace('-', '+').replace('_', '/').replace('.', '=')

    # Decode base64
    decoded = base64.b64decode(normalized)

    # Extract key (first 32 bytes) and ciphertext
    key = decoded[:32]
    ciphertext = decoded[32:]

    # XOR decrypt
    decrypted = []
    for i, cipher_byte in enumerate(ciphertext):
        key_byte = key[i % 32]
        decrypted.append(cipher_byte ^ key_byte)

    # Convert to string, removing null bytes
    try:
        return ''.join(chr(b) for b in decrypted if b != 0)
    except ValueError:
        return bytes(decrypted)


encrypted_strings = [
    "DoZexj3m+CDHWYhAIjYmbIZ1JKRDf+ZkuOlpttpBQjh+7jGyUoGKQbcx/ChXUU4bqAZMyzM=",
    '4IrgDqe83BYkMRyaj6QCn2LHAVw1MDme0RvFNf4BP7yU641rw9u5c1dIMunt1w=='
    # add more here
]

for encrypted in encrypted_strings:
    decrypted = decrypt_string(encrypted)
    if decrypted:
        print(decrypted)

C2 Domains:

photographthughw.shop
cleartotalfisherwo.shop
worryfillvolcawoi.shop
enthusiasimtitleow.shop
dismissalcylinderhostw.shop
affordcharmcropwo.shop
diskretainvigorousiw.shop
communicationgenerwo.shop
pillowbrocccolipe.shop
tamedgeesy.sbs
repostebhu.sbs
thinkyyokej.sbs
ducksringjk.sbs
explainvees.sbs
brownieyuz.sbs
rottieud.sbs
relalingj.sbs
tamedgeesy.sbs

C2 Communication

At this point I stumbled upon Federico Fantini's Lumma Stealer reversing post, which has a ton of information on LummaC2 communication format, and how it has changed over time. It seems the LummaC2 build I've been examining has a similar communication scheme to the first version Federico outlines in his post (4.0). It's interesting to see how quickly the Lumma authors evolve the communication format, and how dynamic the malware can be based on commands it receives from the server.

References: