A walkthrough of one real Lumma Stealer sample, from a password-protected MalwareBazaar zip to a ready-to-block IOC list, focused on the moves and the tools a working analyst actually uses on the bench.
| Attribute | Value |
|---|---|
| Sample | lumma.bin (renamed from sha256-named MalwareBazaar drop) |
| Source | MalwareBazaar (password infected, if you want to follow along) |
| SHA-256 | 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5 |
| MD5 / SHA-1 | 29b42ea66238343322eacb9fd0c8b1b4 / 2ef48e359e02ff7bfdf099efeb38cb7a8deac775 |
| File type | PE32 (i386), Windows GUI subsystem, 4 sections, 317 440 bytes |
| Linker | MSVC 14.x, TimeDateStamp 2024-09-12 11:22:23 UTC |
| Family | Lumma Stealer (a.k.a. LummaC2) |
| Lab | Kali Linux for static work; Windows 10 / FlareVM in a snapshotted Hyper-V box for any dynamic step |
TL;DR: This Lumma build hides almost everything you'd grep for. Only 39 imports across 5 DLLs (no WS2_32, no WININET, no CreateFile), 528 ASCII strings with zero suspicious hits in the obvious buckets, and a .text section that swallows 80 % of the binary. Despite all that, in about an hour we recover 9 hardcoded .shop C2 domains, the full Lumma 4.0 HTTP protocol surface (including the new os_crypt.app_bound_encrypted_key Chrome 127 ABE bypass), and a YARA rule that catches the build family without a single false positive. The interesting moves: spotting a base64 cluster with a 38-character common prefix and treating it as keystream reuse, then watching FLOSS unwrap ~100 inline MBA stack-strings for free.
0. The lab and the rules of engagement
Before you ever click extract on a MalwareBazaar zip, the rules of engagement matter more than the tools.
- Kali for static, Windows VM for dynamic. Kali handles
rabin2,objdump,strings,yara,floss, Ghidra, Cutter, CyberChef-in-a-tab. That's enough to extract every IOC in this writeup without ever running the binary. For dynamic, you want a snapshotted Windows VM (FlareVM is the standard) on an isolated vSwitch with internet routed through a fake-net like INetSim or FakeNet-NG. - Defang on disk. I
chmod -xthe unzipped PE and store it inside a directory literally calledinfected/so any tool I run later (or any future me opening a Finder window) thinks twice. On Windows, append.malzto the name and exclude that extension from Defender's scanner. - Snapshot the VM before every run. Lumma writes registry keys, drops a copy in
%LOCALAPPDATA%\Temp\, and beacons home, all non-revertible state. - No live C2. Even when the campaign is "old," abuse.ch's domain resolver feeds
urlhausand your beacons end up in someone's threat-feed sample volume. Use ahostsfile, INetSim, or sinkhole and watch on Wireshark.
The unzip step is non-trivial. MalwareBazaar packages samples in AES-encrypted zips with the password infected. Plain unzip(1) on Linux can't decrypt AES-zip; use 7z x -pinfected file.zip (works, AES-aware) or unzip from the PKWARE-zip-AES patch. 7-Zip's GUI on Windows handles it natively.
1. The sample lands: fingerprint pass
The first 60 seconds of any sample is the same: hash, type, size, sections, imports. If you have PE-bear or CFF Explorer open in your VM, dragging the file in gives you everything below in a clickable tree. On Kali I prefer the CLI for repeatability, but the screenshots that go in a customer report come from the GUI tools.
$ sha256sum lumma.bin
4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5 lumma.bin
$ file lumma.bin
lumma.bin: PE32 executable for MS Windows 6.00 (GUI), Intel i386, 4 sections
$ rabin2 -I lumma.bin
arch x86
bits 32
class PE32
compiled Thu Sep 12 11:22:23 2024
canary true
nx true
pic true
subsys Windows GUI
$ rabin2 -S lumma.bin
nth paddr size vaddr vsize perm flags type name
―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
0 0x00000400 0x3f400 0x00401000 0x40000 -r-x 0x60000020 ---- .text
1 0x0003f800 0x2a00 0x00441000 0x3000 -r-- 0x40000040 ---- .rdata
2 0x00042200 0x6a00 0x00444000 0x10000 -rw- 0xc0000040 ---- .data
3 0x00048c00 0x4c00 0x00454000 0x5000 -r-- 0x42000040 ---- .relocTwo things jump:
.textis 0x3f400 (~253 KB) of a 317 KB file, so 80 % code..rdatais barely 10 KB, which is tiny for any non-trivial app.- Whole-file Shannon entropy 6.70 bits/byte. That's the sweet spot of "real x86 code with embedded encrypted blobs." A fully packed UPX/Themida binary would be ≥7.5; clean code with strings sits around 5.5–6.0.
import math, collections
d = open('lumma.bin','rb').read()
c = collections.Counter(d); n = len(d)
print(-sum((v/n)*math.log2(v/n) for v in c.values())) # 6.6984The headline number averages over everything. With Detect It Easy (DIE), open the file and hit the entropy graph button: you see entropy per section and a sliding window inside each. Lumma's .text shows two distinct flat zones around 6.4 (real code) interrupted by a 7.9 spike (the encrypted blob). DIE is GUI, cross-platform, free; no excuse not to have it.
2. Imports tell a story (mostly by what's missing)
$ rabin2 -l lumma.bin
user32.dll
kernel32.dll
ole32.dll
oleaut32.dll
gdi32.dllThat's it. Five DLLs. Total 39 imports. Look at what isn't there:
| Expected for a stealer | Present? |
|---|---|
WS2_32.dll, WININET.dll, WINHTTP.dll (network) | No |
ADVAPI32.dll (registry, crypto) | No |
CRYPT32.dll (DPAPI / cert store) | No |
SHELL32.dll / SHLWAPI.dll (path / shell) | No |
KERNEL32!CreateFile*, ReadFile, WriteFile | No |
KERNEL32!LoadLibrary*, GetProcAddress | No |
The present imports paint a deceptive picture: clipboard reads (OpenClipboard/GetClipboardData), GDI screenshot primitives (BitBlt/CreateCompatibleBitmap/GetDIBits/StretchBlt), and COM init for WMI (CoInitializeSecurity, CoSetProxyBlanket, SysReAllocString). A linter would call this "screenshot/clipboard utility." A stealer needs network, registry, and disk; none are imported.
Conclusion before disassembly: dynamic API resolution. The sample resolves kernel32!LoadLibraryW via PEB walk + export-name hashing, then loads winhttp.dll, advapi32.dll, crypt32.dll, shlwapi.dll, wininet.dll at runtime, hashes those exports too, and stores the resolved pointers in a private table. Every later API call goes through that table, so the IAT only contains the few APIs the resolver itself needs to bootstrap.
Flip your import-analysis heuristic from "what's there" to "what should be there but isn't." A stealer with no WS2_32, a ransomware with no CryptEncrypt, an installer with no RegSetValueEx: these absences are louder than presences. PE-bear's "Imports" tab and PEStudio's blacklisted-import view both highlight this; PEStudio additionally flags suspicious-by-default APIs and is a one-click triage tool I run in every report.
3. Strings: when grep finds nothing useful
$ strings -a -n 6 lumma.bin | wc -l
528
$ strings -a -n 6 -e l lumma.bin | wc -l
5528 ASCII + 5 UTF-16. Now the keyword sweep:
$ strings -a -n 6 lumma.bin | egrep -i 'http|\.com|\.shop|\.ru|wallet|chrome|firefox|telegram|discord|cookie|password|user-agent|mozilla'
(no output)Zero hits. 528 strings and not a single URL, browser name, wallet keyword, or HTTP method. Either the sample is trivial, or every interesting string is encrypted. The IAT analysis above already tells us which.
What is in plaintext? Sorting by length and clustering by pattern:
000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F... # 512-char hex table
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ # base32 alphabet
8 8$8(8,8084888<8@8D8H8L8P8T8X8\8`8d8h8l8p8t8x8|8 # Microsoft .pdb relocation noise
CloseClipboard, GetClipboardData, ... # IAT names (expected)
RVPQSWh8 # x86 prologue artifactStandard CRT/PE noise plus IAT names. Nothing from the malware itself. Except for one cluster, and it's the cluster that breaks the case open.
4. The base64 cluster: the moment everything pivots
Sorting strings by length and eyeballing the longest hits, you find this:
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+tFbXHWnmVXgnUGCVzbK95Dw==
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+nHbbYRHmfXgPaGDkyMbR+EFI=
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+nHbbFQWufXwPKAz0zMbR+EFI=
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+nGrraQWuCXQzXBCMyMbR+EFI=
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+mE6jNTGuPXR7MBzpzbK95Dw==
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+jHrrbW32FRAzABCUtMbR+EFI=
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+jALrbW32bVQPUGSNzbK95Dw==
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+3BrLcS3CbWR7AAys4aOllF01D
xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+0AL7JS3CFRB/UHT03aOllF01DNine strings. All 68 base64 characters. First 42 base64 chars are identical, the rest differ. Stop and stare at this for a second. It's the most informative single artifact in the binary.
What does it mean?
- They're inside the binary as structured data, not random output.
- 42 base64 chars decode to 31.5 bytes, call it 32 bytes of identical material at the start of every blob.
- 32 bytes is exactly the size of a 256-bit symmetric key. Or an HMAC-SHA-256 tag. Or a ChaCha20 key. Or a per-string IV+nonce header. Or 32 bytes of the same plaintext through a stream cipher with reused keystream.
In other words: the encryption used here either (a) prepends the same crypto material to every record, or (b) is a stream cipher with reused keystream (which gives the same effect when the first 32 plaintext bytes are identical).
Lumma's older builds (2022–2023) used per-string XOR with a key prepended to the ciphertext, base64-wrapped. Let's test that hypothesis.
When you see them in encrypted blobs, your first three guesses, in order, are: prepended key/IV; CBC with reused IV; stream cipher with reused keystream. Open all three blobs side-by-side in HxD (Windows hex editor, free, indispensable) or ImHex (cross-platform, ImGui, gorgeous diff view), and the moment you see the byte-for-byte alignment you stop guessing and start XORing.
This entire decryption recipe (From Base64, then XOR (Key from internal offset 0..31), then Drop bytes (32)) is three blocks in CyberChef. For the writeup screenshot, paste the full 9-line list, hit Bake, and you have the IOC list in 4 seconds with shareable URL. CyberChef is the GUI tool for this stage.
5. Decryption: proving the hypothesis
Pop the prefix off, treat it as a 32-byte key, XOR the rest:
import base64
samples = [
"xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf+tFbXHWnmVXgnUGCVzbK95Dw==",
# ... 8 more
]
for s in samples:
blob = base64.b64decode(s)
key, ct = blob[:32], blob[32:]
pt = bytes(b ^ key[i % 32] for i, b in enumerate(ct))
print(pt.decode("ascii", "replace"))Output:
ignoracndwko.shop
complainnykso.shop
commisionipwn.shop
charistmatwio.shop
basedsymsotp.shop
glassestacwop.shop
grassemenwji.shop
stitchmiscpaew.shop
preachstrwnwjw.shopNine .shop C2 domains. The English-fragment-plus-jitter naming style is Lumma's domain-generation pattern from the late-2024 panel: vaguely word-like prefix to slip past keyword filters, random suffix to dodge collision with squatters and DNS deny-lists.
The C2 array lives in .rdata at file offsets 0x41574..0x4197c, packed at a fixed 129-byte stride (each entry is len_byte + 68_b64_chars + null + padding). Everything in this writeup so far costs you under a megabyte of disk and a python3 -c.
.shop, .icu, .click, .top, .cyou, .cfd are the cheap-to-register TLDs that stealer panels gravitate toward. Namecheap accepts any registration, and the regex \.(shop|icu|click|top|cyou|cfd|xyz|sbs|fun)$ catches a frightening fraction of stealer C2 in any week's URLhaus dump. Worth a Sigma rule on its own.
6. Inline MBA stack-strings: every other string is hiding here
The 9 C2 domains are the only "static" ciphertext. The hundred-odd strings the malware actually uses at runtime (paths, registry keys, HTTP fields, API names) are stored as 8-byte chunks pushed onto the stack at every callsite, then decrypted in-place by an inline arithmetic loop. Look at the entry point:
0x00408bb5 call fcn.0043b690 ; bootstrap (more on this in §7)
0x00408bc2 call dword [USER32!GetInputState]
0x00408bca call fcn.00431af0 ; another init
0x00408bd7 call dword [KERNEL32!GetCurrentThreadId]
0x00408bdd call dword [KERNEL32!GetCurrentProcessId]
; >>> push 8 ciphertext bytes onto the stack:
0x00408be3 mov [esp+8], 0x1ec50805 ; ciphertext[0..3]
0x00408beb mov [esp+c], 0x2a20fe22 ; ciphertext[4..7]
0x00408bf3 mov [esp+10], 0
0x00408bf8 mov [esp+0], 0 ; counter i = 0
0x00408c10: ; decrypt loop
0x00408c10 mov eax, [esp] ; eax = i
0x00408c13 mov ecx, [esp]
0x00408c16 movzx ecx, byte [esp+ecx+8] ; ecx = ct[i]
0x00408c1d and edx, 0x156f77e7 ; edx = i & K
0x00408c23 xor eax, 0x156f77e7 ; eax = i ^ K
0x00408c28 lea eax, [eax + edx*2] ; eax = (i^K) + (i&K)*2
0x00408c2b xor eax, ecx ; mix in ct[i]
0x00408c2d ... ; (more shuffling)
0x00408c35 add al, 0x7d ; final adjustment
0x00408c3a mov [esp+ecx+8], al ; pt[i] = al
0x00408c3e inc dword [esp] ; i++
0x00408c44 cmp eax, 9
0x00408c47 jb 0x00408c10 ; for 8 bytesThe arithmetic identity hidden in there is MBA: mixed boolean-arithmetic. The pair
edx = i AND K
eax = i XOR K
result = eax + edx*2is one of [Zhou & Liu 2007]'s classic identities for i + K (mod 2^32). So the loop is just: pt[i] = ((i + K) ^ ct[i]) + 0x7d, with K chosen uniquely per string. To a recursive descent disassembler the AND→XOR→LEA chain looks like real maths and refuses to fold; to a human (or a symbolic executor) it collapses to a one-liner.
Counting decrypt sites by the and edx, imm32 instruction (the load-bearing first step of the MBA pattern):
$ objdump -M intel -d lumma.bin | grep -E "and edx,0x[0-9a-f]+$" | wc -l
105105 inline decrypt sites. Each site has its own constant pair, its own length (8 / 16 / 24 bytes), and is followed immediately by the API call that consumes the decrypted string. There's no central decrypt routine to hook. Static reversers used to call this "string encryption interleaved with control flow" and break out the symbolic execution; modern analysts call FLOSS.
FLOSS (Mandiant FLARE Obfuscated String Solver) is the tool for this exact pattern. It's an emulator that walks every function, executes it on synthetic input, and recovers any string that lands in memory. On this sample, floss --only stack tight decoded -- lumma.bin recovers ~70 strings in under 30 seconds. If you're not running FLOSS as the third command in your triage flow (after file and rabin2 -I), you're leaving free intel on the table.
7. The rabbit hole: opaque-predicate control-flow flattening
The function called first from entry0 is at 0x0043b690, and a glance shows why I didn't try to hand-decompile it:
0x0043b690 push ebp
0x0043b691 mov ebp, esp
0x0043b693 push ebx
0x0043b694 push edi
0x0043b695 push esi
0x0043b696 sub esp, 0x28
0x0043b699 mov edi, 0x577995fa ; opaque predicate input
0x0043b69e xor eax, eax
0x0043b6a0 cmp edi, 0x1528e278 ; always false (0x577995fa > 0x1528e278)
0x0043b6a6 setl al ; al = 0
0x0043b6a9 mov eax, [eax*4 + 0x4497bc] ; load real target from a 2-entry jump table
0x0043b6b0 mov ecx, 0xef6d39d8
0x0043b6b5 xor ecx, dword [0x4497c4]
0x0043b6bb add eax, ecx
0x0043b6bd inc eax
0x0043b6c1 jmp eax ; computed jumpEvery basic block ends in jmp <reg> where the register is computed from:
- A hard-coded "comparison" whose outcome is constant (an opaque predicate).
- A 2-entry jump table in
.dataindexed by that constant outcome (only one entry is the real next block; the other is unreachable junk pointed at to confuse data-flow analysis). - An XOR with another
.dataslot, then arithmetic, then jump.
Decompilers (Ghidra's Decompile view, IDA's Hex-Rays) both produce gibberish here without help. There are several attack angles that do work:
- Pin the registers. Symbolically execute from function entry with
angrorTriton. Both are pip-installable; both have a learning curve. The reward is a clean CFG. - Find each opaque predicate, prove it constant. A community Ghidra script (
OpaquePredicateAnalyzer.py) does this and patches the IR. Run it and re-decompile. - Concolic with
qiling. Emulate the function on real CPU semantics, log every concreteeipvalue, reconstruct the trace. This is what FLOSS is doing under the hood for stack-strings.
For the IOC extraction this writeup is targeting, we don't need to fully reverse this stub. It's the API resolver and config initializer; FLOSS gives us its outputs without tracing its internals. Knowing the pattern is here is what counts for documenting the family.
A common rookie move on Lumma is to spend two days hand-reversing the API resolver because it's the first function entry0 calls. Don't. Resolve the question you actually have: "which APIs end up in the resolved table, and where are they called?" You answer that with FLOSS + xrefs to the resolved-table base address (.data:0x444xxx). The resolver's internals are interesting research but rarely change the IOCs.
8. FLOSS does the heavy lifting
$ floss --only stack tight decoded -- lumma.bin > floss.txt~30 seconds later, the strings the malware consumes at runtime are sitting in floss.txt. The interesting hits, deduplicated:
winhttp.dll
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Cookie: __cf_mw_byp=
POST
Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data; boundary=be85de5ipdocierre1
Content-Disposition: form-data; name="
Content-Disposition: form-data; name="file"; filename="
Content-Type: attachment/x-object
act=life
act=recive_message&ver=4.0&lid=
act=get_message&ver=4.0&lid=
send_message
hwid
section
name="atok" value="
\Local State
os_crypt.encrypted_key
os_crypt.app_bound_encrypted_key
\key4.db
key4.db
Wallets/
%AppData%\Notepad++\session.xml
\REGISTRY\MACHINE\SOFTWARE\Valve\Steam
Software/Valve/Steam/Accounts
Software/Valve/Steam/Con
steam.exe
\Last Version
/BrowserVersion.txt
/dp.txt
/ab.txtNow the malware's surface is fully visible without a single instruction executed.
What it talks to
winhttp.dll confirms the resolver loads WinHTTP at runtime, which we already inferred from the missing import. The two interesting beacons:
act=life: heartbeat. First request after launch, posts HWID + build ID, gets back a config blob.act=recive_message&ver=4.0&lid=<id>andact=get_message&ver=4.0&lid=<id>: the panel's tasking endpoints.ver=4.0is the Lumma protocol revision; this version was rolled out in mid-2024.
How it talks
POST with multipart/form-data, fixed boundary be85de5ipdocierre1 baked into the build. The form fields are file, atok, hwid, section. Hard-coded UA pinned to Chrome 119 (a version that was current at build time, not a version that's actually installed on most victims, which is a defender's gift, since seeing UA Chrome 119 in 2026 traffic is itself a tell).
The Cookie: __cf_mw_byp= header is interesting: __cf_mw_byp is a Cloudflare Workers / Managed Challenge bypass cookie that legitimate Cloudflare setups use to mark an authenticated origin request. Lumma's panel operators front their C2 with Cloudflare and configure Workers rules to admit any request carrying that cookie, a cheap way to keep researchers and naïve crawlers off the panel. Burp / mitmproxy logs that don't carry the cookie get a 403 from the worker; logs that do get the real C2 response. If you're sinkholing, send the cookie.
What it steals
| Target | Artifact | Why it matters |
|---|---|---|
| Chromium browsers | \Local State, os_crypt.encrypted_key | Standard Chromium-DPAPI cookie/login decryption path. |
| Chromium 127+ | os_crypt.app_bound_encrypted_key | Bypasses Chrome's August-2024 App-Bound Encryption (ABE). Lumma got there fast: this build is from 2024-09-12. |
| Firefox / Thunderbird | key4.db | NSS master DB. Combined with logins.json, decrypts saved passwords. |
| Crypto wallets | Wallets/ directory walker | All major desktop wallets store under a Wallets/ subtree (Exodus, Atomic, etc.). |
| Notepad++ | %AppData%\Notepad++\session.xml | Open tabs and unsaved buffers, fantastic for grabbing the seed phrase someone pasted in to "remember it for a sec." |
| Steam | HKLM\SOFTWARE\Valve\Steam, Software/Valve/Steam/Accounts, steam.exe | Steam Guard tokens and loginusers.vdf-equivalent registry data. |
The two /dp.txt and /ab.txt paths are server-side panel files: dp.txt is the dynamic plugins manifest (Lumma 4.x ships extra grabber DLLs from the panel on demand; the old static stealer became a modular framework in 2024), and ab.txt is the app-bound key recovery helper. Catching either as a GET toward a .shop host in your egress logs is essentially game-over.
Chrome 127 ABE in 30 seconds
Chrome 127 (August 2024) introduced App-Bound Encryption: instead of encrypting cookies/logins with a DPAPI key tied to the user, it encrypts them with a key derived from a per-app-identity blob, signed by a privileged COM service IElevator. Reading Local State no longer works on its own; you have to ask IElevator to decrypt the new os_crypt.app_bound_encrypted_key value, and that requires running as the same Chrome process identity. Lumma's path to bypass: invoke the elevator interface from a child process spawned by an elevated thread that impersonates the Chrome token. The presence of os_crypt.app_bound_encrypted_key as a static string in this binary is the smoking gun that this build implements the bypass, released to customers within 30 days of Chrome shipping the protection.
When FLOSS finishes, immediately grep the output for os_crypt.app_bound_encrypted_key, __cf_mw_byp=, act=life, boundary=, \Local State. Any one of those uniquely fingerprints a stealer family circa 2024–2025. Build a small ioc-keywords.txt and pipe FLOSS output through egrep -af on every sample. In 200 lines you out-classify most YARA-only triage pipelines.
9. Putting it together: the kill chain
[ entry0 ]
|
|-- bootstrap (0x43b690): opaque-predicate stub, walks PEB,
| resolves kernel32!LoadLibrary*+GetProcAddress* by hash,
| loads winhttp.dll, advapi32.dll, crypt32.dll, etc.,
| builds resolved-API table at .data:0x444xxx
|
|-- 105 inline MBA stack-string sites build paths/headers
|
|-- WMI-via-COM enumerate (CPU, GPU, OS, locale, AV via WbemClassObject)
|
|-- screenshot via GDI BitBlt + GetDIBits, store as ZIP entry
|
|-- per-target collectors:
| - Chromium: read Local State, ask IElevator for app_bound_encrypted_key,
| walk Profile/*/Login Data, Cookies, Web Data
| - Firefox: read key4.db, logins.json
| - Wallets: walk %APPDATA%\<wallet>/Wallets/
| - Notepad++: dump %AppData%\Notepad++\session.xml
| - Steam: read HKLM\SOFTWARE\Valve\Steam, copy loginusers.vdf
|
|-- pack collected data into multipart/form-data with boundary
| "be85de5ipdocierre1", form fields hwid/section/file/atok
|
|-- iterate over 9 .shop C2s; for each:
| POST /api/?act=life with hwid, await config
| POST /api/?act=recive_message&ver=4.0&lid=<id> for tasks
| POST send_message for exfil
| Cookie: __cf_mw_byp=<token> ; CF Workers gate
| User-Agent: Mozilla/5.0 (... Chrome/119.0.0.0 ...)Note the C2 list isn't a fallback chain. Lumma 4.x rotates per-victim or per-batch; the panel decides which the bot uses on the life response. So all 9 are equally hot.
10. IOCs
Hashes
SHA-256: 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5
SHA-1: 2ef48e359e02ff7bfdf099efeb38cb7a8deac775
MD5: 29b42ea66238343322eacb9fd0c8b1b4C2 domains (extracted from .rdata, decrypted offline)
ignoracndwko.shop
complainnykso.shop
commisionipwn.shop
charistmatwio.shop
basedsymsotp.shop
glassestacwop.shop
grassemenwji.shop
stitchmiscpaew.shop
preachstrwnwjw.shopNetwork signatures
- HTTP method:
POST - URI suffixes:
/api/,?act=life,?act=recive_message&ver=4.0&lid=<id>,?act=get_message&ver=4.0&lid=<id>,send_message - Multipart boundary:
be85de5ipdocierre1(build-unique, great Suricata signature) - Header:
Cookie: __cf_mw_byp= - User-Agent:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Host artifacts
- Reads:
\Local State,\Login Data,\Cookies,\Web Data,key4.db,logins.json - Reads:
%AppData%\Notepad++\session.xml - Reads:
HKLM\SOFTWARE\Valve\Steam - Loads at runtime (after dynamic resolution):
winhttp.dll,advapi32.dll,crypt32.dll,shlwapi.dll,wininet.dll
YARA
rule Lumma_Stealer_Sept2024_BuildPattern
{
meta:
author = "0xFuzz"
date = "2026-04-26"
description = "Lumma Stealer build (Sept 2024 lineage): base64-wrapped XOR-key+payload C2 strings, MBA stack-string decrypts, tiny IAT"
sha256 = "4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5"
reference = "https://0xfuzz.com/"
tlp = "white"
strings:
$b64_key_prefix = "xHLbqCgY9jBto3NKXR/HFn8iM7pOAYiBFFFQcaRYEf" ascii
$mba_decrypt = { 81 E2 ?? ?? ?? ?? 35 ?? ?? ?? ?? 8D 04 50 31 C8 }
$imp_clipboard = "GetClipboardData" ascii
$imp_bitblt = "BitBlt" ascii
$imp_getdibits = "GetDIBits" ascii
$imp_wmi_init = "CoInitializeSecurity" ascii
condition:
uint16(0) == 0x5A4D and filesize < 1MB and
( $b64_key_prefix or
( #mba_decrypt > 30 and all of ($imp_*) ) )
}The $b64_key_prefix is build-specific (the 32-byte XOR key changes per build). The $mba_decrypt byte pattern is the family fingerprint and is what catches related builds in retrohunt.
11. MITRE ATT&CK mapping
| Tactic | Technique | Evidence |
|---|---|---|
| Defense Evasion | T1027.013: Encrypted/Encoded File / Stripped Payloads | 32-byte XOR key + base64 wrap on C2 list |
| Defense Evasion | T1027.009: Embedded Payloads | Encrypted blob inside .text raises section entropy |
| Defense Evasion | T1027 (.013): Obfuscated Files or Information | 105 MBA stack-string sites |
| Defense Evasion | T1140: Deobfuscate/Decode Files or Information | Inline runtime decrypt loops |
| Discovery | T1518.001: Security Software Discovery | WMI enumeration via Wbem* |
| Discovery | T1057: Process Discovery | Enumerates running processes |
| Credential Access | T1555.003: Credentials from Web Browsers | \Local State, ABE bypass, key4.db |
| Credential Access | T1539: Steal Web Session Cookie | Chromium \Cookies |
| Credential Access | T1552.001: Credentials In Files | Notepad++ session, loginusers.vdf |
| Collection | T1113: Screen Capture | GDI BitBlt/GetDIBits |
| Collection | T1115: Clipboard Data | GetClipboardData |
| Command and Control | T1071.001: Web Protocols | HTTP POST to .shop C2 |
| Command and Control | T1573.001: Symmetric Cryptography | XOR-encrypted strings, key embedded |
| Exfiltration | T1041: Exfiltration Over C2 Channel | multipart/form-data POST to same hosts |
12. Defender takeaways
- Block the TLDs you don't need. Most enterprises have zero legitimate traffic to
.shop,.icu,.click,.cyou,.cfd,.sbs. A flat block list at the egress proxy or DNS resolver removes 30 %+ of stealer C2 in one config change. - Detect the multipart boundary.
be85de5ipdocierre1is build-unique and will likely match exactly nothing legitimate on your network. A Suricata rule on the literal boundary string (withhttp.request_body; content:andnocase;) catches every infection that uses this build, with effectively no false positives. - Detect static User-Agent strings on POSTs. A Chrome 119 UA in 2026 traffic is almost always either a malware build that was frozen at 119 or a scripted client. Not all your Chrome 119s are malware, but if you join "POST + Chrome 119 + .shop host" you have a tiny, high-precision detection.
__cf_mw_bypcookie sightings outbound = bot trying to talk to a Cloudflare-fronted panel. Add to your DLP/proxy correlation engine.- Watch
\Local Statereads from non-Chrome processes. Sysmon EID 11 + EID 1 join: any process not signed by Google readingLocal Statefrom a Chrome profile. Same trick onkey4.dbfor Firefox. - Memory hunt with the YARA above. This rule runs in YARA's Loki/THOR/Velociraptor wrappers; on a 5k-endpoint estate it scans in minutes.
13. The toolchain (what's GUI, what's CLI, what's worth installing today)
This is the order I'd open them on a fresh analysis bench. The "tier" column is roughly how often I open it.
Static: file structure & strings
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
file, strings, xxd, objdump | CLI | * | A | First 10 seconds: type, ASCII strings, basic disasm |
rabin2 (radare2) | CLI | * | A | PE/ELF metadata: imports, sections, headers, entry, hashes |
| HxD | GUI | Win | A | Hex viewer + diff. Pin three blobs side by side and the common prefix screams |
| ImHex | GUI | * | A | Cross-platform hex with "patterns" (like 010 templates), great structure highlighting |
| 010 Editor | GUI | * | A | Paid, but the PE binary template is the gold standard for visual PE structure walking |
Detect It Easy (DIE / diec) | GUI+CLI | * | A | Compiler/packer ID, per-section entropy graph, signatures |
| PE-bear | GUI | Win | A | PE structure tree, IAT/EAT walker, section editor; best free PE viewer |
| CFF Explorer | GUI | Win | B | Older but everyone knows the keyboard shortcuts |
| PEStudio | GUI | Win | A | One-click triage: blacklisted imports, suspicious indicators, signature check |
| ExifTool | CLI | * | C | Resource version info, build metadata |
| TrID | CLI | * | C | When file isn't sure |
Static: disassembly & decompilation
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
| Ghidra | GUI | * | A | Free, scriptable (Python/Java), handles x86/x64/ARM/etc. The benchmark for static analysis. |
| Cutter (radare2 frontend) | GUI | * | A | Visual graph view of CFG, invaluable for spotting CFG flattening |
| IDA Free | GUI | * | B | Hex-Rays decompiler is the gold standard, but Free is x86-32 only |
| IDA Pro + Hex-Rays | GUI | * | A | If you have it |
| Binary Ninja | GUI | * | B | The MLIL/HLIL view shines on obfuscated x86 |
objdump, r2 | CLI | * | A | Quick scratch disasm |
| angr / Triton | CLI | * | B | Symbolic execution: opaque predicates, MBA, key recovery |
Static: string / config extraction
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
| FLOSS | CLI | * | A | Stack/tight/decoded strings via emulation, the biggest single-tool win on Lumma |
| CyberChef | GUI (browser) | * | A | The decryption-recipe sandbox. base64+XOR in three blocks, shareable URL |
| YARA + yara-x | CLI | * | A | Rule writing + retrohunt |
| capa | CLI | * | B | Maps capabilities (e.g. "screenshot", "encrypted comms") to MITRE/MBC |
Dynamic: VM, sandbox, process
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
| FlareVM + Hyper-V/VirtualBox snapshots | GUI | Win VM | A | Rebuild bench in minutes |
| x64dbg / x32dbg + ScyllaHide | GUI | Win | A | The free user-mode debugger. ScyllaHide handles anti-debug. |
| WinDbg / WinDbg Preview | GUI | Win | B | Kernel-mode if you go there |
| Process Hacker / System Informer | GUI | Win | A | Real-time process tree, handles, network |
| Procmon | GUI | Win | A | Filesystem/registry/network activity log, show your client this, not strings output |
| API Monitor | GUI | Win | B | Per-API logging, custom filters |
| PE-sieve / hollows-hunter | CLI | Win | A | Memory dumping of injected/unpacked code |
| Cuckoo / CAPE | GUI | Linux host | B | Automated sandbox; CAPE is the Lumma-aware fork |
Dynamic: network
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
| Wireshark | GUI | * | A | Capture+dissect. Mandatory. |
| mitmproxy | TUI/GUI | * | A | TLS-MitM with a one-line cert install in the VM |
| Burp Suite Community | GUI | * | A | If you prefer Burp's UI for HTTP MitM |
| INetSim | CLI | Linux | A | Fake every protocol; drop-in Lumma sinkhole |
| FakeNet-NG | CLI | * | A | Per-process redirection, very lab-friendly |
dnschef / dnsmasq + hosts | CLI | * | B | Manual DNS sinkholing |
Special-case: JS deobfuscation
For JavaScript-heavy payloads (npm postinstall stealers, Discord token grabbers, ClickFix HTAs), the toolchain swings:
| Tool | GUI? | OS | Tier | What it's for |
|---|---|---|---|---|
| Sublime Text + JSPrettier / JsFormat | GUI | * | A | The original "gold nugget". Open the obfuscated JS, Ctrl+Alt+F, suddenly it's readable |
| VS Code + Prettier + JS-Beautify | GUI | * | A | Same as above, with a debugger and Snyk-style inline lint |
js-beautify | CLI | * | A | Pipe-based; great in scripts |
| Synchrony (relative.im/synchrony) | CLI | * | A | Targeted unobfuscator for obfuscator.io output (most npm stealers) |
| REstringer (PerimeterX) | CLI | * | A | AST-based string-array unwinder |
| JStillery | GUI (web) | * | B | Browser-based, paste-and-go |
| de4js | GUI (web) | * | B | One-click for common packers (Dean Edwards, sojson, etc.) |
| AST Explorer | GUI (web) | * | A | Paste obfuscated JS, pick parser, browse the AST. Indispensable for writing your own deobfuscator |
Node debugger / node --inspect-brk | GUI (Chrome DevTools) | * | A | Run the unwrapped JS in vm with hooks on eval, Function, WebSocket |
| Frida (for Electron stealers) | CLI | * | B | Hook Node internals at runtime |
14. Reproducing this writeup
# 1. Pull
mkdir -p ~/Desktop/infected/{sample,analysis,iocs}
cd ~/Desktop/infected/sample
# Download from https://bazaar.abuse.ch/sample/4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5/
# (requires a free abuse.ch Auth-Key as of 2024)
# 2. Verify and extract
sha256sum 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5.zip
7z x -pinfected 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5.zip
mv 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5.exe lumma.bin
chmod -x lumma.bin
# 3. Static fingerprint
file lumma.bin
rabin2 -I lumma.bin
rabin2 -S lumma.bin
rabin2 -i lumma.bin
rabin2 -l lumma.bin
# 4. Strings
strings -a -n 6 lumma.bin > strings.ascii.txt
strings -a -n 6 -e l lumma.bin > strings.utf16.txt
awk '{print length, $0}' strings.ascii.txt | sort -rn | head -20
# 5. Decrypt the C2 list (see python snippet in section 5)
# 6. Inline decrypts and dynamic calls
objdump -M intel -d lumma.bin | grep -cE "and edx,0x[0-9a-f]+$" # 105
objdump -M intel -d lumma.bin | grep -cE "call (eax|ebx|ecx|edx|esi|edi|ebp)" # 151
# 7. FLOSS, the big reveal
floss --only stack tight decoded -- lumma.bin > floss.txt
egrep -aiE 'http|chrome|firefox|wallet|act=|boundary|os_crypt|key4|cookie|user-agent' floss.txt | sort -u
# 8. YARA self-test
yara lumma_stealer.yar lumma.bin
yara lumma_stealer.yar /usr/bin/ls # ensure no FPs on benignTotal elapsed analyst time on a fresh bench: roughly 45–60 minutes to get from "MalwareBazaar zip on disk" to "9 C2 domains, full HTTP fingerprint, deployable YARA, MITRE map." 90 % of that time is reading FLOSS output and writing the report. The actual decryption is one Python loop.
15. Closing: why Lumma is the teaching sample
If you can pick three samples to learn modern obfuscated-code analysis on, Lumma deserves a spot. It teaches you to:
- Trust the negative space of an IAT (the missing imports tell the story).
- Recognize stream-cipher reuse by eye in a base64 cluster.
- Spot inline MBA without falling into the trap of decompiling it byte-by-byte.
- Use FLOSS as a force multiplier against stack-strings + tight strings.
- Map an HTTP fingerprint to a Suricata/Sigma rule with no PCAP.
Most importantly, it's a moving target. Each build rotates the 32-byte XOR key, the multipart boundary, the C2 list, and sometimes the API resolver. The family fingerprint (the MBA pattern, the GUI subsystem, the WMI/COM init combo, the act=…&ver=4.0&lid= URI shape) is what you build defenses around.
Happy hunting.
Appendix A: files in this analysis
infected/
├── sample/
│ ├── 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5.zip (MalwareBazaar drop)
│ ├── 4d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5.exe (extracted)
│ └── lumma.bin (working copy)
├── analysis/
│ ├── 01_info.txt # rabin2 -I
│ ├── 02_sections.txt # rabin2 -S
│ ├── 03_imports.txt # rabin2 -i
│ ├── 04_strings_ascii.txt # strings -a -n 6
│ ├── 05_strings_unicode.txt
│ ├── 06_floss.txt # FLOSS full run
│ └── 06_floss_iocs.txt # filtered IOC strings
├── iocs/
│ ├── hashes.txt
│ ├── c2.txt
│ └── lumma_stealer.yar
└── writeup.md # this document