首页
社区
课程
招聘
[原创]木马的密文里,出现了我的窗口标题:从主机取证、内存逆向到 C2 协议与渗透尝试复盘
发表于: 2026-9-21 04:37 2609

[原创]木马的密文里,出现了我的窗口标题:从主机取证、内存逆向到 C2 协议与渗透尝试复盘

2026-9-21 04:37
2609

最让人后背发凉的,并不是屏幕上跳出一个陌生进程,而是从它的密文里读到了自己熟悉的窗口标题。
然而,真正把这次分析拖进深水区的,是另一个看似令人安心的结果:密文解开了,中文也出来了,验证程序还在不断打印成功。

这次排查恰好走到了这一步。调查目录里已经积累了一组载荷文件、几份内存提取件、几十个通信文件,还有一份写着“协议权威实现”的 Python 脚本。它对自己的结论相当有把握:字段含义已经确认,八份样本逐字节匹配,结果是 EXACT。

如果故事停在这里,很容易写成一篇漂亮的战报:木马现形,密钥到手,协议攻破。

可是,把一个只有 84 字节的文件重新拿出来计算,另一件事发生了。

同一份数据,在同一次运行里,一边“精确通过”,一边连自己声称的长度关系都对不上。

这才是本文真正的起点。

木马的密文里确实有本机窗口标题。看到这些原本属于桌面的字串,出现在另一个程序组织的消息中,已经足够让人警觉。但要弄清楚它究竟做了什么,还得穿过另一层东西:那些看起来专业、彼此还能互相印证的错误解释。

这篇文章会尽量保留这段过程里的摩擦:为什么先怀疑 HTTP,为什么 AES 解开以后 UTF-8 仍然报错,为什么四个字节会像幽灵一样插在中文中间,以及为什么一份“全部通过”的回归测试,仍然没有证明协议正确。

这篇复盘沿着三条线展开:主机上留下了什么,内存中的程序怎样组织消息,以及远端探测究竟回答了多少问题。AI 参与了材料梳理、代码编写和核验,但每一个结论仍要回到文件与字节。

文中代码与响应分别标明历史记录、离线重跑和人工测试。 主要实验均实际运行,完整脚本与输出随文附上;读者可以沿着同一份短帧,把关键步骤重新走一遍。

最初能抓住的线索并不复杂:本地微信文件目录中留着一个压缩包,本文以脱敏名称“某某地区招聘数据.zip”指代。后来核查发现,一共有三份内容相同的 ZIP,每份里面都只有一个 MSI,展开后的安装文件长度为 4,411,392 字节。

这不是根据文件名猜的。先前复核读取了 ZIP 的条目,对 ZIP 本身和其中的 MSI 分别计算了 SHA256。三份包的指纹相同,包内 MSI 的指纹也相同。

与此同时,保存的载荷副本中有两个名字:SeGyvmDwXftw.exe 和 SingMusice.exe。它们看上去不像同一个东西,算出来却是同一个 SHA256:

先前取证报告还记录了 FindOversee、ChangeHunt 两项任务,任务动作指向载荷,配置身份为 SYSTEM,重复间隔分别为 61 分钟和 90 分钟。历史进程日志中则出现了相关进程的多个实例。

这些材料值得重视,因为文件、任务和进程能够在同一条线索上互相对应。随机名称本身没有证明力;名字像正版软件也没有免责作用。面对一组未知文件,最好先回答“它是什么内容、谁启动它、它做了什么”,再考虑给它贴标签。

这里还埋着两个容易写过头的地方。

第一,ZIP 位于微信目录,并不能单独证明谁是发送者,更不能证明发送者就是木马操作者。聊天原件、转发过程和实际执行经过仍然需要单独核实。

第二,早期报告记录了 3 月 11 日的安装和落地线索,9 月 20 日又有相关进程记录。这是相隔约半年的两组证据,不是一份覆盖六个月的键盘日志。把“最早发现于三月”写成“连续监控半年”,中间缺少的恰恰是需要证明的部分。

两项任务在清理中已经删除,留下的是当时的检查记录和备份材料。后续复核只能从这些材料继续向前。原件、历史输出和分析结论看起来都可以是一份文件,证据分量却不同。

协议解开以后,很容易产生一种错觉:只要消息足够清楚,感染经过也就跟着清楚了。实际上,从“窗口标题被组织进消息”回到“这个程序何时、由谁、通过什么方式开始运行”,中间仍然隔着安装包、文件系统、任务配置和进程记录。

把安装包、落地文件和任务排在一起,时间线似乎已经成形。可当我回到 PE 分析文本,第一处裂缝就出现在最不起眼的日期里。

以下是 raw/SeGyvmDwXftw.exe-pe.txt 的历史输出节选,保留原文:

早期分析曾把 2026-03-09 15:54:39 当作 PE 编译时间。对照字段后才发现,它对应的是文件修改时间;PE 头的 TimeDateStamp 对应本地时间 15:07:38。此外,同一份文件记录的创建时间是 5 月 26 日。这三种时间不能揉成一个“感染时间”。

报告之间出现矛盾,最直接的办法是回到文件本身。我重新读取了隔离区保存的 PE 字节。脚本没有加载或执行它,只是从 DOS 头找到 PE 头,再读取 COFF 时间戳:

实际读取结果:

这个结果修正了字段混用,但仍不等于找到了真正的编译时刻。PE 时间戳可以被修改;文件时间也会受到复制、恢复、安装和人为设置的影响。这里能确认的是“保存的文件头里写着什么”,而不是攻击者电脑在那个时刻发生了什么。

同样,微信目录里存在 ZIP、安装信息中出现 MSI 产品名、任务配置带有三月的起始日期,可以放在同一条调查时间线上;但要证明“某人在某时发送,用户在某时点击执行”,还需要聊天原件、安装记录和执行痕迹相互对应。截图时间、文件属性时间、任务触发器时间,各自回答的是不同问题。

取证最容易显得“不够爽”的地方就在这里:每当故事快要连成一条漂亮直线,就要停下来检查箭头。可真正能交给读者复查的,恰恰是这些停顿。

这一组落地文件很有迷惑性。有人负责看起来正常,有人负责名字混乱,还有人借了一个系统扩展名,却连对应文件格式都不是。

SodaMusicLauncher.exe 的历史检查结果为:

随机长文件名 TDIEZTJwHlbXkDcWpTqcYxbixDLYfM 的历史检查结果则是:

这两段输出来自保存的原始分析文本,不是这次离线复核重新联网验证证书。签名状态有其采集时点;产品信息和签名主体也不是同一个字段。例如宿主的版本信息写着 Beijing Douyin Technology Co., Ltd.,证书主体另有自己的名称,不应随手替换成一个笼统的公司名。

更重要的是,签名只能帮助核查文件来源与完整性,不能替启动上下文背书。合法程序出现在异常落地目录、邻近可疑 DLL、被异常链条启动,仍值得调查。反过来,只凭它和恶意载荷摆在一起,也不能证明某次 DLL 侧加载确实完成了。要把“白加黑”从候选解释写成具体过程,还需要加载模块、路径解析、导入调用或者动态记录。

同样,wnbios.sys 的原始名称、签名和文件特征支持把它列入驱动滥用线索,但不能单独证明内核杀软已经发生。驱动存在、服务注册、成功加载、设备句柄打开、危险操作执行,是逐步加深的证据。在缺少加载和调用证据时,分析只能停在驱动滥用线索,尚不能写成“已经在内核层终止安全软件”。

另一边,asnmkMCG.sys 在历史 PE 检查里得到的是:

这里 .sys 只是文件名的一部分。非 MZ 开头、PE 偏移越界,使它不符合那次检查所期待的常规 PE 驱动结构。高熵可以提示继续考虑压缩、加密或其他编码,但不能直接指定算法,更不能得出“静态绝对无法分析”。如果能恢复读取它的加载器及密钥路径,静态分析仍可能推进;内存提取只是本案已有材料中更直接的一条路。

把这些东西统称为“木马组件”很省字,代价却是抹掉技术差别。真正值得逆向的是它们之间的调用关系:谁启动谁,谁读取谁,谁把磁盘上的字节变成内存中的程序。

历史任务检查记录给出了两条不同的启动入口:

来源是 t2-report.md 中的历史任务复核;这里是归纳表,不是终端原文。两项配置还记录了隐藏属性。Microsoft 风格的任务目录和 SYSTEM 身份,会使普通用户更难凭名称判断异常,但它们不等于系统组件的真实性证明。

这些材料足以说明存在两套周期启动条件。至于它们是否会“删除一份,另一份就重新释放”,还需要另一类证据:删除后的文件重建事件、负责写入的进程、被调用的释放代码,或者连续观测日志。双份文件与双条任务本身,没有证明相互修复。

取证时可以将任务 XML 中的动作、主体、触发器、设置,与任务调度服务拉起的进程对应起来。清理后再做检查,回答的是另一道题:这些已知启动点是否还存在。它不能补回清理前没有采集的执行历史。

早期记录提到部分审计通道未启用、事件日志已经滚动覆盖。因此“查询没有命中”不等于“行为没有发生”。类似地,某一时刻进程没有网络端点,或者某项 I/O 计数为零,都不应扩写为“半年从未发送任何数据”。窗口快照、单个进程实例与整个感染周期,不能互相替代。

逆向之前还有一道看似琐碎的工序:把输入整理明白。

原调查目录包含不同版本的监听器、协议探针、早期猜测、清理脚本和多份报告。先前目录快照统计到 456 个文件,其中有 74 个脚本。文件不断增加,“最新一份报告”也未必是最可靠的一份。

历史沉洞报告明确列出了自测流量:GET /selftest HTTP/1.1、GET /selftest2 HTTP/1.1,以及其他用于检查监听器的请求。它还记录了重复写盘问题。

这件事对协议识别的影响很直接。如果在整个目录中搜 HTTP/1.1,当然能够搜到。但这只能说明目录里存在 HTTP 文本,不能证明这些字节由木马发送。调查者发出的健康检查,也可能成为后来分析中的“协议证据”。

所以本篇把范围收紧到现存的 23 个 frame-*.bin。不把早期 HTTP 自测和主动伪造的信标混进来,再按文件内容哈希去重。

以下函数来自附件 code/lab_walkthrough.py,ctx["frames"] 是已经读入的 (文件名, 字节内容) 列表。读取的是数据,不启动这些文件:

对本机授权原始目录重新运行,得到:

两个重复组把 23 个文件压缩成了 21 份不同内容。日志对重复落盘也有对应说明。

这一步解决的不是密码学问题,却决定了后面统计的对象。两个同内容文件,可能来自重复写盘,也可能来自协议重传;单凭哈希相同,还不能替它们选择原因。这里的重复写盘解释来自相关历史日志,而不是由哈希直接推导。

同样,文件名中的 19:17:56 是文件命名时间。监听日志里可能先收到内容、隔了几秒才落盘。用这个时间给攻击者画毫秒级行为轨迹,看上去精密,实际上已经离开了证据。

我希望 AI 在这一阶段产出的首先是一份可检查的清单:文件多少、来源是什么、哪里重复、哪些是自测、哪些只有二次报告。这样它后面提出的每个解释,才有明确的输入范围。

如果全文只有几段代码和作者提供的截图,读者仍然只能相信作者。

附件因此单独选出了一份原始帧,编号 F01。它解密后的内容是:

这条记录没有个人浏览标题,适合展示完整解析。公开附件中的 data/F01-original.hex 是它的原始字节十六进制文本,内容未经重加密或重新构造,SHA256 可与原文件核对。它是选取的真实证据,不是人工测试帧。

其余真实帧没有整包公开,因为它们能恢复私人窗口标题。人工构造的数据则放在另一个目录 synthetic_examples/,文件名明确包含 SYNTHETIC。

这两类东西用途不同:F01 可以让读者验证这条真实记录是否被正确解释;人工样本让读者检查解析器遇到长字符串、非 BMP 字符和错误输入时怎么表现。人工数据无论多么“像”,都不能用来证明电脑曾经被窃取了什么。

这次离线复核实际运行环境如下,完整输出见 runs/full-original-run.json:

公开附件可以直接运行下面的命令:

脚本使用排他创建写结果,my-f01-run.json 必须尚不存在。重复运行请换一个输出名称。解析过程没有网络请求;安装依赖是另一项操作,应使用可信包源。

正文中 23/23 的输出来自持有全部原件时的运行。公开包默认只读 F01,因而对应输出会是 1/1,去重结果也是 1。这不是复现失败,而是输入范围不同。两套真实运行结果都随附件保存,避免读者被这个差异绊住。

选定 F01 以后,先不猜加密算法,也不猜消息含义。第一步只看文件长度和前四字节。

真实输出:

前四字节为 50 00 00 00。按小端解释是 80,文件剩下的字节数恰好也是 80。按大端解释则是 1,342,177,280,显然不能直接作为这份文件中实际存在的后续字节数。

这还不是对所有消息形式的证明,但足以把这个样本的工作假设定为:

逐份核对其他文件,外层长度关系也能够成立。这个假设的好处在于它容易失败:只要出现一个原始文件,声明长度与实际内容对不上,就要追查截断、多帧拼接、额外尾部,或者协议假设本身。

相比之下,“这是 HTTP 的某种特殊变体”就容易滑向不可证伪。F01 并不以 GET 、POST 或 HEAD 开头,早期调查记录又明确记录了一批 HTTP 自测请求。把自测从输入中移除以后,HTTP 假设已经没有理由继续占据中心位置。

不过,也不能因为这一个样本不是 HTTP,就断言整个恶意程序没有任何 HTTP 路径。它可能包含更新、下载或备用通信逻辑。当前正在解析的消息格式,与整个程序所有网络能力,是两个范围。

这也是写技术文章时很容易漏掉的限定:代码检查了什么,结论就先落在什么范围上。

未知二进制出现时,计算信息熵是一种常见的观察手段。旧材料里也有类似分析。但我在复盘时特意加了一项小实验,因为这里太容易出现“数值很科学,结论却跳得很远”的情况。

运行结果:

这里只有 64 字节密文。用频数直接估计熵,最多只能观察到 64 种不同符号;即使每个字节都不重复,这种估计的上限也只是 log2(64) = 6,不会达到 8。

所以,拿这份短样本的 5.656250 去套“接近 8 才是加密”的规则,本身就不合适。

更根本的问题是,熵值不能替我们认出 AES。压缩数据也可能看起来杂乱,随机 IV 加上短密文更不适合仅凭统计量推断全部结构。不同算法、不同采样长度和不同消息内容,都可能给出相似的数字。

这一步保留下来的价值是描述输入分布,而不是宣布算法身份。后面真正支撑 AES-CBC 假设的,是明确的候选密钥线索、块长度、严格填充检查,以及解密后能够继续被完整结构解释的数据。

我把这个小实验写出来,是因为它很能说明 AI 辅助分析中的一种风险:模型能够迅速生成“熵很高,所以是某种加密”的顺滑解释,但公式背后的采样前提仍然需要逐项检查。让它打印样本长度和理论上限,比让它再写一段更自信的判断更有用。

在流量之外,先前调查保存了一份从内存中提取的映射文件。为了确认本文提到的技术线索不是只存在于报告措辞中,我又对这份文件做了简单的字节搜索。

这里没有加载程序集,没有运行其中的方法,也没有重新附加受感染进程。只是读取文件内容,计算指纹并寻找特定字符串:

运行命令与实际输出:

BSJB 标记、命名空间以及 SafeQuickLZ、NetSerializer 等字符串,为下一步查找格式提供了方向。它们与后面原始字节的解释相互吻合,比单纯根据文件扩展名猜测更有价值。

但这段代码只证明相应字节序列出现在这个提取件里。它不是完整的 .NET 元数据解析器,更没有证明每个同名方法都可达、都执行过。

例如 HandleGetPasswords 和 HandleGetKeyloggerLogs 的存在值得警惕,提示程序含有相关功能线索。要进一步写成“密码已经上传”“键盘记录运行了半年”,仍然需要调用、取数、文件或通信证据。即使在技术帖中,这一步也不能用惊悚措辞替代。

另外,GetCurrentActiveWindowInfo 与 GetAccountType 是不同的线索。前者引导我们关注窗口标题,后者指向账户类型相关逻辑。看到一条消息里出现“管理员:”标题,并不能把整个消息首字节也解释成账户权限。这个混淆,在后面的旧解析器里恰好发生了。

前文的字节搜索只是方向。要进一步讨论 .NET 程序结构,需要弄清这些名称处在哪一层:它们是普通文本、元数据中的标识符,还是用户字符串堆中的字面量?

内存提取件名为 dumped-36678550-mapped.bin。这个名字提醒我们,它已经是派生文件。磁盘 PE 的原始偏移、映射映像中的 RVA、完整进程转储里的地址,不一定相同。把某个报告给出的 RVA 直接用作另一份文件的偏移,可能会读出零,也可能读到无关数据。

这次离线复核先做一个范围较小、可以重复的检查:定位 BSJB,读取版本串长度和元数据流目录,并确认每个流的边界没有越出当前文件。核心代码如下,完整版本在 code/supplement_audit.py:

真实输出如下。这里的 relative_offset 相对元数据根,不能拿来冒充原始进程虚拟地址:

这比孤立地发现四个魔数字节多了一层结构支持:版本串可读,流名组成合理,目录能被逐项遍历,所声明的范围位于文件内。但它还不是完整的 CLR 校验器,更没有验证每个方法体都完好。

历史 dnmeta-analysis.md 在这份提取件上记载了程序集 Client、模块 Client.exe,以及 333 个 TypeDef、1,252 个字段、1,978 个方法和 846 条用户字符串。这些数量是历史解析报告的结果;这次离线脚本只复核元数据流目录,没有重新计算所有表行数。

这里有一个很实用的区分:#Strings 常能帮助恢复名称,#US 是查找用户字符串字面量的重要位置;找到 ENCRYPTIONKEY 这个字段名,与找到赋给它的具体值,是两件事。再加上方法体不完整,文本上相邻的两个字符串,也不能自动认定在执行时发生过赋值。

所以,命名空间与方法名适合作为后续定位入口,而不是一张“已经发生全部窃密行为”的清单。HandleGetPasswords 可以让分析转向该处理器,但要证明密码被实际读取并传走,还需调用、数据来源和传输记录。

逆向线索在 AES.PreHashKey 这里收拢。散落在字符串里的密码学名称,开始连成一条具体的数据路径。下面是 t7-findings.md 保存的历史反汇编节选:

把这段调用序列翻译成数据关系,就是:参数字符串先变成 UTF-8 字节,再经过 MD5,结果存入 _key。它解释了为什么“数字字符串直接补零”与“先 SHA256 再截断”是不同假设,不能因为都能凑够 16 字节就混为一谈。

但是,追到“输入字符串从哪里来”时,历史材料出现了缺口。settings-cctor.txt 的原始节选为:

这条输出本身只说明该解析路径没有取得有效方法体。历史报告将它解释为对应区域被清零,并注明:配置字段的初始化关系部分依据 #US 堆中的字面量顺序推定。因而不能把 Settings.ENCRYPTIONKEY = "6153" 写得像从完好静态构造函数直接反编译出来的一行源码。

但这个缺口没有让工作停下。另一条验证路线可以独立成立:取候选字节 b"6153",按 MD5 派生,把结果用于保存的原始通信帧;随后检查 PKCS7、压缩层长度、序列化长度与完整消费。接下来的 23 份输入核验,正是沿着这条路线继续收紧条件。

因此结论可以分成两层:MD5(b"6153") 是这批记录已验证可用的解密密钥;而“某个初始化方法如何把它写入某个字段”,仍应保留历史映像缺失和推断成分。这种分层告诉下一位分析者,哪块已经能站住,哪块还值得补。

至于 xClient、NetSerializer、SafeQuickLZ 等名称,可以用来寻找代码血缘。它们不能单独证明某个家族“独有”,更不能跨越工具来源、二次修改与部署者之间的差别,直接认定操作者。“银狐”曾出现在早期分析的归因判断里,但现有材料还不足以把这个名称写成已经确认的操作者身份。技术结构可以逐字节核查,归因还需要更多独立证据。

6153 这个字符串看起来很普通。它不像一段复杂口令,也不像某种编码后的长密钥;如果只按外观筛选,很容易把它当成端口、计数或者配置编号略过。

内存分析留下了这个字面量和 MD5 派生线索。配置赋值与 IL 调用链存在前述缺口,因此接下来换一个验证方向:把候选带回原始帧,检查它能否同时满足解密、压缩与序列化的约束。

四个字符为什么不能直接当作 key?这个问题值得做一次对照实验。下面比较三种派生方式,其中两个错误候选专门为实验构造,并非历史穷举字典的完整重放:

辅助函数 plaintext() 会检查 PKCS7 的每一个填充字节,而不只是看最后一个字节是否落在 1 到 16 之间。它的关键实现为:

实际输出:

直接把四字节 UTF-8 字符串交给 AES 会遇到密钥长度问题;表中的第三种方法为了形成可测试的 16 字节错误候选,特意用零补齐。这样失败的含义是“这个候选解不开当前样本”,而不是“根本没有建立合法长度的 AES 实例”。

这里也不能把 23/23 当成 23 次彼此独立的密码学证明。文件中有重复,消息之间也可能存在结构关联。PKCS7 通过只是筛选条件,后面还必须检查 QuickLZ 和字符串结构。一个偶然符合填充规则的错误明文,未必能继续通过这些检查。

在这批文件上,MD5 派生得到的 16 字节 key 可以让全部输入继续走完后续核验。这才使它从“报告里的一条线索”,变成“有原始数据支撑的解释”。

终于到了一个很容易让人兴奋的节点:密钥能够工作,填充也通过了。

对 F01 进一步打印,我们得到:

先把长度账算一遍:

但是,把去填充后的 53 字节直接按 UTF-8 解码,仍然失败,位置在零基偏移 12,也就是从头数的第 13 个字节。

一个自然的怀疑是:密钥不对,前面只是碰巧通过填充。这个怀疑应该保留,但不应成为唯一方向。密码学里的“明文”只是加密前的字节序列,它完全可以是一段压缩数据、二进制对象或者另一层协议,并不承诺它直接是人类可读文本。

观察这 53 字节,已经可以在中间看到一些可读片段:QQ、0ms/,以及中文的 UTF-8 字节。它们之间又夹着少量无法解释的控制字节。

这就是最容易开始打补丁的时候。把 .decode("utf-8") 改成 .decode("utf-8", "replace"),输出会变得友好;遇到几个不认识的字节就跳过去,后面的中文甚至可能重新连上。

可读性会提高,证据质量却未必提高。原本的异常告诉我们“这里还有一层没有处理”,宽容解码则可能把异常揉成替换字符,让人误以为只剩一点显示问题。

所以这里保留严格解码,让错误原样出现。偏移 12 处的报错不是一个要被消音的麻烦,它是下一层结构留给我们的路标。

84 字节文件的层次关系

依据 F01 字节复算绘制,不是抓包软件截图。

旧版协议脚本把 AES 明文的头部解释成这样:

同时,它允许字段流里出现 00 00 00 80,把它当成空值占位。其回归通过条件的核心是:

这里引用的是旧代码的判断思想,变量名在节选中做了简化;原版完整逻辑在既有 dcrat.py 的 verify() 内。我们没有把那个带构造和运行入口的脚本重新启动,而是单独编写了这一通过条件的最小复现,只给它 F01。

复现代码见附件 old_gate_demo()。它保留了旧解析的关键动作:遇到四字节标记就跳过,否则按 UTF-8 首字节估计步长,用 UTF-16 单元数累加到声明值,并在结尾比较总消费字节数。

运行结果就是开头那段矛盾的来源:

它把数据归成了五段:空值、字段、字段、空值、字段。段的字节数加起来,确实消费了 44 字节,所以 EXACT 为真。

然而头部里那个被它称为“字段流长”的数字是 36,而头部之后实际还有 44 字节。旧通过条件没有把这个矛盾纳入失败条件,更没有独立检查四个业务字符串是否被正确恢复。

于是,“走到了缓冲区末尾”被包装成了“协议逐字段确证”。

这是本案最值得记住的一次踩坑。一个解析循环,只要每次都往前移动,最终就有机会走到末尾。即使分段错了、类型错了、字段语义错了,也可能得到一个非常整齐的消费总数。

这个问题不能通过再加几句 print("PASS") 解决。应该先明确什么叫正确,再把正确性的条件写进去:所有声明长度怎么对应,字符串编码是否严格有效,字段数量是否符合观察,数据结束时是否恰好读完,以及原始消息是否支持这套解释。

AI 可以很快补出一个测试函数,也可以很快给它起一个“权威验证”的名字。测试的名字没有权威性;决定它价值的,是失败条件。

EXACT 与长度矛盾的运行输出

上图由离线复核的实际输出重新排版,原始文本保存在附件日志中,并非历史终端截图。

旧解释还给两个首字节分配了鲜明的语义:

它之所以显得可信,是因为某些 0x4F 帧中恰好出现了“管理员:”字样。如果只挑这几份输入观察,标志和标题很容易形成一种假相关。

这次离线复核把全部输入逐份解析,再打印白名单中的通用窗口标题。个人浏览标题不进入输出。下面是程序实际找到的反例:

这组输出应该怎样理解?

0x4F 可以对应 QQ,也可以对应 ChatGPT;0x4E 则能对应带“管理员:”的命令提示符标题。因此,原解释中“某标志专属某类标题”的对应关系不能成立。

这里还要再收紧一步:窗口标题本身也不是安全令牌证明。某个程序叫自己“管理员”,不等于进程真的提升了权限。任务配置、令牌信息和进程身份应该由相应证据回答,不应让一段标题文本代劳。

对照字节结构,0x4E 和 0x4F 只差最低位;内存提取件中又能找到 SafeQuickLZ。把它们放回 QuickLZ 长头解释,多个现象同时有了位置:

这与 QuickLZ 格式说明相符。对这份样本,标志、两个长度字段、控制字和后续文字都可以在同一个结构下解释,不需要为“管理员消息”额外发明一套分隔规则。

这时假设的质量就发生了变化:它不仅能解释几个好看的字符串,还预测了下一步应该消费多少字节、产生多少字节。

00 00 00 80 是整个过程里最像“特殊业务标记”的东西。

它数值醒目,出现位置又很别扭。按小端整数读出来是 0x80000000。如果已经认定自己拿到的是 .NET 字符串列表,很容易联想到特殊值、空值、分隔符,甚至 int.MinValue。

但在本批压缩数据里,它是 QuickLZ 的控制字。

下面这个函数特意把每次读取控制字时的输入偏移和已经生成的输出长度打印出来。它不是完整解压器,只处理本批实际观察到的 literal-only 情况:

实际响应:

第一次在输入偏移 0x09 读取控制字,此时输出为 0。接着复制 31 个字面量。第二次在输入偏移 0x2C 读取控制字,此时已经输出 31 字节,再复制剩下 5 字节。

所以,53 字节的内容可以逐项写成:

展开以后留下 36 字节,两个控制字正好贡献了多出来的 8 字节。前面那组 36 对 44 的矛盾,就在这里得到解释:旧解析器把压缩体长度和展开后的长度混在了一起。

注意,压缩层按字节组织数据,它没有义务照顾上层 UTF-8 的字符边界。控制字可以出现在一个中文字符串中间,甚至出现在某个多字节字符的字节序列之间。只有把这一层还原掉,才轮到字符串解码器工作。

这比“看到标记就删四字节”多做了一件关键的事:它解释了标记为什么出现在那里、应当消耗多少控制位、接下来生成多少数据。

附带核验器遇到需要回拷匹配串的 QuickLZ token 会明确报不支持。23 个文件能够通过,说明它覆盖了当前观察到的输入,不意味着已经实现 QuickLZ 的全部编码形式。这一范围会被后面的负向测试专门验证。

拿到展开数据以后,最前面的字节已经干净得多:

如果继续沿用“tag + 字节长度 + 内容”,第一项就会出问题。

这次离线复核写了一个故意按照这个错误假设读取的函数。它不是用来分析所有协议的通用失败示例,就是拿 F01 的前几个字节做一件具体的事:把 05 当 tag,把 02 当后续内容的字节长度。

输出如下:

它拿到的是 30 E5。30 是 ASCII 的 0,E5 则只是后面一个三字节 UTF-8 字符的开头。字符串被截断,严格解码器毫不含糊地报了 unexpected end of data。

真实的“0分”是 30 E5 88 86,占 4 个 UTF-8 字节、2 个 UTF-16 码元。这里的两个前缀字段表达的不是类型和内容长度,而是两种编码尺度下的长度信息:

因此 05 02 的含义是 4+1 和 2。继续读下去,03 02 对应 QQ,0B 06 对应 0ms/超时,0C 05 对应 未登录TG。

旧解析为什么在 ASCII 路径上看起来顺畅?因为 ASCII 的字节数与 UTF-16 码元数相同。某些错误假设在英语路径上能够得到正确的表面结果,直到遇到中文才开始露出缝隙。只用同一类数据测试解析器,恰好会把这种缝隙藏起来。

另一个容易被忽略的细节是 Python 的 len(str) 和 .NET 的字符串长度不总相同。对于非 BMP 字符,UTF-16 使用代理对;一个可见字符可能占两个 UTF-16 码元。附带测试因此加入了一个非 BMP 字符,避免“处理中文没问题”被误当成“处理所有 Unicode 都没问题”。

这一编码结构与 NetSerializer 的字符串实现相符。+1 也有用途:0 可以表达 null,1 可以表达空串,非空字符串再携带字节数加 1 和字符长度。真实 F01 是四个非空串;null 与空串分支是在人工测试中检查的,不能说本案数据出现过它们。

到这里,已经可以读出文字。但我仍然希望看见边界。

给每个字段打印起止偏移、两个长度和解码后的文本,能够把“我觉得它像四个字符串”变成一份很容易人工检查的记录。下面的显示函数专门用于 F01,它的四个长度前缀都是单字节;真正的核验器另有完整的变长整数读取逻辑。

真实输出:

这次的 36 字节是可以从头走到尾的:消息编号占第 0 字节,四个字符串依次覆盖 [1,7)、[7,11)、[11,23)、[23,36)。尾部没有剩余字节,也没有依赖猜测跳过某段内容。

这里的消息编号 0x28 是观察到的结构值。它对应服务端类型注册表里的哪个完整类名、其他编号如何分配,不应该只凭这一条消息硬填出来。四个字符串的字面值已经确定,但第一项“0分”究竟表示会话在线时长、空闲时间还是别的计时,仍需进一步追踪产生这个字段的代码。

这种停下来的能力很重要。逆向做到一个能闭合的结构,常常会激起把所有字段一次命名完的冲动;AI 尤其擅长把半张表补成一张整齐的表。可是不知道一个字段的精确业务语义,并不会抹掉已经验证的偏移和编码。相反,把未知保留在正确的位置,下一位分析者才能继续往前走。

四个字符串的解析偏移

上图由这次离线复核输出重新排版。字段起止位置可以与 F01 的解密、展开结果逐一对照。

错误解释与修正结果

依据原始字节与离线复算结果整理,展示分析中被推翻的解释;不是历史终端截图。

旧版脚本还提供了一种很常见的自测:先构造消息,再加密,再解密,最后检查恢复的数据是否等于构造的数据。

这个测试能验证一些实现细节,比如 AES 调用、IV 拼接和填充是否自洽。它的问题在于,很容易被赋予自己没有验证的含义。

我为文章专门做了一个人工反例:给一段根本不是合法协议的文本加密,再解密。

实际运行:

第一行是 True,第二行拒绝了它。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

上传的附件:
收藏
点赞 ・0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回