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)
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
.
execution flow moving into
shdocvw.dll
by calling an address in edi
directly
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.
pla.dll
sleeps for 5 seconds to wait for netsh.exe
to initialize
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
:
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.
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:
Then, the bytes at the end (which show up as an "Overlay" in PE-Bear) must be deleted as well.
"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
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):
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=
Examining the decryption function reveals the following repeating-key XOR decryption algorithm (after decoding the stored base64 string):
- First 32 bytes are the key
- Remaining bytes are the encrypted string
- 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:
- Lenny Zeltser's FOR610 Course: Learning Malware Analysis and Cybersecurity Writing Online