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:
SectionsEntropy
.text5.68
.rdata5.88
.data1.90
.reloc0.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:
SectionsVirtual SizeRaw Size
.text13115041311744
.rdata2906429184
.data13102724608
.reloc20512

Three structural oddities stand out:

  1. The import table is empty. There is no IMAGE_DIRECTORY_ENTRY_IMPORT at all — not a small one, a zero-sized one. A normally-linked MSVC binary of this size has hundreds of imports.
  2. .data has 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.
  3. 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:

InstructionCount
mov156381
imul48282
xor12302
call8986
rdtsc46

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:

OpcodeOperation
0x26acc ^= output_index
0x46acc = ~acc
0x4Aacc ^= 0x80
0x68acc += imm
0x82acc ^= last_output
0x8Cacc = ror8(acc, 2)
0xBAacc *= 0xC9
0xCAacc -= imm
0xDDacc = ror8(acc, 2)
0xE3acc = SBOX2[acc]
0xE6emit acc ^ imm, then acc = imm
0xE9acc ^= key_byte[out_index & 3]
0xF5acc ^= imm
0xF7acc += 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 via GetSystemFirmwareTable, BIOS date 06/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 rdtsc sites; explicitly detects a hooked Sleep and logs Sleep patched
    • On detection it does not abort — it logs vm: detected — reporting to C2 and 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/.log scraping for JWT prefixes), Telegram Desktop tdata, Telegram Web sessions, Steam ssfn* and config.vdf
    • FileZilla recentservers.xml, WinSCP sessions from Software\Martin Prikryl\WinSCP 2\Sessions, Azure CLI accessTokens.json
    • DPAPI via CryptUnprotectData / CryptUnprotectMemory
  • 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 / RmGetList to 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%
  • Privilege Escalation / Injection:
    • SeDebugPrivilege acquisition via AdjustTokenPrivileges
    • Early-bird APC injection with PPID spoofing to explorer.exe, using InitializeProcThreadAttributeList / 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-data with fields hwid, build_id, token, mode, data, format=json
    • Accepts any TLS certificate (WINHTTP_OPTION_SECURITY_FLAGS = 0x3300)
  • Execution:
    • Secondary payload loader handling .exe, .dll (rundll32) and .ps1 (powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NoProfile -File)
  • 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 with cmd.exe /c rmdir /s /q "%s"

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

TypeValue
SHA256712e7912a669a71a7006fd1a0333b5da45dbbc78672c46ae62e794fbc1372959
MD50d0bb2656a73610b2695fc8f3db21723
Dead drop URLhttps://dev.epicgames.com/community/api/user_profiles/profile.json?hash_id=roAjr
Dead drop keywordh61sh
MutexAFDSE3esh32le
Build ID99b11b37a468c7431dc2b824bd8a47fb
Version string3.1
User-AgentMozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:34.0) Gecko/20100101 Firefox/34.0
enc: XOR key2de380c59a6f48a8d0589bc0ffd64a5cc8faa01c87a69e5a9f1ec9777eb74cbe (SHA256 of Glasikprostik)
Embedded key4f8a2b1c9d3e7f6a0b5c1d8e2f9a4b7c6d3e0f1a2b5c8d9e0f1a2b3c4d5e6f7a
Build markers--baras433l, AFDSE3esh32le, Glasikprostik
API hash algorithmFNV-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:

  1. 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.
  2. 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.
  3. Two ntdll API hashes remain unidentified (0x58822D0F, 0x5D318A3C). Neither matched any of the 35,759 harvested export names. A real Windows 10/11 ntdll.dll would 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.