<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title><![CDATA[云散皆星河的博客]]></title>
  <link>https://blog.xn--9kq396ceqaq4si9m.cn</link>
  <description><![CDATA[一个随便发发的博客]]></description>
  <language>zh-cn</language>
  <lastBuildDate>Thu, 15 Oct 2026 16:28:40 +0800</lastBuildDate>
  <atom:link href="https://blog.xn--9kq396ceqaq4si9m.cn/feed" rel="self" type="application/rss+xml"/>
  <item>
    <title><![CDATA[我写了半个用户态杀软--R3 ShieldCore!!!]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E6%88%91%E5%86%99%E4%BA%86%E5%8D%8A%E4%B8%AA%E7%94%A8%E6%88%B7%E6%80%81%E6%9D%80%E8%BD%AF-r3-shieldcore</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E6%88%91%E5%86%99%E4%BA%86%E5%8D%8A%E4%B8%AA%E7%94%A8%E6%88%B7%E6%80%81%E6%9D%80%E8%BD%AF-r3-shieldcore</guid>
    <pubDate>Tue, 06 Oct 2026 23:00:00 +0800</pubDate>
    <category><![CDATA[dll注入]]></category>
    <category><![CDATA[hook]]></category>
    <category><![CDATA[全局注入]]></category>
    <category><![CDATA[开源]]></category>
    <category><![CDATA[杀软]]></category>
    <category><![CDATA[编程]]></category>
    <description><![CDATA[本文介绍基于 C++20 开发的用户态行为拦截引擎 R3 ShieldCore，它无内核驱动，依靠 DLL 注入与 Inline Hook 监控系统行为。项目通过多种注入方式消除时间盲区，挂钩上百个 API 实现文件、进程、网络等行为管控，借助共享内存完成跨进程通信，并设计稳妥的卸载逻辑。该原型可拦截常规用户态恶意行为，但易被直接 Syscall、手工映射等手段绕过，存在明显防护局限。]]></description>
    <content:encoded><![CDATA[<h1 id="我写了半个用户态杀软-r3-shieldcore" class="md-heading"><a class="heading-anchor" href="#我写了半个用户态杀软-r3-shieldcore" aria-hidden="true">#</a>我写了半个用户态杀软--R3 ShieldCore!!!</h1>
<p>它叫 R3 ShieldCore,Windows 上的用户态行为拦截引擎,代码在 <a href="https://github.com/yunsjxh/R3ShieldCore" target="_blank" rel="noopener noreferrer">https://github.com/yunsjxh/R3ShieldCore</a> 做的事一句话:把一份 DLL 注入进机器上几乎每一个进程,在进程里把用户态 API 换掉,在行为落地之前拦下来。</p>
<p>叫它"半个杀软"是因为它只占了用户态这一半——注入、hook、拦 API 调用,都在用户态;另一半在内核和硬件,它够不着。</p>
<p>约 110 处 inline hook,分给 17 个 guard,处理 18 类对象,纯 C++20。</p>
<h2 id="注入把同一份-dll-放进每个进程" class="md-heading"><a class="heading-anchor" href="#注入把同一份-dll-放进每个进程" aria-hidden="true">#</a>注入:把同一份 DLL 放进每个进程</h2>
<p>枚举进程用的是 <code class="inline-code">NtGetNextProcess</code>,一路 next 到 <code class="inline-code">STATUS_NO_MORE_ENTRIES</code>,带 <code class="inline-code">SYNCHRONIZE | DllInject::ProcessAccess</code> 权限。注不注进去,先过 <code class="inline-code">ShouldSkipProcessInjection</code>。</p>
<p>关键是<strong>判断这个进程有没有开始跑</strong>。没跑起来的进程,主线程的指令指针还停在 <code class="inline-code">RtlUserThreadStart</code>,这时候适合用 APC 投递;跑起来了的,只能起远程线程。判断方式是拿 <code class="inline-code">NtGetNextThread</code> 数线程:</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">NtGetNextThread(hProcess, nullptr, access, 0, 0, &amp;thread1);
NTSTATUS status = NtGetNextThread(hProcess, thread1.get(), access, 0, 0, &amp;thread2);
if (status == STATUS_NO_MORE_ENTRIES) {
    // 就一个线程。挂起它，看指令指针
    SuspendThread(thread1.get());
    GetThreadContext(thread1.get(), &amp;c);
    if (c.Rip == pRtlUserThreadStart) {   // 还没跑起来
        // 走 APC 路
    }
}</code></pre></div>
<p>为什么要看这个,而不是无脑起远程线程?因为会踩到一个真实故障:控制台程序如果被我们在它还没通知 <code class="inline-code">csrss.exe</code> 之前就触发了控制台初始化(<code class="inline-code">KERNELBASE!ConsoleCommitState</code> 早于 <code class="inline-code">CsrClientCallServer</code>),<code class="inline-code">csrss</code> 会返回 access denied,<strong>父进程的 CreateProcess 直接失败</strong>。所以没跑起来的必须走 APC,让它自己线程去执行,别抢跑。</p>
<p>APC 那条路会建一个互斥体(<code class="inline-code">ProcessInitAPCMutex</code>)去重,防止轮询路和同步路把同一个进程注两遍。32 位进程里还得跨架构去取 64 位 <code class="inline-code">ntdll</code> 的 <code class="inline-code">RtlUserThreadStart</code>,靠 <code class="inline-code">wow64ext</code>。</p>
<p>送进去的不是 DLL 路径加一句 <code class="inline-code">LoadLibrary</code>。是<strong>一段位置无关的反射 shellcode</strong>:靠 PEB 遍历自己把 <code class="inline-code">LoadLibraryW</code>、<code class="inline-code">GetProcAddress</code> 摸出来,再把 DLL 装起来,全程不依赖目标进程给任何东西。</p>
<h2 id="新进程不能有盲区" class="md-heading"><a class="heading-anchor" href="#新进程不能有盲区" aria-hidden="true">#</a>新进程不能有盲区</h2>
<p>轮询路有个天生的问题:进程已经跑起来了才轮到我们,中间那段就是盲区。所以还有一条同步路——在<strong>父进程</strong>里 hook <code class="inline-code">CreateProcessInternalW</code>(先找 <code class="inline-code">kernelbase.dll</code>,退而求其次 <code class="inline-code">kernel32.dll</code>),强制给创建标志或上 <code class="inline-code">CREATE_SUSPENDED</code>:</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">DWORD dwNewCreationFlags = dwCreationFlags | CREATE_SUSPENDED;
bRet = pThis-&gt;originalCreateProcessInternalW(..., dwNewCreationFlags, ...);</code></pre></div>
<p>子进程建出来、主线程还挂着,这时候注入,成功才 <code class="inline-code">ResumeThread</code>;失败就 <code class="inline-code">TerminateProcess</code> 加关句柄、把 <code class="inline-code">PROCESS_INFORMATION</code> 清空、返回 FALSE。让一个一条钩子都没装的进程跑起来,是比杀掉它更坏的结局。</p>
<p>这里踩过一个坑。原函数在自己内部<strong>也要往刚建出来的子进程里写东西</strong>——<code class="inline-code">RTL_USER_PROCESS_PARAMETERS</code>,也就是环境块、命令行、当前目录,走的是 <code class="inline-code">NtWriteVirtualMemory</code>。而如果本进程被全量注入了,那次写会被我们自己的钩子判成"跨进程写内存 = 高危",BLOCK 模式下当场拒掉,于是原函数回滚、返回 FALSE、<code class="inline-code">GetLastError()</code> 是 5,<strong>根本走不到注入那一步</strong>。实测调用栈:</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>#0 KERNELBASE!NtWriteVirtualMemory   &lt;- 被自己拦下的那一次
#5 KERNEL32!CreateProcessA
#4 KERNELBASE!CreateProcessInternalW &lt;- 原函数</code></pre></div>
<p>解法是开一个"CreateProcess 窗口":窗口内,由本线程建出来的子进程,对跨进程写内存、改页保护、建远程线程这几样一律透传;窗口一关立即失效。这个窗口对象是 POD,手工 Enter/Leave 配对——不能用 RAII,因为函数体里有 <code class="inline-code">__try/__finally</code>,带析构函数的对象一进来 MSVC 就报 C2712。</p>
<p>还有个"瘦会话"的设计。<code class="inline-code">explorer.exe</code>、<code class="inline-code">svchost.exe</code>、<code class="inline-code">runtimebroker.exe</code> 这三个进程<strong>一个 guard 都不装</strong>,只装 <code class="inline-code">CreateProcessInternalW</code>。目的是让它们拉起的子进程能走上面那条同步路,盲区趋近于零——"双击运行一个启动即写 MBR 的样本",只有这条路能赢。代价是这三个进程自己不受监控,这是故意的:<code class="inline-code">explorer.exe</code> 的文件 I/O 频率极高(开目录、缩略图、拖放),全量 hook 在 BLOCK 模式下必炸,而且 DLL 里任何一个 bug 都会让整个桌面挂掉。</p>
<h2 id="进了进程把-api-换掉" class="md-heading"><a class="heading-anchor" href="#进了进程把-api-换掉" aria-hidden="true">#</a>进了进程,把 API 换掉</h2>
<p>DLL 落地后的流程是 <code class="inline-code">MH_Initialize</code> → 设定线程冻结方式 → 各 guard 依次 <code class="inline-code">MH_CreateHook</code> + <code class="inline-code">MH_QueueEnableHook</code> 排队 → 最后一次 <code class="inline-code">MH_ApplyQueued</code> 全部生效。用"排队再一次性生效"而不是逐个立刻挂上,是为了避免钩子挂到一半的中间态。</p>
<p>线程冻结方式分两种:APC 路进来时本进程还没别的线程在跑,用 <code class="inline-code">MH_FREEZE_METHOD_NONE_UNSAFE</code>;其余情况用 <code class="inline-code">MH_FREEZE_METHOD_FAST_UNDOCUMENTED</code>,靠 MinHook 的未文档化手法把别的线程冻住再改代码。</p>
<p>挂点大致分布是:</p>
<ul><li><code class="inline-code">ntdll</code>:<code class="inline-code">NtCreateKey</code> / <code class="inline-code">NtSetValueKey</code> / <code class="inline-code">NtCreateFile</code> / <code class="inline-code">NtWriteFile</code> / <code class="inline-code">NtOpenProcess</code> / <code class="inline-code">NtCreateThreadEx</code> / <code class="inline-code">NtWriteVirtualMemory</code> / <code class="inline-code">NtProtectVirtualMemory</code> / <code class="inline-code">NtAllocateVirtualMemory</code> 等</li><li><code class="inline-code">user32</code>:<code class="inline-code">SetWindowsHookEx</code> / <code class="inline-code">SendInput</code> / <code class="inline-code">BlockInput</code> / <code class="inline-code">ClipCursor</code></li><li><code class="inline-code">ws2_32</code>:<code class="inline-code">connect</code> / <code class="inline-code">WSAConnect</code> / <code class="inline-code">sendto</code> / <code class="inline-code">bind</code></li><li><code class="inline-code">advapi32</code>:服务相关那一批</li><li><code class="inline-code">combase</code> / <code class="inline-code">ole32</code>:<code class="inline-code">CoCreateInstance</code> / <code class="inline-code">CoGetClassObject</code></li></ul>
<p>WMI、计划任务、摄像头设备这三类是纯 COM 接口,<strong>没有可挂的导出函数</strong>,只能去改 vtable 指针,和 MinHook 是两套手艺。</p>
<p>17 个 guard 的安装顺序不能乱:<code class="inline-code">RegistryGuard</code> 必须先装,因为其余 16 个都是先问它的 <code class="inline-code">IsBypassed()</code> 结论来决定自己挂不挂;而且共享通道也是它开的。通道开不起来的时候,这个进程<strong>直接跳过全部 hook</strong>,而不是装一半——装了一半的防护比没装更难查。</p>
<p>拿 <code class="inline-code">NtCreateKey</code> 举例(<code class="inline-code">registry_guard.cpp</code>):</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">NTSTATUS NTAPI NtCreateKey_Hook(PHANDLE KeyHandle, ACCESS_MASK DesiredAccess,
    const RG_OBJECT_ATTRIBUTES* ObjectAttributes, ULONG TitleIndex,
    RG_UNICODE_STRING* Class, ULONG CreateOptions, PULONG Disposition)
{
    InterlockedIncrement(&amp;g_activeHooks);

    R3ShieldCore::Event event;
    Action action = Action::Pass;
    NTSTATUS status = STATUS_UNSUCCESSFUL;

    __try {
        action = Evaluate(R3ShieldCore::Op::CreateKey, nullptr,
            ObjectAttributes ? ObjectAttributes-&gt;ObjectName : nullptr, nullptr,
            DesiredAccess, event, true);
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        action = ExceptionAction(event);
    }

    if (action == Action::Block) {
        event.Status = static_cast&lt;ULONG&gt;(STATUS_ACCESS_DENIED);
        Publish(event);
        status = STATUS_ACCESS_DENIED;
    }
    else {
        status = pOriginalNtCreateKey(KeyHandle, DesiredAccess, ObjectAttributes,
            TitleIndex, Class, CreateOptions, Disposition);
        if (action == Action::Record) {
            event.Status = static_cast&lt;ULONG&gt;(status);
            if (status &gt;= 0 &amp;&amp; KeyHandle) {
                __try { RefineKeyPath(*KeyHandle, event); }
                __except (EXCEPTION_EXECUTE_HANDLER) {}
            }
            Publish(event);
        }
    }

    InterlockedDecrement(&amp;g_activeHooks);
    return status;
}</code></pre></div>
<p>骨架就四件事:<code class="inline-code">Evaluate</code> 判、<code class="inline-code">Block</code> 就直接返 <code class="inline-code">STATUS_ACCESS_DENIED</code> 不往下走、<code class="inline-code">Record</code> 就先调原函数再补路径再上报、首尾一对计数。</p>
<p>这段代码我试过抽象。想用模板把这一百来个函数收敛成一个,省掉那 130 处手工记账,结果 MSVC 在 <code class="inline-code">__try</code> 上直接甩 C2712——异常处理和带析构函数的 C++ 对象不能共处一个函数。所以每个 hook 都是手写、长得一模一样。注释里写了原因,免得下次再有"优化一下"的冲动。</p>
<p>顺带说一个判据上的规矩:多层过滤的顺序是<strong>先判断是不是文件对象 → 再判高危 → 最后才走豁免</strong>。高危路径往往正好落在豁免范围里,先走豁免就会被当噪音放过去。这个顺序在 <code class="inline-code">registry_guard.cpp</code> 和 <code class="inline-code">file_guard.cpp</code> 里反复标着。</p>
<h2 id="事件从别的进程传回来" class="md-heading"><a class="heading-anchor" href="#事件从别的进程传回来" aria-hidden="true">#</a>事件从别的进程传回来</h2>
<p>引擎和注入的 DLL 之间有两块共享内存,职责分得很开:</p>
<ul><li><strong>Policy</strong>:引擎写,DLL 用 <code class="inline-code">FILE_MAP_READ</code> <strong>只读</strong>打开。里面是模式、开关位、各种名单、统计。</li><li><strong>Events</strong>:环形缓冲,各进程写、引擎单消费者读。</li></ul>
<p>Policy 只读不是随手写的——早先有个字段是"高危命中计数",让 hook 侧去递增,结果被注入的进程一写就 <code class="inline-code">0xC0000005</code>,异常被 <code class="inline-code">__except</code> 吞掉变成"静默放行",症状是"高危既不拦也不记"。所以凡是 hook 侧要递增的计数,一律放在 Events 那边的 <code class="inline-code">ChannelHeader</code> 里,那个映射才是可写的。</p>
<p><code class="inline-code">ChannelHeader</code> 卡在 32 字节(<code class="inline-code">static_assert</code> 钉死),后面直接跟着 <code class="inline-code">Event[Capacity]</code>。发布一条事件的协议是:</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">LONG index = InterlockedIncrement(&amp;channel-&gt;WriteIndex) - 1;
// 消费者没追上就丢弃 —— 绝不阻塞调用方
bool dropped = (index - channel-&gt;ReadIndex) &gt;= channel-&gt;Capacity;
Event* slot = ChannelEventAt(channel, index);
if (dropped) { memset(slot, 0, sizeof(Event)); InterlockedIncrement(&amp;channel-&gt;DroppedCount); }
else         { memcpy(slot, &amp;event, sizeof(Event)); }
slot-&gt;Sequence = 0;
MemoryBarrier();
InterlockedExchange(&amp;slot-&gt;Sequence, index + 1);   // 消费者只认 Sequence == index+1</code></pre></div>
<p>生产者<strong>任何时候都不阻塞</strong>——hook 跑在别人的业务线程上,卡一下就是卡别人整个程序,所以缓冲满了就丢,记 <code class="inline-code">DroppedCount</code>。丢弃的那格也得占位,不占的话消费者会永远卡在那个序号上、此后一条都读不出来。</p>
<p>消费者这边的套圈处理更绕一点。如果 <code class="inline-code">writeIndex - readIndex &gt; Capacity</code>,说明生产者已经把那一圈覆盖了、<code class="inline-code">Sequence</code> 再也不可能等于 <code class="inline-code">readIndex + 1</code>,这时必须主动把 <code class="inline-code">readIndex</code> 推到 <code class="inline-code">writeIndex - Capacity</code>,跳过这段已丢的事件。不推的话消费者就永久死锁在那儿。</p>
<p><code class="inline-code">Event</code> 结构里字段挺多,挑几个有意思的:<code class="inline-code">ObjectType</code> 决定 <code class="inline-code">Op</code> 该按哪套枚举解释(注册表、文件、进程各有各的 <code class="inline-code">Op</code> 空间,共用同一个结构、同一条通道、同一套 UI);<code class="inline-code">TargetProcessId</code> / <code class="inline-code">CreatorProcessId</code> 专门用来区分进程创建里的"谁建的"和"建了谁";网络那些事件单独有 <code class="inline-code">TargetPort</code> / <code class="inline-code">NetProtocol</code> / <code class="inline-code">LocalAddress</code>,不把 IP 和端口拼成一个字符串,因为 UI 要分列,而且 IPv6 的 <code class="inline-code">::</code> 跟端口分隔符会打架。</p>
<p><code class="inline-code">Event::Flags</code> 是 32 位,到 <code class="inline-code">0x80000000</code> 就用满了。所以从某个版本起新语义一律走尾部追加的 <code class="inline-code">Flags2</code>,不再往 <code class="inline-code">Flags</code> 里挤。</p>
<p>跨进程 ABI 版本号现在到 20。<strong>尾部追加字段虽然不动前面的偏移,但会改 <code class="inline-code">sizeof(Policy)</code></strong>,而 <code class="inline-code">sizeof</code> 变了就必须升版本,原因是两个方向都会坏事:新 DLL 配旧引擎,旧引擎建的 section 更小,新 DLL 按新尺寸 <code class="inline-code">MapViewOfFile</code> 会直接失败;旧 DLL 配新引擎,旧 DLL 只映射自己那一段能成功,但它会按旧布局去读新加的 <code class="inline-code">NeverInjectPaths</code> 那块字节。两个方向最后都落成同一句话——<strong>版本对不上就当作"共享通道不可用",本进程跳过全部 hook</strong>,而不是含糊地按错误布局读内存。Policy 和 Channel 各带一个 magic(<code class="inline-code">&#039;RGP1&#039;</code> / <code class="inline-code">&#039;RGE1&#039;</code>),加上版本号和引擎 pid 一起校验。</p>
<h2 id="弹窗怎么跨进程" class="md-heading"><a class="heading-anchor" href="#弹窗怎么跨进程" aria-hidden="true">#</a>弹窗怎么跨进程</h2>
<p><code class="inline-code">ask</code> 模式要弹窗,可麻烦在于:我的 DLL 跑在<strong>别人的进程</strong>里,窗得画在<strong>引擎进程</strong>里。这条通道没用命名管道,也没用窗口消息——那两样都带着能被目标进程观察、甚至干扰的语义。用的是命名共享内存加命名事件。</p>
<p>共享内存里排 8 个槽。每个槽有个 <code class="inline-code">State</code>(Free / Claimed / Pending / Answered)和一个 <code class="inline-code">Generation</code>(世代号)。生产者是任意进程里那个被卡住的 hook 线程,流程是这样:</p>
<ol><li>逐个槽 CAS 抢,把 <code class="inline-code">State</code> 从 Free 抢成 Claimed</li><li><code class="inline-code">InterlockedIncrement(Generation)</code>——<strong>必须在发布之前</strong></li><li>填请求字段</li><li><code class="inline-code">MemoryBarrier()</code>,再把 <code class="inline-code">State</code> 置成 Pending</li><li><code class="inline-code">SetEvent</code> 通知引擎</li><li>等本槽的应答事件,带超时。醒来先确认 <code class="inline-code">State == Answered</code> <strong>且</strong> <code class="inline-code">AnsweredGeneration == 自己的世代号</code>,不满足说明这是上一次请求的迟到答复,继续等</li><li>超时就自己把槽释放掉,返回兜底结论(默认 deny)</li></ol>
<p>引擎那边是 UI 线程做的:扫 Pending 的槽、快照、弹窗、写结论和 <code class="inline-code">AnsweredGeneration</code>,然后 <code class="inline-code">InterlockedCompareExchange(State, Answered, Pending)</code>——<strong>只在槽还是 Pending 的时候才置成 Answered</strong>。如果 DLL 那边已经超时把槽释放了,CAS 就失败,这个答复直接被丢掉,不会串到别人的新请求上。</p>
<p>第 2 步和第 7 步合起来就是整个协议的命门。没有世代号的话,"上一个请求超时释放了槽、下一个请求占了同一个槽、上一个的答复这时候才到"——这条时序会把 A 的结论喂给 B,而 B 可能正好是个写注册表的操作。所以每个请求带着自己的世代号走,答复必须对号。</p>
<p>几个边角:等的时候按 250 毫秒切片轮询,不是死等一整个超时,这样卸载通知一来就能立刻收手;同一个进程已经有请求在等用户时,新的直接返回兜底结论,既防刷屏也防"用户还没点完、同一个进程被问十遍";<code class="inline-code">log</code> 模式在 <code class="inline-code">Ask</code> 入口直接短路返回 Allow,压根不碰通道——观测模式不能因为通道不在就变成拒绝。</p>
<h2 id="卸载顺序一步都不能换" class="md-heading"><a class="heading-anchor" href="#卸载顺序一步都不能换" aria-hidden="true">#</a>卸载顺序,一步都不能换</h2>
<p>卸载这五步是这个项目里我最不敢动的地方:</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">R3ShieldCorePrompt::NotifyShutdown();      // 1. 让还在等答复的 Ask 立刻收手
MH_DisableHook(MH_ALL_HOOKS);              // 2. 新调用走回原函数
Sleep(100);                                // 3. 给&quot;跨过前几字节、还没进计数&quot;的线程一点时间
RegistryGuard::WaitForHooksToDrain(10000); //    ...17 个 guard 各等一次
m_newProcessInjector.reset();              // 4. 等 CreateProcessInternalW 的在飞调用归零
MH_Uninitialize();                         // 5. 确认没人在 DLL 里了，才释放 trampoline</code></pre></div>
<p>第 5 步之前的每一件事,都是为了确保没有线程还待在这个 DLL 的代码里。早期版本直接 <code class="inline-code">MH_Uninitialize</code>,结果把宿主打崩:那一步会释放 trampoline,而此刻可能有线程正卡在 hook 里(ask 模式下甚至停在等用户点按钮,最长几十秒),它返回的时候就是野指针。WER 报的是 <code class="inline-code">r3shieldcore-lib.dll_unloaded</code>,异常码 <code class="inline-code">c0000005</code>。实测打崩过 <code class="inline-code">MyApp.exe</code> 和 <code class="inline-code">OfficeClickToRun.exe</code>。</p>
<p>第 3 步能奏效,靠的是每个 hook 首尾那对 <code class="inline-code">InterlockedIncrement/Decrement(&amp;g_activeHooks)</code>。这也是我说"130 处手工记账"是笔债的原因——这 130 处里任何一处漏了递减,<code class="inline-code">WaitForHooksToDrain</code> 就永远等不到归零,卸载卡死在那儿。</p>
<h2 id="几个真踩过的坑" class="md-heading"><a class="heading-anchor" href="#几个真踩过的坑" aria-hidden="true">#</a>几个真踩过的坑</h2>
<p><strong>掩码用错位。</strong> 注册表那边判断"是不是写意图",我一开始拿 <code class="inline-code">KEY_WRITE</code> 当掩码。问题是它的 <code class="inline-code">READ_CONTROL(0x00020000)</code> 位和 <code class="inline-code">KEY_READ</code> 完全重叠,结果把一大票纯读打开全判成了写意图。实测十二秒里刷出 240 条噪音。这种错它不报错,只是安静地做错事。</p>
<p><strong>一个连字符。</strong> 轮询路和同步路本来该共用同一个拼名函数去造那个去重互斥体的名字。同步路那份写成了 <code class="inline-code">L&quot;\\ProcessInitAPCMutex-pid=%u&quot;</code>——拼出来是个对象路径,而私有命名空间根本没被真的创建过,于是 <code class="inline-code">CreateMutex</code> 每次都返回 <code class="inline-code">ERROR_PATH_NOT_FOUND</code>,每次都抛异常。<strong>同步注入路从来没成功过一次</strong>,子进程被原样放行,全靠 10 毫秒轮询兜底,而日志里看起来一切正常。</p>
<p><strong>注入两次就死。</strong> 会话信号量是按 pid 命名的。同一个进程被注入两次时,第二次的 <code class="inline-code">create</code> 拿到的是同一个信号量对象(计数已被第一次拿走),<code class="inline-code">acquire()</code> 就永久阻塞。如果第二次走的是 APC 路,这段代码跑在<strong>主线程</strong>上,于是主线程卡死、<code class="inline-code">main</code> 永远进不去:现象是窗口一片空白、进程常驻。改成先 <code class="inline-code">OpenSemaphoreW</code> 探测,存在就直接放弃这次注入——<strong>fail-fast</strong>,后来的让位给先到的。</p>
<p><strong>只堵最后一步等于没堵。</strong> 代码注入链是四步:<code class="inline-code">NtAllocateVirtualMemory</code> 申请可执行内存 → <code class="inline-code">NtWriteVirtualMemory</code> 写 shellcode → <code class="inline-code">NtProtectVirtualMemory</code> 改成可执行 → <code class="inline-code">NtCreateThreadEx</code> 起线程。早先我只挂了最后一步,前面三步完全裸奔。补上之后,判据的唯一关键是<strong>目标进程 ≠ 当前进程</strong>:这几个 API 在每个进程里都被合法高频调用(.NET/Java 的 JIT、浏览器的 JIT、任何带 GC 的运行时都在自己进程里 allocate + write + protect),对本进程必须一律放行,否则整个系统当场瘫掉。</p>
<h2 id="它拦不到什么" class="md-heading"><a class="heading-anchor" href="#它拦不到什么" aria-hidden="true">#</a>它拦不到什么</h2>
<p>上面这一整套都建立在"它走用户态 API"的前提上。反过来,程序自己带 syscall 号直接进内核、手工映射一份 DLL、或者复制一个令牌——这三条我一条都拦不住。不是没修,是用户态这个位置本身够不着。</p>
<p>仓库里带了个 <code class="inline-code">dskill.exe</code>,就是用直 syscall 绕过我全部 hook 的。我把它打进发布包,一来让"能被绕过"这句话有实证,二来它也是自我保护生效后留给自己的逃生门。</p>
<p>顺着这个边界往下推:写 MBR 的恶搞程序拦不到(那不走任何用户态 API,得靠 minifilter 在内核拦 <code class="inline-code">IRP_MJ_WRITE</code>);提前进内核覆写硬盘、改 BIOS 的那种更够不着。但注册表自启、计划任务、加载 DLL、拉起子进程、出站连接这些要经过我挂着的 API 的动作,能在半路截住——银狐、熊猫烧香这类样本落在用户态的那部分手脚,就属于这一类。</p>
<h2 id="说到底" class="md-heading"><a class="heading-anchor" href="#说到底" aria-hidden="true">#</a>说到底</h2>
<p>它是那半个杀软:用户态这一半,注入、hook、拦 API,做得还算认真;内核和硬件那半边够不着。110 处钩子装在每一个进程里,从里面往外看。你要是想搞清楚自己机器上谁在动手,它够用——墙它变不出来。</p>]]></content:encoded>
  </item>
  <item>
    <title><![CDATA[用 Python 复现极域 V6.0 教师端通信协议]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E7%94%A8-python-%E5%A4%8D%E7%8E%B0%E6%9E%81%E5%9F%9F-v60-%E6%95%99%E5%B8%88%E7%AB%AF%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%AE%AE</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E7%94%A8-python-%E5%A4%8D%E7%8E%B0%E6%9E%81%E5%9F%9F-v60-%E6%95%99%E5%B8%88%E7%AB%AF%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%AE%AE</guid>
    <pubDate>Mon, 05 Oct 2026 19:27:00 +0800</pubDate>
    <description><![CDATA[作者基于 IDA 静态逆向与网络抓包，使用 Python 实现极域 V6.0 教师端模拟器，用于协议安全研究。软件依靠两组 UDP 多播通道完成设备发现、登录握手、保活、消息下发、学生信息上报、屏幕缩略图传输。项目使用`socket`、`struct`构造二进制数据包，还原完整状态机，解决多个协议难点：黑屏锁屏需要组合两套不同报文；处理 UDP 图像分片重组，修复图像翻转、色彩反相问题；通用 MESS 消息必须先解析类别再做子类型解析，避免解析错乱。工程上增加多线程、分级日志、自动解锁等调试与安全防护机制。文章总结逆向经验：静态分析与抓包要互相印证，优先还原状态机而非孤立数据包，解析需要分层处理；该项目为研究原型，仅可在授权环境测试，开源发布。]]></description>
    <content:encoded><![CDATA[<h1 id="用-python-复现极域-v60-教师端通信协议" class="md-heading"><a class="heading-anchor" href="#用-python-复现极域-v60-教师端通信协议" aria-hidden="true">#</a>用 Python 复现极域 V6.0 教师端通信协议</h1>
<blockquote><p>本文介绍一个基于 Python 的第三方极域课堂管理系统 V6.0 教师端模拟器。项目通过静态逆向分析与网络抓包，还原教师端和学生端之间的 UDP 通信过程，并将发现、登录、保活、消息、学生信息上报与屏幕缩略图等能力组合成一个可观察、可验证的协议实验平台。</p>
<p>本项目仅用于协议学习、安全研究与教学演示。请只在自己拥有或已获得明确授权的网络和设备上测试。</p></blockquote>
<p>项目源码：<a href="https://github.com/yunsjxh/Third-party-JiYu-Teacher-Endpoint" target="_blank" rel="noopener noreferrer">yunsjxh/Third-party-JiYu-Teacher-Endpoint</a></p>
<h2 id="为什么要重新实现一个教师端" class="md-heading"><a class="heading-anchor" href="#为什么要重新实现一个教师端" aria-hidden="true">#</a>为什么要重新实现一个教师端</h2>
<p>课堂管理软件看起来是一个普通的桌面应用，但它背后其实包含了一套完整的局域网通信协议：教师端需要先在网络中宣告自己的存在，学生端发现教师后发起登录，双方继续交换保活包、状态包和控制包；当教师请求屏幕预览时，学生端还要把一张图像拆成多个 UDP 数据片段再发送回来。</p>
<p>如果只观察软件界面，我们看到的是“学生上线”“发送消息”“查看缩略图”等按钮；从协议研究的角度看，真正值得追踪的是另一组问题：</p>
<ul><li>学生端如何判断网络中存在一个合法的教师端？</li><li>登录握手由哪些数据包组成，顺序是否重要？</li><li>同一个界面功能是否可能由多条协议路径共同完成？</li><li>UDP 不保证到达和顺序，缩略图应当如何重组？</li><li>二进制协议中的长度、字节序、编码和填充字段如何确定？</li></ul>
<p>这个项目的目标不是简单模仿一个界面，而是把这些藏在可执行文件和网络流量里的规则，整理成一份能够运行的 Python 实现。</p>
<h2 id="从黑盒到协议地图" class="md-heading"><a class="heading-anchor" href="#从黑盒到协议地图" aria-hidden="true">#</a>从黑盒到协议地图</h2>
<p>协议还原主要依靠两类证据。</p>
<p>第一类是 IDA Pro 静态分析。它适合定位协议魔数、字段偏移、分支条件和关键常量。例如，一个 32 位整数在内存中以小端序出现时，<code class="inline-code">0x434E4F4F</code> 对应 ASCII 字符串 <code class="inline-code">OONC</code>；沿着接收函数继续追踪，就能看到程序如何按照不同 Magic 进入不同处理分支。</p>
<p>第二类是真实环境中的网络抓包。静态分析可以告诉我们“代码可能怎样解释这个字段”，抓包则能验证“正常教师端实际上发送了什么”。将多组样本逐字节比较后，才可能区分固定值、序列号、GUID、IP 地址、长度字段和可选负载。</p>
<p>最终得到的通信结构可以概括为两条 UDP 通道：</p>
<div class="table-wrap"><table><thead><tr><th>通道</th><th>地址</th><th>主要职责</th></tr></thead><tbody><tr><td>主通道</td><td><code class="inline-code">224.50.50.42:4705</code></td><td>教师发现、保活、控制命令、屏幕缩略图</td></tr><tr><td>会话通道</td><td><code class="inline-code">225.2.2.1:5512</code></td><td>登录宣告、MESS 消息、状态与信息上报</td></tr></tbody></table></div>
<p>教师端会周期性发送 <code class="inline-code">OONC</code>、<code class="inline-code">CANC</code> 和 <code class="inline-code">ANNO</code>，学生端通过 <code class="inline-code">LOGI</code> 进入登录流程，并用 <code class="inline-code">KACA</code>、<code class="inline-code">TRMC</code> 等报文维持会话。教师端随后回送 <code class="inline-code">WACA</code>、<code class="inline-code">LPNT</code> 和 <code class="inline-code">DMOC</code>，让学生端确认教师身份并进入稳定连接状态。</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">教师端模拟器                         极域学生端
     |                                  |
     |---- OONC / CANC / ANNO ---------&gt;|  发现教师
     |&lt;------------- LOGI --------------|  请求登录
     |---- MESS / LPNT / DMOC ----------&gt;|  完成握手
     |&lt;------------- KACA ---------------|  保活/发现
     |---------------- WACA ------------&gt;|  保活响应
     |---- TNRS / 信息请求 / 消息 -------&gt;|  业务请求
     |&lt;---- LANT / TNAL / MESS ----------|  数据回流</code></pre></div>
<p>这张图也是整个程序的骨架。实现不再是一堆彼此孤立的十六进制常量，而是广播线程、会话接收线程、主通道接收线程和命令行交互之间的一组协作关系。</p>
<h2 id="python-如何表达二进制协议" class="md-heading"><a class="heading-anchor" href="#python-如何表达二进制协议" aria-hidden="true">#</a>Python 如何表达二进制协议</h2>
<p>项目没有引入复杂的协议框架，而是直接使用标准库中的 <code class="inline-code">socket</code> 和 <code class="inline-code">struct</code>。原因很直接：协议字段大多是固定长度的小端整数、GUID、IPv4 地址和 UTF-16LE 字符串，<code class="inline-code">struct.pack</code> 足以准确表达它们。</p>
<p>以教师在线宣告包为例，其实现由 Magic、版本、包体长度、教师 GUID、本机 IP 和递增序列号组成：</p>
<div class="code-block" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-python">pkt = (
    struct.pack(&#039;&lt;II&#039;, 0x434E4F4F, 0x10000)
    + struct.pack(&#039;&lt;I&#039;, 16)
    + teacher_guid.bytes_le
    + socket.inet_aton(local_ip)
    + struct.pack(&#039;&lt;III&#039;, 1, 1, sequence)
)</code></pre></div>
<p>这里最容易出错的并不是 Python 语法，而是协议细节：</p>
<ul><li><code class="inline-code">&lt;I</code> 表示小端无符号 32 位整数；</li><li>Windows GUID 的内存布局需要使用 <code class="inline-code">bytes_le</code>；</li><li>中文文本必须按 UTF-16LE 编码，并正确计算字符数和结尾空字符；</li><li>包头中的长度不一定等于最终 UDP 数据报长度，而可能只描述某一层负载；</li><li>某些接收路径会额外读取负载尾部，因此构造包时需要保留兼容填充。</li></ul>
<p>二进制协议复现的核心不是“让程序发出数据”，而是让每个字节都能对应到可解释、可验证的字段。</p>
<h2 id="难点一黑屏和键鼠锁定其实是两条路径" class="md-heading"><a class="heading-anchor" href="#难点一黑屏和键鼠锁定其实是两条路径" aria-hidden="true">#</a>难点一：黑屏和键鼠锁定其实是两条路径</h2>
<p>项目中最有代表性的发现，是黑屏功能并不由一个命令独立完成。</p>
<p>逆向分析显示，学生端存在两条相关处理路径：<code class="inline-code">MESS type=0x20</code> 负责创建黑屏窗口并设置内部状态位，<code class="inline-code">COMD case 6</code> 负责实际锁定键盘和鼠标。只发送其中一条路径，表面上可能暂时有效，却会在解锁时暴露问题。</p>
<p>例如，仅通过 COMD 显示黑屏时，学生端内部的 bit <code class="inline-code">0x20</code> 没有被正确设置。后续解锁命令进入另一段分支，无法执行预期的解锁函数。真实教师端的做法是组合两条路径，模拟器也因此采用同样的流程：</p>
<ol><li>通过会话通道组播 MESS 黑屏包，建立黑屏状态；</li><li>需要锁定输入时，再向目标学生单播 COMD 命令；</li><li>定时黑屏到期后，由教师端主动发送解锁消息；</li><li>手动解锁时取消已有定时器，避免重复动作。</li></ol>
<p>这说明逆向工程中一个很重要的原则：不能只根据界面效果判断协议是否还原正确。真正可靠的实现，还要匹配状态变化、后续行为和异常路径。</p>
<h2 id="难点二udp-分片不是简单拼接" class="md-heading"><a class="heading-anchor" href="#难点二udp-分片不是简单拼接" aria-hidden="true">#</a>难点二：UDP 分片不是简单拼接</h2>
<p>屏幕预览是另一个典型问题。学生端会通过 <code class="inline-code">LANT</code>/<code class="inline-code">TNAL</code> 报文分片发送 JPEG 数据，教师端需要维护每个学生独立的接收状态，包括总长度、当前缓冲区和已接收字节数。</p>
<p>项目使用学生 IP 作为重组键：新图像开始时创建状态，后续分片写入对应位置；只有在确认数据完整后才尝试交给 Pillow 解码。这样可以避免多个学生同时回传缩略图时互相覆盖。</p>
<p>成功解码并不代表图像已经正确。抓包样本还暴露了两个图像层问题：画面是 bottom-up 的，色度通道也存在反相。项目的修复流程是：</p>
<div class="code-block" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-python">img = Image.open(io.BytesIO(buffer))
img = img.transpose(Image.FLIP_TOP_BOTTOM)
y, cb, cr = img.convert(&#039;YCbCr&#039;).split()
cb = ImageOps.invert(cb)
cr = ImageOps.invert(cr)
img = Image.merge(&#039;YCbCr&#039;, (y, cb, cr)).convert(&#039;RGB&#039;)</code></pre></div>
<p>这段处理体现了协议分析经常遇到的情况：传输层的数据结构正确，只能说明“收到了一张图”；要得到视觉上正确的结果，还需要理解发送端图像缓冲区的存储方式和颜色空间。</p>
<h2 id="难点三同一个-mess必须先判断语义类别" class="md-heading"><a class="heading-anchor" href="#难点三同一个-mess必须先判断语义类别" aria-hidden="true">#</a>难点三：同一个 MESS，必须先判断语义类别</h2>
<p><code class="inline-code">MESS</code> 是双向的通用消息容器。聊天、黑屏、周期状态、系统信息、进程列表和窗口列表都会经过它。如果只读取某个固定偏移并直接把数字解释成 subtype，就可能把进程 PID 当成 Wi-Fi 数量，把窗口句柄当成另一类状态值。</p>
<p>模拟器的处理方式是先读取 category，再进入对应解析器：</p>
<ul><li><code class="inline-code">0x800000</code> 表示信息上报；</li><li>type 5 是系统信息；</li><li>type 6 是窗口列表；</li><li>type 7 是进程列表；</li><li>周期状态则走另一套 subtype 解释规则。</li></ul>
<p>系统信息包含计算机名、用户、MAC、操作系统、CPU 和内存等定长字段。窗口和进程列表采用分片形式，每个条目由一个 32 位 ID 和一个以空字符结尾的 UTF-16LE 名称组成。收到首片时清空旧数据，再将后续分片追加到对应学生的状态中。</p>
<p>这套设计让项目从“能发控制包”进一步变成了“能维护学生状态的教师端”。命令行中的 <code class="inline-code">list</code>、<code class="inline-code">ps</code> 和 <code class="inline-code">wins</code> 读取的正是这份动态建档数据。</p>
<h2 id="让研究脚本具备可调试性" class="md-heading"><a class="heading-anchor" href="#让研究脚本具备可调试性" aria-hidden="true">#</a>让研究脚本具备可调试性</h2>
<p>协议实验往往会产生大量重复数据。若每次在线宣告和心跳都输出完整十六进制转储，真正有价值的异常包很快会被淹没。</p>
<p>项目为此增加了几项工程化处理：</p>
<ul><li>使用四个后台线程分别处理主通道广播、会话广播和两个接收循环；</li><li>过滤本机发出的多播，避免模拟器处理自己的数据；</li><li>将日常广播与高频心跳降到 DEBUG 级别；</li><li>使用轮转日志限制单个日志文件大小，并保留有限备份；</li><li>主窗口只负责 <code class="inline-code">teacher&gt;</code> 命令交互，独立 PowerShell 窗口实时显示日志；</li><li>对未知包保留 hexdump，便于继续补充协议知识；</li><li>对黑屏操作设置自动解锁，降低实验中因参数错误造成的影响。</li></ul>
<p>这些设计并不会改变协议本身，却显著改善了研究体验。协议实现最怕“偶尔能用但无法解释”，而良好的日志、状态隔离和错误输出，能够把一次偶发现象变成可重复验证的结论。</p>
<h2 id="运行与观察" class="md-heading"><a class="heading-anchor" href="#运行与观察" aria-hidden="true">#</a>运行与观察</h2>
<p>项目运行环境很轻量，只依赖 Python 3.8+ 和 Pillow：</p>
<div class="code-block" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-bash">pip install Pillow
python &quot;teacher_sim .py&quot;</code></pre></div>
<p>程序启动后会自动选择本机网络地址，加入两个多播组，并启动四个后台线程。学生端完成登录后，可以通过 <code class="inline-code">list</code> 查看已建档设备，通过 <code class="inline-code">preview &lt;ip&gt;</code> 请求屏幕缩略图，或使用 <code class="inline-code">msg &lt;ip&gt; &lt;text&gt;</code> 验证双向消息链路。</p>
<p>仓库还包含一个纯静态项目主页，由 <code class="inline-code">index.html</code>、<code class="inline-code">styles.css</code> 和 <code class="inline-code">script.js</code> 组成。页面用 Canvas 动画呈现数据包在教师端与学生端之间流动的效果，并把功能、协议通道、运行方式和安全边界整理成更直观的展示页面。它不参与教师端协议通信，因此核心模拟器仍然可以作为单文件 Python 程序独立运行。</p>
<h2 id="项目带来的几个启发" class="md-heading"><a class="heading-anchor" href="#项目带来的几个启发" aria-hidden="true">#</a>项目带来的几个启发</h2>
<p>回顾整个实现过程，有几条经验尤其值得保留。</p>
<p>第一，抓包和静态分析必须互相校验。只看流量，很难知道字段为何这样排列；只看反编译代码，也可能误判实际执行路径。二者结合，才能逐步建立可靠的协议模型。</p>
<p>第二，要优先还原状态机，而不是孤立的数据包。一个报文能被接收，不代表功能已经完成；登录顺序、状态位、定时器和响应包往往同样重要。</p>
<p>第三，解析器要先分层再解释。Magic、外层头、category、type 和具体负载各自属于不同层次，跳过上层语义直接解释下层字段，很容易得到“数值合理但含义错误”的结果。</p>
<p>第四，研究工具也需要安全护栏。自动解锁、目标登录校验、清晰日志和明确的授权范围，不只是使用体验问题，也是负责任地开展协议研究所必需的部分。</p>
<h2 id="结语" class="md-heading"><a class="heading-anchor" href="#结语" aria-hidden="true">#</a>结语</h2>
<p>这个项目把一个封闭桌面软件背后的通信过程，转化成了可阅读的代码、可观察的日志和可重复的实验。从教师在线宣告、学生登录，到消息解析和图像分片重组，每一项功能都建立在对具体字节和状态变化的理解上。</p>
<p>它目前仍是面向极域 V6.0 的研究性实现，并不追求替代成熟的课堂管理产品。它更有价值的身份，是一份活的协议笔记：当新的字段、新的包类型或更准确的握手顺序被发现时，代码、文档和实验结果可以继续相互印证。</p>
<p>项目采用 MIT License。无论阅读源码、复现实验还是继续扩展协议，都请始终把合法授权和最小影响放在第一位。</p>
<p>完整源码和后续更新可在 GitHub 仓库 <a href="https://github.com/yunsjxh/Third-party-JiYu-Teacher-Endpoint" target="_blank" rel="noopener noreferrer">yunsjxh/Third-party-JiYu-Teacher-Endpoint</a> 查看。</p>]]></content:encoded>
  </item>
  <item>
    <title><![CDATA[疏影：面向 Weeback 的专项破解与工程实现]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E7%96%8F%E5%BD%B1%E9%9D%A2%E5%90%91-weeback-%E7%9A%84%E4%B8%93%E9%A1%B9%E7%A0%B4%E8%A7%A3%E4%B8%8E%E5%B7%A5%E7%A8%8B%E5%AE%9E%E7%8E%B0</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E7%96%8F%E5%BD%B1%E9%9D%A2%E5%90%91-weeback-%E7%9A%84%E4%B8%93%E9%A1%B9%E7%A0%B4%E8%A7%A3%E4%B8%8E%E5%B7%A5%E7%A8%8B%E5%AE%9E%E7%8E%B0</guid>
    <pubDate>Mon, 05 Oct 2026 19:23:00 +0800</pubDate>
    <description><![CDATA[疏影是针对 Weeback 的专项破解工具，主体工作在用户态，附带可选自研内核驱动。通过 IDA 逆向还原其数据库密码异或变换算法，可自动定位软件目录、管理进程与 DLL、清理驱动残留、解密管理员凭据。驱动默认不加载，通过版本 profile 避免硬编码内核偏移。工具仅限授权环境使用，该软件密码仅简单可逆混淆，安全设计薄弱。]]></description>
    <content:encoded><![CDATA[<h1 id="疏影面向-weeback-的专项破解与工程实现" class="md-heading"><a class="heading-anchor" href="#疏影面向-weeback-的专项破解与工程实现" aria-hidden="true">#</a>疏影：面向 Weeback 的专项破解与工程实现</h1>
<p>先把项目定位说清楚：疏影（Sparse Shadow）不是一款通用的 Windows 系统维护软件，而是围绕 Weeback 定制开发的专项破解工具。项目代码中出现的 MPPG / eClass Client 名称、进程、安装目录和数据库，都是这条破解链路的一部分。</p>
<p>它要解决的不是单一功能，而是一整套围绕对象程序展开的工作：定位安装信息和运行进程、管理 DLL 模块、读取数据库并还原管理员凭据字段、处理关联驱动，以及在用户态信息不足时借助自研内核驱动观察和管理进程状态。后来加入的 <code class="inline-code">YsjxhProcess</code>，把这些能力进一步延伸到了内核层。</p>
<p>其中，凭据字段的还原逻辑不是通过猜测或枚举尝试得出的，而是使用 IDA 静态分析 Weeback 的 Setup.exe 后，沿数据库写入流程定位到 <code class="inline-code">sub_4CB0D0</code>（地址 <code class="inline-code">0x4CB0D0</code>），再根据反汇编结果整理出的算法。这篇文章记录这条分析链路，以及疏影如何把分析结果落成一个完整工具。</p>
<blockquote><p>疏影包含系统级和内核级操作能力，仅应用于本人拥有、管理或得到明确授权的设备。涉及驱动、进程保护和未公开内核结构的功能，应当在具有快照和内核调试条件的隔离虚拟机中测试。</p></blockquote>
<p>下载:<a href="https://wwaux.lanzoul.com/i09ox3y8kzcd" target="_blank" rel="noopener noreferrer"><a href="https://wwaux.lanzoul.com/i09ox3y8kzcd" target="_blank" rel="noopener noreferrer">https://wwaux.lanzoul.com/i09ox3y8kzcd</a></a></p>
<h2 id="一个统一的破解与调试界面" class="md-heading"><a class="heading-anchor" href="#一个统一的破解与调试界面" aria-hidden="true">#</a>一个统一的破解与调试界面</h2>
<p>疏影主程序使用 C++20、Win32 和 GDI+ 编写。界面没有采用传统工具软件中密集堆叠的系统控件，而是围绕 Material Design 3 的层级重新组织了信息：</p>
<ul><li>侧栏负责功能导航；</li><li>状态卡片呈现安装、进程和驱动概况；</li><li>危险操作与普通查询使用不同的颜色和确认流程；</li><li>操作结果统一进入活动日志；</li><li>“扩展驱动工具”窗口使用与主界面一致的浅色 MD3 风格。</li></ul>
<p>目前主界面覆盖 Weeback 安装信息扫描、相关进程管理、DLL 模块操作、数据库字段还原、关联驱动处理、内核进程和活动日志等页面。功能虽然较多，但所有入口都服务于同一个明确目标，而不是试图做成包罗万象的系统工具。</p>
<h2 id="主程序真正做了什么" class="md-heading"><a class="heading-anchor" href="#主程序真正做了什么" aria-hidden="true">#</a>主程序真正做了什么</h2>
<p>疏影的大部分功能都在用户态完成，不需要安装 <code class="inline-code">YsjxhProcess</code>。普通启动时，程序会把安装记录、目标目录、运行进程、数据库和关联组件串成一条连续的破解流程；内核驱动只是处理少数特殊状态时才使用的可选扩展。</p>
<h3 id="自动检测与破解概览" class="md-heading"><a class="heading-anchor" href="#自动检测与破解概览" aria-hidden="true">#</a>自动检测与破解概览</h3>
<p>启动后的概览页不是静态菜单。程序会读取 32 位和 64 位卸载注册表视图，寻找与 Weeback、MPPG 和 eClass Client 相关的安装条目，并从 <code class="inline-code">DisplayName</code>、<code class="inline-code">InstallLocation</code> 和卸载命令中整理产品名称、版本与安装目录。找到有效目录后，后续的进程扫描和 DLL 页面会自动使用同一个目标范围。</p>
<p>同一次检测还会尝试定位 <code class="inline-code">mppgsvrc.db</code>、读取管理员记录，并扫描关联的服务、SYS 文件、键盘过滤项和网络过滤组件。概览卡片最终汇总安装条目数、运行进程数、数据库状态与关联组件数量，用户不必逐页点击才能判断当前机器的大致状态。</p>
<p>如果注册表信息缺失、路径已经失效，或者使用的是非标准安装位置，也可以手动选择目录。手动目录会替代自动结果，避免工具因为安装程序没有正确写入卸载信息而失去后续功能。</p>
<h3 id="安装目录与进程联动" class="md-heading"><a class="heading-anchor" href="#安装目录与进程联动" aria-hidden="true">#</a>安装目录与进程联动</h3>
<p>“安装与进程”页面并不是按一份写死的进程名列表工作。疏影会递归收集目标安装目录中的 <code class="inline-code">.exe</code> 文件名，再用 Tool Help 进程快照匹配当前正在运行的实例。列表中显示可取得的 PID、进程名称和完整映像路径，因此新版本增加或更换可执行文件后，通常不需要同步修改程序内的固定名称表。</p>
<p>用户可以重新扫描，也可以结束当前识别出的相关进程。结束操作会执行两轮，中间短暂等待，再重新生成快照：第一轮处理当前实例，第二轮用于发现退出较慢或短时间内重新出现的同名进程。每个成功和失败结果都会计数并写入活动日志，拒绝访问的进程不会被伪装成处理成功。</p>
<p>这套“目录先限定范围、进程再动态匹配”的方式也服务于 DLL 页面。它比单纯按名称操作多了一层路径约束，可以减少同名程序被误判为 Weeback 组件的可能。</p>
<h3 id="dll-模块管理" class="md-heading"><a class="heading-anchor" href="#dll-模块管理" aria-hidden="true">#</a>DLL 模块管理</h3>
<p>DLL 页面允许在已识别的目标进程之间切换，选择本地 <code class="inline-code">.dll</code> 文件后执行加载或卸载。真正操作前，程序会再次打开目标进程、确认它仍在运行、重新读取当前映像路径，并验证路径仍位于选定的 Weeback 安装目录内。这样可以防止列表刷新前后 PID 被复用，或同名进程来自其它目录时误操作。</p>
<p>加载成功后，界面会记录模块文件名、目标进程名和 PID；重复加载会明确提示模块已经存在。卸载时按所选 DLL 的模块名在目标进程中定位，不要求用户手工输入基址。目标消失、路径变化、权限不足或模块不存在时，界面保留系统错误码与可读错误信息，便于继续排查。</p>
<p>DLL 操作属于会改变目标进程状态的功能，因此它只接受明确选择的安装目录内进程和本地 DLL，并在执行前显示确认。它不是面向任意 PID 的通用注入器。</p>
<h3 id="数据库定位与字段还原" class="md-heading"><a class="heading-anchor" href="#数据库定位与字段还原" aria-hidden="true">#</a>数据库定位与字段还原</h3>
<p>数据库页面既支持手动选择 <code class="inline-code">mppgsvrc.db</code>，也会按盘符检查 Weeback / eClass Client 的常见目录。如果已经识别出安装路径，程序还会在该目录下继续查找数据库。选定文件后，路径与处理状态会保留在页面中，不需要每次操作重新输入。</p>
<p>读取凭据时，疏影通过 SQLite 查询 <code class="inline-code">users</code> 表中大小写不敏感匹配的 <code class="inline-code">Admin</code> 记录，并用 <code class="inline-code">CAST(password AS BLOB)</code> 保留字段的原始字节。随后才执行 IDA 分析得到的 <code class="inline-code">0xB3</code> 变换、截断空字节并转换为界面字符串。独立脚本 <code class="inline-code">decrypt_mppg_password.py</code> 提供相同的文件定位和命令行核对入口，方便在 GUI 之外验证数据库内容。</p>
<p>这里没有字典、口令枚举或联网查询。功能的核心是复现目标程序自身的数据变换；具体逆向过程放在下一节单独说明。</p>
<h3 id="关联组件检查与清理" class="md-heading"><a class="heading-anchor" href="#关联组件检查与清理" aria-hidden="true">#</a>关联组件检查与清理</h3>
<p>“驱动清理”页面虽然处理的是驱动遗留物，但整个编排发生在用户态。检查阶段会分别列出固定服务、<code class="inline-code">System32\drivers</code> 中的关联 SYS 文件、键盘类注册表里的 <code class="inline-code">kafe</code> UpperFilters 项、<code class="inline-code">MS_NdisNetFilter</code> 网络组件，以及 Driver Store 中匹配的 OEM INF 包。页面区分正在运行、文件存在、等待重启删除和已暂存等状态。</p>
<p>执行完整清理时，疏影会先停止并删除对应服务，备份键盘类 <code class="inline-code">UpperFilters</code> 后只移除目标项，再处理 SYS 文件、网络过滤组件和 Driver Store 包。正在占用的文件可以登记为重启后删除；只读属性会先被清除。完成后会重新扫描、生成详细日志和 UpperFilters 备份，并让用户决定是否安排延迟重启。</p>
<p>这不是简单地遍历目录删除文件。服务、注册表过滤器、网络组件和驱动包各自使用对应的 Windows API，失败项单独统计，避免某一步失败后仍给出“全部完成”的结论。</p>
<h3 id="活动日志、确认与窗口能力" class="md-heading"><a class="heading-anchor" href="#活动日志、确认与窗口能力" aria-hidden="true">#</a>活动日志、确认与窗口能力</h3>
<p>安装检测、目录选择、进程结束、DLL 操作、数据库处理、组件清理和驱动状态查询都会进入当前会话的活动日志。界面同时使用短暂提示展示即时结果，而较长的路径、错误码和统计信息留在日志页，避免提示框承载过多文本。</p>
<p>会改变系统状态的操作会先进入统一确认流程，危险操作使用独立的视觉样式。主窗口还支持普通置顶和同用户 UIAccess 增强置顶，并保存置顶偏好；这主要用于破解时同时观察其它程序窗口，不改变当前用户本身的权限。</p>
<p>从实际使用顺序看，疏影首先是一套用户态破解工作台：自动发现环境，限定目标范围，完成进程、模块、数据库和残留组件处理，并记录每一步结果。只有这些能力不足时，才需要进入后面的内核进程页面。</p>
<h2 id="用-ida-还原-sub4cb0d0" class="md-heading"><a class="heading-anchor" href="#用-ida-还原-sub4cb0d0" aria-hidden="true">#</a>用 IDA 还原 sub_4CB0D0</h2>
<p>Weeback 使用的服务端数据库 <code class="inline-code">mppgsvrc.db</code> 是一个 SQLite 数据库。管理员记录位于 <code class="inline-code">users</code> 表中，凭据内容保存在 <code class="inline-code">password</code> 字段。直接查看字段只能看到处理后的字节，但它并不是不可逆摘要，而是 Setup.exe 写入前做过一次可逆变换。</p>
<p>在 IDA 中跟进数据库写入相关调用后，可以定位到地址 <code class="inline-code">0x4CB0D0</code> 的函数 <code class="inline-code">sub_4CB0D0</code>。交叉引用说明它位于凭据字段进入数据库之前的数据处理路径上。去掉反编译器生成的类型和临时变量后，其核心逻辑可以整理为：</p>
<div class="code-block" data-lang="c"><div class="code-head"><span class="code-lang">c</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-c">while (*input) {
    if (*input == 0xB3)
        *output = *input;
    else
        *output = *input ^ 0xB3;

    ++output;
    ++input;
}</code></pre></div>
<p>从 IDA 伪代码中可以直接提取四个判断点：循环条件读取当前输入字节，说明 <code class="inline-code">0x00</code> 是结束标记；条件分支单独比较 <code class="inline-code">0xB3</code>；普通分支只执行一次固定常量 XOR；循环尾部同时递增输入和输出指针。函数没有动态密钥或状态表，因此整个字段处理过程可以被完整复现。</p>
<p>也就是说，函数逐字节读取以 <code class="inline-code">\0</code> 结尾的输入：字节等于 <code class="inline-code">0xB3</code> 时原样复制，其余字节与 <code class="inline-code">0xB3</code> 异或。异或满足自反性：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">(x XOR 0xB3) XOR 0xB3 = x</code></pre></div>
<p>因此，正向处理和还原可以使用同一套变换。不过这里有一个容易被一句“再异或一次”掩盖的边界条件：<code class="inline-code">0xB3</code> 在原函数中不会参与异或，所以还原时也必须保持不变。对于只包含常见 ASCII 字符的输入，无条件异或通常能得到相同结果；若要严格复刻原函数，则应保留这个特殊分支。</p>
<p>严格复刻 <code class="inline-code">sub_4CB0D0</code> 时，等价的解码循环应写成：</p>
<div class="code-block" data-lang="cpp"><div class="code-head"><span class="code-lang">cpp</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-cpp">for (auto&amp; byte : cipher) {
    if (byte != 0xB3) {
        byte ^= 0xB3;
    }
}</code></pre></div>
<p>为什么这个变换能够原样还原？输入循环不会处理作为字符串结束符的 <code class="inline-code">0x00</code>。对于有效输入字节，只有 <code class="inline-code">0x00 ^ 0xB3</code> 才会产生 <code class="inline-code">0xB3</code>，而 <code class="inline-code">0x00</code> 不属于被处理的字段内容。因此，结果中的 <code class="inline-code">0xB3</code> 可以按原函数语义解释为输入字节 <code class="inline-code">0xB3</code>，不会与另一个有效输入字节冲突。</p>
<p>在疏影中，数据还原页面先以 BLOB 方式读取字段，避免 SQLite 文本转换提前截断或改变字节：</p>
<div class="code-block" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-sql">SELECT name, CAST(password AS BLOB)
FROM users
WHERE name = &#039;Admin&#039; COLLATE NOCASE;</code></pre></div>
<p>随后对返回的字节序列执行还原，在第一个空字节处截断，再按 UTF-8 转为界面使用的宽字符串。项目还提供独立的辅助脚本 <code class="inline-code">decrypt_mppg_password.py</code>，既可接收数据库路径，也可扫描常见安装目录寻找 <code class="inline-code">mppgsvrc.db</code>。如果原始内容可能包含字节 <code class="inline-code">0xB3</code>，GUI 与脚本中的处理循环也应保留上述特殊分支；对于普通 ASCII 内容，逐字节无条件 XOR 的结果与这条分支一致。</p>
<p>这段 IDA 分析也说明了可逆字段处理与安全存储的区别。固定常量的逐字节异或没有密钥管理、随机盐、初始化向量或单向性；一旦算法被确认，数据库中的内容即可按原流程还原。疏影所做的是把这个静态分析结论自动化。工具展示结果时仍应限制在本人拥有或得到明确授权的设备上，也不应把还原后的内容写入持久日志。这是对产品对客户不负责任的态度造成的。</p>
<h2 id="可选的内核扩展ysjxhprocess" class="md-heading"><a class="heading-anchor" href="#可选的内核扩展ysjxhprocess" aria-hidden="true">#</a>可选的内核扩展：YsjxhProcess</h2>
<p>安装扫描、普通进程管理、DLL 模块操作、数据库字段还原和关联组件清理都不依赖 <code class="inline-code">YsjxhProcess</code>。只有需要观察或修改普通 Win32 接口无法覆盖的内核进程状态时，疏影才使用这个独立驱动组件。</p>
<p>项目结构大致如下：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">疏影.exe
  ├─ Win32 / GDI+ 主界面
  ├─ YsjxhProcessClient 用户态客户端
  └─ 内嵌 YsjxhProcessDriver.sys

YsjxhProcessDriver.sys
  ├─ 设备与 IOCTL 分发
  ├─ 进程状态枚举
  ├─ 进程状态管理与恢复
  ├─ 自身保护回调
  └─ profile 配置与运行时校验

YsjxhProcessCtl.exe
  └─ 命令行诊断和测试入口</code></pre></div>
<p>主程序与驱动通过共享 IOCTL 协议通信。Release 构建会把同一次生成的 SYS 嵌入疏影，运行时再释放到 <code class="inline-code">%ProgramData%\YsjxhProcess</code>。这样可以避免用户误拿旧驱动，也能确保界面、协议和驱动版本保持一致。</p>
<h2 id="默认不加载驱动" class="md-heading"><a class="heading-anchor" href="#默认不加载驱动" aria-hidden="true">#</a>默认不加载驱动</h2>
<p>内核驱动的错误可能直接影响整个操作系统，因此疏影没有把“启动程序”和“加载驱动”绑定在一起。</p>
<p>普通启动时，疏影只查询 <code class="inline-code">YsjxhProcess</code> 是否已经运行，不会创建或启动驱动服务，也不会自动修改自身进程保护级别。如果确实需要在启动阶段加载驱动，可以在启动程序的同时按住 <code class="inline-code">Shift</code>。程序会在进入界面前记录这一按键状态，随后执行驱动释放、服务启动、profile 配置和疏影自身保护。</p>
<p>这套设计保留了快捷启动能力，同时为驱动异常提供了一个明确的安全回退入口：不按 Shift 即可进入纯用户态界面，继续查看日志、卸载服务或处理环境问题。</p>
<h2 id="每秒刷新但不打断操作" class="md-heading"><a class="heading-anchor" href="#每秒刷新但不打断操作" aria-hidden="true">#</a>每秒刷新，但不打断操作</h2>
<p>“内核进程”页面可见时会每秒读取一次驱动进程快照。自动刷新最容易出现的问题，是列表不断跳回顶部，或者用户刚刚选中的进程在下一次刷新时丢失。</p>
<p>疏影没有直接保存表格行号，而是保存选中进程的 PID，并使用当前顶部进程作为滚动锚点。刷新完成后再根据新的快照重新定位。这样即使进程数量和排序发生变化，界面也能尽量保持原来的操作上下文。离开“内核进程”页面后，定时驱动查询会停止，避免无意义的后台 IO。</p>
<h2 id="不猜测内核偏移" class="md-heading"><a class="heading-anchor" href="#不猜测内核偏移" aria-hidden="true">#</a>不猜测内核偏移</h2>
<p>进程保护和部分进程状态依赖 Windows 内核中的未公开字段。这类实现最大的风险之一，是把某个系统版本的 <code class="inline-code">_EPROCESS</code> 偏移直接套用到另一个版本。</p>
<p>YsjxhProcess 的配置流程会读取当前 <code class="inline-code">ntoskrnl.exe</code> 的机器类型、时间戳和映像大小，再与构建期生成的 profile 矩阵进行精确匹配。对于缺失数据，只允许在同一版本序列中使用“前后锚点完全一致”的桥接值；无法可靠确认的字段会被标记为不支持，而不是根据相邻版本猜测。</p>
<p>偏移下发后，驱动还会在内核态检查 System PID、活动进程链表互指关系和保护字段布局。任何关键校验失败都会拒绝启用配置。这不能消除使用未公开结构的全部风险，但可以把明显错误挡在真正修改内核状态之前。</p>
<h2 id="扩展驱动工具窗口" class="md-heading"><a class="heading-anchor" href="#扩展驱动工具窗口" aria-hidden="true">#</a>扩展驱动工具窗口</h2>
<p>疏影的常用页面只保留经过约束的进程操作；需要诊断完整驱动能力时，可以打开独立的“扩展驱动工具”窗口。</p>
<p>该窗口把驱动服务、进程状态操作、模块与规则、内核回调、路径操作、驱动查询和活动日志分区呈现。所有修改型操作都需要再次确认，危险按钮使用独立的错误色，查询与浏览操作则保持中性色。长状态信息会在顶部摘要中省略，并完整记录到日志区域。</p>
<p>这些能力面向隔离环境中的驱动开发、故障定位和授权实验，不应在日常工作机或未知目标上使用。</p>
<h2 id="构建与签名顺序" class="md-heading"><a class="heading-anchor" href="#构建与签名顺序" aria-hidden="true">#</a>构建与签名顺序</h2>
<p>项目同时包含用户态程序和 WDK 驱动，完整 Release 可以在 Visual Studio Developer PowerShell 中构建：</p>
<div class="code-block" data-lang="powershell"><div class="code-head"><span class="code-lang">powershell</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-powershell">.\build-release.ps1</code></pre></div>
<p>脚本会依次构建驱动、图形启动器、命令行工具和疏影主程序，最终输出位于 <code class="inline-code">dist</code> 目录。</p>
<p>如果需要对发布文件签名，顺序不能颠倒：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">冻结驱动版本
  → 签名最终 SYS
  → 将签名 SYS 重新嵌入疏影
  → 完成发布前二进制处理
  → 最后签名发布 EXE</code></pre></div>
<p>任何发生在签名之后的二进制修改都会破坏对应签名。驱动能够在测试签名环境加载，也不代表它已经获得普通 Windows 和 Secure Boot 环境所要求的生产级 Microsoft 内核签名。</p>
<h2 id="开源致谢" class="md-heading"><a class="heading-anchor" href="#开源致谢" aria-hidden="true">#</a>开源致谢</h2>
<p>感谢 <a href="https://github.com/KSwordDEV/KSword" target="_blank" rel="noopener noreferrer">KSwordDEV/KSword</a> 项目带来的开源、实用的 ARK 工具，同时感谢该项目源码对本项目驱动设计与实现的启发。</p>
<p>优秀的系统工具不仅提供了可用功能，也展示了用户态与内核态如何划分职责、如何组织协议、如何处理动态数据，以及如何在高风险能力周围建立必要的验证与边界。疏影在开发过程中从这些经验中受益良多。</p>
<p>同时，项目使用到的第三方库、SDK 和工具仍分别遵循其原有许可证。引用和学习不等于抹去来源，清晰记录依赖与启发来源也是工程工作的一部分。</p>
<h2 id="写在最后" class="md-heading"><a class="heading-anchor" href="#写在最后" aria-hidden="true">#</a>写在最后</h2>
<p>疏影目前仍在持续迭代，但它的定位已经很明确：这是一个建立在 Weeback 静态分析结果之上的专项破解工具，而不是通用系统管理平台。安装路径、对象进程、数据库结构、字段变换和驱动操作都围绕这一目标设计。</p>
<p>凭据字段部分最关键的结论来自 IDA 中的 <code class="inline-code">sub_4CB0D0</code>：除 <code class="inline-code">0xB3</code> 自身保持不变外，其余输入字节都与 <code class="inline-code">0xB3</code> 异或。把这一结论与 SQLite 字段读取、进程管理和驱动能力组合起来，才形成了现在的疏影。后续工作仍需要把高风险能力限制在清晰的前置条件内，并为失败操作保留日志与恢复路径。</p>]]></content:encoded>
  </item>
  <item>
    <title><![CDATA[从黑盒到可交互画面：极域 V6.0 连续投屏与远程操控协议逆向]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E4%BB%8E%E9%BB%91%E7%9B%92%E5%88%B0%E5%8F%AF%E4%BA%A4%E4%BA%92%E7%94%BB%E9%9D%A2%E6%9E%81%E5%9F%9F-v60-%E8%BF%9E%E7%BB%AD%E6%8A%95%E5%B1%8F%E4%B8%8E%E8%BF%9C%E7%A8%8B%E6%93%8D%E6%8E%A7%E5%8D%8F%E8%AE%AE%E9%80%86%E5%90%91</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E4%BB%8E%E9%BB%91%E7%9B%92%E5%88%B0%E5%8F%AF%E4%BA%A4%E4%BA%92%E7%94%BB%E9%9D%A2%E6%9E%81%E5%9F%9F-v60-%E8%BF%9E%E7%BB%AD%E6%8A%95%E5%B1%8F%E4%B8%8E%E8%BF%9C%E7%A8%8B%E6%93%8D%E6%8E%A7%E5%8D%8F%E8%AE%AE%E9%80%86%E5%90%91</guid>
    <pubDate>Mon, 05 Oct 2026 19:14:00 +0800</pubDate>
    <description><![CDATA[该文逆向极域 V6.0 连续投屏与远控协议。监看由 UDP 信令触发，4806/TCP 传输 H.264/JPEG 分片画面，独立报文回传光标；远控通过 COMD‑MCMD 开启输入通道，28 字节报文实现鼠标控制，键盘功能实验性未闭环。总结逆向踩坑：TCP 流重组、魔数小端序、YUV 色度修正、区分多层证据，实现增加会话限流、异常防护。仅限授权环境研究。]]></description>
    <content:encoded><![CDATA[<h1 id="从黑盒到可交互画面极域-v60-连续投屏与远程操控协议逆向" class="md-heading"><a class="heading-anchor" href="#从黑盒到可交互画面极域-v60-连续投屏与远程操控协议逆向" aria-hidden="true">#</a>从黑盒到可交互画面：极域 V6.0 连续投屏与远程操控协议逆向</h1>
<blockquote><p>本文记录 <code class="inline-code">2026.07.31 研究预览版</code>中连续屏幕观看与远程操控链路的逆向过程。结论来自 IDA Pro 静态分析、官方教师端/学生端真实流量对照、逐字节重放和运行日志验证。本文只讨论隔离实验环境中的协议研究，不应被用于未经授权的监控或控制。</p></blockquote>
<h2 id="摘要" class="md-heading"><a class="heading-anchor" href="#摘要" aria-hidden="true">#</a>摘要</h2>
<p>极域 V6.0 的“观看学生屏幕”和“远程控制学生”不是一个简单的 UDP 命令，也不是把截图循环发送回来。完整链路至少跨越三类通道：</p>
<ol><li>教师端先经 <code class="inline-code">5512/UDP</code> 的 <code class="inline-code">MESS feature=8</code> 通知学生端启动桌面发送功能；</li><li>教师端连接学生端 <code class="inline-code">4806/TCP</code>，发送 <code class="inline-code">SHCO</code> 选择 UMSP channel 10，再从 <code class="inline-code">TKPC</code> 中重组 <code class="inline-code">HHRF</code>/<code class="inline-code">HJRF</code> 桌面记录；</li><li>进入控制模式时，教师端再用 <code class="inline-code">COMD</code> 事务承载 <code class="inline-code">MCMD</code>，让学生端在指定 UDP 端口启动模拟输入接收器；鼠标和键盘事件通过固定 28 字节输入包发送。</li></ol>
<p>截至当前版本，连续观看、H.264/JPEG 解封装、色度修正、<code class="inline-code">SPUC</code> 光标解析以及远程鼠标移动/点击已经在测试环境验证。键盘只验证到教师端捕获和 <code class="inline-code">kind=16</code> 发包，学生端是否最终完成 <code class="inline-code">SendInput</code> 仍不稳定，因此仍是实验性功能。</p>
<div class="table-wrap"><table><thead><tr><th>能力</th><th>当前证据</th></tr></thead><tbody><tr><td>4806/TCP 建连与 SHCO 选路</td><td>已验证</td></tr><tr><td>TKPC 分片重组</td><td>已验证</td></tr><tr><td>HHRF/H.264、HJRF/JPEG 解析</td><td>已验证</td></tr><tr><td>U/V 色度平面修正</td><td>已验证</td></tr><tr><td>SPUC 远端光标解析</td><td>已验证</td></tr><tr><td>MCMD mode=2 建立控制</td><td>已验证</td></tr><tr><td>28 字节鼠标包</td><td>移动与点击已验证</td></tr><tr><td>kind=16 键盘包</td><td>捕获与发包已验证，学生端执行未稳定验证</td></tr></tbody></table></div>
<hr>
<h2 id="一、先建立正确的系统模型" class="md-heading"><a class="heading-anchor" href="#一、先建立正确的系统模型" aria-hidden="true">#</a>一、先建立正确的系统模型</h2>
<p>最初最容易犯的错误，是把“投屏”理解成缩略图协议 <code class="inline-code">LANT/TNAL</code> 的高频版本。抓包很快就能否定这个假设：点击官方教师端的“监看”后，学生端除了继续保活，还会开放或使用 <code class="inline-code">4806/TCP</code>；真正的连续画面主要出现在这条 TCP 流中。</p>
<p>远程控制同样不是直接向 <code class="inline-code">4705/UDP</code> 塞一个 <code class="inline-code">WM_MOUSEMOVE</code>。官方流程会先复用已经建立的桌面观看会话，然后通过通用命令事务启动一个独立的模拟输入通道。</p>
<div class="code-block" data-lang="mermaid"><div class="code-head"><span class="code-lang">mermaid</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-mermaid">sequenceDiagram
    participant T as 第三方教师端
    participant S as 极域学生端

    T-&gt;&gt;S: TCP connect probe :4806（只探测）
    T-&gt;&gt;S: MESS feature=8 / start（5512/UDP）
    T-&gt;&gt;S: TCP connect :4806
    T-&gt;&gt;S: SHCO / channel 10
    S--&gt;&gt;T: TKPC / channel 10 / 桌面分片
    S--&gt;&gt;T: HHRF(H.264) 或 HJRF(JPEG)
    T-&gt;&gt;S: COMD transaction → MCMD mode=2
    Note over S: BeginSimulate，监听指定 UDP 端口
    T-&gt;&gt;S: 28-byte mouse input
    S--&gt;&gt;T: SPUC cursor record
    T-&gt;&gt;S: 28-byte kind=16 keyboard input（实验性）</code></pre></div>
<p>这个模型解释了几个看似无关的现象：</p>
<ul><li>只发送 feature 8，不连接 TCP，不会得到可显示的连续画面；</li><li>直接发输入包时，学生端没有对应 UDP 接收器，数据只会被操作系统丢弃；</li><li>从 <code class="inline-code">view</code> 切换到 <code class="inline-code">control</code> 时重启 H.264 解码器，容易永久黑屏，因为新解码器没有收到此前的 SPS/PPS/IDR；</li><li>本地鼠标与学生端鼠标短暂错位并非只有坐标算法问题，还包含编码、网络、显示和 <code class="inline-code">SPUC</code> 回传延迟。</li></ul>
<hr>
<h2 id="二、在-ida-中如何找到入口" class="md-heading"><a class="heading-anchor" href="#二、在-ida-中如何找到入口" aria-hidden="true">#</a>二、在 IDA 中如何找到入口</h2>
<h3 id="21-不要只搜索可见字符串" class="md-heading"><a class="heading-anchor" href="#21-不要只搜索可见字符串" aria-hidden="true">#</a>2.1 不要只搜索可见字符串</h3>
<p>这类老式 C/C++ 网络库常把 FourCC 写成 32 位立即数。文件里的字节顺序是人眼可读的 <code class="inline-code">SHCO</code>，反汇编中却可能出现：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">wire bytes: 53 48 43 4F  -&gt; &quot;SHCO&quot;
IDA imm32 : 0x4F434853

wire bytes: 54 4B 50 43  -&gt; &quot;TKPC&quot;
IDA imm32 : 0x43504B54</code></pre></div>
<p>原因是 x86 使用小端序。仅在 Strings 窗口里搜索 <code class="inline-code">SHCO</code> 很可能什么也找不到，应该同时搜索对应的立即数，然后查看交叉引用。</p>
<p>本次逆向中最有效的一组锚点是：</p>
<div class="table-wrap"><table><thead><tr><th>线索</th><th>作用</th></tr></thead><tbody><tr><td><code class="inline-code">0x4F434853</code></td><td>定位 UMSP channel 选择包 <code class="inline-code">SHCO</code></td></tr><tr><td><code class="inline-code">0x43504B54</code></td><td>定位 UMSP 数据包 <code class="inline-code">TKPC</code></td></tr><tr><td><code class="inline-code">0x46524848</code></td><td>定位 <code class="inline-code">HHRF</code> H.264 桌面记录</td></tr><tr><td><code class="inline-code">0x46524A48</code></td><td>定位 <code class="inline-code">HJRF</code> JPEG 桌面记录</td></tr><tr><td><code class="inline-code">MCMD</code></td><td>定位监看/控制模式切换结构</td></tr><tr><td><code class="inline-code">SPUC</code></td><td>定位光标状态记录</td></tr><tr><td><code class="inline-code">0x2321347C</code></td><td>定位模拟输入包分发器</td></tr><tr><td>常量 <code class="inline-code">4806</code>、channel <code class="inline-code">10</code></td><td>从通用传输库缩小到桌面通道</td></tr></tbody></table></div>
<h3 id="22-从网络-api-反推对象边界" class="md-heading"><a class="heading-anchor" href="#22-从网络-api-反推对象边界" aria-hidden="true">#</a>2.2 从网络 API 反推对象边界</h3>
<p>打开 <code class="inline-code">LibBaseTrans.dll</code> 后，先建立导入函数关系：<code class="inline-code">connect</code>、<code class="inline-code">send</code>、<code class="inline-code">recv</code>、<code class="inline-code">WSARecv</code>、线程创建、临界区和缓冲区复制。不要急着给每个函数命名，先回答三个问题：</p>
<ol><li>哪个对象持有 socket？</li><li>哪个循环读取 12 字节头并按长度字段切包？</li><li>哪个分支在 payload 起始位置读取 channel id？</li></ol>
<p>从 <code class="inline-code">0x43504B54</code> 的交叉引用向上回溯，可以看到一个典型的“版本 + magic + payload length”检查；继续向下跟踪，payload 的首个 <code class="inline-code">u32</code> 被当作 channel。channel 10 随后进入桌面数据处理对象，而其他 channel 被分派给不同业务。</p>
<p>在 IDA 中可以先做以下重命名，之后伪代码会清晰很多：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">sub_xxx_connect     -&gt; umsp_connect
sub_xxx_read_loop   -&gt; umsp_recv_loop
sub_xxx_dispatch    -&gt; dispatch_umsp_channel
sub_xxx_channel10   -&gt; handle_desktop_fragment
sub_xxx_record      -&gt; handle_desktop_record</code></pre></div>
<p>名字不必一开始就完全正确。关键是让“传输层、分片层、记录层、编解码层”不再混在同一张调用图里。</p>
<h3 id="23-从-feature-分发器找到-617-字节参数块" class="md-heading"><a class="heading-anchor" href="#23-从-feature-分发器找到-617-字节参数块" aria-hidden="true">#</a>2.3 从 feature 分发器找到 617 字节参数块</h3>
<p>在学生端的 MESS 处理路径中，feature id <code class="inline-code">8</code> 会进入远程观看分支。这个分支不是只读一个布尔值，而是复制一个较大的参数对象。把官方教师端报文和伪代码中的复制长度对照后，可以确定：</p>
<ul><li>内层 feature payload 总长为 617 字节；</li><li>前 12 字节是 <code class="inline-code">length / feature / flags</code>；</li><li>中间 604 字节是远程观看参数；</li><li>最后还有 1 字节尾部填充。</li></ul>
<p>不要把参数块里的端口直接当成学生端 <code class="inline-code">4806/TCP</code>。其中两个端口属于教师会话/桌面监控端点；学生端的 TCP 服务端口是另一层连接目标。这两个概念混用，会构造出长度正确但状态错误的请求。</p>
<h3 id="24-从-mcmd-跟到模拟输入" class="md-heading"><a class="heading-anchor" href="#24-从-mcmd-跟到模拟输入" aria-hidden="true">#</a>2.4 从 MCMD 跟到模拟输入</h3>
<p><code class="inline-code">MCMD</code> 位于通用 <code class="inline-code">COMD</code> 事务内部。沿 <code class="inline-code">MCMD</code> 分支继续跟踪，可以看到 mode 分派以及模拟输入初始化路径。当前观察到的行为是：</p>
<ul><li><code class="inline-code">mode=0</code>：Share，双方可操作；</li><li><code class="inline-code">mode=2</code>：Control Student，启动模拟输入并额外进入学生端本地输入锁定路径；</li><li><code class="inline-code">mode=1</code>：本项目用于停止当前模拟输入会话。</li></ul>
<p>再从输入接收线程向下跟踪，固定 magic <code class="inline-code">0x2321347C</code> 是很强的定位点。分发器读取紧随其后的 <code class="inline-code">kind</code>，然后按鼠标或键盘结构解释剩余 20 字节。</p>
<p>逆向时必须继续跟到系统调用边界。看到 UDP <code class="inline-code">recvfrom</code> 并不表示控制已经生效，真正需要确认的是后续是否构造 <code class="inline-code">INPUT</code>、是否调用 <code class="inline-code">SendInput</code>、调用发生在哪个 Windows Session/desktop，以及本地 Hook 是否会再次拦截注入事件。</p>
<hr>
<h2 id="三、用抓包给反汇编定类型" class="md-heading"><a class="heading-anchor" href="#三、用抓包给反汇编定类型" aria-hidden="true">#</a>三、用抓包给反汇编“定类型”</h2>
<p>IDA 告诉我们代码可能怎样解释内存，抓包则告诉我们线上实际发送了什么。两者必须互相约束。</p>
<p>建议只在隔离测试网中采集，并把一次操作拆成单变量实验：</p>
<ol><li>只打开“观看屏幕”，不移动鼠标；</li><li>只切换到“远程控制”，不输入；</li><li>只移动一次鼠标；</li><li>只按一次左键；</li><li>只按一个普通字母键；</li><li>分别停止控制和停止观看。</li></ol>
<p>Wireshark 初始过滤器：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">udp.port == 4705 || udp.port == 5512 || tcp.port == 4806</code></pre></div>
<p>找到对应 TCP stream 后，再单独查看：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">tcp.stream eq &lt;n&gt;</code></pre></div>
<p>抓包分析时不要依赖 TCP segment 边界。一次 <code class="inline-code">recv</code> 可能拿到半个 UMSP 包，也可能一次拿到多个包。正确做法是维护流缓冲区，只有当缓冲区至少包含 12 字节头，并且达到 <code class="inline-code">12 + payload_len</code> 后才取出一包。</p>
<h3 id="31-差分比对比猜字段更快" class="md-heading"><a class="heading-anchor" href="#31-差分比对比猜字段更快" aria-hidden="true">#</a>3.1 差分比对比猜字段更快</h3>
<p>对 MCMD 和输入包尤其适合做差分：固定所有条件，只改变一个操作。例如把鼠标从画面左上移动到右下，比较两包中变化的 4 字节字段；连续点击左键，观察 <code class="inline-code">0x0201</code> 与 <code class="inline-code">0x0202</code> 的成对出现；滚轮则会让 data 字段变化。</p>
<p>字段命名按证据逐步升级：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">unknown_08
  -&gt; changes_with_x
  -&gt; normalized_x
  -&gt; normalized_x_u16_range_in_u32</code></pre></div>
<p>不要因为某次值恰好等于屏幕像素，就立即命名为 <code class="inline-code">pixel_x</code>。本协议鼠标坐标实际使用 <code class="inline-code">0..65535</code> 归一化范围，窗口尺寸和远端编码尺寸只是映射输入。</p>
<hr>
<h2 id="四、观看请求mess-feature-8" class="md-heading"><a class="heading-anchor" href="#四、观看请求mess-feature-8" aria-hidden="true">#</a>四、观看请求：MESS feature 8</h2>
<p>当前实现构造的 feature payload 如下。所有多字节整数均为小端；IP 地址保持网络序原始字节。</p>
<h3 id="41-feature-8-内层结构" class="md-heading"><a class="heading-anchor" href="#41-feature-8-内层结构" aria-hidden="true">#</a>4.1 feature 8 内层结构</h3>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>大小</th><th>当前解释</th><th>观察值</th></tr></thead><tbody><tr><td><code class="inline-code">0x000</code></td><td>4</td><td>payload length</td><td><code class="inline-code">617</code></td></tr><tr><td><code class="inline-code">0x004</code></td><td>4</td><td>feature id</td><td><code class="inline-code">8</code></td></tr><tr><td><code class="inline-code">0x008</code></td><td>4</td><td>start flags</td><td><code class="inline-code">0x80000000</code></td></tr><tr><td><code class="inline-code">0x00C</code></td><td>604</td><td>remote-view params</td><td>见下表</td></tr><tr><td><code class="inline-code">0x268</code></td><td>1</td><td>tail padding</td><td><code class="inline-code">0</code></td></tr></tbody></table></div>
<p>604 字节参数块中，已经通过官方流量和代码路径确认或稳定观察到的字段：</p>
<div class="table-wrap"><table><thead><tr><th>参数内偏移</th><th>大小</th><th>含义</th><th>当前值</th></tr></thead><tbody><tr><td><code class="inline-code">+0x00</code></td><td>4</td><td>RemoteWithVoice</td><td><code class="inline-code">0</code></td></tr><tr><td><code class="inline-code">+0x04</code></td><td>4</td><td>教师 IP</td><td>network order</td></tr><tr><td><code class="inline-code">+0x08</code></td><td>2</td><td>教师会话端口</td><td>当前频道端口</td></tr><tr><td><code class="inline-code">+0x0A</code></td><td>4</td><td>教师 IP（第二端点）</td><td>network order</td></tr><tr><td><code class="inline-code">+0x0E</code></td><td>2</td><td>桌面监控端口</td><td>当前频道端口</td></tr><tr><td><code class="inline-code">+0x24</code></td><td>4</td><td>NetworkType</td><td><code class="inline-code">1</code></td></tr><tr><td><code class="inline-code">+0x28</code></td><td>4</td><td>ShowMonitorControlMessage</td><td><code class="inline-code">1</code></td></tr><tr><td><code class="inline-code">+0x2C</code></td><td>4</td><td>MaxSendSpeed</td><td><code class="inline-code">20480</code></td></tr><tr><td><code class="inline-code">+0x30</code></td><td>4</td><td>RepairMode</td><td><code class="inline-code">1</code></td></tr><tr><td><code class="inline-code">+0x34</code></td><td>4</td><td>MaxPacketSize</td><td><code class="inline-code">1440</code></td></tr><tr><td><code class="inline-code">+0x38</code></td><td>4</td><td>FrameLimit</td><td><code class="inline-code">25</code></td></tr><tr><td><code class="inline-code">+0x3C</code></td><td>4</td><td>CaptureQuality</td><td><code class="inline-code">75</code></td></tr><tr><td><code class="inline-code">+0x40</code></td><td>4</td><td>TcpCommMode</td><td><code class="inline-code">1</code></td></tr><tr><td><code class="inline-code">+0x44</code></td><td>4</td><td>学生 IP</td><td>network order</td></tr><tr><td><code class="inline-code">+0x48</code></td><td>12</td><td>未完全命名的三元组</td><td><code class="inline-code">5, 12, 16</code></td></tr></tbody></table></div>
<p>最后一组三元组虽然已经稳定复现，但在没有进一步交叉引用证据前不应强行解释其业务名称。保留“已知偏移 + 已知值 + 未知语义”比写一个听起来合理的名字更有研究价值。</p>
<p>停止观看使用另一种短结构：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">u32 length  = 13
u32 feature = 0
u32 flags   = 0
u8  tail    = 0</code></pre></div>
<h3 id="42-正确启动时序" class="md-heading"><a class="heading-anchor" href="#42-正确启动时序" aria-hidden="true">#</a>4.2 正确启动时序</h3>
<p>当前稳定时序是：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">TCP connect probe 4806（立即关闭，不发 SHCO）
    -&gt; MESS feature 8 start
    -&gt; 单次连接 4806/TCP
    -&gt; 立即发送 SHCO channel 10
    -&gt; 从第一条 H.264 记录开始喂给同一个解码器</code></pre></div>
<p>探测步骤只检查 TCP 是否监听，不启动编码器，也不发送 <code class="inline-code">SHCO</code>。正式启动后不能在失败时高频重试：学生端编码器和内部线程存在状态，重复 feature 请求或并发 TCP 会话可能导致学生端异常退出。当前实现限制每个 IP 同时只有一个观看会话，并在失败后冷却 60 秒。</p>
<hr>
<h2 id="五、umspshco、tkpc-与-channel-10" class="md-heading"><a class="heading-anchor" href="#五、umspshco、tkpc-与-channel-10" aria-hidden="true">#</a>五、UMSP：SHCO、TKPC 与 channel 10</h2>
<h3 id="51-shco-选择包" class="md-heading"><a class="heading-anchor" href="#51-shco-选择包" aria-hidden="true">#</a>5.1 SHCO 选择包</h3>
<p>连接 <code class="inline-code">4806/TCP</code> 后，教师端发送 20 字节选择包：</p>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>类型</th><th>值</th></tr></thead><tbody><tr><td><code class="inline-code">0x00</code></td><td><code class="inline-code">u32</code></td><td>version <code class="inline-code">0x00010000</code></td></tr><tr><td><code class="inline-code">0x04</code></td><td><code class="inline-code">u32</code></td><td><code class="inline-code">0x4F434853</code>，线上为 <code class="inline-code">SHCO</code></td></tr><tr><td><code class="inline-code">0x08</code></td><td><code class="inline-code">u32</code></td><td>body length <code class="inline-code">8</code></td></tr><tr><td><code class="inline-code">0x0C</code></td><td><code class="inline-code">u32</code></td><td>selector/op，观察值 <code class="inline-code">1</code></td></tr><tr><td><code class="inline-code">0x10</code></td><td><code class="inline-code">u32</code></td><td>desktop channel <code class="inline-code">10</code></td></tr></tbody></table></div>
<p>这里的 channel 10 是业务复用编号，不是 TCP/UDP 端口。</p>
<h3 id="52-tkpc-数据包" class="md-heading"><a class="heading-anchor" href="#52-tkpc-数据包" aria-hidden="true">#</a>5.2 TKPC 数据包</h3>
<p>学生端返回的 UMSP 包使用 12 字节公共头：</p>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>类型</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">0x00</code></td><td><code class="inline-code">u32</code></td><td>version，预期 <code class="inline-code">0x00010000</code></td></tr><tr><td><code class="inline-code">0x04</code></td><td><code class="inline-code">u32</code></td><td><code class="inline-code">0x43504B54</code>，线上为 <code class="inline-code">TKPC</code></td></tr><tr><td><code class="inline-code">0x08</code></td><td><code class="inline-code">u32</code></td><td>payload length</td></tr><tr><td><code class="inline-code">0x0C</code></td><td><code class="inline-code">u32</code></td><td>channel id，桌面为 <code class="inline-code">10</code></td></tr><tr><td><code class="inline-code">0x10</code></td><td>bytes</td><td>channel payload</td></tr></tbody></table></div>
<p>代码或符号中可能把它写作 <code class="inline-code">TCPK</code>，但线上字节是 <code class="inline-code">TKPC</code>。逆向协议时应始终区分“源码/反编译变量名”和“wire bytes”，否则 Wireshark 搜索与代码常量会互相对不上。</p>
<hr>
<h2 id="六、桌面分片重组" class="md-heading"><a class="heading-anchor" href="#六、桌面分片重组" aria-hidden="true">#</a>六、桌面分片重组</h2>
<p>channel 10 的 payload 不是直接的 H.264 NAL，而是先经过一层 12 字节桌面分片：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">+0x00  u32 frame_seq
+0x04  u32 fragment_offset
+0x08  u32 record_total_size
+0x0C  bytes fragment_data</code></pre></div>
<p>重组器按 <code class="inline-code">frame_seq</code> 保存状态，以 <code class="inline-code">record_total_size</code> 分配缓冲区，然后把每个 fragment 写入指定 offset。实现时至少要处理：</p>
<ul><li>TCP 读取边界与 UMSP 包边界不一致；</li><li>同一分片重复到达；</li><li>分片乱序；</li><li>新 frame_seq 到来时旧帧仍未完成；</li><li>offset 或 length 越界；</li><li>恶意或损坏的 total size 导致大内存分配。</li></ul>
<p>当前实现给每帧维护一份 <code class="inline-code">seen</code> 位图，只把第一次出现的字节计入 <code class="inline-code">received</code>，避免重复分片使计数提前达到 total。最多同时保留四个未完成帧，并将桌面记录大小限制在 <code class="inline-code">0x241800</code>。</p>
<p>一个重要踩坑是：并非所有完整记录都大于 64 字节。<code class="inline-code">SPUC</code> 光标记录只有 16 字节。如果在分片层直接用“total < 64”判定非法，就会把光标状态误报为坏帧。正确做法是先完成通用重组，再在记录层按 magic 和长度分流。</p>
<hr>
<h2 id="七、hhrfhjrf-桌面记录" class="md-heading"><a class="heading-anchor" href="#七、hhrfhjrf-桌面记录" aria-hidden="true">#</a>七、HHRF/HJRF 桌面记录</h2>
<p>视频类记录包含一个 64 字节头：</p>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>大小</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">0x00</code></td><td>4</td><td>magic：<code class="inline-code">HHRF</code> / <code class="inline-code">HJRF</code> / <code class="inline-code">HMRF</code></td></tr><tr><td><code class="inline-code">0x04</code></td><td>4</td><td>declared record size</td></tr><tr><td><code class="inline-code">0x08</code></td><td>4</td><td>timestamp</td></tr><tr><td><code class="inline-code">0x0C</code></td><td>16</td><td>encoded rect：left/top/right/bottom</td></tr><tr><td><code class="inline-code">0x1C</code></td><td>16</td><td>visible rect：left/top/right/bottom</td></tr><tr><td><code class="inline-code">0x2C</code></td><td>4</td><td>frame type</td></tr><tr><td><code class="inline-code">0x30</code></td><td>16</td><td>尚未完全命名的字段</td></tr><tr><td><code class="inline-code">0x40</code></td><td>variable</td><td>编码 payload</td></tr></tbody></table></div>
<p>magic 对应关系：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">0x46524848 -&gt; wire &quot;HHRF&quot; -&gt; H.264 Annex-B
0x46524A48 -&gt; wire &quot;HJRF&quot; -&gt; JPEG
0x46524D48 -&gt; wire &quot;HMRF&quot; -&gt; legacy/兼容记录</code></pre></div>
<p>日志中的一个真实样本为：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">H264 frame=1 seq=11 record=623 declared=623
encoded=(0,0,1728,928) visible=(0,0,1718,918)</code></pre></div>
<p>这说明编码尺寸和实际可见尺寸并不总相同。直接按 encoded rect 展示会在边缘出现填充区域，所以解码后还需要按 visible rect 裁剪。</p>
<h3 id="71-为什么请求真实-h264-时容易黑屏" class="md-heading"><a class="heading-anchor" href="#71-为什么请求真实-h264-时容易黑屏" aria-hidden="true">#</a>7.1 为什么请求真实 H.264 时容易“黑屏”</h3>
<p>H.264 是有状态码流。解码器通常需要先看到 SPS、PPS 和 IDR，才能解释后续 P/B 帧。下面几种做法都会造成只有数据、没有画面：</p>
<ul><li>TCP 建连太晚，错过码流初始化；</li><li>先用一个播放器观看，切换 control 时杀掉播放器再启动另一个；</li><li>把多次观看留下的半段 <code class="inline-code">.h264</code> 直接拼在一起；</li><li>将 UMSP/桌面记录头误写进 H.264 payload；</li><li>按 TCP recv 块直接喂解码器，破坏了上层记录边界。</li></ul>
<p>当前实现从第一帧开始固定使用 bundled FFmpeg，<code class="inline-code">view -&gt; control</code> 只改变窗口交互状态，不重启解码进程。可选裸流也按会话时间戳独立保存，避免把上一次的不完整码流接到新会话前面。</p>
<h3 id="72-为什么画面颜色会错" class="md-heading"><a class="heading-anchor" href="#72-为什么画面颜色会错" aria-hidden="true">#</a>7.2 为什么画面颜色会错</h3>
<p>拿到画面后，最明显的问题曾是整体色相不对：本应为蓝色的区域显示成另一种颜色。几何内容正确、亮度基本正确而色相系统性错误，通常不是 RGB/BGR 顺序问题，而是 YUV 色度平面顺序问题。</p>
<p>对同一帧分别尝试常见转换后，可以确认极域这条 H.264 链路的 U/V 平面需要交换。FFmpeg 过滤器为：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">shuffleplanes=0:2:1:3</code></pre></div>
<p>然后再按可见尺寸裁剪：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">shuffleplanes=0:2:1:3,crop=&lt;visible_w&gt;:&lt;visible_h&gt;:0:0</code></pre></div>
<p>这里选择在显示管线修正，而不改写保存的 H.264 裸流。这样原始证据仍可用于后续协议分析，同时实时窗口显示正确颜色。</p>
<hr>
<h2 id="八、spuc远端光标不是画面的一部分" class="md-heading"><a class="heading-anchor" href="#八、spuc远端光标不是画面的一部分" aria-hidden="true">#</a>八、SPUC：远端光标不是画面的一部分</h2>
<p>光标状态使用独立的 16 字节记录：</p>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>类型</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">0x00</code></td><td>4 bytes</td><td><code class="inline-code">SPUC</code></td></tr><tr><td><code class="inline-code">0x04</code></td><td><code class="inline-code">u32</code></td><td>record size，预期 <code class="inline-code">16</code></td></tr><tr><td><code class="inline-code">0x08</code></td><td><code class="inline-code">u32</code></td><td>timestamp</td></tr><tr><td><code class="inline-code">0x0C</code></td><td><code class="inline-code">u32</code></td><td>packed position</td></tr></tbody></table></div>
<p>位置打包方式：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">x = packed_position &amp; 0xFFFF
y = packed_position &gt;&gt; 16</code></pre></div>
<p>实际控制窗口中有三个“鼠标位置”：</p>
<ol><li>本机系统指针；</li><li>刚刚发送给学生端的预测位置；</li><li>学生端通过 <code class="inline-code">SPUC</code> 回传的确认位置。</li></ol>
<p>如果三者同时显示，用户会看到两个指针互相追赶。因此投屏区域隐藏本机系统指针；发送鼠标事件后的短时间内显示本地预测位置，约 180 ms 后切回 <code class="inline-code">SPUC</code> 确认位置。这个策略不能消除网络和编码延迟，但能减少操作感上的跳变。</p>
<p>画面显示可能经过缩放，坐标必须基于实际图像区域，而不是整个窗口或屏幕：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">normalized_x = round(local_x * 65535 / (display_width  - 1))
normalized_y = round(local_y * 65535 / (display_height - 1))</code></pre></div>
<p>预测光标绘制回源画面时，再从 <code class="inline-code">0..65535</code> 映射到 encoded width/height。忽略这两次映射，会在窗口缩放后产生越来越明显的位置偏差。</p>
<hr>
<h2 id="九、远控建立comd-事务中的-mcmd" class="md-heading"><a class="heading-anchor" href="#九、远控建立comd-事务中的-mcmd" aria-hidden="true">#</a>九、远控建立：COMD 事务中的 MCMD</h2>
<p><code class="inline-code">MCMD</code> 不能作为裸 UDP 报文发送，它是通用 <code class="inline-code">COMD</code> transaction 的 payload。COMD 外层负责版本、事务 GUID、超时、发送者和 payload 长度；学生端先进入事务分发器，再把 MCMD 交给监看/控制模块。</p>
<p>当前 MCMD 内层固定为 53 字节：</p>
<div class="table-wrap"><table><thead><tr><th>偏移</th><th>大小</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">0x00</code></td><td>4</td><td>inner length <code class="inline-code">53</code></td></tr><tr><td><code class="inline-code">0x04</code></td><td>4</td><td>feature/category <code class="inline-code">8</code></td></tr><tr><td><code class="inline-code">0x08</code></td><td>4</td><td>flags <code class="inline-code">0</code></td></tr><tr><td><code class="inline-code">0x0C</code></td><td>4</td><td>magic <code class="inline-code">MCMD</code></td></tr><tr><td><code class="inline-code">0x10</code></td><td>4</td><td>mode：<code class="inline-code">0</code> / <code class="inline-code">1</code> / <code class="inline-code">2</code></td></tr><tr><td><code class="inline-code">0x14</code></td><td>4</td><td>学生 IP，网络序字节</td></tr><tr><td><code class="inline-code">0x18</code></td><td>2</td><td>学生端输入监听 UDP 端口</td></tr><tr><td><code class="inline-code">0x1A</code></td><td>27</td><td>保留/零填充</td></tr></tbody></table></div>
<p>项目默认从 <code class="inline-code">49152..65535</code> 随机选择一个高位端口。使用固定端口虽然方便抓包，但学生端异常退出后旧线程或状态可能仍占用它；随机端口更适合反复调试。</p>
<p>MCMD 发出后不能立即发送第一条输入。学生端需要创建接收线程并完成 <code class="inline-code">BeginSimulate</code> 初始化。当前实现保守等待约 350 ms；这不是协议字段，而是从实际时序中得到的工程性保护。</p>
<p>为了避免学生端进入“有控制状态、没有画面会话”的不完整状态，<code class="inline-code">control</code> 只在 4806/TCP 已经连接后发送 MCMD。停止时发送 <code class="inline-code">mode=1</code> 并关闭本地 UDP sender。</p>
<hr>
<h2 id="十、28-字节模拟输入包" class="md-heading"><a class="heading-anchor" href="#十、28-字节模拟输入包" aria-hidden="true">#</a>十、28 字节模拟输入包</h2>
<p>所有输入事件使用相同外壳：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">+0x00  u32 magic = 0x2321347C
+0x04  u32 kind
+0x08  u8  payload[20]
total = 28 bytes</code></pre></div>
<p>线上 magic 的小端字节是：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">7C 34 21 23</code></pre></div>
<p>固定总长非常重要。多一个字节或少一个字节都可能让学生端接收线程丢包，或者让后续字段错位。</p>
<h3 id="101-鼠标kind1" class="md-heading"><a class="heading-anchor" href="#101-鼠标kind1" aria-hidden="true">#</a>10.1 鼠标：kind=1</h3>
<p>鼠标 payload 是五个 <code class="inline-code">u32</code>：</p>
<div class="table-wrap"><table><thead><tr><th>payload 偏移</th><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">+0x00</code></td><td>message</td><td>Windows 鼠标消息</td></tr><tr><td><code class="inline-code">+0x04</code></td><td>x</td><td><code class="inline-code">0..65535</code> 归一化坐标</td></tr><tr><td><code class="inline-code">+0x08</code></td><td>y</td><td><code class="inline-code">0..65535</code> 归一化坐标</td></tr><tr><td><code class="inline-code">+0x0C</code></td><td>data</td><td>滚轮等附加数据</td></tr><tr><td><code class="inline-code">+0x10</code></td><td>swapped</td><td>键位交换/保留状态</td></tr></tbody></table></div>
<p>已验证的 message：</p>
<div class="table-wrap"><table><thead><tr><th>动作</th><th>值</th></tr></thead><tbody><tr><td>move</td><td><code class="inline-code">0x0200</code></td></tr><tr><td>left down / up</td><td><code class="inline-code">0x0201</code> / <code class="inline-code">0x0202</code></td></tr><tr><td>right down / up</td><td><code class="inline-code">0x0204</code> / <code class="inline-code">0x0205</code></td></tr><tr><td>middle down / up</td><td><code class="inline-code">0x0207</code> / <code class="inline-code">0x0208</code></td></tr><tr><td>wheel</td><td><code class="inline-code">0x020A</code></td></tr></tbody></table></div>
<p>鼠标移动和点击在当前学生端环境已经观察到实际执行结果，因此证据链不仅停留在“教师端 sendto 成功”。</p>
<h3 id="102-键盘kind16仍是实验性" class="md-heading"><a class="heading-anchor" href="#102-键盘kind16仍是实验性" aria-hidden="true">#</a>10.2 键盘：kind=16，仍是实验性</h3>
<p>当前根据反汇编字段访问和教师端行为构造的 20 字节 payload：</p>
<div class="table-wrap"><table><thead><tr><th>payload 偏移</th><th>类型</th><th>说明</th></tr></thead><tbody><tr><td><code class="inline-code">+0x00</code></td><td><code class="inline-code">u16</code></td><td>scan code</td></tr><tr><td><code class="inline-code">+0x02</code></td><td><code class="inline-code">u16</code></td><td>virtual key</td></tr><tr><td><code class="inline-code">+0x04</code></td><td><code class="inline-code">u32</code></td><td>flags</td></tr><tr><td><code class="inline-code">+0x08</code></td><td>12 bytes</td><td>zero/reserved</td></tr></tbody></table></div>
<p>当前 flags：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">bit 0    = extended key
bit 7    = key up</code></pre></div>
<p>普通按下为 <code class="inline-code">flags=0</code>，释放为 <code class="inline-code">flags=0x80</code>；扩展键按下/释放再或上 <code class="inline-code">0x01</code>。</p>
<p>Tk 的键盘事件会受输入法和焦点影响，尤其在中文输入法状态下，部分按键不会按预期到达 Tk。因此控制窗口额外使用 <code class="inline-code">WH_KEYBOARD_LL</code> 获取 Windows 提供的 VK、扫描码、扩展键标志以及 key down/up，并拦截 Alt、Windows 键，防止这些键先作用于教师机本地桌面。</p>
<p>但是，“Hook 日志出现”和“学生端收到有效键盘输入”之间仍有多层边界。当前只能确认：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">本地物理按键
  -&gt; WH_KEYBOARD_LL 捕获成功
  -&gt; kind=16 的 28 字节包构造成功
  -&gt; UDP sendto 成功
  -x-&gt; 学生端解析/SendInput/目标应用收到字符：尚未稳定证明</code></pre></div>
<p>可能影响学生端最终执行的因素包括：学生端所在 Windows Session、当前 input desktop、UAC 安全桌面、接收线程权限、学生端本地 Hook、扫描码/VK 组合、扩展键标志以及注入事件过滤。没有接收端 ACK 时，教师端日志不能跨越这些边界作结论。</p>
<hr>
<h2 id="十一、如何判断真的成功而不是日志看起来成功" class="md-heading"><a class="heading-anchor" href="#十一、如何判断真的成功而不是日志看起来成功" aria-hidden="true">#</a>十一、如何判断“真的成功”，而不是日志看起来成功</h2>
<p>逆向远控最危险的误区是把 <code class="inline-code">sendto()</code> 返回成功当成远端执行成功。建议把证据拆成六级：</p>
<div class="table-wrap"><table><thead><tr><th>等级</th><th>证据</th><th>鼠标</th><th>键盘</th></tr></thead><tbody><tr><td>A</td><td>教师端 UI/Hook 捕获事件</td><td>已验证</td><td>已验证</td></tr><tr><td>B</td><td>教师端构造并发送正确长度报文</td><td>已验证</td><td>已验证</td></tr><tr><td>C</td><td>抓包看到报文到达学生端</td><td>已验证</td><td>需持续复核</td></tr><tr><td>D</td><td>学生端接收器进入对应 kind 分支</td><td>已验证</td><td>尚缺稳定动态证据</td></tr><tr><td>E</td><td>学生端系统注入 API 成功</td><td>已验证实际效果</td><td>未稳定验证</td></tr><tr><td>F</td><td>目标桌面/应用收到输入</td><td>已验证</td><td>未稳定验证</td></tr></tbody></table></div>
<p>如果要继续研究键盘，下一步不应继续盲改教师端字段，而应在授权测试机上对学生端接收分支做动态观测：</p>
<ol><li>在 <code class="inline-code">recvfrom</code> 返回后记录 28 字节缓冲区；</li><li>确认 magic、kind 和 payload 分支；</li><li>在 <code class="inline-code">SendInput</code> 前检查构造出的 <code class="inline-code">INPUT</code>；</li><li>记录 <code class="inline-code">SendInput</code> 返回值和 <code class="inline-code">GetLastError</code>；</li><li>确认线程所属 Session 与当前 input desktop；</li><li>最后再观察目标窗口是否收到键盘消息。</li></ol>
<p>只有走完这条链，才能判断问题究竟是协议字段错误，还是 Windows 输入隔离与 Hook 策略导致。</p>
<hr>
<h2 id="十二、几个真正耗时的坑" class="md-heading"><a class="heading-anchor" href="#十二、几个真正耗时的坑" aria-hidden="true">#</a>十二、几个真正耗时的坑</h2>
<h3 id="121-把-4806-与-feature-参数端口混为一谈" class="md-heading"><a class="heading-anchor" href="#121-把-4806-与-feature-参数端口混为一谈" aria-hidden="true">#</a>12.1 把 4806 与 feature 参数端口混为一谈</h3>
<p>feature 参数里的教师端点不等于学生端 TCP 监听端口。包长和 IP 都正确时，这种错误特别难发现，因为学生端可能进入部分状态，却不产生正确桌面流。</p>
<h3 id="122-按-recv-边界解析-tcp" class="md-heading"><a class="heading-anchor" href="#122-按-recv-边界解析-tcp" aria-hidden="true">#</a>12.2 按 recv 边界解析 TCP</h3>
<p>本地网络里一次 recv 经常“刚好”是一包，导致错误解析器看似可用。一旦包被拆分或合并，就出现随机 magic、超大 length 和画面断流。TCP 必须按字节流处理。</p>
<h3 id="123-把-16-字节-spuc-当坏视频帧" class="md-heading"><a class="heading-anchor" href="#123-把-16-字节-spuc-当坏视频帧" aria-hidden="true">#</a>12.3 把 16 字节 SPUC 当坏视频帧</h3>
<p>早期解析器假设桌面记录至少 64 字节，日志中不断出现 <code class="inline-code">total=16</code> 的“非法帧”。保留这批原始数据并查看 magic 后，才发现它是独立光标记录。</p>
<h3 id="124-在码流中途更换播放器" class="md-heading"><a class="heading-anchor" href="#124-在码流中途更换播放器" aria-hidden="true">#</a>12.4 在码流中途更换播放器</h3>
<p><code class="inline-code">view</code> 能显示、<code class="inline-code">control</code> 一切换就卡在“等待画面”，根因不是 MCMD，而是切换时重启了解码器。H.264 参数集已经在流开头发过，新解码器只能等待下一次可恢复点。</p>
<h3 id="125-把颜色问题误判为-rgbbgr" class="md-heading"><a class="heading-anchor" href="#125-把颜色问题误判为-rgbbgr" aria-hidden="true">#</a>12.5 把颜色问题误判为 RGB/BGR</h3>
<p>如果交换 R/B 后肤色和亮度仍不合理，应检查 YUV 平面。当前链路需要交换 U/V，而不是在最终 RGB 图像上交换红蓝通道。</p>
<h3 id="126-本地指针、预测指针和远端指针混在一起" class="md-heading"><a class="heading-anchor" href="#126-本地指针、预测指针和远端指针混在一起" aria-hidden="true">#</a>12.6 本地指针、预测指针和远端指针混在一起</h3>
<p>鼠标“不同步”不一定是包没生效。若本机系统指针可见，同时又绘制预测位置和 <code class="inline-code">SPUC</code> 位置，视觉上必然出现错位。隐藏本地指针并定义明确的预测回退窗口后，问题才可测量。</p>
<h3 id="127-用错误的-comd-边界调-mcmd" class="md-heading"><a class="heading-anchor" href="#127-用错误的-comd-边界调-mcmd" aria-hidden="true">#</a>12.7 用错误的 COMD 边界调 MCMD</h3>
<p>MCMD 是通用事务的 payload，不是 COMD 应用命令里的任意 cmdId。外层长度、GUID、事务字段或 payload offset 错一处，学生端通用分发器就可能走入完全不同的命令分支。调试时若出现与鼠标无关的窗口或系统提示，应先停止发送，重新核对事务边界，而不是继续猜键码。</p>
<hr>
<h2 id="十三、实现中的防崩溃与可观测性设计" class="md-heading"><a class="heading-anchor" href="#十三、实现中的防崩溃与可观测性设计" aria-hidden="true">#</a>十三、实现中的防崩溃与可观测性设计</h2>
<p>协议能跑通不等于实现足够稳。当前版本加入了以下约束：</p>
<ul><li><code class="inline-code">view_probe</code> 只做 TCP connect 探测，不发送 feature 8 或 SHCO；</li><li>每个学生 IP 同时只允许一个 RemoteViewSession；</li><li>正式观看只进行一次 TCP 连接，不循环轰炸学生端；</li><li>连接/接收失败后发送停止 feature，并进入 60 秒冷却；</li><li>UMSP payload 和桌面 record 都有长度上限与越界检查；</li><li>未完成分片数量受限，重复字节不重复计数；</li><li>H.264 原始流按会话独立保存；</li><li><code class="inline-code">control</code> 必须等待 TCP 会话 connected event；</li><li>MCMD 后等待接收线程初始化，再发送首个输入包；</li><li>远控可用 <code class="inline-code">TEACHER_ENABLE_REMOTE_CONTROL=0</code> 紧急禁用；</li><li>日志区分“捕获”“发包”“远端 SPUC 反馈”，避免把不同证据混成一句成功。</li></ul>
<p>建议保留的关键日志字段：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">[RemoteView] feature start / stop
[RemoteView] TCP connected, SHCO channel
[RemoteView] frame_seq / offset / total
[RemoteView] record magic / declared size / rect / frame type
[RemoteControl] MCMD mode / UDP endpoint
[RemoteControl] input count / kind / exact 28-byte hex
[RemoteControl] SPUC x / y / timestamp</code></pre></div>
<p>日志必须限频。鼠标 move 可能达到每秒上百次，全量 INFO 不仅难读，还会反过来增加延迟；当前实现只详细记录前几包、关键按键和周期性样本。</p>
<hr>
<h2 id="十四、当前结论与下一步" class="md-heading"><a class="heading-anchor" href="#十四、当前结论与下一步" aria-hidden="true">#</a>十四、当前结论与下一步</h2>
<p>连续投屏链路已经形成闭环：</p>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">IDA 定位 feature 8 和 UMSP
  -&gt; 抓包确认 SHCO/TKPC/channel 10
  -&gt; 重组桌面分片
  -&gt; 识别 HHRF/HJRF/SPUC
  -&gt; FFmpeg 解码、U/V 修正、visible rect 裁剪
  -&gt; Pillow/Tk 内嵌交互窗口</code></pre></div>
<p>远程鼠标也形成了从 MCMD 建立接收器到 28 字节输入包、学生端实际动作、SPUC 位置反馈的闭环。</p>
<p>键盘尚未闭环。当前研究最重要的结论不是“再换一个键码试试”，而是已经明确缺失的证据位于学生端接收分支到 Windows 输入桌面之间。下一阶段应优先补动态观测和返回值证据，再决定是否修改 kind=16 字段。</p>
<p>逆向协议真正有价值的部分，不是记住几个 magic 和 offset，而是把每个结论绑定到可重复的证据：反汇编交叉引用说明代码如何读，抓包说明线上如何写，日志说明我们的实现走到了哪一层，远端可见行为才说明功能最终生效。</p>
<p>这也是本项目在 README 中坚持区分“已验证”“可用”“部分验证”和“实验性”的原因。</p>
<hr>
<h2 id="附录-a协议层级速查" class="md-heading"><a class="heading-anchor" href="#附录-a协议层级速查" aria-hidden="true">#</a>附录 A：协议层级速查</h2>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">MESS feature 8 (5512/UDP)
└── 617-byte remote-view payload

4806/TCP
└── UMSP
    ├── SHCO: select channel 10
    └── TKPC
        └── channel 10
            └── desktop fragment: seq + offset + total
                ├── HHRF: H.264 Annex-B
                ├── HJRF: JPEG
                ├── HMRF: legacy record
                └── SPUC: cursor position

COMD transaction (4705/UDP)
└── MCMD mode 0/1/2 + student input UDP port
    └── 28-byte input packet
        ├── kind=1: mouse
        └── kind=16: keyboard (experimental)</code></pre></div>
<h2 id="附录-b运行命令" class="md-heading"><a class="heading-anchor" href="#附录-b运行命令" aria-hidden="true">#</a>附录 B：运行命令</h2>
<div class="code-block" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-text">view_probe &lt;ip&gt;       # 只探测 4806/TCP
view &lt;ip&gt;             # 启动连续观看
control &lt;ip&gt;          # 在观看会话上启用远控
control_stop &lt;ip&gt;     # 停止输入模拟
view_stop &lt;ip&gt;        # 停止桌面流</code></pre></div>
<p>请先执行 <code class="inline-code">view_probe</code>，并只在自己拥有管理权限或已获得明确授权的设备上测试。</p>]]></content:encoded>
  </item>
  <item>
    <title><![CDATA[我翻新了JiYu Trainer!!!部分重写一个停更五年的反电子教室软件 —— Dzjs Trainer v1.0.0]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E6%88%91%E7%BF%BB%E6%96%B0%E4%BA%86jiyu-trainer%E9%83%A8%E5%88%86%E9%87%8D%E5%86%99%E4%B8%80%E4%B8%AA%E5%81%9C%E6%9B%B4%E4%BA%94%E5%B9%B4%E7%9A%84%E5%8F%8D%E7%94%B5%E5%AD%90%E6%95%99%E5%AE%A4%E8%BD%AF%E4%BB%B6-dzjs-trainer-v100</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E6%88%91%E7%BF%BB%E6%96%B0%E4%BA%86jiyu-trainer%E9%83%A8%E5%88%86%E9%87%8D%E5%86%99%E4%B8%80%E4%B8%AA%E5%81%9C%E6%9B%B4%E4%BA%94%E5%B9%B4%E7%9A%84%E5%8F%8D%E7%94%B5%E5%AD%90%E6%95%99%E5%AE%A4%E8%BD%AF%E4%BB%B6-dzjs-trainer-v100</guid>
    <pubDate>Mon, 05 Oct 2026 17:51:43 +0800</pubDate>
    <description><![CDATA[我翻新了JiYu Trainer!!!部分重写一个停更五年的反电子教室软件 —— Dzjs Trainer v1.0.0 我是云散皆星河。这篇文章讲讲我怎么把快乐的梦鱼的 JiYuTrainer，从 SSDT Hook 时代搬进内核回调时代。 为什么接手这个项目 2019 年，快乐的梦鱼（imengyu）写了 JiYuTrainer——一个让极域电子教室全屏…]]></description>
    <content:encoded><![CDATA[<h1 id="我翻新了jiyu-trainer部分重写一个停更五年的反电子教室软件-dzjs-trainer-v100" class="md-heading"><a class="heading-anchor" href="#我翻新了jiyu-trainer部分重写一个停更五年的反电子教室软件-dzjs-trainer-v100" aria-hidden="true">#</a>我翻新了JiYu Trainer!!!部分重写一个停更五年的反电子教室软件 —— Dzjs Trainer v1.0.0</h1>
<blockquote><p>我是云散皆星河。这篇文章讲讲我怎么把快乐的梦鱼的 JiYuTrainer，从 SSDT Hook 时代搬进内核回调时代。</p></blockquote>
<h2 id="为什么接手这个项目" class="md-heading"><a class="heading-anchor" href="#为什么接手这个项目" aria-hidden="true">#</a>为什么接手这个项目</h2>
<p>2019 年，快乐的梦鱼（imengyu）写了 JiYuTrainer——一个让极域电子教室全屏广播失效的工具。被老师全屏广播时自动转成窗口模式：自己能操作，老师演示也照看。项目开源在 GitHub 上，MIT 协议，从 2019 年 5 月维护到 2021 年。</p>
<p>然后他毕业了。2021 年 12 月 3 日，仓库归档，顶部一行字："已废弃。本项目将永远不再更新。"</p>
<p>我 fork 它的时候面对的现实是：原版驱动的核心是 <strong>SSDT Hook</strong>——直接改写系统服务描述符表。这在 x86 时代是经典操作，但 x64 系统的 <strong>PatchGuard（内核补丁保护）</strong> 会周期性校验内核关键结构，发现被篡改直接蓝屏。也就是说，原版的驱动方案在今天的 Win10/11 x64 上本身就是颗不定时炸弹。</p>
<p>所以这不是"fork 接着用"的事。要让它活在 2026 年的机房里，驱动层必须重写。</p>
<p>我联系了原作者，拿到了口头许可，在保留原项目贡献说明的基础上继续演进。这就是 Dzjs Trainer。</p>
<h2 id="一、驱动从-ssdt-hook-到内核回调" class="md-heading"><a class="heading-anchor" href="#一、驱动从-ssdt-hook-到内核回调" aria-hidden="true">#</a>一、驱动：从 SSDT Hook 到内核回调</h2>
<p>新驱动（<code class="inline-code">JiYuTrainerDriver</code>，x64 WDM）完全重写了。拦截能力不变，但实现全部换成操作系统官方提供的注册机制：</p>
<p><strong>进程创建/退出通知</strong>——<code class="inline-code">PsSetCreateProcessNotifyRoutineEx</code>（<code class="inline-code">Monitor.c</code>）。驱动随时知道哪个进程起了、哪个进程退了。受保护的 PID 对应的进程退出时，自动调 <code class="inline-code">KxUnProtectProcessWithPid</code> 解除保护，不留悬空状态。</p>
<p><strong>句柄拦截</strong>——<code class="inline-code">ObRegisterCallbacks</code>（<code class="inline-code">Protection64.c</code>）。注册进程/线程对象的 pre-operation 回调，在外部进程试图打开或复制受保护进程/线程句柄时，直接剥掉危险的访问权限。这是替代 SSDT Hook 的核心：不改系统表，只订阅系统事件，PatchGuard 无从下手。</p>
<p><strong>保护目标绑定</strong>——<code class="inline-code">JdrvProtectionSetTarget</code> 通过 <code class="inline-code">PsLookupProcessByProcessId</code> 按 PID 绑定极域主进程，是用户态告诉驱动"保护谁"的入口。</p>
<h3 id="ioctl-通信协议" class="md-heading"><a class="heading-anchor" href="#ioctl-通信协议" aria-hidden="true">#</a>IOCTL 通信协议</h3>
<p>用户态和驱动的通道是经典的 <code class="inline-code">DeviceIoControl</code>，设备类型 <code class="inline-code">0x8338</code>，全部 <code class="inline-code">METHOD_BUFFERED</code>。<code class="inline-code">IoCtl.h</code> 里定义了 14 个控制码：</p>
<div class="table-wrap"><table><thead><tr><th>功能码</th><th>IOCTL</th><th>用途</th></tr></thead><tbody><tr><td>0x800</td><td><code class="inline-code">CTL_QUERY_VERSION</code></td><td>查询驱动协议版本</td></tr><tr><td>0x801</td><td><code class="inline-code">CTL_INITPARAM</code></td><td>初始化参数</td></tr><tr><td>0x802</td><td><code class="inline-code">CTL_INITSELFPROTECT</code></td><td>开启驱动自保护</td></tr><tr><td>0x803</td><td><code class="inline-code">CTL_KILL_PROCESS</code></td><td>内核级杀进程</td></tr><tr><td>0x804/0x805</td><td><code class="inline-code">CTL_SUSPEND_PROCESS</code> / <code class="inline-code">CTL_RESUME_PROCESS</code></td><td>挂起/恢复进程</td></tr><tr><td>0x806/0x807</td><td><code class="inline-code">CTL_SHUTDOWN</code> / <code class="inline-code">CTL_REBOOT</code></td><td>关机/重启</td></tr><tr><td>0x808</td><td><code class="inline-code">CTL_CLIENT_QUIT</code></td><td>客户端退出通知</td></tr><tr><td>0x809</td><td><code class="inline-code">CTL_UNINIT</code></td><td>反初始化</td></tr><tr><td>0x80A</td><td><code class="inline-code">CTL_READ_EVENTS</code></td><td>读取内核事件队列</td></tr><tr><td>0x80B</td><td><code class="inline-code">CTL_HEARTBEAT</code></td><td>心跳保活</td></tr><tr><td>0x80C/0x80D</td><td><code class="inline-code">CTL_DRIVER_GUARD_CONFIG</code> / <code class="inline-code">CTL_DRIVER_GUARD_QUERY</code></td><td>驱动守卫配置/查询</td></tr></tbody></table></div>
<p>主程序（<code class="inline-code">DriverLoader.cpp</code>）启动一个事件监控线程循环读 <code class="inline-code">CTL_READ_EVENTS</code>，把内核上报的事件搬回 UI；同时定时发 <code class="inline-code">CTL_HEARTBEAT</code>，校验协议版本——驱动和主程序任何一边失联，另一边都能立刻察觉，不会出现"驱动还在拦、程序已经死了"的半死状态。</p>
<h3 id="卸载令牌ecdsa-p-256-签名" class="md-heading"><a class="heading-anchor" href="#卸载令牌ecdsa-p-256-签名" aria-hidden="true">#</a>卸载令牌：ECDSA P-256 签名</h3>
<p>原版驱动装上去就下不来，这不是好设计。新版驱动卸载需要一枚<strong>签名的卸载令牌</strong>（<code class="inline-code">UnloadToken64.c</code>），流程是：</p>
<ol><li>校验令牌结构：长度、size 字段、magic、version、action 字段，任何一项不符直接 <code class="inline-code">STATUS_INVALID_PARAMETER</code></li><li>对令牌头部（到 <code class="inline-code">signature</code> 字段之前）计算 <strong>SHA-256</strong>（内核里用 <code class="inline-code">BCryptHash</code>，<code class="inline-code">BCRYPT_SHA256_ALGORITHM</code>）</li><li>用编译进驱动的 <strong>ECDSA P-256 公钥</strong>（<code class="inline-code">UnlockPublicKey.h</code>）做 <code class="inline-code">BCryptVerifySignature</code> 验签</li></ol>
<p>密钥对由 <code class="inline-code">tools\New-DriverUnlockKeys.ps1</code> 生成，令牌由 <code class="inline-code">tools\New-DriverUnlockToken.ps1</code> 构造 payload、算 SHA-256、用私钥签名后输出 <code class="inline-code">.unlock</code> 文件。<strong>私钥永不入库</strong>——仓库里只有一个 <code class="inline-code">JiYuTrainerDriver.unlock.enc.sample</code> 占位。也就是说，公开构建的驱动能装能跑，但只有持有私钥的发布者能生成合法的卸载令牌。这是"谁发布、谁负责"的信任链。</p>
<h2 id="二、hook-模块战场在-studentmain-进程内" class="md-heading"><a class="heading-anchor" href="#二、hook-模块战场在-studentmain-进程内" aria-hidden="true">#</a>二、Hook 模块：战场在 StudentMain 进程内</h2>
<p><code class="inline-code">JiYuTrainerHooks.dll</code> 是注入进极域学生端（<code class="inline-code">StudentMain.exe</code>，也支持 <code class="inline-code">MasterHelper.exe</code>）的 DLL，用 <strong>mhook</strong> 做 inline hook。主程序（<code class="inline-code">TrainerWorker.cpp</code>）定时刷新进程列表，定位到目标进程后注入，然后走心跳/控制握手。DLL 里还挂了一个 <code class="inline-code">WH_CBT</code> 钩子盯着窗口事件。</p>
<p>hook 的 API 面覆盖了极域的全部控制手段：</p>
<ul><li><strong>窗口控制类</strong>：<code class="inline-code">SetWindowPos</code>、<code class="inline-code">MoveWindow</code>、<code class="inline-code">DeferWindowPos</code>、<code class="inline-code">SetWindowLongA/W</code>、<code class="inline-code">ShowWindow</code>、<code class="inline-code">SetForegroundWindow</code>、<code class="inline-code">BringWindowToTop</code>——全屏广播转窗口、窗口置顶、黑屏安静的对抗都在这一层</li><li><strong>输入类</strong>：<code class="inline-code">SendInput</code>、<code class="inline-code">mouse_event</code>、<code class="inline-code">SetWindowsHookExA</code>——防键盘鼠标锁定</li><li><strong>执行类</strong>：<code class="inline-code">CreateProcessA/W</code>、<code class="inline-code">WinExec</code>、<code class="inline-code">ShellExecuteW/ExW</code>、<code class="inline-code">ExitWindowsEx</code>——拦截教师端远程下发的命令，用户可以逐条选择放行还是拒绝</li><li><strong>通信类</strong>：<code class="inline-code">DeviceIoControl</code>、<code class="inline-code">CreateFileA/W</code>、<code class="inline-code">FilterConnectCommunicationPort</code>——监控极域自有驱动的通信</li><li><strong>显示类</strong>：<code class="inline-code">ChangeDisplaySettingsW</code>、<code class="inline-code">DwmEnableComposition</code>、<code class="inline-code">GetDesktopWindow</code>、<code class="inline-code">GetWindowDC</code>、<code class="inline-code">CreateDCW</code>、<code class="inline-code">BitBlt</code></li></ul>
<h3 id="反监视三层屏幕捕获对抗" class="md-heading"><a class="heading-anchor" href="#反监视三层屏幕捕获对抗" aria-hidden="true">#</a>反监视：三层屏幕捕获对抗</h3>
<p>这是整个项目里我花时间最多的部分。极域的"打开看"（教师端实时监视学生屏幕）有好几条捕获路径，得逐条堵：</p>
<p><strong>路径一：DispFilter.dll 的 DXGI 捕获。</strong> 用 IDA 逆向确认它只按序号导出：ordinal 1 是 <code class="inline-code">DispDXGIBitBlt</code>（真正读桌面像素），ordinal 4 是 <code class="inline-code">DispDXGIGetScreenUpdate</code>（只取脏矩形，结构体大小 0xC84、200 个 RECT）。<code class="inline-code">DispDXGIBitBlt</code> 把 DXGI 桌面帧按 RGBA 逐行 memcpy 进调用方缓冲——我的钩子在原始函数返回后，把整块像素填成不透明黑（B=G=R=0, A=0xFF），教师端看到的就是纯黑一片。</p>
<p>还有一个首帧空窗问题：StudentMain 是通过 <code class="inline-code">LibDeskMonitor::TDDeskCreateInstance → libTDDesk2</code> 在"打开看"开始时才 <code class="inline-code">LoadLibraryW(&quot;DispFilter.dll&quot;)</code> 动态加载它的。所以我同时钩了 <code class="inline-code">LoadLibraryW</code>/<code class="inline-code">LoadLibraryExW</code>——DispFilter 一被加载，捕获钩立即装上，不留空窗。</p>
<p><strong>路径二：LibDeskMonitor.dll 的 GDI 抓屏。</strong> 它静态导入 <code class="inline-code">BitBlt</code>/<code class="inline-code">StretchBlt</code>，把源窗口 DC 拷进内存位图。之前试过钩 <code class="inline-code">GetDesktopWindow</code>/<code class="inline-code">GetWindowDC</code> 拦不住——它的源 DC 是 MFC <code class="inline-code">CWnd::GetDC</code> 直接拿的，不走这几个 API。解法是直接改写 LibDeskMonitor 自身的 IAT：钩住它内部的 <code class="inline-code">BitBlt</code>/<code class="inline-code">StretchBlt</code>，捕获完成后用 <code class="inline-code">PatBlt(BLACKNESS)</code> 把目标内存位图刷黑。还有一个细节：<code class="inline-code">libTDDesk2</code> 要等最终 <code class="inline-code">GdiFlush</code> 之后才提交捕获位图，所以我用 <code class="inline-code">thread_local</code> 记录同线程最近一次捕获的目标 DC，等原始 flush 返回后再覆盖整张持久位图——刷早了会被真实画面盖回去。</p>
<p><strong>路径三：JPEG 编码流。</strong> 极域用 LibJPEG20 的 <code class="inline-code">EncodeToJPEGBuffer</code>（及 I422 变体）把帧编码后发走。这里我做了<strong>三层钩</strong>：函数实现体 inline hook + 多模块 IAT patch + <code class="inline-code">GetProcAddress</code> 拦截——因为它按序号导出、且被多个模块以不同方式引用，单层钩总会漏。卸载时逐个还原 IAT 槽位。</p>
<h3 id="信息流保护不只是涂黑" class="md-heading"><a class="heading-anchor" href="#信息流保护不只是涂黑" aria-hidden="true">#</a>信息流保护：不只是涂黑</h3>
<p>反监视之外，v1.0.0 还加了"替换"能力——教师端看到的不是黑屏，而是你指定的图片或视频：</p>
<ul><li>图片路径：RGB24 缓存 + 顶向下 BGR24 DIB 双份像素，<code class="inline-code">StretchDIBits</code> 直接画</li><li>视频路径（MP4/WMV）：独立 <strong>Media Foundation</strong> 线程解码，捕获热路径只读已发布的当前帧，不做文件 I/O 不做解码——捕获是高频路径，这里卡一下教师端就掉帧了</li><li>并发安全：临界区 + 引用计数管理共享 DC 和像素缓冲，"正在使用的 DC 不会被删除"，旧缓冲进 pending 列表等引用归零再释放</li></ul>
<h2 id="三、发布链路把信任做成流程" class="md-heading"><a class="heading-anchor" href="#三、发布链路把信任做成流程" aria-hidden="true">#</a>三、发布链路：把"信任"做成流程</h2>
<p>代码写完只是一半，另一半是让用户能验证拿到手的二进制没被掉包。</p>
<p><code class="inline-code">tools\Build-JiYuTrainerRelease.ps1</code> 串起整个发布流程：</p>
<ol><li>MSBuild 重编 x64 驱动</li><li><code class="inline-code">signtool sign /sha1 &lt;指纹&gt; /fd SHA256 /tr &lt;时间戳服务器&gt;</code> 对驱动签名（含 RFC3161 时间戳）</li><li><code class="inline-code">Get-AuthenticodeSignature</code> 验证签名，提取证书 Subject 和 SHA-1 指纹</li><li><strong>关键一步</strong>：把签名者 Subject 和指纹写进 <code class="inline-code">JiYuTrainer\DriverSignerPin.h</code>，编译进主程序——主程序加载驱动前会校验驱动签名者是不是发布者本人，防偷换</li><li>调 <code class="inline-code">Complete-DriverSigning.ps1</code>：<code class="inline-code">Inf2Cat</code> 生成 catalog，再对 catalog 做 SHA-256 签名</li></ol>
<p>也就是说：驱动签名 → 签名者指纹固化进主程序 → 主程序运行时校验 → catalog 二次签名。四道锁，任何一环被替换，整条链路报警。</p>
<h2 id="四、周边工具箱" class="md-heading"><a class="heading-anchor" href="#四、周边工具箱" aria-hidden="true">#</a>四、周边工具箱</h2>
<p>翻新过程中沉淀下来一批独立小工具，都在仓库里：</p>
<ul><li><strong>GuardTerminator</strong>：对付设了 <code class="inline-code">ProcessBreakOnTermination</code>（进程信息类 29）保护进程的终结工具，测试驱动自保护时用来模拟"敌人"</li><li><strong>JiYuAvKernel / JiYuAvCtl / JiYuAvShared</strong>：辅助驱动与控制端（<code class="inline-code">Driver.c</code>/<code class="inline-code">Controller.c</code>/<code class="inline-code">Scanner.c</code>），配套 <code class="inline-code">Build-JiYuAv.ps1</code> 和独立签名脚本，做模式扫描一类的事</li><li><strong>SlayerAnalyzer / PhoneDialogProbe / ExitInputSimulator</strong>：逆向分析和场景模拟用的小工具</li><li><strong>DriverPublisherTests / JiYuAvPatternTests</strong>：发布链路和模式匹配的测试工程</li></ul>
<h2 id="五、诚实的支持范围" class="md-heading"><a class="heading-anchor" href="#五、诚实的支持范围" aria-hidden="true">#</a>五、诚实的支持范围</h2>
<div class="table-wrap"><table><thead><tr><th>类型</th><th>状态</th></tr></thead><tbody><tr><td>极域电子教室</td><td>持续维护</td></tr><tr><td>机房管理助手</td><td>已提供适配基础</td></tr><tr><td>Gakataka</td><td>计划中</td></tr><tr><td>红蜘蛛</td><td>计划中</td></tr></tbody></table></div>
<p>Hook 模块里保留了 <code class="inline-code">jiYuVersions40</code> / <code class="inline-code">2016HH</code> 的版本适配逻辑。但我不想虚标——计划中就是计划中，每个版本的内脏（DLL 名、导出序号、窗口类）都不一样，没实测过的平台绝不说"支持"。</p>
<h2 id="写在最后" class="md-heading"><a class="heading-anchor" href="#写在最后" aria-hidden="true">#</a>写在最后</h2>
<p>原版 README 结尾，梦鱼说："如果您有其他功能需求，可以 fork 项目之后自己研究开发。"</p>
<p>我就是那个 fork 的人。五年后，SSDT Hook 换成了内核回调，裸驱动换成了带 ECDSA 卸载令牌和签名者锁定的发布链路，单一黑屏对抗换成了三层捕获钩加视频流替换。项目以 MIT 协议继续开源：</p>
<ul><li>GitHub：<a href="https://github.com/yunsjxh/Dzjs-Trainer" target="_blank" rel="noopener noreferrer"><a href="https://github.com/yunsjxh/Dzjs-Trainer" target="_blank" rel="noopener noreferrer">https://github.com/yunsjxh/Dzjs-Trainer</a></a></li><li>官网：<a href="https://dzjstrainer.xn--9kq396ceqaq4si9m.cn/" target="_blank" rel="noopener noreferrer"><a href="https://dzjstrainer.xn--9kq396ceqaq4si9m.cn/" target="_blank" rel="noopener noreferrer">https://dzjstrainer.xn--9kq396ceqaq4si9m.cn/</a></a></li><li>感谢 JiYuTrainer 原作者快乐的梦鱼；第三方组件 curl、mhook、MemoryModule、XZip/XUnZip、dnlib 各自遵循其许可证</li></ul>
<p>最后照例强调：<strong>请仅在你拥有、管理或获得明确授权的设备与网络中使用本项目</strong>。先在隔离测试环境验证，再做经授权的部署。工具没有立场，边界由使用者决定。</p>
<p>欢迎 Issue，欢迎 PR。加个星星⭐就更好了。</p>]]></content:encoded>
  </item>
  <item>
    <title><![CDATA[闲来无事,逆向一个银狐病毒]]></title>
    <link>https://blog.xn--9kq396ceqaq4si9m.cn/post/%E9%97%B2%E6%9D%A5%E6%97%A0%E4%BA%8B%E9%80%86%E5%90%91%E4%B8%80%E4%B8%AA%E9%93%B6%E7%8B%90%E7%97%85%E6%AF%92</link>
    <guid isPermaLink="true">https://blog.xn--9kq396ceqaq4si9m.cn/post/%E9%97%B2%E6%9D%A5%E6%97%A0%E4%BA%8B%E9%80%86%E5%90%91%E4%B8%80%E4%B8%AA%E9%93%B6%E7%8B%90%E7%97%85%E6%AF%92</guid>
    <pubDate>Mon, 05 Oct 2026 17:21:57 +0800</pubDate>
    <description><![CDATA[闲来无事,逆向一个银狐病毒 样本：chat-deepseek.exe，2,085,620 字节，x64，无签名。样本下载在最后面。 SHA256 68ae0cabadce8467534f8a41e9ca139b4b9a066aa20d154254793bb4eb9a6b17 分两段：先在宿主机上纯静态把文件拆开（这段没运行样本的任何代码），确认它会做什么之后…]]></description>
    <content:encoded><![CDATA[<h1 id="闲来无事逆向一个银狐病毒" class="md-heading"><a class="heading-anchor" href="#闲来无事逆向一个银狐病毒" aria-hidden="true">#</a>闲来无事,逆向一个银狐病毒</h1>
<blockquote><p>样本：<code class="inline-code">chat-deepseek.exe</code>，2,085,620 字节，x64，无签名。样本下载在最后面。</p>
<p>SHA256 <code class="inline-code">68ae0cabadce8467534f8a41e9ca139b4b9a066aa20d154254793bb4eb9a6b17</code></p>
<p>分两段：先在宿主机上<strong>纯静态</strong>把文件拆开（这段没运行样本的任何代码），确认它会做什么之后，再放进<strong>隔离虚拟机</strong>里跑，抓运行态证据——包括所有走错的路。</p></blockquote>
<hr>
<h2 id="先别急着扔-ida把没反应拆成能验证的问题" class="md-heading"><a class="heading-anchor" href="#先别急着扔-ida把没反应拆成能验证的问题" aria-hidden="true">#</a>先别急着扔 IDA：把"没反应"拆成能验证的问题</h2>
<p>拿到文件时唯一的线索是一句很含糊的话："看着像仿冒 DeepSeek 的东西，但双击之后什么都没有。"</p>
<p>"什么都没有"是最没用的一句话。它至少指四种完全不同的情况：进程没被创建、起来了但秒退、活着但什么都不做、在跑但被什么东西拦住了。四种的排查方向完全相反。所以第一步不是打开 IDA，而是把它翻译成几个<strong>可测量</strong>的量：</p>
<div class="table-wrap"><table><thead><tr><th>待测量</th><th>怎么测</th><th>后来的答案</th></tr></thead><tbody><tr><td>进程有没有被创建、活了多久</td><td>Process Monitor 的 Create → Exit 时间</td><td>创建了，但<strong>亚秒级退出</strong></td></tr><tr><td>有没有落文件</td><td>看约定路径下有没有产物</td><td><strong>没落任何文件</strong>（连日志都没有）</td></tr><tr><td>有没有出网</td><td><code class="inline-code">netstat</code>、DNS 缓存</td><td><strong>没有</strong></td></tr><tr><td>退出方式</td><td>正常 <code class="inline-code">ret</code>（退出码 0、无崩溃报告）还是异常崩溃（有 WER 日志）</td><td><strong>干净的 ret</strong></td></tr></tbody></table></div>
<p>最后一行是关键。崩溃退出的话方向会是"找 bug、找兼容性"；而它是<strong>自己决定不干、正常返回</strong>——直接指向"内部有个判断把它拦下来了"。后面所有"推翻重来"，本质都是某条测量结果和判断对不上。</p>
<h2 id="它到底有没有壳不要看节名要看三件事" class="md-heading"><a class="heading-anchor" href="#它到底有没有壳不要看节名要看三件事" aria-hidden="true">#</a>它到底有没有壳：不要看节名，要看三件事</h2>
<p>第一次打开 PE，节表长这样：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>.boot      vsize=0x592    entropy=6.00      ← 节名像 Armadillo 的引导节
.text      vsize=0x19BB0  entropy=6.44
.pkcfg     vsize=0xA0     entropy=0.22      ← 魔数 ARMPKCFG
.rdata     vsize=0x1C0000 chars=0xE0000040  (RWX, 1.75MB)
.fptable   vsize=0x100    entropy=0</code></pre></div>
<p><code class="inline-code">.boot</code>、<code class="inline-code">.pkcfg</code>、<code class="inline-code">.fptable</code> 是 Armadillo / SoftwarePassport 的招牌节名，加上 <code class="inline-code">ARMPKCFG</code> 魔数，很容易一眼就下"这是商业壳"。但我吃过看节名判壳的亏，所以定了三条更硬的判据：<strong>熵、导入表能不能正常解析、函数的序言模式</strong>。节名可以伪造，这三样不行。</p>
<p>结果第一条就出人意料：<strong>三个内嵌模块反而是明文</strong>。<code class="inline-code">.text</code> 熵 6.44（正常 MSVC 代码水平），导入目录解析出 13 个 DLL / 293 个函数全部正常，RTTI 完整可读 35 个类名，跳转表 100 项目标全落在 <code class="inline-code">.text</code> 内没损坏。我最初"主控被加壳"的悬念就此关掉——代码就在那儿，全都能读。</p>
<p>同样，我对 <code class="inline-code">stub.exe</code> 也误判过一次"加壳"：<strong>解析节表时把 <code class="inline-code">VirtualSize</code> 当成了 <code class="inline-code">VirtualAddress</code></strong>（字段顺序读反），导致 RVA→偏移错位、导入表解析落到全零区。改过来后：导入目录 <code class="inline-code">rva=0x206ac size=80</code> 正常，<code class="inline-code">.text</code> 熵 6.471，头部 <code class="inline-code">48 8b c4 48 89 58 08</code> 是标准 MSVC x64 序言——<strong>明文，没壳</strong>。这提醒我：<strong>工具给你一个输出，不代表那个输出是对的。</strong></p>
<h2 id="找字符串消费点我在这里踩了一个坑" class="md-heading"><a class="heading-anchor" href="#找字符串消费点我在这里踩了一个坑" aria-hidden="true">#</a>找字符串消费点，我在这里踩了一个坑</h2>
<p>接下来要定位 <code class="inline-code">\deputies.bin</code> 这个串在代码里被谁用。我先用最直观的办法：从函数起点线性反汇编整个 <code class="inline-code">.text</code>，扫所有 RIP 相对引用。扫出来是——<strong>零引用</strong>。</p>
<p>一个明明存在于 <code class="inline-code">.rdata</code> 里的字符串，居然没有任何代码引用？我回头查反汇编输出，发现从 <code class="inline-code">0x2630C</code> 往后指令流整体"错位"了。<code class="inline-code">0x2630C</code> 是一张<strong>跳转表</strong>（switch 分派表），里面是纯数据；线性反汇编器把表里的数据当指令解析，从那里开始一路错到段尾——<strong>后面所有引用自然全扫不到</strong>。</p>
<p>改法是不再相信反汇编器，改<strong>字节级暴力扫描</strong>：逐字节匹配 <code class="inline-code">48/4C 8D|8B</code> + ModRM(<code class="inline-code">mod=00 rm=101</code>) + disp32，<code class="inline-code">target = rva + 7 + disp</code>；<code class="inline-code">call [rip+disp32]</code> 匹配 <code class="inline-code">FF 15</code>。同一字符串立刻扫出 30 多处引用。这条教训进了我的固定流程：<strong>先在字节层确认引用，再看反汇编</strong>——反汇编是解释，字节才是事实。</p>
<h2 id="用已知明文抠出密钥" class="md-heading"><a class="heading-anchor" href="#用已知明文抠出密钥" aria-hidden="true">#</a>用已知明文抠出密钥</h2>
<p>最关键的转折点，是发现它其实是个"壳 + 加密载荷"的封装结构。</p>
<p>第二个 <code class="inline-code">.rdata</code>（1.75MB，权限 RWX，<code class="inline-code">chars=0xE0000040</code> 展开就是 <code class="inline-code">MEM_READ|MEM_WRITE|MEM_EXECUTE</code>）最可疑——<strong>可写又可执行</strong>，典型的"运行时解密落地区"。我按它的文件偏移整段读出来异或，在段内偏移 <code class="inline-code">0x4000D</code> 处解出了一个合法 PE32+：ImageBase <code class="inline-code">0x180000000</code>，入口 <code class="inline-code">0x526F0</code>，821,760 字节。密钥是 8 个字节：<code class="inline-code">5A 3C 91 E7 2B 64 D8 0F</code>。</p>
<p>我不是"猜到"的。定位方式是找到解密循环本身——<code class="inline-code">sub_1400023D4</code> 里 <code class="inline-code">mov al,[rax+r8+0x1c550]</code> / <code class="inline-code">xor [rdx+r8+0x29000],al</code>，密钥表就摆在 <code class="inline-code">.rdata</code> 的 <code class="inline-code">0x1C550</code>，逐字节读出来即可。<strong>能读到循环，就不需要猜。</strong></p>
<p>同一把密钥顺手把 <code class="inline-code">.data 0x29000</code> 起的一片数据解成了完整的编译期配置串——这张表后来比密钥本身有用得多。</p>
<h2 id="它到底能干什么从导出表和-rtti-反推作者思路" class="md-heading"><a class="heading-anchor" href="#它到底能干什么从导出表和-rtti-反推作者思路" aria-hidden="true">#</a>它到底能干什么：从导出表和 RTTI 反推作者思路</h2>
<p>解出来的 <code class="inline-code">login.dll</code> 只有 821KB，导出表只有 7 个函数，每个都很有信息量：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>ArmouryCapHelperW              armoury_beacon_start
ArmouryUCapHostW               armoury_beacon_stop
Main                           armoury_beacon_on_packet
armoury_beacon_on_conn_closed</code></pre></div>
<p>这是<strong>恶意代码自己报出的名字</strong>，和后面磁盘上的 <code class="inline-code">ArmouryLogin.log</code>、<code class="inline-code">ArmouryStager.log</code>、<code class="inline-code">Roaming\Armoury</code> 目录完全互证。<code class="inline-code">beacon</code> 说明它是<strong>信标型 C2</strong>（被动上线、心跳、回调驱动），而 <code class="inline-code">on_conn_closed</code> 尤其关键——它意味着"连接断开"是<strong>预期内的常态事件</strong>，后来直接解释了日志里的"故障转移阶梯"：那不是异常，是设计。</p>
<p>RTTI 里有 <code class="inline-code">armoury::transport::ITransport</code>（抽象基类）和 <code class="inline-code">TcpTransport</code>（实现），配合 <code class="inline-code">std::function</code> + <code class="inline-code">shared_ptr</code>——这是<strong>面向接口的现代 C++17 框架</strong>，不是 Gh0st 那种老式 C++。这个判断决定了后面归因不能乱套家族。</p>
<p><code class="inline-code">Main</code> 那几个参数（host / port / proto / mark）的顺序我也没靠猜，而是<strong>从日志格式串反推</strong>：<code class="inline-code">Main enter proto=%d port=%u host=%s mark=%s</code>——格式串里的占位符顺序就是实参排布顺序。用"日志长什么样"反推"函数签名是什么"，是这时最省力的一招。</p>
<h2 id="命令表100-个槽位怎么逐个定性" class="md-heading"><a class="heading-anchor" href="#命令表100-个槽位怎么逐个定性" aria-hidden="true">#</a>命令表：100 个槽位怎么逐个定性</h2>
<p><code class="inline-code">armoury_beacon_on_packet</code> 是命令总入口：取 <code class="inline-code">packet[0]</code>，判两个前置值，其余走跳转表——<code class="inline-code">TBL @ 0x2630C</code>（100×u32）+ <code class="inline-code">IDX @ 0x26348</code>（100×u8），索引 <code class="inline-code">= cmd - 0x88</code>。</p>
<p>解表不难，难的是<strong>给每个命令定性</strong>。我用的方法不是读一遍逻辑，而是"<strong>把调用链上的 API 按顺序排出来</strong>"——API 的调用顺序几乎自带语义。最典型的一次纠错是 <code class="inline-code">0xC8</code>：一开始我看到包里有两个数值（默认 120 / 68），加上"参数设置"的联想，判成了"运行参数设置"。后来把它整条链的 API 排出来：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>GetDC → GetSystemMetrics×2 → CreateCompatibleDC → CreateCompatibleBitmap →
SelectObject → SetStretchBltMode → StretchBlt → GetDIBits → 清理</code></pre></div>
<p>这是教科书级的截屏序列。而且 120×68 和上限 640×360 <strong>都是 16:9</strong>——两个数字都是图像尺寸，不是时间间隔。于是 <code class="inline-code">0xC8</code> 从"参数设置"改成"<strong>按需截图，服务端可指定分辨率</strong>"。</p>
<p>另一个硬结论是报文格式：<code class="inline-code">[0]=命令字节</code>、<code class="inline-code">[1..16]=16 字节 payload</code>、后面跟几个定长字段，最小长度 0x1B。<strong>协议头第 1 字节就是命令号</strong>——这条是从机器码里 <code class="inline-code">cmp byte[rcx],0x88</code> 这种硬编码比较直接读出来的，没有推断成分。</p>
<h2 id="它在我机器上留下了什么靠静态串-落盘名对上号" class="md-heading"><a class="heading-anchor" href="#它在我机器上留下了什么靠静态串-落盘名对上号" aria-hidden="true">#</a>它在我机器上留下了什么：靠"静态串 ↔ 落盘名"对上号</h2>
<p>接下来想知道它靠什么活着、重启后怎么回来。这段的推法不一样——不是从代码推行为，而是<strong>拿静态里解出的配置串，去运行态产物里找对应物</strong>。那张编译期配置表里有一批长得像 ID 的串（<code class="inline-code">ARMAUTO1</code>、<code class="inline-code">ARMVMS1</code>、<code class="inline-code">ARMUAC0</code>、<code class="inline-code">ARMGRD1</code>、<code class="inline-code">ARMGID833B463E</code>、<code class="inline-code">ARMGN10</code>、<code class="inline-code">ARMPRS01</code>、<code class="inline-code">ARMPIDA30BAE5E</code>、<code class="inline-code">ARMSIDA30BAE5E</code>、<code class="inline-code">ARMOBF1</code>、<code class="inline-code">ARMSELP1</code>、<code class="inline-code">ARMSCMAP</code>）。</p>
<p>当时它们只是一堆常量名。等我把运行态的注册表和计划任务拉出来，对上了：</p>
<div class="table-wrap"><table><thead><tr><th>静态配置串</th><th>运行态对应物</th></tr></thead><tbody><tr><td><code class="inline-code">ARMGID833B463E</code></td><td>Run 键 <strong><code class="inline-code">G-833B463E</code></strong> → <code class="inline-code">…\Microsoft\833B463E\LeafChatHost.exe --arm-boot</code></td></tr><tr><td><code class="inline-code">ARMPIDA30BAE5E</code></td><td>计划任务 <strong><code class="inline-code">\P-A30BAE5E</code></strong> → <code class="inline-code">stub.exe --arm-payload &quot;…payload.bin&quot;</code></td></tr><tr><td><code class="inline-code">ARMS1-%s-%lu</code></td><td>互斥标记文件 <code class="inline-code">ARMS1-y0YUmsuGTDqIgQTb-1</code></td></tr><tr><td>其中的 <code class="inline-code">A30BAE5E</code></td><td>释放目录 <code class="inline-code">%LOCALAPPDATA%\Microsoft\A30BAE5E\</code></td></tr></tbody></table></div>
<p><strong>这是整段分析里最舒服的一次——静态和动态第一次严丝合缝扣上。</strong> 一个本来只是"常量"的字符串，突然告诉你它就是那几个持久化机制的名字，等于凭空拿到了一份作者的设计图。计划任务的 XML 也让规律更清楚：<code class="inline-code">CalendarTrigger</code> + <code class="inline-code">Repetition PT1H</code>（<strong>每小时重注入一次</strong>）、<code class="inline-code">Hidden=true</code>、<code class="inline-code">StartBoundary</code> 是落盘后 120 秒内的时间戳——<strong>它落地不到两分钟就把持久化做完了</strong>。</p>
<p>还有个细节值得单说：守护器文件名是 <code class="inline-code">LeafChatHost.exe</code>，我差点把它当 IOC 写进报告。后来在投递器里翻到一张<strong>12 个名字的池子</strong>：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>QuickNoteHelper  PhotoGlanceHost  SkySyncHost   PlayLiteHelper
DeskCalcHost     FileNestHelper   GuardPulseHost TunePadHelper
SnapCamHost      PageMarkHelper   LeafChatHost   PrefPanelHost</code></pre></div>
<p>运行时随机挑一个当文件名，我拿这 12 个名字去公开情报里批量检索，<strong>零命中</strong>。所以正确写法是：<strong>文件名不是 IOC</strong>（<code class="inline-code">LeafChatHost.exe</code> 只是这次抽到的），能当锚点的是<strong>路径形态</strong>（<code class="inline-code">%LOCALAPPDATA%\Microsoft\&lt;8位十六进制&gt;\*.exe</code>）和<strong>命令行参数</strong>（<code class="inline-code">--arm-grd</code> / <code class="inline-code">--arm-src</code> / <code class="inline-code">--arm-boot</code> / <code class="inline-code">--arm-payload</code>）。图省事把文件名当特征，检测规则下个月就失效。</p>
<p>顺带一提，守它们的目录里有个 <code class="inline-code">target.path</code>，90 字节。我一开始不知道它是什么，直到发现 <strong>90 = 44 个字符 × 2（UTF-16）+ 2 字节 BOM</strong>——按 UTF-16LE 读出来，正好是那个桌面样本的完整路径。<strong>算术先对上了，语义才敢下结论。</strong></p>
<h2 id="它是怎么把自己塞进内存的" class="md-heading"><a class="heading-anchor" href="#它是怎么把自己塞进内存的" aria-hidden="true">#</a>它是怎么把自己塞进内存的</h2>
<p><code class="inline-code">stub.exe</code> 我只说了它是"进程镂空投递器"，做法值得展开，因为它解释了"为什么杀进程没用"。定性还是排 API 序列：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>NtAllocateVirtualMemory   NtWriteVirtualMemory   NtGetContextThread
NtSetContextThread        NtResumeThread         NtClose</code></pre></div>
<p>再配上 <code class="inline-code">CreateProcessW</code> 和一个字符串 <code class="inline-code">e%s\notepad.exe</code>——顺序连起来就是完整的 <strong>RunPE（傀儡进程）</strong>链：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>CreateProcessW(notepad.exe, CREATE_SUSPENDED)  ← 宿主挂起
  → NtAllocateVirtualMemory( RWX, size = payload + 0x20000 )
  → NtWriteVirtualMemory → NtSetContextThread( 改入口点 )
  → NtResumeThread    ← 跑起来的是 notepad，内容是载荷</code></pre></div>
<p><strong>为什么不直接 <code class="inline-code">CreateThread</code>？</strong> 因为镂空之后，进程在任务管理器里显示的是 <code class="inline-code">notepad.exe</code>，模块列表里没有可疑 DLL，磁盘上也没有可执行文件——查杀软件按"可疑进程名 / 可疑模块"去匹配，什么都找不到。</p>
<p>配套看那个被写进去的 <code class="inline-code">payload.bin</code>（39,541 字节）：它<strong>不是 PE</strong>，没有 <code class="inline-code">MZ</code> 头，是一段纯 x64 位置无关 shellcode——入口三条指令 <code class="inline-code">sub rsp,0x38</code> → 在栈上拼出 <code class="inline-code">&quot;boot&quot;</code> → <code class="inline-code">lea rcx,[rsp+0x20]</code> → <code class="inline-code">call</code>，也就是说它自己会去找并加载 <code class="inline-code">a64.dll</code>。<strong>"投递器不落地可执行文件、载荷也不是 PE"——这条链上每一步都在躲静态特征。</strong></p>
<h2 id="a64dll-里还藏着一整套无文件工具链" class="md-heading"><a class="heading-anchor" href="#a64dll-里还藏着一整套无文件工具链" aria-hidden="true">#</a>a64.dll 里还藏着一整套"无文件"工具链</h2>
<p>真正联网的只有 <code class="inline-code">a64.dll</code>。这一点是从导入表反过来确认的：<code class="inline-code">stub.exe</code>（3 个 DLL / 95 函数）与守护器（2 个 DLL / 97 函数）<strong>都不导入 <code class="inline-code">WS2_32</code> / <code class="inline-code">WININET</code> / <code class="inline-code">WINHTTP</code> 中的任何一个</strong>；只有 <code class="inline-code">a64.dll</code> 是 16 个 DLL / 301 个函数，网络、截屏、令牌、设备枚举一应俱全。<strong>"守护器和投递器不联网"是设计，不是被断网了。</strong></p>
<p>然后我在 <code class="inline-code">a64.dll</code> 字符串里翻到一段很扎眼的东西——<code class="inline-code">powershell.exe … -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand</code>，紧接着一段 Base64，解码后是<strong>内联 C# 代码</strong>。四段脚本：① 单次截屏；② <strong>连续截屏</strong>，超 1600×900 等比缩小、先写 <code class="inline-code">.tmp</code> 再原子 <code class="inline-code">Copy</code>，<strong>200 ms/帧（5 fps）</strong>；③ 内联类 <code class="inline-code">ArmFg</code> 取前台窗口标题 + 空闲秒数，<strong>每 500 ms</strong> 写 <code class="inline-code">arm_fg_%u.txt</code>；④ 内联类 <code class="inline-code">ArmWinQ</code> 导出全部窗口信息到 <code class="inline-code">arm_winq_%u_%u.txt</code>。</p>
<p><strong>为什么用 PowerShell 而不是写 C++？</strong> 因为脚本是 <code class="inline-code">-EncodedCommand</code> 传进去的，<strong>命令行里只有一串 Base64</strong>，磁盘上不留 <code class="inline-code">.ps1</code>，进程树里也看不到脚本内容。5 fps 连续截屏 + 每 500 ms 一次前台窗口/空闲采样，合起来就是"<strong>他在看什么、有没有在操作</strong>"——社工诈骗最需要的情报。</p>
<p>还有一条业务线索，藏在同目录的 <code class="inline-code">FILTER</code> 文件里（2,940 字节，UTF-16），是一张两列对照表：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>signal → SL      telegram → TG      safeW → SW      WhatsApp → WA</code></pre></div>
<p><strong>一份即时通讯软件的监控/劫持名单。</strong> 结合前面的浏览器扩展注入串 <code class="inline-code">ArmouryIntercept</code> 和 <code class="inline-code">Chrome / Edge / Brave</code> 的扩展目录路径，变现路径就清楚了：<strong>盯着 IM，找机会冒充本人去骗联系人</strong>——技术分析到这里，第一次看到"它图什么"。</p>
<h2 id="c2-到底在哪先排除再下结论" class="md-heading"><a class="heading-anchor" href="#c2-到底在哪先排除再下结论" aria-hidden="true">#</a>C2 到底在哪：先排除，再下结论</h2>
<p>到这时我已有完整的代码骨架，却始终没找到最该有的东西——<strong>C2 地址</strong>。先假设它写得比较直接，做了七轮搜索，每轮换一种"藏法"：</p>
<div class="table-wrap"><table><thead><tr><th>通道</th><th>我假设它藏成什么样</th><th>结果</th></tr></thead><tbody><tr><td>明文直搜 <code class="inline-code">macio</code>、IP 的 8 种编码（原样/反转/UTF-16/十六进制/点分十进制）</td><td>明文或编过码</td><td><strong>0 命中</strong></td></tr><tr><td>重复密钥 XOR 的 crib-drag（6 crib × 周期 1–32 × 全文件每偏移）</td><td>被 XOR 加密</td><td>唯一"命中"解出 <code class="inline-code">mmmm…</code> 噪声 → <strong>假阳性</strong></td></tr><tr><td>单字节加法/减法混淆、单字节 XOR（各全 255 密钥）</td><td>简单混淆</td><td><strong>0 命中</strong></td></tr><tr><td><code class="inline-code">a64.dll</code> 高熵区 / 版本资源 / 构建配置表</td><td>小段密文 / 写在资源里</td><td>全是<strong>查表数据</strong>，无相关字段</td></tr></tbody></table></div>
<p>七轮全空之后，反而不该继续硬搜，而是回头看代码里那些"看起来在配置网络"的地方。<code class="inline-code">a64.dll</code> 里有一段协议配置块——<code class="inline-code">&quot;ARPLWire&quot;</code>、<code class="inline-code">&quot;/arpl/ws&quot;</code>、<code class="inline-code">&quot;AFR2&quot;</code>、<code class="inline-code">&quot;ArmouryFrameV2-AUTH&quot;</code>、<code class="inline-code">&quot;ArmouryFrameV2-ENC&quot;</code>，以及一句日志格式串：<code class="inline-code">Main enter proto=%d port=%u host=%s mark=%s</code>。</p>
<p><strong><code class="inline-code">host</code> 是 <code class="inline-code">Main</code> 的入参。</strong> 也就是说，这个样本从设计上就不带 C2——它等着<strong>外面的人把地址递进来</strong>（通过 <code class="inline-code">deputies.bin</code> 配置、或 <code class="inline-code">armoury_beacon_start</code> 的入参结构体）。这也解释了为什么"下载/更新"通道一直在报 <code class="inline-code">pullfail</code>：<strong>它在拿到配置之前，本来就无事可做。</strong></p>
<p>所以"没见过的东西"和"不存在的东西"要分开。<code class="inline-code">45.64.52.179</code> 是存在的，只是不在文件里——<strong>它是从外部投递进来的</strong>。这条"排除得出外置"的结论，正好把注意力推向文件里唯一一块还没打开的地方：<strong>末尾那 50,932 字节</strong>。</p>
<h2 id="为什么没反应我推翻了四次" class="md-heading"><a class="heading-anchor" href="#为什么没反应我推翻了四次" aria-hidden="true">#</a>"为什么没反应"——我推翻了四次</h2>
<p><strong>第一次。</strong> 我在静态里全量核对了经典反 VM 手法：<code class="inline-code">GetSystemFirmwareTable</code>、<code class="inline-code">GlobalMemoryStatus</code>、<code class="inline-code">IsDebuggerPresent</code>、CPUID hypervisor leaf、各种 MAC OUI……<strong>全部 0 命中</strong>。那些 <code class="inline-code">VMware / VirtualBox / WireGuard</code> 字符串我也追到了消费点，发现它们是<strong>网卡打分器的扣分项</strong>（给虚拟网卡扣 90 分，满分 120），只影响排序，<strong>没有任何退出/自杀逻辑</strong>。于是第一版结论是：没有反 VM，只有 5 个"静默闸门"，最可能是环境变量 <code class="inline-code">ARMOURY_ENABLE_TRANSPORT_LAYER</code> 没设。</p>
<p><strong>第二次（被实测打脸）。</strong> 实测结果是 <code class="inline-code">C:\Windows\Temp\ArmouryLogin.log</code> <strong>根本不生成</strong>——第一版说的"传输层/C2 问题"完全错了。重查发现两件事：一是程序真的起来了，跑到 <code class="inline-code">sub_2010</code> 尾部是一个 <strong><code class="inline-code">Sleep(0x36EE80)</code> = 1 小时</strong>的死循环；二是 <code class="inline-code">login.dll::Main</code> 要求 <strong>5 个非空参数</strong>（前置 <code class="inline-code">test rdx,rdx</code> / <code class="inline-code">test r8,r8</code> / … 任一为空就 <code class="inline-code">je</code> 直接返回），双击时宿主传参不满足，所以"写日志"那行<strong>从来没被执行过</strong>。这版结论：它不是"能双击跑的完整木马"，而是需要特定 loader / 参数才"活"的组件。</p>
<p><strong>第三次（我把样本真的跑起来，推翻了第二版）。</strong> 我在 VMware 里跑，<strong>亚秒级静默退出</strong>，任务管理器都看不见。重查启动链，在 <code class="inline-code">sub_2010</code> 前 0x60 字节里找到了真正的卡点：</p>
<div class="code-block" data-lang="asm"><div class="code-head"><span class="code-lang">asm</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-asm">0x2047  cmp  byte[0x29016], &#039;1&#039;    ; 配置开关 ARMVMS
0x2050  call sub_54A8              ; ★ 反 VM/沙箱 多因子打分
0x2057  jne  0x2062                ; al==1 → 落进退出块
0x2062  xor  eax,eax
0x2064  jmp  0x2359                ; ← 直接返回</code></pre></div>
<p><code class="inline-code">sub_54A8</code> 是打分子函数。在 VMware 里，注册表键 <code class="inline-code">SOFTWARE\VMware, Inc.\VMware Tools</code> 和进程 <code class="inline-code">vmtoolsd.exe</code> <strong>必然命中</strong>，得分达阈值就直接返回 1 → 静默 <code class="inline-code">ret</code> 退出。<strong>"任务管理器看不到进程、日志不生成、无网络、无持久化"——现象全部吻合。</strong></p>
<p><strong>第四次（反汇编精化）。</strong> 我把 <code class="inline-code">sub_54A8</code> 逐条拆开，发现之前的"4 个因子"理解错了：那几个传进去的 10 / 9 / 10 / 7 <strong>是数组长度，不是权重</strong>——真实规模是 <strong>8 路检测、其中 4 路是数组、共 36 个检查项</strong>。同时发现"第二道门"<code class="inline-code">sub_4ADC</code> 开头 <code class="inline-code">cmp byte[0x2901E],&#039;1&#039;</code>（配置 <code class="inline-code">ARMUAC0</code>，实测 <code class="inline-code">&#039;0&#039;</code>），条件不成立就直接走到"放行"出口，<strong>它天然放行</strong>。结论：<strong>唯一的阻断点就是那个打分器。</strong></p>
<p><strong>定案（单字节差分）。</strong> 最后我用桌面上另一个版本的样本做差分——两个文件<strong>只差 1 个字节</strong>：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>文件偏移 0x27616    原始样本 0xE9  →  变体 0xE8
用同一把密钥解出：   ARMVMS1        →  ARMVMS0</code></pre></div>
<p>一个字节，虚拟机检测开关就关掉了——这也证明那张编译期配置表不是"清单"，而是<strong>运行时读取的功能开关板</strong>。</p>
<p>这四次推翻里我做对的只有一件事：<strong>每轮结论都写清楚"怎么证伪"</strong>——所以每被推翻一次，下一次的起点都更靠后、更精确。</p>
<p>顺手一提，打补丁还有个坑。两道 <code class="inline-code">jne</code> 的<strong>极性是相反的</strong>：</p>
<div class="code-block" data-lang="asm"><div class="code-head"><span class="code-lang">asm</span><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code class="language-asm">0x2057  jne 0x2062   ; al==1 → 跳到退出块      → 这一道可以 NOP
0x2060  jne 0x2069   ; al==1 → 跳到继续分支    → 这一道 NOP 就完蛋</code></pre></div>
<p>第二道是"通过才跳转"，如果我按第一道的思路把它也 NOP 掉，控制流会顺序落进退出块，<strong>反而保证退出</strong>；正确做法是改成无条件跳转（<code class="inline-code">75 07</code> → <code class="inline-code">EB 07</code>，只动一个字节）。这种"看起来对称、实际方向相反"的地方，是补丁最容易翻车的位置。</p>
<h2 id="去-vm-里抓-c2先学会不把噪声当证据" class="md-heading"><a class="heading-anchor" href="#去-vm-里抓-c2先学会不把噪声当证据" aria-hidden="true">#</a>去 VM 里抓 C2：先学会不把噪声当证据</h2>
<p>绕过了反 VM、样本在 VM 里上线之后，接下来是拿到它到底连谁。<code class="inline-code">netstat -an</code> 出来一串 ESTABLISHED。<strong>如果直接按 IP 排序，<code class="inline-code">175.12.122.167</code> 会排在头号嫌疑</strong>——175.0.0.0/8 是中国段，看着最可疑。但我坚持做了一步"逐 PID 映射进程名"：</p>
<div class="table-wrap"><table><thead><tr><th>PID</th><th>进程</th><th>对端</th></tr></thead><tbody><tr><td>8140</td><td><strong>chat-deepseek.switch.exe</strong></td><td><strong>45.64.52.179:443</strong></td></tr><tr><td>9380</td><td><strong>chat-deepseek.switch.exe</strong></td><td><strong>45.64.52.179:443</strong></td></tr><tr><td>7428</td><td>SearchApp.exe</td><td>175.12.122.167:443</td></tr><tr><td>6892</td><td>WinStore.App.exe</td><td>23.199.2.97:443</td></tr></tbody></table></div>
<p>那个"头号嫌疑"其实是 <strong>Windows 搜索索引器</strong>。而 <code class="inline-code">45.64.52.179</code> 独占两条连接，<strong>两条都来自样本自身</strong>。铁律一条：<strong>不映射到进程名的 IP 排序，全是噪声。</strong></p>
<p>有了 IP 还要拿域名。查 DNS 缓存时又踩了一个坑：<code class="inline-code">ipconfig /displaydns</code> 里 <code class="inline-code">findstr &quot;Record Name&quot;</code> 零命中，我一度以为"DNS 缓存是空的"。真相是——<strong>中文 Windows 的表头是「记录名称」，不是英文</strong>。改成 <code class="inline-code">Get-DnsClientCache</code> 按 IP 反查（语言无关），立刻拿到：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>Entry : jd.macio-china.com
Data  : 45.64.52.179</code></pre></div>
<p><strong>C2 = <code class="inline-code">jd.macio-china.com</code> → <code class="inline-code">45.64.52.179:443</code>。</strong></p>
<p>顺手验证了一件事：两个进程 PPID 相同、命令行相同 → 是<strong>兄弟进程</strong>而非父子派生（排除了"loader 注入子体"的常规模型）；域名归属放到最后归因一节。</p>
<h2 id="日志把我静态里下的协议判断推翻了" class="md-heading"><a class="heading-anchor" href="#日志把我静态里下的协议判断推翻了" aria-hidden="true">#</a>日志把我静态里下的协议判断推翻了</h2>
<p>拿到 C2 之后，我一度很有把握地判定协议是 <strong>WSS（WebSocket over TLS）</strong>：内存里挖到了 RFC6455 的魔数 GUID <code class="inline-code">258EAFA5-E914-47DA-95CA-C5AB0DC85B11</code>，还有 <code class="inline-code">Sec-WebSocket-Key</code>、<code class="inline-code">Upgrade: websocket</code>，以及 <code class="inline-code">websocket.dll</code> 和 SChannel 相关串。看起来板上钉钉。然后 VM 里取到了两份<strong>明文日志</strong>（<code class="inline-code">ArmouryLogin.log</code> / <code class="inline-code">ArmouryStager.log</code>）：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>dial host=jd.macio-china.com port=443 proto=0 tmo=12000
dial OK port=443 tcp via=direct</code></pre></div>
<p>三个字把上面的判断推翻了：</p>
<ul><li><strong><code class="inline-code">proto=0</code></strong> —— 全程固定为 0。真走 WS/TLS 会是 1/2/3。</li><li><strong><code class="inline-code">tcp via=direct</code></strong> —— 拨号层明确报的是 <strong>tcp</strong>，不是 tls、不是 ws。</li><li><strong><code class="inline-code">dial OK</code> 到 <code class="inline-code">send_login</code> 之间没有握手步骤</strong>，全程 203ms，没有 TLS 往返开销。</li></ul>
<p>再加一个反证：我早先用裸 IP 去做 TLS 握手，对端<strong>直接断开</strong>——现在解释通了，<strong>因为那上面根本不是 TLS 服务</strong>。</p>
<p>最终定性：<strong><code class="inline-code">443</code> 端口上跑的是裸 TCP + 自研 ARPLWire 帧 + 自研加密</strong>（<code class="inline-code">ArmouryFrameV2-AUTH</code> / <code class="inline-code">ArmouryFrameV2-ENC</code> 两阶段帧，魔数 <code class="inline-code">AFR2</code>）。WS / SChannel 的代码<strong>编译在内但默认关闭</strong>——这正是 <code class="inline-code">transport_disabled</code> 的含义。<strong>防御意义很大</strong>：443 上没有 TLS，基于 JA3 / 证书的检测手段<strong>全部失效</strong>；选 443 就是为了混进 HTTPS 流量，又不吃 TLS 检测。日志还顺手给了故障转移阶梯：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>443 (tmo=12000) 失败 → 80 (tmo=5000) 失败 → backoff=200 → 回 443</code></pre></div>
<p><strong>所以封禁时 443 和 80 要一起拦，只封 443 它会掉到 80。</strong></p>
<h2 id="破解打包器从一把小钥匙到全域密钥律" class="md-heading"><a class="heading-anchor" href="#破解打包器从一把小钥匙到全域密钥律" aria-hidden="true">#</a>破解打包器：从一把小钥匙到全域密钥律</h2>
<p>线索来自磁盘：样本跑起来后在磁盘上落了三个文件——<code class="inline-code">a64.dll</code>（821,760 B）、<code class="inline-code">a64u.dll</code>（<strong>与 a64.dll 同 SHA256，说明更新槽没被写入</strong>），以及同样大小但头部是 <code class="inline-code">7D 6A</code>（不是 <code class="inline-code">4D 5A</code>）的 <code class="inline-code">a64</code>。同尺寸 + 明文可得 ⇒ <strong>直接算密钥流</strong>：<code class="inline-code">keystream = a64 XOR a64.dll</code>。手工核算前几行：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>a64.dll:  4D 5A 90 00 03 00 00 00 04 00 00 00  | FF FF 00 00 B8 00 …
a64    :  7D 6A F0 89 86 9D A3 A5 73 84 74 30  | 9F 76 85 9D 1B A5 …
XOR    :  30 30 60 89 85 9D A3 A5 77 84 74 30  | 60 89 85 9D A3 A5 …
                              └──── 周期 10 ────┘</code></pre></div>
<p>从偏移 2 起出现周期 10 的重复。这是<strong>已知明文攻击</strong>，不需要知道任何算法。</p>
<p>回到投递器本体。第二个 <code class="inline-code">.rdata</code>（那 1.75MB RWX 节）我按 256KB / 1MB / 256KB / 256KB 切成四个槽，逐个看占用长度：</p>
<div class="table-wrap"><table><thead><tr><th>槽</th><th>节内偏移</th><th>实际占用</th><th>减去 13 后</th></tr></thead><tbody><tr><td>1</td><td><code class="inline-code">0x040000</code></td><td>821,773</td><td><strong>821,760</strong> ← a64.dll</td></tr><tr><td>2</td><td><code class="inline-code">0x140000</code></td><td>168,461</td><td><strong>168,448</strong> ← 守护器</td></tr><tr><td>3</td><td><code class="inline-code">0x180000</code></td><td>144,397</td><td><strong>144,384</strong> ← stub</td></tr></tbody></table></div>
<p>三个槽"多出来的部分"都精确等于 13 字节。围绕这 13 字节，我纠正了自己两次：</p>
<p><strong>第一，它不是二进制结构，是 NUL 结尾的 ASCII 角色标签。</strong> 我一开始当成"长度/校验字段"穷举，一无所获；换成用同一把密钥直接解这 13 字节，得到可读英文：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>槽1 → loginpattern\0     （a64.dll，C2 主体）
槽2 → guardpattern\0     （守护器）
槽3 → stubpattern\0+     （进程镂空注入器）</code></pre></div>
<p><code class="inline-code">.text</code> 里还有独立的交叉引用可以证实：<code class="inline-code">0x626A–0x629A</code> 逐字节比对槽 2 的首地址和 <code class="inline-code">&#039;g&#039;,&#039;u&#039;,&#039;a&#039;,&#039;r&#039;,&#039;d&#039;,&#039;p&#039;</code>，<code class="inline-code">0x62DE–0x630E</code> 比对槽 3 和 <code class="inline-code">&#039;s&#039;,&#039;t&#039;,&#039;u&#039;,&#039;b&#039;,&#039;p&#039;,&#039;a&#039;</code>。加载函数在 <code class="inline-code">+13</code> 处取址——和标签长度完全吻合。</p>
<p><strong>第二，"每个模块一套密钥"是错的，真相是一把钥匙统治整个文件。</strong> 我原以为三个模块各自从相位 0 开始异或，推广验证后的规律是：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>plain[file_off] = cipher[file_off] ^ KEY8[ (file_off + 3) mod 8 ]
KEY8 = 64 D8 0F 5A 3C 91 E7 2B</code></pre></div>
<p>之所以会看成"每模块相位 0"，是因为三个模块的 raw 起点 <code class="inline-code">0x6A000</code> / <code class="inline-code">0x16A000</code> / <code class="inline-code">0x1AA000</code> <strong>刚好都 ≡ 0 (mod 8)</strong>，偏移量恰好被吸收成了相位 3。最强旁证是：这条规律在<strong>完全不同的节</strong>（<code class="inline-code">.data</code> 配置表，偏移 <code class="inline-code">0x27600</code>）上也成立——同一把密钥解出 8 条语义完整的配置串。这不可能是巧合。</p>
<p>最后是<strong>第四份载荷</strong>。那个 256KB 的槽 0 我一开始当成"稀疏工作区"跳过（每 4096 字节只有 ~617 个非零字节）。直到把"非零字节总数"算出来：<strong>39,464</strong>——和 <code class="inline-code">payload.bin</code> 明文的 <strong>39,541</strong> 只差 77。追下去发现它就是用<strong>另一把独立密钥</strong>（<code class="inline-code">2C 30 FE F5 74 5F EA 42 A4 23 EE 49 6A 82 96 73</code>，16 字节）加密的 <code class="inline-code">payload.bin</code>，布局是 <strong>64 段 × 618 字节、步长 4043、首段起点 3425</strong>，有效载荷率只有 15.1%。</p>
<p>这里我踩过一次陷阱：一开始用"过滤掉所有零字节"的办法还原，得到 39,464 字节——<strong>错了</strong>。因为密文里有 <strong>77 个字节恰好等于 <code class="inline-code">0x00</code></strong>，和间隙的填充零在视觉上无法区分，会被一起滤掉；必须按<strong>段几何</strong>（固定起点 + 固定长度）重建。改过来后，重建结果与原始槽 0 <strong>逐字节相同</strong>，SHA256 精确等于 VM 侧那份 <code class="inline-code">payload.bin</code>。把 39KB shellcode 切成 64 段散布在 256KB 里，意图很明显：<strong>让文件里不存在任何 ≥618 字节的连续 shellcode 片段</strong>，废掉字节序列特征码。</p>
<h2 id="overlay解不开的时候把边界划清楚也是结论" class="md-heading"><a class="heading-anchor" href="#overlay解不开的时候把边界划清楚也是结论" aria-hidden="true">#</a>overlay：解不开的时候，把边界划清楚也是结论</h2>
<p>前面"C2 外置"那段把注意力推到了最后一块没打开的地方：文件末尾有 <strong>50,932 字节不属于任何节</strong>（算法：文件总长减去最后一个节的结束偏移，算术自证）。它前 8 个字节是标准 PNG 签名 <code class="inline-code">89 50 4E 47 0D 0A 1A 0A</code>——看起来像图片。但它<strong>第 9 个字节起就破坏了 PNG 结构</strong>：规范要求紧接着是 <code class="inline-code">00 00 00 0D</code> + <code class="inline-code">IHDR</code>，实测完全不符；解析 chunk 长度得到 <code class="inline-code">len=321167813</code>（非法），没有 IHDR 也没有 IEND。所以那 8 字节只是<strong>明写的诱饵魔数</strong>。</p>
<p>接下来大量排除，一共试了 <strong>11 条路径</strong>：</p>
<div class="table-wrap"><table><thead><tr><th>攻击</th><th>结果</th></tr></thead><tbody><tr><td>单字节 XOR / 周期 1–16 重复密钥 XOR（用已知 IHDR 反推）</td><td>密钥互不一致；或解出的 comp/filter/interlace 非 0 → 排除</td></tr><tr><td>IoC 逐周期扫（p 到 1024）+ ECB 重复 16 字节块</td><td>全落在随机基线；3,183 块重复 <strong>0</strong></td></tr><tr><td>zlib/gzip/bz2/lzma（off 0–23）、RC4 × 46 密钥、复刻多态引擎、模块密钥 8 相位、K16 全 16 相位</td><td>全失败；K16 解完熵仍 7.99645</td></tr></tbody></table></div>
<p>统计量给出了终局答案：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>body = 50,924 B（去掉 8 字节魔数）
entropy = 7.99636   真随机期望 = 7.99639      ← 差 3×10⁻⁵
chi²    = 256.0  (df=255, 临界值 293 @p=0.05)  ← 教科书级均匀</code></pre></div>
<p><strong>它统计上不可区分于真随机。</strong> 到这里我没有假装把它解开，而是给了一个有边界的结论：它要么是强加密（密钥运行时派生），要么就是<strong>纯随机填充</strong>——后者的话，那 8 字节 PNG 魔数就是纯粹的诱饵。我倾向"随机填充"：这个 builder 在所有<strong>已知</strong>的加密环节都只用弱 XOR，overlay 如果真是关键配置，没理由独独升级密码强度；而且全文检索不到任何引用 <code class="inline-code">50932 / 0x1F0C00</code> 这个尺寸的代码。<strong>但在拿到第二份同源样本做差分之前，我不把这条写成结论。</strong></p>
<p>这段看着像"没做完"，其实是我最有把握写进报告的部分之一：<strong>知道自己卡在哪条边界上、并能证明这条边界在哪，比给一个含糊的"应该是加密配置吧"有价值得多。</strong></p>
<h2 id="归因基础设施能定代码血统不能硬套" class="md-heading"><a class="heading-anchor" href="#归因基础设施能定代码血统不能硬套" aria-hidden="true">#</a>归因：基础设施能定，代码血统不能硬套</h2>
<p>归因我拆成两层，用完全不同的证据标准。</p>
<p><strong>基础设施层：证据很硬。</strong> C2 域名注册商是 <strong>Gname.com</strong>（IANA 1923，长期位居恶意域名注册量榜首）；域名注册于 2026-06-07、只有 4 个月；<code class="inline-code">macio-china.com</code> 仿冒 <code class="inline-code">macio.com.cn</code>，子域 <code class="inline-code">jd.</code> 再仿冒京东；<code class="inline-code">crt.sh</code> 返回空（零证书透明度记录，说明它从不申请公开 CA 证书，与"TLS 握手被拒"互相印证）。和 ReliaQuest《Silver Fox》报告对一遍：<strong>Typosquatting、Gname.com、子域承载 C2、短命新域名、无公开证书、香港 IDC 托管——六项全部吻合。</strong></p>
<p><strong>代码血统层：我给不出，而且我拒绝硬套。</strong> 我逐条复核最初"像银狐"的依据：有没有 Gh0st 源码痕迹？<strong>0 条</strong>。RTTI 显示的是 <code class="inline-code">armoury::transport::ITransport</code> + C++17 的 <code class="inline-code">std::function</code>/lambda/<code class="inline-code">shared_ptr</code>——和 Gh0st 系那种"无命名空间、类名形如 <code class="inline-code">CClientSocket</code>"的老式 C++ 完全对不上；内部命名 "Armoury" 在公开情报里也查不到任何归因。所以我的写法是：<strong>代码层是一个自研的 C++17 远控框架，对外宣称代号 Armoury，与已知家族无代码同源证据；基础设施层高度符合银狐生态。</strong> 我会把它交给 CNCERT 做代码比对，而不是自己拍板说"这就是银狐"。</p>
<p><strong>中间还有一次必须做的证伪。</strong> 公开情报里确实有三个 "Armoury"：微软的检测名 <code class="inline-code">Trojan:Win32/Armoury</code>，安天的 ArmouryLoader（x86、劫持华硕 Armoury Crate 的 <code class="inline-code">ArmouryA.dll</code>、用 OpenCL/GPU 解密），以及 Zscaler 记录的 CoffeeLoader 的 "Armoury" 壳。名字撞得很厉害。我做了五个阴性检测：<code class="inline-code">OpenCL</code>、<code class="inline-code">clCreateBuffer</code>、<code class="inline-code">ASUS</code>、<code class="inline-code">ArmouryA</code>、<code class="inline-code">freeBuffer</code>——5 个文件里<strong>全部 0 命中</strong>；而且本样本是 <strong>x64 原生</strong>（不是 x86），注入手法是 <code class="inline-code">Nt*</code> 进程镂空 + <code class="inline-code">notepad.exe</code>（没有天堂之门/系统调用号搜索）。<strong>结论：命名碰撞，非同源，归因时不能合并。</strong> 偷懒把两个 "Armoury" 当成一家，整个归因就毁了。</p>
<p>顺带说个容易被当成"归因铁证"的东西：三个模块里都有个叫 <code class="inline-code">.fptable</code> 的节，我一度想拿它当家族锚点——但它在所有模块里<strong>都是 512 字节全零</strong>，而且多个<strong>互不相关</strong>的恶意家族里都有同名同形的空节。那是构建工具链留下的，不是团伙留下的：<strong>能当"狩猎 pivot"，不能当归因。</strong></p>
<h2 id="收尾把样本和补丁做成一个能安全复现的释放器" class="md-heading"><a class="heading-anchor" href="#收尾把样本和补丁做成一个能安全复现的释放器" aria-hidden="true">#</a>收尾：把样本和补丁做成一个能安全复现的释放器</h2>
<p>分析写完了，但东西交不出去。手上有<strong>五个文件</strong>：原始样本 + 四个补丁版（<code class="inline-code">switch</code> 1 字节 / <code class="inline-code">nop</code> 3 字节 / <code class="inline-code">kill</code> 5 字节 / <code class="inline-code">all</code> 9 字节）。直接扔文件夹有两个问题：<strong>误触</strong>（手滑双击就真跑了）、<strong>留不住</strong>（本机实时防护会在落盘后约 1 秒内按<strong>内容特征</strong>删掉，改后缀也没用）。</p>
<p><strong>打包：</strong> AES-256 压缩包，口令 <code class="inline-code">infected</code>。坑在于<strong>必须在内存里拼完再写出去</strong>——先落一个临时明文文件再压缩的话，实时防护会在压缩途中把它删掉，包就残缺了。流程：读原文件 → 校验 SHA-256 → 内存里 <code class="inline-code">writestr</code> → 一次写出 → 再从内存回读逐条比对。</p>
<p><strong>自包含启动器：</strong> 解压出来还是五个独立文件，照样被吃；干脆把五个样本作为 <strong>RCDATA 资源</strong>塞进一个 EXE（成品 10,467,328 字节），运行时再释放，<strong>磁盘上不存在"单独的样本文件"</strong>。核心是<strong>三道关</strong>：① 免责声明默认按钮是「<strong>不同意，退出</strong>」，回车即放弃且不释放任何文件；② 默认勾着「<strong>仅释放到磁盘，不要运行</strong>」；③ 取消安全选项后才出现的最后确认。<strong>手滑双击不会跑起来。</strong></p>
<p>几个细节：释放文件<strong>保持原名</strong>（样本从自身路径推导守护参数，改名会改行为）；<strong>防自覆盖</strong>（启动器若叫 <code class="inline-code">chat-deepseek.exe</code>，自动转存 <code class="inline-code">payload\</code>）；<strong>被杀软删掉要说清楚</strong>（<code class="inline-code">Sleep(1200)</code> 后核对大小，对不上就弹「被实时防护拦截」）。另外留了两个模式：<code class="inline-code">--verify</code> <strong>不写盘、不执行</strong>，只从内存资源算 SHA-256；<code class="inline-code">--extract &lt;目录&gt;</code> 纯命令行释放。<strong><code class="inline-code">--verify</code> 和做静态分析的纪律是同一个思路：证明字节是对的，但一个字节都不落地、也绝不运行。</strong></p>
<p><strong>先拿无害测试载荷（只弹一个消息框）跑通全链路——不同意→不释放、仅释放不运行、真启动、改名自覆盖、<code class="inline-code">--verify</code>/<code class="inline-code">--extract</code> 哈希核对，七个场景全过才换真实样本重建。</strong> 构建坑：<code class="inline-code">windres</code> 要加 <code class="inline-code">-c 65001</code>（否则中文目录名按 CP936 解码找不到）、<code class="inline-code">.rc</code> 必须写 <code class="inline-code">#define IDR_PAYLOAD_*</code>（否则被当字符串资源名）、链接 <code class="inline-code">-municode -mwindows -static -lbcrypt -lshell32</code>。顺手还写了个只读的本机 IOC 排查脚本——每条判据都是从前面那些分析结论来的。</p>
<p>交付物：</p>
<div class="code-block" data-lang=""><div class="code-head"><button type="button" class="code-copy" data-copy aria-label="复制代码">复制</button></div><pre><code>银狐样本启动器.exe  10,467,328 B  sha256 10c91cc3cd88c6a375d5dbcf427b2dcde4ab436ef1a131b5a5818c1b4f46cdb5
样本包.zip          4,594,752 B   sha256 595589ebf36b332b032877288d616da42bf36c684f98784bc747669e94ddafd6  （AES-256，口令 infected）</code></pre></div>
<p>五个变体哈希在包里和 <code class="inline-code">--verify</code> 报告里是同一组（<code class="inline-code">68ae0cab…</code> / <code class="inline-code">8879984a…</code> / <code class="inline-code">31620c4b…</code> / <code class="inline-code">30927d0a…</code> / <code class="inline-code">bd93e88f…</code>），两处对得上。</p>
<h2 id="复盘这几条纪律是踩出来的" class="md-heading"><a class="heading-anchor" href="#复盘这几条纪律是踩出来的" aria-hidden="true">#</a>复盘：这几条纪律是踩出来的</h2>
<p><strong>一、读到最后一章再下判断。</strong> 这份分析里"为什么双击没反应"被推翻了四次，"协议是什么""各模块是不是一套密钥""高熵区是不是密文"各被推翻过一次。只读前三分之一就写总结，写出来的东西会全错。</p>
<p><strong>二、工具会给你"看起来正常"的错误答案。</strong> 线性反汇编遇跳转表会静默失步，把"30 处引用"报成"零引用"；把 <code class="inline-code">VirtualSize</code> 读成 <code class="inline-code">VirtualAddress</code> 会让导入表解析落到空区，然后你判"它加壳了"。工具不报错不代表它是对的——<strong>最终以字节为准</strong>。</p>
<p><strong>三、高熵不等于密文，随机也不等于随机。</strong> 字节表可以熵 7.9；真随机要用 χ² 和熵的偏离度验。反过来，我一直以为的高熵密文最后发现是 8 字节短周期 XOR——<strong>"高熵 ⇒ 强加密"这个直觉在本案被实证推翻了。</strong></p>
<p><strong>四、搜到 2 字节魔数先算随机期望命中数。</strong> <code class="inline-code">4D 5A</code> 在 1.75MB 里的随机期望命中约 28 处——单次命中就是噪声级别。判据必须是长串或整文件哈希。</p>
<p><strong>五、不映射进程名的 IP 排序全是噪声。</strong> 那个中国段 IP 一度是头号嫌疑，实际是 Windows 自带进程。</p>
<p><strong>六、能当 IOC 的东西，先问"它会不会变"。</strong> 文件名从 12 个里随机抽，真正的锚点是路径形态和命令行参数。同理，静态串与运行态产物对得上号（<code class="inline-code">ARMGID833B463E</code> ↔ <code class="inline-code">G-833B463E</code>）才是最强证据——<strong>这种互证我遇到四五次。</strong></p>
<p><strong>七、操作系统会骗你，是因为它没说英文。</strong> 中文 Windows 的 <code class="inline-code">ipconfig /displaydns</code> 表头是「记录名称」；PowerShell 5.1 不支持三元运算符；<code class="inline-code">.ps1</code> 双击默认不执行。每个都能让你得出错误结论或浪费一整天。</p>
<p><strong>八、取证产物不要落在受快照管辖的盘上。</strong> 我在虚拟机里跑完采集、打完快照后做硬盘还原，<code class="inline-code">C:\forensics</code> 连同产物一起被回滚吃掉了——白做一遍。<strong>每跑完一个阶段立刻外传。</strong></p>
<p><strong>九、交付物也要设计"防误触"和"防被吃"。</strong> 样本直接放文件夹里，可能被误双击，也会被实时防护<strong>悄无声息删掉</strong>。所以做成了"内存里打包 + 内嵌 EXE + 默认选项偏安全"的释放器——<strong>默认值不是随手定的，它是最后一道防线。</strong></p>
<p>最后一句实话：这份分析能推进到 C2 和归因，靠的不是某个聪明方法，而是<strong>每次得出结论后都逼自己写清楚"什么证据能推翻它"</strong>。每一条被推翻的结论，都是下一次更精确的起点。真正拿到手的，都是被推翻到只剩骨架后还站着的那些。</p>
<p>样本下载:<a href="https://wwaux.lanzoul.com/b00jflpl5i" target="_blank" rel="noopener noreferrer"><a href="https://wwaux.lanzoul.com/b00jflpl5i" target="_blank" rel="noopener noreferrer">https://wwaux.lanzoul.com/b00jflpl5i</a></a></p>
<p>密码:4a97</p>]]></content:encoded>
  </item>
</channel>
</rss>