0. Preface
This is a different sample from the one covered in the ClickFix ‘softmix-online’ writeup. That campaign ended in a Go payload obfuscated with garble. This one is a native C++ x64 build that self-reports version 3.1, and it protects itself very differently: no imports at all, and no strings stored anywhere in the file.
Everything below is static analysis. The sample was never executed and no request was made to its C2 infrastructure. Decompilation was done with kuna driven from the shell.
1. Sample Information
- Original file name:
712e7912a669a71a7006fd1a0333b5da45dbbc78672c46ae62e794fbc1372959.exe - File type: executable, 64-bit (PE32+), GUI subsystem
- File size: 1.347584 MB
- Hash values:
- MD5: 0d0bb2656a73610b2695fc8f3db21723
- SHA256: 712e7912a669a71a7006fd1a0333b5da45dbbc78672c46ae62e794fbc1372959
- Compiler: Microsoft Visual C/C++ 19.29 (C++)
- Compile Timestamp: Wed Jul 01 11:04:07 2026
- Packing/Obfuscation: Not packed. Heavily obfuscated post-compilation (junk arithmetic, API hashing, per-string code generation)
- File Entropy Details:
| Sections | Entropy |
|---|---|
| .text | 5.68 |
| .rdata | 5.88 |
| .data | 1.90 |
| .reloc | 0.23 |
No section exceeds 7.0, and a sliding 16 KB window across the whole file never exceeds 7.2. This sample is not packed, which is worth stating early because the empty import table below is the kind of thing that usually means a packer stub.
2. PE File Structure
- Sections overview:
| Sections | Virtual Size | Raw Size |
|---|---|---|
| .text | 1311504 | 1311744 |
| .rdata | 29064 | 29184 |
| .data | 1310272 | 4608 |
| .reloc | 20 | 512 |
Three structural oddities stand out:
- The import table is empty. There is no
IMAGE_DIRECTORY_ENTRY_IMPORTat all — not a small one, a zero-sized one. A normally-linked MSVC binary of this size has hundreds of imports. .datahas a 1.25 MB virtual size but only 4608 bytes on disk. That gap is runtime-allocated space for resolved function pointers and decrypted strings.- Only five relocations exist, and the image base is an unusual
0x460000000.
- Suspicious Imports:
- (none — the import directory is empty)
- Exports:
- (none — this is an EXE)
- Certificate:
- Unsigned
The only readable ASCII string in the entire 1.3 MB file is GetCommandLineA. A strings -el pass for UTF-16 returns nothing at all. There is no .rsrc section, no overlay, and no appended configuration blob.
So where is the code’s data? Cross-referencing the exception directory (.pdata, 1594 RUNTIME_FUNCTION entries) against the .text section shows that only 562 bytes of the 1,311,504-byte section are not part of a function. .text is 99.96% code. There is no encrypted string blob to carve out — every string must be generated by code.
An instruction histogram makes the obfuscation visible:
| Instruction | Count |
|---|---|
| mov | 156381 |
| imul | 48282 |
| xor | 12302 |
| call | 8986 |
| rdtsc | 46 |
48,282 imul instructions in a stealer is not arithmetic. Disassembly shows why — every array index is computed instead of written as a constant:
mov eax,0x1
imul rax,rax,0x9
mov BYTE PTR [rsp+rax*1+0x30],0xd8
That is buffer[9] = 0xD8 written in three instructions so no constant index appears in the disassembly.
3. Code and Disassembly Observation
3.1 Resolving APIs by hash
With no import table, the first question is how the sample calls anything. Searching the disassembly for gs: segment access finds the answer at four sites reading gs:[0x60] — the PEB.
unsigned long long sub_460017a44(int4 a0)
{
// ... obfuscation locals omitted
v1 = sub_460015fa0(); // returns TEB->ProcessEnvironmentBlock
if ((v1) && (*(int8 *)(v1 + 0x18))) { // PEB->Ldr
v2 = (unsigned long long *)(*(int8 *)(v1 + 0x18) + 0x20); // InMemoryOrderModuleList
v7 = (unsigned long long *)*v2;
do {
if (v7 == v2)
return 0;
if (v7[10]) { // BaseDllName.Buffer
sub_460017d90(v7[10],v3,0x40); // wide -> ansi
if (sub_46001664c(v3) == v4) // hash(name) == wanted
return v7[4]; // DllBase
}
v7 = (unsigned long long *)*v7;
} while( true );
}
return 0;
}
This is a hand-written GetModuleHandle that walks PEB->Ldr->InMemoryOrderModuleList and matches modules by a hash of their base name. A companion function does the same for exports, parsing the export directory manually and — notably — handling forwarded exports by recursing when the RVA falls inside the export directory range.
The hash function itself is short enough to lift directly:
uint4 sub_46001664c(uint1 *a0)
{
uint4 v3;
v3 = 0x850096f1;
for (; *a0; a0 = &a0[1]) {
v3 = (*a0 ^ v3) * 0x10003c9;
}
return v3;
}
That is FNV-1a with non-standard constants: offset basis 0x850096F1 instead of 0x811C9DC5, prime 0x010003C9 instead of 0x01000193. Swapping the constants is enough to defeat a signature lookup against public API-hash databases, but it does nothing against a brute-force with the right wordlist.
Collecting every hash constant passed to the two resolvers gave 189 values. Hashing the export names of the PE files available locally (35,759 unique names) recovered 187 of them:
def h(name, v=0x850096F1):
for c in name.encode():
v = ((c ^ v) * 0x010003C9) & 0xFFFFFFFF
return v
# module hashes
# 0x5012F00D -> ntdll.dll
# 0x88CABDB9 -> kernel32.dll
Two hashes (0x58822D0F, 0x5D318A3C, both resolved from ntdll.dll) matched nothing in the wordlist or in ~14,000 synthesized Nt*/Rtl* candidates. Both are written into the function-pointer table but never read in the decompiled code, so they may simply be unused.
3.2 The string key, hidden behind a PEB read
Every string-building function begins by calling the same tiny helper. Following it back leads here:
unsigned int sub_460038bd8(void)
{
int8 v1; // gs_offset
if (*(int8 *)(*(int8 *)(v1 + 0x60) + 0x30) && *(int8 *)(*(int8 *)(v1 + 0x60) + 0x10))
return 0x4511b3c9;
return 0xdeadbeef;
}
PEB+0x30 is ProcessHeap and PEB+0x10 is ImageBaseAddress. Both are always non-null in any real process, so this check always passes — it is not a meaningful anti-tamper gate. Its real purpose is twofold: it keeps the constant 0x4511B3C9 from sitting in the binary as an obvious key, and it breaks naive emulation-based string decrypters that call the function without a valid PEB and silently get 0xDEADBEEF back.
3.3 Strings are functions, not data
There is no string table. Each string is its own function that rebuilds its bytes from the key. Here is the complete decryptor for one string:
int8 sub_460041840(void)
{
if (dat_460285030)
v6 = dat_460285030; // cached from a previous call
else {
v6 = sub_4600380ec(10); // HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 10)
v16 = v6;
v4 = sub_46003912c(); // key = 0x4511B3C9
v8 = (uint1)v4; // k0
v7 = v8 ^ 0x99;
v1 = (uint1)((uint4)v4 >> 8); // k1
v9 = v1 + 0x4d;
v2 = (uint1)((uint4)v4 >> 0x10); // k2
v10 = v2 ^ 0x5e;
v3 = (uint1)((uint4)v4 >> 0x18); // k3
v11 = 0x45 - v3;
v12 = v8 + 0x8a;
v13 = v1 ^ 0xb3;
v14 = v2 ^ 0x45;
v15 = v3 ^ 0x45;
sub_46003922c(v16,&v7,8); // memcpy 8 bytes
*(unsigned short *)(v16 + 8) = 0; // wide NUL
sub_46003929c(&v7,0,8); // wipe the stack copy
dat_460285030 = v16;
}
return v6;
}
With the key bytes C9 B3 11 45 this evaluates to 50 00 4F 00 53 00 54 00 — UTF-16LE "POST". Note the housekeeping: the result is cached in a global so the string is only built once, and the stack copy is zeroed afterwards so a memory dump taken later finds fewer plaintext strings lying around.
Four variants of this pattern exist. Two use a 256-byte substitution table in .rdata:
b = data[i] + 0x2f;
b = (b >> 6) | (b << 2); // ror8(b, 2)
b = SBOX[b] + 0x1d; // SBOX at 0x460146C70
Rather than reverse each of the 708 functions by hand, they were solved by writing an interpreter for the decompiled C: parse each function, evaluate the assignments with the known key, then read out the bytes the memcpy copies. 707 of 708 resolved. The one holdout turned out not to be a string function at all — it is the VM dispatcher described next.
3.4 A bytecode VM protecting the strings that matter
186 strings — the C2, build ID, exfiltration templates, injection log messages — use a second, stronger scheme. Their functions do not compute bytes inline. They call two helpers instead:
sub_46003915c(&v3,0x40,v2,0xc); // run 0x40 bytes of bytecode -> 12 bytes out
sub_460038a38(v2,8,v4,4,&v6,0x50,v5); // decrypt 0x50 bytes with key=8, iv=4
The first is a virtual machine. Each byte of the 0x40-byte program indexes a 256-entry function-pointer table, and the handler is called with a context struct holding the key, source pointer, and destination pointer:
while ((v11 < v10 && (v14 < v13))) {
v2 = (uint8)v11;
v11 += 1;
v1 = *(code **)((uint8)*(uint1 *)(v9 + v2) * 8 + 0x4602844f0);
if (!v1)
return;
(*v1)(&v3);
}
The table at 0x4602844F0 is built at runtime, so it is empty in the file. It is populated by a long, regular sequence of stores that can be parsed straight out of the disassembly:
mov eax,0x8
imul rax,rax,0xf5 ; opcode 0xF5
lea rcx,[rip+0x24b559] ; 0x4602844f0 (table)
lea rdx,[rip+0xffffff8e] ; 0x460038f2c (handler)
mov QWORD PTR [rcx+rax*1],rdx
Extracting all of these yields exactly 14 opcodes. Each handler is 20–80 bytes and trivially readable:
| Opcode | Operation |
|---|---|
| 0x26 | acc ^= output_index |
| 0x46 | acc = ~acc |
| 0x4A | acc ^= 0x80 |
| 0x68 | acc += imm |
| 0x82 | acc ^= last_output |
| 0x8C | acc = ror8(acc, 2) |
| 0xBA | acc *= 0xC9 |
| 0xCA | acc -= imm |
| 0xDD | acc = ror8(acc, 2) |
| 0xE3 | acc = SBOX2[acc] |
| 0xE6 | emit acc ^ imm, then acc = imm |
| 0xE9 | acc ^= key_byte[out_index & 3] |
| 0xF5 | acc ^= imm |
| 0xF7 | acc += 0x3E |
The 12 bytes the VM produces are an 8-byte key and a 4-byte IV, which drive the second helper — a custom stream cipher built from an FNV-1a seed and an xorshift-style mixer:
void sub_460038a38(int8 a0,uint4 a1,int8 a2,uint4 a3,int8 a4,uint4 a5,int8 a6)
{
v2 = 0x5381bb77;
for (v3 = 0; v3 < a1; v3 = v3 + 1) { // absorb key
v2 = (v2 ^ *(uint1 *)(a0 + (uint8)v3)) * 0x1000193;
}
for (v3 = 0; v3 < a3; v3 = v3 + 1) { // absorb IV
v2 = (v2 ^ (uint4)*(uint1 *)(a2 + (uint8)v3) << 8) + 0x9e3779b9;
}
for (v4 = 0; v4 < a5; v4 = v4 + 1) { // keystream
v1 = ((v2 ^ v2 >> 0x10) + 0x457879c5 ^ 0x8f35d375) + 0xc0e7a61;
v1 = (v1 ^ v1 >> 0x10) + 0xe8f3b92;
v1 = (v1 ^ v1 >> 0x10) + 0xd71ed54c;
v2 = ((v1 & 0xffff) >> 1 | (v1 >> 0x10 | v1 * 0x10000) << 0xf) + 0x2a85d0ed;
*(uint1 *)(a6 + (uint8)v4) = *(uint1 *)(a4 + (uint8)v4)
^ (uint1)v2 ^ (uint1)(v2 >> 8) ^ (uint1)(v2 >> 0x10) ^ (uint1)(v2 >> 0x18);
}
}
Reimplementing the 14 opcodes and this cipher in Python decrypted 186 of 186. Combined with the first scheme, all 893 strings in the binary were recovered statically.
3.5 The C2 is a dead drop, not an address
With the strings out, the C2 registration function becomes readable. It takes a URL, a keyword, and a user agent, and supports up to seven entries — but only one is populated:
v1 = sub_4600651c0(); // user agent
v2 = sub_460065050(); // "h61sh"
sub_4600281d0(sub_46006463c(),v2,v1); // the only real entry
sub_4600281d0(0x460146ae3,0x460146ae3,0x460146ae3); // empty string
sub_4600281d0(0x460146ae3,0x460146ae3,0x460146ae3); // empty string
// ... four more empty entries
Decrypting sub_46006463c() gives the URL:
https://dev.epicgames.com/community/api/user_profiles/profile.json?hash_id=roAjr
That is not the C2. It is a dead drop — a public Epic Games developer-community profile that the operator controls. The resolver fetches it, searches the response for the keyword h61sh, skips one delimiter (|, space, tab or :), and reads the address that follows up to the next | or whitespace. If the keyword is missing it falls back to using the URL directly, logging “Кодовое слово не найдено, пробуем напрямую” (“code word not found, trying directly”).
If the extracted value begins with enc:, it is decrypted first:
unsigned long long sub_4601410f0(int8 a0,int8 a1,int4 a2)
{
if (sub_46013fad0(v6)) { // v6 = 32-byte key
do {
v2 = sub_460035a50(*(char *)(a0 + (int4)(v1 * 2))); // hex nibble
v3 = sub_460035a50(*(char *)(a0 + 1 + (int8)(int4)(v1 * 2)));
if ((int4)v2 < 0) return 0;
if (v3 < 0) return 0;
*(uint1 *)(a1 + v4) = ((char)v2 << 4 | (uint1)v3) ^ v6[v1 & 0x1f];
v4 = (uint8)(v1 + 1);
} while ((int4)(v1 + 1) < v7);
}
}
Hex-decode, then XOR against a repeating 32-byte key. And sub_46013fad0, which produces that key, turns out to be a BCrypt SHA-256 of a hardcoded passphrase — the string Glasikprostik, which looks like a build marker until you see what it is used for:
key = SHA256("Glasikprostik")
= 2de380c59a6f48a8d0589bc0ffd64a5cc8faa01c87a69e5a9f1ec9777eb74cbe
So despite the sample importing the entire BCryptEncrypt/BCryptDecrypt/BCryptGenerateSymmetricKey surface for its browser-cookie work, this particular payload is protected by plain repeating-key XOR with a key derivable from the binary alone. Anyone holding a copy of that profile page can recover the operator’s real C2 offline, without ever running the sample.
One more detail from the HTTP layer is useful to defenders. When the scheme is HTTPS the client sets:
(*WinHttpSetOption_p)(v7,0x1f,&Stack0000000000000010,4); // 0x1F = WINHTTP_OPTION_SECURITY_FLAGS
// value = 0x3300
0x3300 is the combination of ignore unknown CA, ignore wrong CN, ignore invalid date and ignore wrong usage. The sample accepts any TLS certificate, which means lab interception needs no CA certificate installed in the guest.
3.6 Single-instance mutex
v1 = (code *)sub_460017864(0xfb30117b); // CreateMutexW
v2 = (code *)sub_460017864(0xa54f376d); // GetLastError
if ((v1) && (v2)) {
sub_460034300(sub_4600657b8(),v4,0x80); // ansi -> wide of "AFDSE3esh32le"
dat_4602844d0 = (*v1)(0,0,v4);
if (dat_4602844d0) {
if ((*v2)() != 0xb7) // ERROR_ALREADY_EXISTS
return 1;
The mutex name is AFDSE3esh32le. If it already exists the sample does not exit — it waits, retrying 60–120 times with randomised 2–5 second sleeps, logging Another instance running, waiting (%d attempts).
3.7 An operator console left in the build
The decrypted strings include a verbose progress log, partly in Russian, clearly written for whoever deploys the build:
[analysis] FAIL — %s
[cpu_brand] FAIL — QEMU
[windows] FAIL — VBoxTray window
v20: fork-scan (%d pids)...
v20: ключ извлечён (pid %lu) (key extracted)
Ключей: %d | v10: %d | v20: %d (keys: ...)
copied: %d files (%d handle steal)
Crypto: %d кошельков (%s), %d файлов (wallets, files)
C2 unreachable after %lu attempts (loop ended at %lu)
=== All stages done ===
One string is not a log line. It is a builder watermark that nags an affiliate who deploys the payload without buying the crypter:
This is a VIRUS!!!! Do you want to run the file?
To remove the window, you must CRYPT FILE!
4. Malware Capabilities
- Defense Evasion:
- Empty import table; all 187 APIs resolved at runtime by FNV-1a hash from a manual PEB walk
- All 893 strings generated by code at runtime and cached; stack copies wiped after use
- ~60 VM/sandbox checks: CPUID hypervisor vendor, guest drivers (
VBoxSF,vmci,viostor,netkvm, …), device paths (\\.\VBoxGuest,\\.\vmci), registry keys (HARDWARE\ACPI\DSDT\VBOX__), SMBIOS read directly viaGetSystemFirmwareTable, BIOS date06/23/99 - Analysis tool detection:
x64dbg,x32dbg,windbg,ida64,processhacker,petools,importrec,autoruns,fiddler,httpdebugger,dumpcap,frida-server,joeboxcontrol - AV detection by process and by injected hook module name (
aswh,eamsi,klhk,atcuf,symam) - Timing checks via 46
rdtscsites; explicitly detects a hookedSleepand logsSleep patched - On detection it does not abort — it logs
vm: detected — reporting to C2and POSTs telemetry, suggesting the decision to withhold a config is server-side
- Credential Access:
- Chromium and Gecko profiles:
Login Data,cookies.db,webdata.db,logins.json,key4.db,cert9.db,places.sqlite,Local State - Chrome App-Bound Encryption (
v20) bypass: injects an APC into a live browser process to decrypt keys in-context, relaunching the browser headless (--headless --disable-gpu --user-data-dir=) when no suitable process exists - Discord tokens (including
.ldb/.logscraping for JWT prefixes), Telegram Desktoptdata, Telegram Web sessions, Steamssfn*andconfig.vdf - FileZilla
recentservers.xml, WinSCP sessions fromSoftware\Martin Prikryl\WinSCP 2\Sessions, Azure CLIaccessTokens.json - DPAPI via
CryptUnprotectData/CryptUnprotectMemory
- Chromium and Gecko profiles:
- Collection:
- Handle stealing — enumerates another process’s handle table via
NtQueryInformationProcess(0x33, ProcessHandleInformation)and duplicates locked file handles into itself, reading browser databases without closing the browser - Restart Manager abuse —
RmStartSession/RmRegisterResources/RmGetListto identify and act on processes holding file locks - Full-desktop screenshot encoded to JPEG entirely in memory via GDI+ (
BitBlt→GdipSaveImageToStream) - Config-driven file grabber over
%DESKTOP%,%DOCUMENTS%,%DOWNLOADS%,%USERPROFILE%,%APPDATA%,%PROGRAMDATA%,%RECENT%,%DRIVE_FIXED%,%DRIVE_REMOVABLE%
- Handle stealing — enumerates another process’s handle table via
- Privilege Escalation / Injection:
SeDebugPrivilegeacquisition viaAdjustTokenPrivileges- Early-bird APC injection with PPID spoofing to
explorer.exe, usingInitializeProcThreadAttributeList/UpdateProcThreadAttribute
- Command and Control:
- WinHTTP to a dead-drop resolver on
dev.epicgames.com; real C2 read from the page body - Exfiltration as
multipart/form-datawith fieldshwid,build_id,token,mode,data,format=json - Accepts any TLS certificate (
WINHTTP_OPTION_SECURITY_FLAGS = 0x3300)
- WinHTTP to a dead-drop resolver on
- Execution:
- Secondary payload loader handling
.exe,.dll(rundll32) and.ps1(powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NoProfile -File)
- Secondary payload loader handling
- Persistence:
- None. No Run key, service, scheduled task or startup entry. The sample logs
Work Dir: In memory, runs once, and removes its working directory withcmd.exe /c rmdir /s /q "%s"
- None. No Run key, service, scheduled task or startup entry. The sample logs
The absence of persistence is a deliberate design choice, not an oversight. This is a single-shot smash-and-grab: by the time a host is triaged, the theft is complete and the process is gone.
5. Indicators of Compromise
| Type | Value |
|---|---|
| SHA256 | 712e7912a669a71a7006fd1a0333b5da45dbbc78672c46ae62e794fbc1372959 |
| MD5 | 0d0bb2656a73610b2695fc8f3db21723 |
| Dead drop URL | https://dev.epicgames.com/community/api/user_profiles/profile.json?hash_id=roAjr |
| Dead drop keyword | h61sh |
| Mutex | AFDSE3esh32le |
| Build ID | 99b11b37a468c7431dc2b824bd8a47fb |
| Version string | 3.1 |
| User-Agent | Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:34.0) Gecko/20100101 Firefox/34.0 |
enc: XOR key | 2de380c59a6f48a8d0589bc0ffd64a5cc8faa01c87a69e5a9f1ec9777eb74cbe (SHA256 of Glasikprostik) |
| Embedded key | 4f8a2b1c9d3e7f6a0b5c1d8e2f9a4b7c6d3e0f1a2b5c8d9e0f1a2b3c4d5e6f7a |
| Build markers | --baras433l, AFDSE3esh32le, Glasikprostik |
| API hash algorithm | FNV-1a, basis 0x850096F1, prime 0x010003C9 |
A note on hunting: dev.epicgames.com is a legitimate, widely-allowed domain, so blocking it is not viable. Detection has to key on the request path and the profile identifier (hash_id=roAjr), or on the mutex name at the host level.
6. Analysis Limitations
Three things could not be resolved from the file, and it is worth being explicit about which are properties of the sample rather than gaps in the analysis:
- The target list is not in the binary. Browser, wallet and grabber rules arrive from the C2 as JSON at runtime. The sample carries only the configuration keys (
browsers_name,browser_path,Wallet Rules,exclusions,zip_threshold,thread_count). Recovering the actual targets requires a live server response. - The real C2 address requires the dead-drop page. The decryption is fully solved; only the ciphertext is missing. One HTTP fetch of that public profile would supply it — no detonation needed.
- Two ntdll API hashes remain unidentified (
0x58822D0F,0x5D318A3C). Neither matched any of the 35,759 harvested export names. A real Windows 10/11ntdll.dllwould likely close this.
Whether the sample continues execution after reporting sandbox detection is stated above as a static reading of the control flow; confirming it would need a controlled run.