Reverse engineering a Lumma infection
by Spyros Boulasikis
Lumma is an information stealer that the WithSecure Detection and Response Team (DRT) have encountered several times. It has seen wider use over the past couple of years, and makes for an interesting threat to monitor.
In this post we will focus on a Lumma infection with an initial loader written in .NET/C#, that was observed during a review of open source samples between February and March of 2025.
# Background
Besides vulnerabilities in externally facing infrastructure, which are often the entry point to compute environments, threat actors often rely on leaked or stolen credentials.
A leak implies an infrastructure breach of some kind, such as of a website, and user credential exfiltration. In such as scenario, threat actors either obtain a database of credentials, pick users, and try to crack and reuse their credentials across websites and services, or purchase credentials or targeted initial access information. Another vector credentials are obtained through, is infostealer malware.
Initial Access Brokers (IABs) play exactly this role; they spend significant time searching through underground forums and open source information for ways in, and then broker that access. They also very often target individual users with phishing or malware (infostealer) campaigns or otherwise, to obtain that information.
A breach can be the result of a very wide network of chained groups, services and vulnerabilities, and very often parties across the groups enabling a breach do not know how their actions fit into any wider action.
ENISA herself has acknowledged that IABs "are now an essential component of attack chains". Along malware or ransomware as a service (MaaS or RaaS), IAB is just another specialised service in the shifting threat landscape.
Information stealers are not only aimed at and dangerous for organisations. Based on the type of information exfiltrated, one can say that any person could be severely impacted, and their information can be used for any number of further actions, lateral access, or impersonation.
Stolen information most often include browser databases, which could include browser history, user credentials, as well as crypto currency wallet information, but can in theory also include exfiltrated documents and other users' files. The overall infection however could lead to persistent access on users' personal devices as well.
Another aspect of the evolving threat landscape, is constant revision and updating of tactics, techniques and procedures (TTPs), leading threat actors, attackers and defenders, to adapt and adopt or update malware they deploy as detections tag on threats.
Regarding our detection capability, I couldn't have phrased it better than Ben here, "WithSecure DRT uses a layered approach to detecting multi-stage threats, focusing more on attacker behaviour instead of simple atomic indicators". This in essence is one of the positives of an EDR, tagging on some part of the threat chain, to highlight a wider pattern of activity.
Keeping track of how tactics techniques and procedures evolve on a wider scale is invaluable for detection and response.
Analysing threats as they evolve and going beyond reviewing TTPs as stated in open source reporting is a significant aspect of this process and allows for additional feedback that can be used to improve our detection capability and enable threat hunting.
# LummaStealer
Lumma is a C++ information stealer that WithSecure DRT have encountered several times. It has seen wider use over the past couple of years, and makes for an interesting threat to monitor.
According to Wikipedia, "Lumma" could be a reference to a Mesopotamian god. However, in an interview conducted by g0njxa (interview), Lumma's author stated that the malware was named after a bird as a "symbol of peace, lightness and tranquility", and has been in development since December 21st 2022.
At the time of the interview, which was around the first year's mark, it had about 400 active users; which could of course be groups and not just individuals, but the user base appears to have grown since.
Anecdotally, every time I have looked at Bazaar Abuse.ch's statistics page, it was in the top 10 of most reported malware families.

Lumma was known to contain poetry references in past C2 domains, however not anymore.
Lumma is distributed through Telegram channels and has several service tiers.
In a follow-up Q&A with the developer, they state that Lumma requires to be packed to remain FUD (fully undetectable). The significance of this is that the developer focuses on the payload itself, and outsources the loader

News broke while this post was being written, that, the US Department of Justice (DOJ), Europol and Japan's Cybercrime Center (JC3) seized Lumma's control panel and infrastructure around the world.
Microsoft also stated that between March 16 and May 16 of 2025, they identified over 394000 Windows computers globally infected by Lumma. Microsoft Threat Intelligence team's work on Lumma can be found here.
The operation was a significant disruption, however it seems that the threat actors are still active.
Based on how the threat landscape shifts in general, Lumma will recover, or the same threat actor(s) will sprout back up in some form, or a similar strain will take Lumma's place.
Regardless, analysing Lumma behaviourally is important based on its prevalence and market share, as information stealers are a growing trend that is here to stay, and others might also mimic its TTPs.
Synopsis:
- FUD
- Information stealer
- Malware as a Service
# stage 1 - C# unpack
The sample I selected to analyse for this blogpost was observed as far as I can tell, based on its uploading date on both Bazaar, as well as on VirusTotal, in February of 2025.
Overall this looks like a 32-bit .NET binary, written in C#.


Looking through the file's information in a .NET decompiler, shows the assembly's name, "Purpose".


Some of the assembly's methods appear to be obfuscated, however there are also references to Windows API functions.


ImplMap contains references to functions that, from .NET's point of view, are unmanaged. These are interesting API calls. VirtualProtect is a Windows32 API function used to modify memory page permissions. CallWindowProcA per Microsoft's documentation, "passes a message to the specified window procedure".

The Main function can be seen below.
The sample, first gets the path and filename of the executable that started the application, its own information, using the property Application.ExecutablePath
from within System.Windows.Forms.dll
.

Regarding checks, the loader reads itself from disk using File.ReadAllBytes
, and its contents are then accessible via the byte array.
It then checks a two-byte value at position 0 within the array using BitConverter.ToInt16
as well as a four-byte value at byte position 60 (BitConverter.ToUInt32
), and returns if they are not the ones it expects.

The first check reads 2 bytes from the array at offset 0, converts them to a 16-bit unsigned integer, and compares them to 23117
.
At the start of the file, is the DOS header, and 0x4D 0x5A
(MZ
) are the header's magic bytes.

The bytes are read in reverse due to the CPU being little endian. 0x5a4d
represented as an unsigned integer (uint16) is 23117
.

Therefore, if the file has a valid DOS header, the loader continues.
Following, this, Main()
, using BitConverter.ToInt32
, reads 4 bytes at offset 60 in the byte array, saves that in variable num
, and uses it as an offset into the array. It reads 4 bytes from that location, and compares them to 17744
.

Decimal 60 is 0x3c in hexadecimal. This, is the DOS Header's field e_lfanew
, which contains the offset to the PE header. (ms-docs)

The below struct, is IMAGE_DOS_HEADER
, which is the DOS Header struct definition.
//0x40 bytes (sizeof)
struct _IMAGE_DOS_HEADER
{
USHORT e_magic; //0x0
USHORT e_cblp; //0x2
USHORT e_cp; //0x4
USHORT e_crlc; //0x6
USHORT e_cparhdr; //0x8
USHORT e_minalloc; //0xa
USHORT e_maxalloc; //0xc
USHORT e_ss; //0xe
USHORT e_sp; //0x10
USHORT e_csum; //0x12
USHORT e_ip; //0x14
USHORT e_cs; //0x16
USHORT e_lfarlc; //0x18
USHORT e_ovno; //0x1a
USHORT e_res[4]; //0x1c
USHORT e_oemid; //0x24
USHORT e_oeminfo; //0x26
USHORT e_res2[10]; //0x28
LONG e_lfanew; //0x3c
};
The bytes read are the ones highlighted below. They are read from right to left. The number read is 0x00000080. As this field is e_lfanew
, this means that if we go to offset 0x80 in the file, we should find the PE header.

Main()
reads 4 bytes there as well, the ones highlighted below, the magic bytes of the PE Header, 0x5045
, or PE
.

The 4 byte value, is compared to 17744
, which based on the below is a check to verify that the file has a valid PE header.

At this point, the variable num contains the base address of the PE Header.

The next line reads, in 2 bytes at offset 0x6
of the PE header.
The PE Header struct, IMAGE_NT_HEADERS
.
//0xf8 bytes (sizeof)
struct _IMAGE_NT_HEADERS
{
ULONG Signature; //0x0
struct _IMAGE_FILE_HEADER FileHeader; //0x4
struct _IMAGE_OPTIONAL_HEADER OptionalHeader; //0x18
};
Its embedded struct, IMAGE_FILE_HEADER
.
//0x14 bytes (sizeof)
struct _IMAGE_FILE_HEADER
{
USHORT Machine; //0x0
USHORT NumberOfSections; //0x2
ULONG TimeDateStamp; //0x4
ULONG PointerToSymbolTable; //0x8
ULONG NumberOfSymbols; //0xc
USHORT SizeOfOptionalHeader; //0x10
USHORT Characteristics; //0x12
};
So reading 2 bytes at (PE Header base) + (offset 0x6)
, means that Main reads the NumberOfSections
and saves it into num2
.
SizeOfOptionalHeader
(0xe0) is then saved into num3
, and is used to find the address of the first section header.
//0xe0 bytes (sizeof)
struct _IMAGE_OPTIONAL_HEADER
{
USHORT Magic; //0x0
UCHAR MajorLinkerVersion; //0x2
UCHAR MinorLinkerVersion; //0x3
ULONG SizeOfCode; //0x4
ULONG SizeOfInitializedData; //0x8
ULONG SizeOfUninitializedData; //0xc
ULONG AddressOfEntryPoint; //0x10
ULONG BaseOfCode; //0x14
ULONG BaseOfData; //0x18
ULONG ImageBase; //0x1c
ULONG SectionAlignment; //0x20
ULONG FileAlignment; //0x24
USHORT MajorOperatingSystemVersion; //0x28
USHORT MinorOperatingSystemVersion; //0x2a
USHORT MajorImageVersion; //0x2c
USHORT MinorImageVersion; //0x2e
USHORT MajorSubsystemVersion; //0x30
USHORT MinorSubsystemVersion; //0x32
ULONG Win32VersionValue; //0x34
ULONG SizeOfImage; //0x38
ULONG SizeOfHeaders; //0x3c
ULONG CheckSum; //0x40
USHORT Subsystem; //0x44
USHORT DllCharacteristics; //0x46
ULONG SizeOfStackReserve; //0x48
ULONG SizeOfStackCommit; //0x4c
ULONG SizeOfHeapReserve; //0x50
ULONG SizeOfHeapCommit; //0x54
ULONG LoaderFlags; //0x58
ULONG NumberOfRvaAndSizes; //0x5c
struct _IMAGE_DATA_DIRECTORY DataDirectory[16]; //0x60
};
Variable num4
is at the start of the first IMAGE_SECTION_HEADER
struct the file has.
//0x28 bytes (sizeof)
struct _IMAGE_SECTION_HEADER
{
UCHAR Name[8]; //0x0
union
{
ULONG PhysicalAddress; //0x8
ULONG VirtualSize; //0x8
} Misc; //0x8
ULONG VirtualAddress; //0xc
ULONG SizeOfRawData; //0x10
ULONG PointerToRawData; //0x14
ULONG PointerToRelocations; //0x18
ULONG PointerToLinenumbers; //0x1c
USHORT NumberOfRelocations; //0x20
USHORT NumberOfLinenumbers; //0x22
ULONG Characteristics; //0x24
};
The first one can be seen below.

Notice that the size of an IMAGE_SECTION_HEADER
struct is 40 bytes and that its first property is the section's name.
We can see that the malware iterates through IMAGE_SECTION_HEADER
structs, with num5 pointing at the base of each of them.

What the loop does:
- ParseTree calculates a hash based on the name of each section's name, which is compared to a string generated by a static base64 string. This is a way to select a specific section, without directly showing which one.
- Hashing the section names of the binary with sha256, shows that the malware is searching for the
.CODE
section. - Converting the base64 string results in a hash (CyberChef - recipe)
- This is the same as
sha256(.CODE)
(CyberChef - recipe)
- Hashing the section names of the binary with sha256, shows that the malware is searching for the
Encoding.ASCII.GetString(array, num5, 8)
, reads 8 bytes at indexnum5
(the base of one of theseIMAGE_SECTION_HEADER
structs), which contains the name of the given section.- The loop effectively copies the section to the byte array
sectionContent
.
In pseudocode:
num6 = PointerToRawData
num7 = SizeOfRawData
sectionContent = new byte[section->SizeOfRawData]
Array.Copy(array, PointerToRawData, sectionContent, 0L, SizeOfRawData)
=
Array.Copy(Array sourceArray, long sourceIndex, Array destinationArray, long destinationIndex, long length)
The program then calls Finder().FindNewState()
.

Finder.FindNewState()
calls LookupPointer()
and VirtualProtect()
.

LookupPointer()
, is a decryption function that is first used to decrypt the section's contents. -> (LookupPointer(byte[] data, int dataLength, byte[] key, int keyLength)
)

VirtualProtect()
(ms-docs) is used to set the permissions of the target memory location (memory page). Based on the protection constant being 64u = 0x40
, the target memory page is set to PAGE_EXECUTE_READWRITE
(RWX).
- In this case, the target memory is the page where the array
Program.inputData[0]
resides in.

After the page has RWX permissions, the decrypting function LookupPointer()
, can decrypt contents of the byte array inputData
, using the key dataKey
.

CallWindowProcA()
is effectively used as an eval()
, to effectively resume execution once stage one has been decrypted in memory.
Infection Chain (at least for now):

# stage 2 - C# unpack - in debugger
Running the sample in a Virtual Machine, results in the below infection chain. The sample appears to be spawning another instance of itself, which in turn communicates with a C2. In this case, an internal test proxy was set up, at 192.168.253[.]132
, to simulate active threat actor (TA) infrastructure.

Capturing network traffic reveals the domains the sample attempts to reach out to; these are the below (besides the windows update one).
The sample attempts to reach out to a collection of [.]com
, [.]top
, [.]biz
, [.]pro
, and [.]help
domains, followed by a connection to Steam (steamcommunity[.]com
).

Based on the analysis of stage 1 and this activity graph, it seems that overall, this is a packed sample acting as a loader/dropper.
Packing (MITRE 1027.002) is a technique used to obfuscate and hide code, in essence, concealing an executable file within another, by a combination of encryption and compression. The payload is typically de-obfuscated in memory and run.
This is done to hide APIs and strings that the payload contains, to evade analysis mechanisms, as well as hinder manual analysis.
Furthermore, based on this Unpac me entry and this LoaderInsight agency entry, this seems to be Amadey loader dropping Lumma.
With this and the stage 1 dotnet disassembly in mind, we can trace the loader's execution in a debugger. As the binary is a 32-bit binary, I opened the file in x32dbg.
I set a breakpoint on CallWindowsProcA()
, as that is the "eval" function in Finder.FindNewState()
.


Running the program results in the breakpoint being hit.

If execution resumes from that point on, the program throws an exception and exits.

At the same time, one can see network connections to C2 addresses.
Restarting execution and heading back to the breakpoint and manually stepping through the code is the best approach to trace what the sample does.

Eventually, the function will execute what the instruction whose location is in EDI during the call.


We can see that the section starts with a CALL <address>
followed by POP <register>
to the next instruction, making this look very much shellcode-like.
As a "gadget", it is a way to reliably obtain the address of the beginning of the current shellcode block, characteristic of position independent code (PIC).
As this is effectively the section deobfuscated from stage 1, it might be written to a different memory location each time stage 1 is run. This is a way for the shellcode to refer to some other instruction in itself, as a pointer or otherwise.
At instruction 0x02B42272, the sample obtains a pointer to the current Process' Environment Block (PEB) through FS register. This is saved in EDI at 0x02B42272
.
Parsing the Process Environment Block (PEB) is a way to identify loaded DLLs, parse their exports, resolve their location dynamically in memory, and essentially be able to call any exported functions, without revealing their names in the given DLL's Import Address Table (IAT).
This is typically seen in conjunction with functions that allow loading a DLL such as GetProcAddress
, VirtuallAlloc
, or LoadLibraryA
/LoadLibraryW
and is done by most malware seen today.
Broadly, the steps followed when parsing the PEB to retrieve exported functions is:
- Through the Thread Environment Block pointer stored in FS or GS registers, obtain the base address of the PEB in memory. In x86 architecture, this is at
FS:[0x30]
(which means0x30
bytes after the address stored in the FS register), and in x64, atGS[0x60]
, (which means0x60
bytes from the address stored in the GS register) - Load / Find DLL
- Parse its exports looking for a list of functions.
- Store pointers to the functions needed somewhere.
- do work.
FS register in x86
, contains the base address of the Thread Environment Block (TEB). At offset 0x30
, is the base address of the Process Environment Block (PEB).
//0x1000 bytes (sizeof)
struct _TEB
{
struct _NT_TIB NtTib; //0x0
VOID EnvironmentPointer; //0x1c
struct _CLIENT_ID ClientId; //0x20
VOID ActiveRpcHandle; //0x28
VOID ThreadLocalStoragePointer; //0x2c
struct _PEB ProcessEnvironmentBlock; //0x30
...
}
As this is x86, at offset 0xc
, one can find the pointer _PEB_LDR_DATA ldr
.
//0x480 bytes (sizeof)
struct _PEB
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages:1; //0x3
UCHAR IsProtectedProcess:1; //0x3
UCHAR IsImageDynamicallyRelocated:1; //0x3
UCHAR SkipPatchingUser32Forwarders:1; //0x3
UCHAR IsPackagedProcess:1; //0x3
UCHAR IsAppContainer:1; //0x3
UCHAR IsProtectedProcessLight:1; //0x3
UCHAR IsLongPathAwareProcess:1; //0x3
};
};
VOID* Mutant; //0x4
VOID* ImageBaseAddress; //0x8
struct _PEB_LDR_DATA* Ldr; //0xc
struct _RTL_USER_PROCESS_PARAMETERS* ProcessParameters; //0x10
...
//0x30 bytes (sizeof)
struct _PEB_LDR_DATA
{
ULONG Length; //0x0
UCHAR Initialized; //0x4
VOID* SsHandle; //0x8
struct _LIST_ENTRY InLoadOrderModuleList; //0xc
struct _LIST_ENTRY InMemoryOrderModuleList; //0x14
struct _LIST_ENTRY InInitializationOrderModuleList; //0x1c
VOID* EntryInProgress; //0x24
UCHAR ShutdownInProgress; //0x28
VOID* ShutdownThreadId; //0x2c
};
With the above in mind, we can read these instructions as below.

0x02B42272 EDI = TEB->ProcessEnvironmentBlock
0x02B42276 EDI = PEB->Ldr
0x02B42279 ESI = _PEB_LDR_DATA->InLoadOrderModuleList
What is in ESI at this point is _PEB_LDR_DATA->InLoadOrderModuleList
, a doubly-linked list that references all DLLs loaded by the process, in the order they were loaded. The forward link (flink) and backward link (blink) are pointers to the next and previous _LIST_ENTRY
objects.
//0x8 bytes (sizeof)
struct _LIST_ENTRY
{
struct _LIST_ENTRY* Flink; //0x0
struct _LIST_ENTRY* Blink; //0x4
};
The Flink
pointer, if cast as a LDR_DATA_TABLE_ENTRY structure, can be used to read information about the given DLL.
The overall structure is effectively a Loader Data Table, with a doubly linked list pointing to next and previous LDR_DATA_TABLE_ENTRY
objects.
Below, one can see the _LDR_DATA_TABLE_ENTRY
struct.
- Notably at offset 0x18 the property DllBase contains the address where the DLL is loaded
- At offset 0x2c, the property BaseDllName, contains a pointer to the DLL's name.
//0xa8 bytes (sizeof)
struct _LDR_DATA_TABLE_ENTRY
{
struct _LIST_ENTRY InLoadOrderLinks; //0x0
struct _LIST_ENTRY InMemoryOrderLinks; //0x8
struct _LIST_ENTRY InInitializationOrderLinks; //0x10
VOID* DllBase; //0x18
VOID* EntryPoint; //0x1c
ULONG SizeOfImage; //0x20
struct _UNICODE_STRING FullDllName; //0x24
struct _UNICODE_STRING BaseDllName; //0x2c
union
{
UCHAR FlagGroup[4]; //0x34
ULONG Flags; //0x34
struct
{
ULONG PackagedBinary:1; //0x34
ULONG MarkedForRemoval:1; //0x34
ULONG ImageDll:1; //0x34
ULONG LoadNotificationsSent:1; //0x34
ULONG TelemetryEntryProcessed:1; //0x34
ULONG ProcessStaticImport:1; //0x34
ULONG InLegacyLists:1; //0x34
ULONG InIndexes:1; //0x34
ULONG ShimDll:1; //0x34
ULONG InExceptionTable:1; //0x34
ULONG ReservedFlags1:2; //0x34
ULONG LoadInProgress:1; //0x34
ULONG LoadConfigProcessed:1; //0x34
ULONG EntryProcessed:1; //0x34
ULONG ProtectDelayLoad:1; //0x34
ULONG ReservedFlags3:2; //0x34
ULONG DontCallForThreads:1; //0x34
ULONG ProcessAttachCalled:1; //0x34
ULONG ProcessAttachFailed:1; //0x34
ULONG CorDeferredValidate:1; //0x34
ULONG CorImage:1; //0x34
ULONG DontRelocate:1; //0x34
ULONG CorILOnly:1; //0x34
ULONG ChpeImage:1; //0x34
ULONG ReservedFlags5:2; //0x34
ULONG Redirected:1; //0x34
ULONG ReservedFlags6:2; //0x34
ULONG CompatDatabaseProcessed:1; //0x34
};
};
USHORT ObsoleteLoadCount; //0x38
USHORT TlsIndex; //0x3a
struct _LIST_ENTRY HashLinks; //0x3c
ULONG TimeDateStamp; //0x44
struct _ACTIVATION_CONTEXT* EntryPointActivationContext; //0x48
VOID* Lock; //0x4c
struct _LDR_DDAG_NODE* DdagNode; //0x50
struct _LIST_ENTRY NodeModuleLink; //0x54
struct _LDRP_LOAD_CONTEXT* LoadContext; //0x5c
VOID* ParentDllBase; //0x60
VOID* SwitchBackContext; //0x64
struct _RTL_BALANCED_NODE BaseAddressIndexNode; //0x68
struct _RTL_BALANCED_NODE MappingInfoIndexNode; //0x74
ULONG OriginalBase; //0x80
union _LARGE_INTEGER LoadTime; //0x88
ULONG BaseNameHashValue; //0x90
enum _LDR_DLL_LOAD_REASON LoadReason; //0x94
ULONG ImplicitPathOptions; //0x98
ULONG ReferenceCount; //0x9c
ULONG DependentLoadFlags; //0xa0
UCHAR SigningLevel; //0xa4
};
Focusing on the next instruction, at 0x02B42282
, one can see, through 0x18
, _LDR_DATA_TABLE_ENTRY->DllBase
being saved into EAX.

// previously:
0x02B42272 EDI = TEB->ProcessEnvironmentBlock
0x02B42276 EDI = PEB->Ldr
0x02B42279 ESI = _PEB_LDR_DATA->InLoadOrderModuleList
// now:
EAX = _LDR_DATA_TABLE_ENTRY->DllBase
EDI = _LDR_DATA_TABLE_ENTRY->DllBase
EDI = DllBase + e_lfanew => EDI = &(PE Header)
Here, at instruction 0x02B4228a
, one can see that EDI is set to contain the address of the PE Header of the first DLL in the list, as what is effectively being done is:
DllBase + 0x3c
=>
DllBase + e_lfanew = PE Header
At instruction 0x02B4228D
, what is effectively being done is obtaining the RVA (Relative Virtual Address) of the Export Directory Table of the DLL. This is the first object in the DataDirectory[16]
array.
DllBase + 0x3c
=>
DllBase + e_lfanew = (PE Header)
followed by:
(PE Header) + 0x78
=>
(PE Header) + 0x18 + 0x60
=>
(PE Header) + OptionalHeader + 0x60
=>
(PE Header) + OptionalHeader + DataDirectory[0]
=> DataDirectory[0]->VirtualAddress
=> Export Directory Table RVA of the DLL
As a reminder, below I have attached the structs that are used in this calculation.
The DOS Header, _IMAGE_DOS_HEADER
(the MZ
struct).
//0x40 bytes (sizeof)
struct _IMAGE_DOS_HEADER
{
USHORT e_magic; //0x0
USHORT e_cblp; //0x2
USHORT e_cp; //0x4
USHORT e_crlc; //0x6
USHORT e_cparhdr; //0x8
USHORT e_minalloc; //0xa
USHORT e_maxalloc; //0xc
USHORT e_ss; //0xe
USHORT e_sp; //0x10
USHORT e_csum; //0x12
USHORT e_ip; //0x14
USHORT e_cs; //0x16
USHORT e_lfarlc; //0x18
USHORT e_ovno; //0x1a
USHORT e_res[4]; //0x1c
USHORT e_oemid; //0x24
USHORT e_oeminfo; //0x26
USHORT e_res2[10]; //0x28
LONG e_lfanew; //0x3c
};
The PE Header struct, IMAGE_NT_HEADERS
, ( the PE
struct).
//0xf8 bytes (sizeof)
struct _IMAGE_NT_HEADERS
{
ULONG Signature; //0x0
struct _IMAGE_FILE_HEADER FileHeader; //0x4
struct _IMAGE_OPTIONAL_HEADER OptionalHeader; //0x18
};
The Optional Header, whose property, DataDirectory
, is an array of _IMAGE_DATA_DIRECTORY
objects.
In the PE file specification, the first one is the RVA to Export Directory Table (ms-docs -> winnt.h).
//0xe0 bytes (sizeof)
struct _IMAGE_OPTIONAL_HEADER
{
USHORT Magic; //0x0
UCHAR MajorLinkerVersion; //0x2
UCHAR MinorLinkerVersion; //0x3
ULONG SizeOfCode; //0x4
ULONG SizeOfInitializedData; //0x8
ULONG SizeOfUninitializedData; //0xc
ULONG AddressOfEntryPoint; //0x10
ULONG BaseOfCode; //0x14
ULONG BaseOfData; //0x18
ULONG ImageBase; //0x1c
ULONG SectionAlignment; //0x20
ULONG FileAlignment; //0x24
USHORT MajorOperatingSystemVersion; //0x28
USHORT MinorOperatingSystemVersion; //0x2a
USHORT MajorImageVersion; //0x2c
USHORT MinorImageVersion; //0x2e
USHORT MajorSubsystemVersion; //0x30
USHORT MinorSubsystemVersion; //0x32
ULONG Win32VersionValue; //0x34
ULONG SizeOfImage; //0x38
ULONG SizeOfHeaders; //0x3c
ULONG CheckSum; //0x40
USHORT Subsystem; //0x44
USHORT DllCharacteristics; //0x46
ULONG SizeOfStackReserve; //0x48
ULONG SizeOfStackCommit; //0x4c
ULONG SizeOfHeapReserve; //0x50
ULONG SizeOfHeapCommit; //0x54
ULONG LoaderFlags; //0x58
ULONG NumberOfRvaAndSizes; //0x5c
struct _IMAGE_DATA_DIRECTORY DataDirectory[16]; //0x60
};
//0x8 bytes (sizeof)
struct _IMAGE_DATA_DIRECTORY
{
ULONG VirtualAddress; //0x0
ULONG Size; //0x4
};
Therefore thus far, the assembly does the below.
// previously:
0x02B42272 EDI = TEB->ProcessEnvironmentBlock
0x02B42276 EDI = PEB->Ldr
0x02B42279 ESI = _PEB_LDR_DATA->InLoadOrderModuleList
EAX = _LDR_DATA_TABLE_ENTRY->DllBase
EDI = _LDR_DATA_TABLE_ENTRY->DllBase
EDI = DllBase + e_lfanew => EDI = &(PE Header)
// now:
(EDX = DataDirectory[0]->VirtualAddress)
0x02B42290 EDX += EAX => EDX = (DataDirectory[0]->VirtualAddress + DllBase)
0x02B42292 EDI = (Export Directory Table)->AddressOfNames (as 0x20 = 0d32)
0x02B42295 EDI = Address of the Name Pointer Table (RVA)
Microsoft's documentation on Export Directory Table can be found here.
(Export Directory Table)->AddressOfNames
contains the RVA of the Name Pointer Table, which contains RVAs into the Export Name Table.
The Export Name Table contains the strings other executables can use to import functions.
As a sidenote, EAX contains the DllBase
of the target DLL. Here it is kernel32.dll
.


From here on, we can see a loop between the instructions 0x02B42299
and 0x02B422AE
.
First, at instruction 97, EBP is set to 0 (EBX had been XORed against itself as a way to zero it out)
EBP is then used as an index at offset EDI. We can see that the index is used to read a DWORD (4 bytes) during each loop iteration, and that is saved into ESI.

At 0x02B42299, during the first iteration, ESI
is set to EDI + 0
, therefore to the RVA of the Name Pointer Table.
- This is literally a table of RVA pointers.
- EDI contains the base address of the Name Pointer Table

Looking at this in memory:

At 0x02B4229c, the instruction ADD ESI, EAX
is effectively ESI += EAX
, which is in turn ESI += DllBase
.
Therefore, after this instruction ESI will contain the base address of an Export Table Entry in memory. The name of a function exported.

Indeed the memory location contains the null terminated function name.

At 0x02B4229F, the comparison compares a dword amount of bytes at the memory location pointed at by ESI, that is 4 bytes at the start of the string, to the bytes 0x64616F4C
Consulting the ascii table on this one, shows that the values are: "daoL".

Due to the CPU being little-endian, the bytes are flipped, therefore the actual string the malware is looking for is "Load"
.
Looking for LoadLibrary is characteristic of malware, as this allows loading a DLL dynamically.
Indeed, at 0x02B422A7
, 4 bytes are being compared to 0x41797261
, which stands for "Ayra"
.
Hence, this loop overall is searching the DLL for LoadLibraryA
(ms-docs).

Let's calculate one of these as an example, based on the screenshot below.
After the loop ESI contains the address of the string LoadLibraryA
.
EAX still points at the base address of "kernel32.dll
".
EDI still points to the base of the name pointer table.
EBP is the index that is multiplied by 4 (as an instruction's address is 4 bytes long in x86, and we are iterating over RVAs), and is then added to EDI, to identify the RVA to the given exported API.
This then is added to EAX to obtain the address of the string, which is dereferenced in the comparisons (via dword ptr ds:[esi]
).

This is stored in ESI and was used in the comparison statements.

Looking at this memory location, we can see the RVA 0x09CBD5
.

Adding the base address of the DLL from EAX and the RVA.

The memory address is the address of the null terminated string LoadLibraryA
.

At the last loop's iteration this would mean that we had:
0x3c5
(as EBP has been incremented after the calculation).- Adding the base address of the Name Pointer Table
Right after the loop
- At
0x02B422B0
, we can find the(Export Directory Table)->AddressOfNameOrdinals
, which contains the Export Ordinal Table RVA (ms-docs) saved to EDI. - The Export Ordinal Table contains an array of indices into the Export Address Table, and has the same index as the Export Name Table.

At 0x02B422B3
, the absolute address of the Export Ordinal Table is saved in EDI.
(Export Directory Table)->AddressOfNameOrdinals + (previous index) * 2
=> (Export Directory Table)->AddressOfNameOrdinals + 0x03c6 * 2
EBP = ordinal of the function = 0x3c7
Following through manually:

The Export Ordinal Table in memory:

The Ordinal of LoadLibraryA()
in memory:

And at 0x02B422B5
EBP is set to contain 0x3c7, which is effectively the ordinal of LoadLibraryA()
.

At 0x02B422B9
, based on the below calculation, EDX is set to contain the RVA of the Export Address Table, (Export Directory Table)->AddressOfFunctions
.
EDI = (DataDirectory[0]->VirtualAddress + DllBase)
EDI += 0x1c =>
0x02B422B9 EDI = (Export Directory Table)->AddressOfFunctions
0x02B422BC EDI = (Export Directory Table)->AddressOfFunctions + DllBase
At 0x02B422BE
, EDI is set to contain the RVA of LoadLibraryA()
based on its ordinal stored in EBP; this is the calculation:
0x02B422BE EDI = EDI + EBP * 4 - 4
=>
0x02B422BE EDI = (Address of the Export Address Table) + Ordinal * 4 - 4
=>
0x02B422BE EDI = RVA of function

Following through manually:
Export Address Table in memory.


The RVA of LoadLibraryA()
in memory, 0x020BD0
.

After adding DllBase to that at 0x02B422C2
, EDI contains the address of LoadLibraryA()
in memory.

After this, the address of LoadLibraryA() in memory is saved at the memory location pointed at by EBP. This location was stored on the stack, and is retrieved via pop ebp
. At 0x02B422C5
one can see the LoadLibraryA pointer being written there.
0x02B422C4 pop ebp
0x02B422C5 mov dword ptr ss:[ebp],edi
0x02B422C8 push ebp
0x02B422C9 mov eax,dword ptr ss:[ebp+8]
This is followed up by storing the memory location pointer on the stack once again, and loading a value from a location past that, in eax
.
That value is the base address of kernel32.dll
.
In the below screenshot, notice the value of EAX and the 3d column in the data dump below; it's the same value stored in little-endian.

At 0x02B422c9
, EAX is set to be the base address of kernel32.dll, just as it was before.
At 0x02B422CC
, the base address is stored in EDI. This then is followed by obtaining the same offsets as before, to the Export Directory Table.

Notice that another loop follows, between addresses 0x02B422DB
and 0x02B422F2
, this time searching for GetProcAddress()
.
0x50746547
, which in ASCII isPteG
0x73736572
, which in ASCII issser
As before
- At
0x02B422F4
&0x02B422F7
, the absolute address of the Export Ordinal Table is saved in EDI, just like during the previous loop. - At
0x02B422F9
,GetProcAddress
' ordinal is read into bp and so on.
Essentially, at 0x02B42306
, EDI will contain the address of GetProcAddress()
in memory.
This is then at the location stored in EBP, 4 bytes after. This memory location is right next to the address of LoadLibraryA
saved earlier. The below screenshot is from before the writing.

The highlighted location is the address of GetProcAddress
.

GetProcAddress()
and LoadLibraryA()
form a very useful combination to malware, as:
LoadLibrary
loads a DLL by name.GetProcAddress
retrieves the address of an exported function or variable from the target DLL.
Following this, there is a long section of instruction blocks that:
- loads register ESI with the base address of "
kernel32.dll
" - loads register EDI with the address of a string saved at an offset from EBP. This string contains the name of a Windows API.
- loads register EAX with the address of
GetProcAddress
in memory. - pushes contents of EDI and ESI onto the stack, effectively passing their contents as arguments to the function called subsequently.
- calls the function whose location is stored in EAX, which is
GetProcAddress, as below
GetProcAddress(&(DLL), &(str_api))
- EAX contents are then saved at an offset from EBP. This is the return value of
GetProcAddress()
.

Notice that in the data section, one can see the APIs that will be imported, at an offset past the address stored in EBP, after the base address of "kernel32.dll
", LoadLibraryA
, and GetProcAddress
.
The address of CreateProcessW
is stored at 0x02B42224
, which is at EBP + 0x150
.

Overall, these 8 APIs listed can be used for remote process injection, which fits with the dynamic analysis activity graph, but we will cross the bridge when we get there.

The table of resolved API addresses.

Immediately after forming the API table, the malware prepares a call to CreateProcessW.
CreateProcessW takes 10 arguments.
Of course, as this is x86, arguments are passed to it from right to left onto the stack, so all PUSH instructions push arguments.
The instructions between 0x02B423A7
and 0x02B423F9
are the preparation for the CALL.

At 0x02B423A7
, it copies the address of CreateProcessW
into EAX
Notably, at 0x02B423F3
, one can see the offset 0x28
of a _LDR_DATA_TABLE_ENTRY
struct, _LDR_DATA_TABLE_ENTRY->0x28
which is between the properties: FullDllName
and BaseDllName
being saved into EDI. Effectively a pointer to the DLL's name is saved in EDI.
The below shows all arguments to CreateProcessW.
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName, -> path to executable
[in, out, optional] LPWSTR lpCommandLine, -> 0 = NULL
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes, -> 0 = NULL
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes, -> 0 = NULL
[in] BOOL bInheritHandles, -> 0 = NULL
[in] DWORD dwCreationFlags, -> 4 -> suspended
[in, optional] LPVOID lpEnvironment, -> 0
[in, optional] LPCWSTR lpCurrentDirectory, -> 0
[in] LPSTARTUPINFOW lpStartupInfo, -> ESI
[out] LPPROCESS_INFORMATION lpProcessInformation -> EDX
);
Note that the dwCreationFLags passed here is 0x4, which means suspended; CREATE_SUSPENDED -> 0x00000004

This was somewhat expected based on the APIs the sample wanted to locate. These overall make it seem as if it will launch a new process, suspended, and overwrite the new process' memory, before resuming the new process' execution.
Effectively remote process injection.

Then, the sample calls VirtualAlloc as below.

This essentially commits a page of memory with read write permissions (ms-docs protection constants). This is often done to make sure that the committed memory is zeroed out in addition to the permissions being read write when the page is actually committed.
LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress, -> 0x0 => let the OS decide where
[in] SIZE_T dwSize, -> 0x4 => however the smallest unit this operates on is a memory page
[in] DWORD flAllocationType, -> 0x1000 = MEM_COMMIT
[in] DWORD flProtect -> 0x4 = PAGE_READWRITE
);
The returned address is the memory location, and its address is also saved at an offset from EBP.


The sample then calls GetThreadContext
.
BOOL GetThreadContext(
[in] HANDLE hThread,
[in, out] LPCONTEXT lpContext
);
The first argument to that is a handle to the target thread. This is obtained through a PROCESS_INFORMATION structure, which is populated by the call to CreateProcessW. Subsequent actions use it as it contains handles to the target process and thread.
typedef struct _PROCESS_INFORMATION {
HANDLE hProcess;
HANDLE hThread;
DWORD dwProcessId;
DWORD dwThreadId;
} PROCESS_INFORMATION, *PPROCESS_INFORMATION, *LPPROCESS_INFORMATION;
Here, the handle to the target thread is 0x338
, and we can tell that it refers to process with PID 5240.

This matches the PID of the suspended process as well.

During the call this handle is the first argument to the function call.

The second argument, lpContext
is of type CONTEXT. Essentially, it retrieves CPU state for the thread.
typedef struct _CONTEXT {
DWORD ContextFlags;
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
FLOATING_SAVE_AREA FloatSave;
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
DWORD Ebp;
DWORD Eip;
DWORD SegCs;
DWORD EFlags;
DWORD Esp;
DWORD SegSs;
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
The sample then calls ReadProcessMemory
against the new process.
BOOL ReadProcessMemory(
[in] HANDLE hProcess, -> again from the PROCESS_INFORMATION structure from CreateProcessW
[in] LPCVOID lpBaseAddress, -> read bytes from here.
[out] LPVOID lpBuffer, -> save bytes read at the memory location pointed at by lpBuffer
[in] SIZE_T nSize, -> 0x4 => 4 bytes
[out] SIZE_T *lpNumberOfBytesRead -> integer that returns how many bytes were read
);
Notice that the process handle also matches the one of the target process, in the call shown below.


The malware then calls VirtualAllocEx, which "reserves, commits, or changes the state of a region of memory within the virtual address space of a specified process".
LPVOID VirtualAllocEx(
[in] HANDLE hProcess, -> 0xf4
[in, optional] LPVOID lpAddress, -> 0x00400000
[in] SIZE_T dwSize, -> 0x5f000 size in bytes
[in] DWORD flAllocationType, -> 0x3000 => MEM_COMMIT | MEM_RESERVE
[in] DWORD flProtect -> 0x40 => PAGE_EXECUTE_READWRITE
);
The returned value is the location of the newly allocated memory.

The newly allocated memory location is 0x00400000
, in EAX.

Following this, the malware calls WriteProcessMemory
against the suspended process.
BOOL WriteProcessMemory(
[in] HANDLE hProcess,
[in] LPVOID lpBaseAddress, -> the newly allocated memory's base address
[in] LPCVOID lpBuffer, -> buffer to be written to the target memory location
[in] SIZE_T nSize, -> number of bytes to write
[out] SIZE_T *lpNumberOfBytesWritten
);

Looking at the buffer written, it appears to be 0x400
bytes of PE Header.

EAX is 0x1 after the call, therefore the function was successful.
After this memory write, between 0x02B424E4 and 0x02B42528, there is another loop.
This iterates over IMAGE_SECTION_HEADER structs of the file the PE header written to the remote process was from. The location is stored in EDX.

Each iteration reads the section header struct, and effectively writes the section to the remote process.
- The loop uses DX as an index, and compares it against DI, which contains the number of sections as parsed by the PE header.
The first one, writes the .text section, directly following the header wrote earlier, which was the 0x400 bytes.
- (as
0x03BA0D70 + 0x400 = 0x03BA1170)


BOOL WriteProcessMemory(
[in] HANDLE hProcess,
[in] LPVOID lpBaseAddress, -> 0x00401000
[in] LPCVOID lpBuffer, -> 0x03BA1170
[in] SIZE_T nSize, -> 0x49000
[out] SIZE_T *lpNumberOfBytesWritten
);
Next, one can see .rdata, followed by .data, and . reloc being written to the remote process.


This loop appears to be mapping the PE file into memory.
Following it, there is another WriteProcessMemory call, which appears to be writing the 4 bytes long SizeOfHeapReserve property of the PE header, to the remote process.

Immediately aftewards, we see a call to SetThreadContext.
BOOL SetThreadContext(
[in] HANDLE hThread,
[in] const CONTEXT *lpContext
);
Argument 2 is a CONTEXT struct.
typedef struct _CONTEXT {
DWORD ContextFlags;
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
FLOATING_SAVE_AREA FloatSave;
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
DWORD Ebp;
DWORD Eip;
DWORD SegCs;
DWORD EFlags;
DWORD Esp;
DWORD SegSs;
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
typedef struct _WOW64_FLOATING_SAVE_AREA {
DWORD ControlWord;
DWORD StatusWord;
DWORD TagWord;
DWORD ErrorOffset;
DWORD ErrorSelector;
DWORD DataOffset;
DWORD DataSelector;
BYTE RegisterArea[WOW64_SIZE_OF_80387_REGISTERS];
DWORD Cr0NpxState;
} WOW64_FLOATING_SAVE_AREA;
With that done, the malware calls ResumeThread. The handle again refers to the thread of the suspended process.


From here, I dumped the binary.
Based on this, we can update the infection graph overall:

# stage 3
The unpacked sample, unpacked.exe
, still has high entropy, which means that it is still likely obfuscated.

I was surprised to find that running unpacked.exe
itself through a double-click shows the below "do you want to run malware" box.

This appears to be because the developers of Lumma made sure that people cannot directly send the malware to victims without packing it, as a countermeasure to perhaps help them keep the Lumma payload "fully undetectable" (FUD) for a longer time.
- Clicking yes runs the malware.
- Clicking no calls process exit.
This stage is best analysed by tracing this in a debugger and disassembler side by side. I used Binary Ninja for static analysis, and x32dbg and WinDBG for dynamic analysis.
Here I split up analysis to what happens before spawning the window, and what happens after pressing yes.
The sample at several spots calculates a value dynamically, saves it in a register, and uses that value in an arithmetic operation such as EAX * 4 + <static integer>
. The result of this is an address where execution jumps to, using an instruction like this one JMP dword [EAX * 4 + <static integer>]
.
While in the malware there certainly are functions and function calls, this way, functions are effectively broken down into way smaller code blocks, that could even be in random order in memory, and are connected via JMPs to dynamically calculated addresses.
This is a technique known as Control Flow Flattening.

Perhaps due to remote references to the starting addresses of these code blocks, as well as due to the overall dynamic nature of the calculations in between them, disassemblers consider these individual blocks as separate functions.
While this effectively makes these code blocks a bit easier to look at and analyse in isolation, having to track local variables in registers as well as stack or other memory locations adds a layer of complexity.
Due to this, analysis in this stage will focus more on the overall action flow rather than instruction flow.
Based on quick dynamic analysis, the overall infection chain should be something like this.

# before spawning the window.
First, the sample needs to do some set up. Usually that means resolving the addresses of functions it needs, and deobfuscation of key information.
Going to the PE entry point and following through, reveals that the first thing the sample seems to be doing, is resolving the address of ntdll.dll
.
The process starts with a loop that decodes the string L"ntdll.dll"
- the loop starts with static strings seen below (as offsets to EAX)
- ECX is used as the counter
- EDX starts at 0xb4
- each byte of the buffer is XORed with the lowest byte in EDX, 0xb8 is added to it, and the byte is written back to its source location, at EAX + ECX (offset)

In python pseudocode.

The function sub_2079a0
resolves the base address of the target DLL given the DLL's name. Function sub_2079a0
calls sub_207940
, where, the malware obtains a pointer to the PEB. Just as described in the analysis of stage 2, this is the offset 0x30
into the TEB.

This is done in a way that does not directly call FS[0x30]
, rather it is done via FS[EAX]
, as a way to confuse automated disassembly and slow down analysis a little bit. There is effectively a loop that sets ESP so that eventually it will contain the location of 0x30
in memory.

The address of the PEB is stored in EAX.

In sub_2079f0
, one can observe the _LDR_DATA_TABLE_ENTRY->DllBase
, being returned from the function, the base of ntdll.dll
.

At 0x002079f0
, 0x18
is added to EDI, which is the base address of a LDR_DATA_TABLE_ENTRY
struct; 0x18
is the offset to DllBase
.
At 0x002079f3
, the address of ntdll.dll
is written to the memory pointed at by ESP (top of the stack), which can be seen on the bottom right. Notice that it contains 0x77950000
.

Looking at the memory map, confirms that this is indeed where ntdll
is loaded.

Below, I added a part of the struct _LDR_DATA_TABLE_ENTRY
for clarity. The previous section contains more about how the overarching DLL information resolution is done.
//0xa8 bytes (sizeof)
struct _LDR_DATA_TABLE_ENTRY
{
struct _LIST_ENTRY InLoadOrderLinks; //0x0
struct _LIST_ENTRY InMemoryOrderLinks; //0x8
struct _LIST_ENTRY InInitializationOrderLinks; //0x10
VOID* DllBase; //0x18
VOID* EntryPoint; //0x1c
ULONG SizeOfImage; //0x20
struct _UNICODE_STRING FullDllName; //0x24
struct _UNICODE_STRING BaseDllName; //0x2c
...
After applying the correct type to the argument, what sub_2079f0
, does is a bit clearer.

After the jump, EAX is set to contain the base address of the DLL and the function returns.

Once the function returns, the base address of ntdll.dll
is saved in a raw variable for later use, at 0x21a68c
.

In Binary Ninja, that is data_21a68c
. The overall process can be seen in the disassembly below.
Essentially, the overarching sub_2079a0
resolved the base address of the DLL, and due to this I renamed it.

Immediately after this, the sample retrieves static values that are saved in variables such as data_2118a8
, which are then used in calculations to prepare for the next operation.
The next operation takes place in sub_205a13
. This function effectively calls sub_207cd0
several times in a row with two arguments.
sub_207cd0(<base of ntdll.dll>, <API hash>)
Note that I renamed the function to sub_207cd0_mw_retrieve_function_address
, because it effectively uses the second argument, to identify where the target function is in the memory of the loaded DLL.

There are a lot of references to this function in the malware. One would expect the flow overall to be, it effectively collecting all or some of the functions it needs, and then proceeding with its aim. It seems to be doing just that.

The function is provided some API hash and returns the API's address in memory. The caller then stores the API's address to a variable.

For instance, here, at 0x00205a8f
, the function resolves ntdll!RtlAllocateHeap
, and stores it at data_21a694
, and at 0x00205aa7
, it resolves ntdll!RtlReAllocateHeap
.

The sample then resolves the base of kernel32.dll
in memory using the same function that was used to resolve the base of ntdll.dll
, sub_2079a0
.

Once it does that, it retrieves the address of LoadLibraryExW
in memory, using sub_207cd0_mw_retrieve_function_address
, and saves it in a variable as well.


Then, the malware using sub_2079a0
, retrieves the base address of user32.dll
's in memory and the flow continues.

After initial setup, in sub_205790
, the malware continues set up in sub_1fe680
.

In there, eventually, the malware ends up in sub_1fed90
, which decodes the strings present in the pop-up.


Once that is done, it calls sub_206040
.

At first glance, this looks like a function where one passes something and it is executed, however the disassembler does not have enough information to show what exactly that is.


Following through with x32dbg shows that the CALL leads to a JMP 33:<address>
instruction.

Executing this jump spawns the window:

Unfortunately, x32dbg cannot show information about how this was done, as instructions include effectively jumping into a different segment to execute x64-bit code, a syscall, and it is not designed for that, therefore I used WinDbg.
I set up breakpoints at the entry point and at 0x206040
and followed through.
The break point at 0x001ff274
is hit.

The instruction that follows the CALL can be seen below.

The jump syntax is jmp 33:<address>
. This is called a far jump.
The below screenshot shows exactly what follows this. Notice anything weird? -> The registers have swapped to x64; for instance, one can see rax
instead of eax
.

The far jump essentially set the code segment register to 0x33
, which effectively is what swaps the CPU from x86
mode, to x64
.

Based on the loaded modules, the jump is to wow64cpu.dll
.

What follows is set up of a lot of registers and the saving of the EFLAGS register to the stack (using the pushfq
instruction).

It is a bit clearer that execution has jumped inside wow64cpu.dll
. After some preparatory work, execution jumps into wow64.dll
. This is also visible in the call stack on the bottom right.

Preparatory work continues; one can observe a series of calls, including to wow64!Wow64ProcessPendingCrossProcessItems
.

In the meantime, these are the loaded modules during this.

All of this process eventually leads to a call in ntdll
, and more specifically ntdll!NtRaiseHardError
.

This syscall is what spawns the window in the end. Notice the syscall number 0x167
being passed into EAX, at 0x7ffc - 821afa33
. Executing the syscall instruction spawns the warning window.

This technique of moving from x32-bit to x64-bit and back is also referred to as Heaven's Gate.

With this information, let's revisit what the malware does before it spawns the window.
- In
sub_205790_init_setup
resolves the base ofntdll
,kernel32
anduser32
, and uses them to find functions that the DLLs export that it needs. - In
sub_1fe680
, it eventually spawns the window.

After this, the malware either jumps to 0x001cbc24
, where it continues its execution flow, or it exits after calling FreeLibrary
and ExitProcess
.

Analysis needs to continue in sub_1fe680
. In sub_1fe680
, as analysed earlier, in sub_207940
, the malware obtains the address of the PEB.
Then, in sub_1fec91
, obtains a handle to the malware file on disk by calling sub_209620
, which internally calls the Heaven's gate function to call ZwOpenFile
.
The malware earlier obtained its full file path.


Eventually the function calls ZwOpenFile
; this returns a handle to the malware file on disk.

After this, again with a call to the heaven's gate function, which internally calls NtQueryInformationFile
, the malware obtains the size of the file on disk.


The third argument, FileInformation
, is a pointer to a buffer that receives the requested information; in this case this was the file's size.

Following through the flow, the malware calls the heaven's gate function again. Behaviourally, one can see that it read itself from disk.

After the syscall [ESP + 14]
, 0x0061d730
contains the contents of the file.


Thus we know that the malware read its entire file from disk.
Immediately after the function this was done in, ESI contains the base address of the read from disk malware file in memory.

Flow continues to sub_2002e4
.

Notice that ESI
is stored on the stack, as well as in ECX
.
A static value is added to it in ECX
.
A static value is saved in EDI
, and that one is also used to set EDX
.
The pointers in ESI
and EDX
are effectively dereferenced to retrieve one byte from each, and these bytes are compared at 0x0020031b
.
This is a loop with EAX
as the counter. EAX
is initialised to 0x14
(20 in decimal) and is decreased in each step at 0x0020030c
with the add eax, 0xffffffff
(which is -1).
ECX
and EDI
are incremented by one in each iteration to effectively move and compare one byte at a time.
If the compared buffers are the same, ECX
is XORed with itself, is therefore effectively zeroed out (ECX = 0
), otherwise it contains another value.
After the loop, at 0x00200331
, ECX
is checked to check if its contents are zero or not. The TEST
instruction followed by the SETE
instruction are a "gadget" used to set the value of the lowest byte of ECX
depending on the value.
The value of ESI
which is also affected by the loop is used to set where execution flow will go next.
Looking at this in a debugger shows that what is being checked is 0x14
(20 in decimal) bytes of the currently running process' address space, against 0x14
bytes from the file on disk.
Below is the last iteration of the loop. This iteration is checking the byte 0xBC
against the value in the lowest byte in EBX
, BL
, 0xBC
.

If the check passes, execution flow continues to 0x00200329
.
Note the bytes that were compared; highlighted below are the bytes of the currently running process.

Highlighted below are the bytes of the malware file that was loaded from disk.

Highlighted below are the bytes of the malware file on disk.

Again, this is where the file was loaded by the previous heaven's gate syscall.

If the executable is not packed and has been run itself, the flow continues at 0x1ffddd
, where the value of ECX is used to determine where to jump, and eventually the malware spawns the warning window.


Here one can observe that the value of ECX was set in this case, as here the unpacked malware was run.

If the executable is packed, execution flow runs the rest of the malware's functionality, without spawning the window.

As a final note on this, please notice the string "FATE99--test" in close proximity to the 0x14 bytes being checked.
It is possible that the bytes being checked are a unique identifier like a "license" or form an extended build version number, along with the "fate" string.
It is also however possible that the version number is the "fate" string and these are totally random numbers assigned during the build process.
Regardless, this is the mechanism that the malware uses to identify whether it has been run in a packed form.
As an additional pre-flight check, Lumma retrieves the current OS' default language setting by resolving the address of, and calling GetUserDefaultUILanguage
.

This function returns an integer that identifies the current OS' default language setting. The values are specified in ms-docs. This value is returned in EAX.


Notice that at 0x001ff5a9
, this returned value is saved in EDX.
This value is then compared indirectly, by checking the result of the SUB instruction at 0x001ff66f
. The register AL is set based on the result via SETE AL
.
Right before the SUB instruction, EAX contains the value 0x419
, which according to Microsoft's documentation is ru-RU
, therefore this is a check to verify if the system the malware was detonated on is set up in the Russian language.

With both file/packing check and OS language check done, the sample is ready to continue with what happens after one would press yes.
# spawning the window and pressing yes
So what happens when one presses Yes?

The malware resolves the base addresses of ws2_32.dll
and winhttp.dll
.


It retrieves the base address of the function WSAStartup
, and calls it to initiate socket communication.

It resolves the below APIs from winhttp.dll
. Notice that the function sub_207cd0
, given the DLL's name and an API hash, once again, returns the address of the given API and the caller saves it to a variable for future use.



This is followed by what is effectively a loop, although it is once again broken down into several segments due to control flow obfuscation.
The loop begins by decrypting a C2 domain, in sub_1d0620
with ChaCha20, and then sends a POST request to the domain, in sub_1cf0b0
.

Part of the decryption block between 0x001d0800
and 0x001d0a07
can be seen below.
- Each of these below sequences of ADD, XOR and rotate operations are ChaCha quarter rounds.
- Paraphrasing the Salsa specification (as this is true for ChaCha's nature as well.): "The entire series of modifications is a series of 10 identical double-rounds. Each double-round is a series of 2 rounds. Each round is a set of 4 parallel quarter-rounds. Each quarter-round modifies" 4 DWORDs.

We can see that the loop runs 10 times, through the comparison against 0xa
(10
in decimal).

Once the decryption function returns, the C2 domain is passed into sub_1ce520
, so that the null terminated C string can be cast to a UTF-16 (wide) string.
The C2 domain is converted one byte at a time.
The below two screenshots show the destination buffer before and after the call.


The POST request workflow starts in the wrapper that called the decryption function, however, the actual request is sent in sub_1cf4a0
.

We can see a big buffer being written to the stack, followed by retrieving of the previously saved address of winhttp!WinHttpOpen
.


After a call to WinHttpOpen, the sample retrieves the previously resolved address of WinHttpConnect, and sets the request to connect to the port 443 of the C2 server; 0x1bb in hex is 443 in decimal.

The sample then creates a POST request, via a call to WinHttpOpenRequest.

Eventually, the POST request is sent to the C2 via WinHttpSendRequest
.

WINHTTPAPI BOOL WinHttpSendRequest(
[in] HINTERNET hRequest, -> handle to the request obtained from WinHttpOpenRequest.
[in, optional] LPCWSTR lpszHeaders, -> pointer to the request headers, highlighted in the dump section below
[in] DWORD dwHeadersLength, -> 0xffffffff = -1 => headers will be null terminated
[in, optional] LPVOID lpOptional, -> pointer to the POST data sent => "act=life"
[in] DWORD dwOptionalLength, -> length of POST data sent => 0x8 bytes
[in] DWORD dwTotalLength,
[in] DWORD_PTR dwContext
);

Once that is run, the C2 receives the network connection.

The return value of WinHttpSendRequest
is checked, and depending on its value, the sample may or may not jump to a code block that runs WinHttpReceiveResponse
. Alternatively, the flow just continues on to the next domain.

The return value of WinHttpReceiveResponse if the call is successful, is true. This value is checked as well, and if successful, eventually, the malware calls WinHttpQueryDataAvailable
.
Then, depending on whether WinHttpQueryDataAvailable is successful, execution jumps to a block of code that calls WinHttpReadData
.

This flow eventually executes WinHttpReadData
.

Due to the fact that some time has passed since this campaign took place, the sample cannot retrieve the reply it expects, which most likely will be the config, which is partly why I will end my analysis of this here. The scope of this post is limited, in that I attempted to avoid actually interacting with any C2 servers.
Nevertheless, there are enough information here for threat hunting, detection improvements, as well as for the curious.
# threat hunting indicators
Threat hunting ideas:
- new file -> remote process injection against itself
- new file -> remote memory allocation (with RWX permissions)
- remote memory allocation or remote process injection combined with loading of
ws2_32.dll
,winhttp.dll
or a network connection - all common information stealer actions (accessing browser data or user's files) done by an unexpected binary
- rare binary accessing sensitive user's files in conjunction with network connections
- social media as C2 vector. Rare binary that connects to "social media" domains but is unaffiliated with the social media itself, just like the steamcommunity connection seen in Lumma
- first stage lolbin commands (such as "
cmd.exe -c
",powershell
with "-iex
", or any other lolbin in the "/download
" category), as one would expect of a LNK infection or a clickfix infection. - explorer or browser -launching-> rare binary
Sigma rule: Binary that connects to 3 or more domains in the below TLDs.
title: Suspicious Connection to TLDs or Steamcommunity API as Seen in Lumma Infections.
description: Detects possible Lumma infections based on non-browser network connections to TLDs or steamcommunity API as often seen in Lumma infections.
references:
- https://labs.withsecure.com
author: Spyros Boulasikis (ren-zxcyq)
date: 2025/06/20
tags:
- c2
- T1071.001
- T1102
logsource:
product: windows
category: network_connection
detection:
selection:
DestinationHostname|contains:
- 'steamcommunity.com'
- '.help'
- '.top'
- '.shop'
- '.biz'
- '.pro'
- '.xyz'
- '.com'
filter_chrome:
Image|endswith:
- '\chrome.exe
filter_firefox:
Image|endswith:
- '\firefox.exe'
filter_edge:
Image|endswith:
- '\msedge.exe'
- '\msedgewebview2.exe'
- '\microsoftedge.exe'
filter_ie:
Image|endswith: '\iexplore.exe'
filter_safari:
Image|endswith: '\safari.exe'
filter_brave:
Image|endswith: '\brave.exe'
filter_opera:
Image|endswith: '\opera.exe'
filter_vivaldi:
Image|endswith: '\vivaldi.exe'
condition: 3 of selection and not 1 of filter_*
falsepositives:
- Pretty broad filter, mainly use as a hunting idea. Legitimate applications communicating with an api or any legitimate website in selection.
level: medium
Sigma rule: Tagging on the initial POST request to the C2 domains.
title: Lumma Stealer - Possible egress POST request
description: Detects the initial Lumma POST request sent by Lumma. Information tagged on are the User Agent and URI.
references:
- https://labs.withsecure.com
author: Spyros Boulasikis (ren-zxcyq)
date: 2025/06/20
tags:
- c2
- T1071.001
logsource:
product: windows
category: network_connection
detection:
selection:
cs-method: 'POST'
cs-uri|contains: '/api'
cs-uri-query|contains: 'act=life'
c-useragent|contains: 'Mozilla/5.0 (Windows NT 10; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119'
cs-content-type|contains: 'application/x-www-form-url-encoded'
condition: selection
falsepositives:
- Processes that communicate with applications which use the URI as a legitimate part of their flow or API.
level: high
Yara rule: Based on strings observed in the samples analysed.
rule possible_lumma_infostealer {
meta:
description = "Detects possible Lumma infostealer binary or related file based on embedded strings."
author = "Spyros Boulasikis (ren-zxcyq)"
date = "2025-6-28"
severity = "MEDIUM"
strings:
$s0 = "LummaC2"
$s1 = "@lummanowork"
$s2 = "@lumamarketplace_bot"
$s3 = "LID"
$s4 = "DiscordCanary"
$s5 = "DiscordPTB"
$s6 = "FATE99"
$s7 = "--test"
$s8 = "Purpose.exe"
$s9 = "steamcommunity.com"
$s10 = "=="
condition:
filesize >= 4000KB
and 2 of ($s*)
}
# anything actionable in terms of administration?
In case of infection:
- Make sure to password reset any accounts in the user's browser database.
- Reset AD creds just in case.
- Consider 2FA usage.
Prior to infection:
- Phishing training for your users.
- Monitor TI feeds and block egress to suspicious domains.
- Depending on your organisation's risk appetite and deployed tooling you might be able to block egress traffic to TLDs such as .top / .fun / .xyz / .help / .pro.
- Blacklisting of lolbins that could be used to deliver a second stage based on user group.
- For instance, an HR or less technical employee, might perhaps not need to be able to run
cmd.exe
,powershell.exe
orcurl
. Consider blocking them by user group.
- For instance, an HR or less technical employee, might perhaps not need to be able to run
- Consider 2FA usage for important corporate accounts.
- Depending on your organisation's risk appetite, consider using a password manager to store credentials off device, for instance on an external hard drive, until credentials are actually needed.