>_
The Fuzz.
← /blog
Malware Analysis

Pulling C2s out of a Lumma Stealer build, step by step

Walking one MalwareBazaar Lumma sample from password-protected zip to a deployable IOC list. Static-only path: 9 hardcoded .shop C2s, the Lumma 4.0 protocol surface, the Chrome 127 App-Bound Encryption bypass, and a YARA rule for the build family.

$ author 0xFuzz·Apr 24, 2026·22 min read
lummastealermalware-analysisyaraflossobfuscationc2

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.

AttributeValue
Samplelumma.bin (renamed from sha256-named MalwareBazaar drop)
SourceMalwareBazaar (password infected, if you want to follow along)
SHA-2564d5fd4d96346fb4b09dccb7836ca2dedd339622a6e6063b4dbec1af83cca94f5
MD5 / SHA-129b42ea66238343322eacb9fd0c8b1b4 / 2ef48e359e02ff7bfdf099efeb38cb7a8deac775
File typePE32 (i386), Windows GUI subsystem, 4 sections, 317 440 bytes
LinkerMSVC 14.x, TimeDateStamp 2024-09-12 11:22:23 UTC
FamilyLumma Stealer (a.k.a. LummaC2)
LabKali 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 -x the unzipped PE and store it inside a directory literally called infected/ so any tool I run later (or any future me opening a Finder window) thinks twice. On Windows, append .malz to 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 urlhaus and your beacons end up in someone's threat-feed sample volume. Use a hosts file, INetSim, or sinkhole and watch on Wireshark.
i
MalwareBazaar zips need an AES-aware extractor

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.

fingerprint.sh
$ 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 ---- .reloc

Two things jump:

  1. .text is 0x3f400 (~253 KB) of a 317 KB file, so 80 % code. .rdata is barely 10 KB, which is tiny for any non-trivial app.
  2. 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.
entropy.py
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.6984
i
Section-level entropy beats file-level entropy

The 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.dll

That's it. Five DLLs. Total 39 imports. Look at what isn't there:

Expected for a stealerPresent?
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, WriteFileNo
KERNEL32!LoadLibrary*, GetProcAddressNo

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.

i
Read absences, not just presences

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
5

528 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 artifact

Standard 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/UHT03aOllF01D

Nine 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.

i
Long-aligned common prefixes are not coincidence

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.

i
Keep CyberChef in a pinned tab

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:

decrypt-c2.py
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.shop

Nine .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.

i
The 2024-2026 stealer TLD shortlist

.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:

entry0-fragment.asm
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 bytes

The arithmetic identity hidden in there is MBA: mixed boolean-arithmetic. The pair

edx = i AND K
eax = i XOR K
result = eax + edx*2

is 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
105

105 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.

i
FLOSS is the third command in any triage flow

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:

bootstrap-stub.asm
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 jump

Every basic block ends in jmp <reg> where the register is computed from:

  1. A hard-coded "comparison" whose outcome is constant (an opaque predicate).
  2. A 2-entry jump table in .data indexed by that constant outcome (only one entry is the real next block; the other is unreachable junk pointed at to confuse data-flow analysis).
  3. An XOR with another .data slot, 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 angr or Triton. 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 concrete eip value, 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.

i
Pick your battle

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:

floss-iocs.txt
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.txt

Now 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> and act=get_message&ver=4.0&lid=<id>: the panel's tasking endpoints. ver=4.0 is 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

TargetArtifactWhy it matters
Chromium browsers\Local State, os_crypt.encrypted_keyStandard Chromium-DPAPI cookie/login decryption path.
Chromium 127+os_crypt.app_bound_encrypted_keyBypasses Chrome's August-2024 App-Bound Encryption (ABE). Lumma got there fast: this build is from 2024-09-12.
Firefox / Thunderbirdkey4.dbNSS master DB. Combined with logins.json, decrypts saved passwords.
Crypto walletsWallets/ directory walkerAll major desktop wallets store under a Wallets/ subtree (Exodus, Atomic, etc.).
Notepad++%AppData%\Notepad++\session.xmlOpen tabs and unsaved buffers, fantastic for grabbing the seed phrase someone pasted in to "remember it for a sec."
SteamHKLM\SOFTWARE\Valve\Steam, Software/Valve/Steam/Accounts, steam.exeSteam 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.

i
Keyword-grep FLOSS output for instant family ID

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:     29b42ea66238343322eacb9fd0c8b1b4

C2 domains (extracted from .rdata, decrypted offline)

ignoracndwko.shop
complainnykso.shop
commisionipwn.shop
charistmatwio.shop
basedsymsotp.shop
glassestacwop.shop
grassemenwji.shop
stitchmiscpaew.shop
preachstrwnwjw.shop

Network 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

lumma_stealer.yar
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

TacticTechniqueEvidence
Defense EvasionT1027.013: Encrypted/Encoded File / Stripped Payloads32-byte XOR key + base64 wrap on C2 list
Defense EvasionT1027.009: Embedded PayloadsEncrypted blob inside .text raises section entropy
Defense EvasionT1027 (.013): Obfuscated Files or Information105 MBA stack-string sites
Defense EvasionT1140: Deobfuscate/Decode Files or InformationInline runtime decrypt loops
DiscoveryT1518.001: Security Software DiscoveryWMI enumeration via Wbem*
DiscoveryT1057: Process DiscoveryEnumerates running processes
Credential AccessT1555.003: Credentials from Web Browsers\Local State, ABE bypass, key4.db
Credential AccessT1539: Steal Web Session CookieChromium \Cookies
Credential AccessT1552.001: Credentials In FilesNotepad++ session, loginusers.vdf
CollectionT1113: Screen CaptureGDI BitBlt/GetDIBits
CollectionT1115: Clipboard DataGetClipboardData
Command and ControlT1071.001: Web ProtocolsHTTP POST to .shop C2
Command and ControlT1573.001: Symmetric CryptographyXOR-encrypted strings, key embedded
ExfiltrationT1041: Exfiltration Over C2 Channelmultipart/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. be85de5ipdocierre1 is build-unique and will likely match exactly nothing legitimate on your network. A Suricata rule on the literal boundary string (with http.request_body; content: and nocase;) 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_byp cookie sightings outbound = bot trying to talk to a Cloudflare-fronted panel. Add to your DLP/proxy correlation engine.
  • Watch \Local State reads from non-Chrome processes. Sysmon EID 11 + EID 1 join: any process not signed by Google reading Local State from a Chrome profile. Same trick on key4.db for 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

ToolGUI?OSTierWhat it's for
file, strings, xxd, objdumpCLI*AFirst 10 seconds: type, ASCII strings, basic disasm
rabin2 (radare2)CLI*APE/ELF metadata: imports, sections, headers, entry, hashes
HxDGUIWinAHex viewer + diff. Pin three blobs side by side and the common prefix screams
ImHexGUI*ACross-platform hex with "patterns" (like 010 templates), great structure highlighting
010 EditorGUI*APaid, but the PE binary template is the gold standard for visual PE structure walking
Detect It Easy (DIE / diec)GUI+CLI*ACompiler/packer ID, per-section entropy graph, signatures
PE-bearGUIWinAPE structure tree, IAT/EAT walker, section editor; best free PE viewer
CFF ExplorerGUIWinBOlder but everyone knows the keyboard shortcuts
PEStudioGUIWinAOne-click triage: blacklisted imports, suspicious indicators, signature check
ExifToolCLI*CResource version info, build metadata
TrIDCLI*CWhen file isn't sure

Static: disassembly & decompilation

ToolGUI?OSTierWhat it's for
GhidraGUI*AFree, scriptable (Python/Java), handles x86/x64/ARM/etc. The benchmark for static analysis.
Cutter (radare2 frontend)GUI*AVisual graph view of CFG, invaluable for spotting CFG flattening
IDA FreeGUI*BHex-Rays decompiler is the gold standard, but Free is x86-32 only
IDA Pro + Hex-RaysGUI*AIf you have it
Binary NinjaGUI*BThe MLIL/HLIL view shines on obfuscated x86
objdump, r2CLI*AQuick scratch disasm
angr / TritonCLI*BSymbolic execution: opaque predicates, MBA, key recovery

Static: string / config extraction

ToolGUI?OSTierWhat it's for
FLOSSCLI*AStack/tight/decoded strings via emulation, the biggest single-tool win on Lumma
CyberChefGUI (browser)*AThe decryption-recipe sandbox. base64+XOR in three blocks, shareable URL
YARA + yara-xCLI*ARule writing + retrohunt
capaCLI*BMaps capabilities (e.g. "screenshot", "encrypted comms") to MITRE/MBC

Dynamic: VM, sandbox, process

ToolGUI?OSTierWhat it's for
FlareVM + Hyper-V/VirtualBox snapshotsGUIWin VMARebuild bench in minutes
x64dbg / x32dbg + ScyllaHideGUIWinAThe free user-mode debugger. ScyllaHide handles anti-debug.
WinDbg / WinDbg PreviewGUIWinBKernel-mode if you go there
Process Hacker / System InformerGUIWinAReal-time process tree, handles, network
ProcmonGUIWinAFilesystem/registry/network activity log, show your client this, not strings output
API MonitorGUIWinBPer-API logging, custom filters
PE-sieve / hollows-hunterCLIWinAMemory dumping of injected/unpacked code
Cuckoo / CAPEGUILinux hostBAutomated sandbox; CAPE is the Lumma-aware fork

Dynamic: network

ToolGUI?OSTierWhat it's for
WiresharkGUI*ACapture+dissect. Mandatory.
mitmproxyTUI/GUI*ATLS-MitM with a one-line cert install in the VM
Burp Suite CommunityGUI*AIf you prefer Burp's UI for HTTP MitM
INetSimCLILinuxAFake every protocol; drop-in Lumma sinkhole
FakeNet-NGCLI*APer-process redirection, very lab-friendly
dnschef / dnsmasq + hostsCLI*BManual DNS sinkholing

Special-case: JS deobfuscation

For JavaScript-heavy payloads (npm postinstall stealers, Discord token grabbers, ClickFix HTAs), the toolchain swings:

ToolGUI?OSTierWhat it's for
Sublime Text + JSPrettier / JsFormatGUI*AThe original "gold nugget". Open the obfuscated JS, Ctrl+Alt+F, suddenly it's readable
VS Code + Prettier + JS-BeautifyGUI*ASame as above, with a debugger and Snyk-style inline lint
js-beautifyCLI*APipe-based; great in scripts
Synchrony (relative.im/synchrony)CLI*ATargeted unobfuscator for obfuscator.io output (most npm stealers)
REstringer (PerimeterX)CLI*AAST-based string-array unwinder
JStilleryGUI (web)*BBrowser-based, paste-and-go
de4jsGUI (web)*BOne-click for common packers (Dean Edwards, sojson, etc.)
AST ExplorerGUI (web)*APaste obfuscated JS, pick parser, browse the AST. Indispensable for writing your own deobfuscator
Node debugger / node --inspect-brkGUI (Chrome DevTools)*ARun the unwrapped JS in vm with hooks on eval, Function, WebSocket
Frida (for Electron stealers)CLI*BHook Node internals at runtime

14. Reproducing this writeup

repro.sh
# 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 benign

Total 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

Appendix B: references

$ echo "thanks for reading" | tee /dev/null
$ next post

More in Malware Analysis