ARTICLE · 1228055
只用两个 exe,把窗口修复工具逆向回源码
RTO 的 Model 窗口有时会停在屏幕外面。修复它的程序只留下两个可执行文件:RTOModelRepair.exe 和 GeometryTests.exe。没有工程、没有 PDB。
这次用 REA 做静态逆向,先回答它是什么、入口在哪、怎样判断窗口该不该移动,再把程序集还原成能编译的 C#。事后拿到原工程对照:两个目录里的 exe 哈希相同,几何测试 16 项全部通过。
下面按实际操作顺序写。静态分析没有启动 RTO,也没有把程序集装进 CLR 里执行。
样本就这么大
39070aadd345c2b3c69cc3dd2b0c4056e8c95f2eaef3ec106367eb744eb5424e | ||
ec64f509738fd311e1c984740ead5f7fbbad828ca1e24a23c9fa387cfe447443 |
两个文件的修改时间相同。主程序的程序集名是 RTOModelRepair,版本 1.0.0.0。测试程序的程序集名是 GeometryTests,版本 0.0.0.0。
REA 在这次调查里做什么
REA 是一组面向已发布制品的检查工具。给它一个本地文件,它返回带证据编号的观察:文件哈希、格式、元数据行、方法体位置、覆盖范围,以及它没有做的事。
这次用到的能力可以按问题来记:
这是不是原生程序? 打开文件后看 PE 头。结果是带 CLI 头的 64 位托管程序,标志为纯 IL。 里面有哪些类型? 读程序集、模块、类型、字段、方法,不加载 CLR。 它调用了哪些系统库? 读 P/Invoke 声明。声明只说明托管代码准备调用哪个 DLL 的哪个函数,不能代替“这个导出函数一定存在”。 界面和报错在说什么? 读方法体里 ldstr装入的用户字符串。某个判断的数字是多少? 用方法体的文件偏移把 IL 解出来,并用 REA 记录的 IL SHA-256 核对这段字节。
REA 的托管检查明确写了自己的边界:没有加载程序集,没有反编译成 C#,没有执行。C# 源码是后一步用 ilspycmd 从同一份 exe 反编译出来的,再拿原工程核对。
本机没有可用的 Ghidra、IDA 或 Kuna。对这个样本,那也不是第一选择。文件是托管 IL,类型名和方法名还在元数据里,不需要先把机器码反编译成伪代码。

图 1. 这次调查的顺序。每一步都先拿摘要,再按还没回答的问题翻页。
第一步:先承认它是托管程序
打开 RTOModelRepair.exe 之后,格式判断是 PE、x86_64、可执行文件。摘要里的 CLI 头写着:
运行时头版本 2.5——这是 CLI 头的版本号,不是 .NET 2.5 元数据版本 v4.0.30319,对应 .NET Framework 4 这一代 CLR标志只有 il-only入口是元数据记号 0x0600007b没有 ReadyToRun,也没有混合模式的本地头
引用的程序集是 mscorlib、System、System.Core、System.Drawing、System.Windows.Forms、System.Web.Extensions,版本都是 4.0.0.0。这只说明它按 Framework 4 的程序集版本编译。后面看到 Task.Run,才知道源码用了 4.5 才有的 API。Framework 4.5 到 4.8 的这些基础库,程序集版本号仍然常常是 4.0.0.0。
GeometryTests.exe 同样是纯 IL 的 64 位 PE。它的入口记号是 0x06000036。
第二步:从类型表看见骨架
完整元数据里有 30 个类型定义、177 个方法、121 个字段。把编译器生成的匿名类型和闭包去掉,业务类型很集中:
Program:入口,只有一个方法RepairForm:窗口RepairEngine:检测和修复RepairReport、ProfilePlan:结果和一份待写入计划Geometry、Box:矩形是否可用、不可用时放到哪里DisplayLayout、DpiScope、ModelWindow:显示器和窗口ProfileSession、ProfileEntry:已打开的 RTO 配置Native:Win32 和 NT 函数声明
入口记号 0x0600007b 正好落在 Program 唯一的方法上,所以入口就是 RTOModelRepair.Program.Main。这个对应关系来自方法表的行号,不是根据 Main 这个名字猜的。

图 2. 类型名来自元数据。GeometryTests 把同一套几何和窗口类型又编译了一份,用来做边界检查。
第三步:字符串先把产品行为说出来
用户字符串是方法体用 ldstr 装入的字面量,共 166 条。它们还不是控制流,但已经把工具的自我说明写全了。
界面标题是「RTO Model 窗口修复工具」。按钮是「重新检测」「修复 Model 窗口」「打开日志文件夹」。互斥体名字是 Local\RTOModelWindowRepair。命令行开关是 --check。
它点名的外部程序是:
C:\Client\Bin\UI_Client.exe
旁边还要看 UI_RMManager.dll,并写明只处理已经核验的 9.0 系列。窗口类名是 AfxMDIFrame140 和 AfxFrameOrView140,标题里出现 RTO Online Performance 和 Model。
注册表路径的正则指向:
\REGISTRY\USER\<SID>\Software\RTO\<版本>\Manager
值名是 Window Position - 3。多条报错都要求它是 16 字节的 REG_BINARY。日志在 logs,备份在 backups。备份说明写着:这是历史坐标,不要直接导入。
到这一步可以讲产品故事,还不能讲判断公式。32、160、20 这些像素阈值不在字符串里,在 Geometry.Usable 和 Geometry.Center 的 IL 里。
P/Invoke 表有 34 条,来自 kernel32.dll、advapi32.dll、ntdll.dll、user32.dll。名字包括枚举窗口、读显示器、按窗口设置线程 DPI、移动窗口,以及查询系统句柄、打开和读写注册表键。这是声明,不是一次真实调用的记录。
第四步:把几何规则从方法体里算出来
REA 对方法体给出的是指令锚点:调用、字符串、常量和跳转,以及文件偏移、IL 长度和 IL 的 SHA-256。锚点之间的加减法不会全部列出来。Geometry.Usable 有 80 条指令、51 个锚点,所以只看锚点会把比较顺序看错。
处理办法是:用 REA 记录的文件偏移取出方法体,算出 IL 字节的 SHA-256,和证据里的 il_sha256 一致之后,再把这 80 条指令完整反汇编。Geometry.Center、Box.Valid、Native.IsModel 和修复主流程也按同样的办法核对。
矩形 Box 是 Left、Top、Right、Bottom 四个有符号 32 位整数。Decode 用 BitConverter.ToInt32 从偏移 0、4、8、12 读取。宽或高小于等于 0,或任一边超过 100000,就算无效。
可用的含义是:在某一块显示器工作区上,同时满足
水平重叠不少于 min(160, 窗口宽度)像素窗口顶部最多 32 像素的标题带,重叠不少于 min(20, 窗口高度)像素
只露出内容、标题在工作区外,不算可用。标题带只重叠 10 像素,也不算。窗口在左侧或上方副屏上时,坐标可以是负的;只要和那块工作区的重叠够,就不改。边框略微超出、标题带仍够,也不移动。
需要修正时,Center 把窗口放到主屏工作区正中。主屏工作区无效,或宽度小于 160、高度小于 120,就拒绝。边距是 min(24, min(宽, 高) / 16)。原矩形有效就保留原尺寸,否则用 1000×700,再夹进工作区,并尽量不小于 320×240。
工作区来自显示器枚举回调里的工作矩形。标志位的最低位为 1 时,那块矩形被记成主屏。读坐标之前,线程的 DPI 感知会改成目标窗口的 DPI 上下文,用完再改回。

图 3. 判断用的是标题带和工作区的重叠。窗口中心在屏幕外,本身还不是这条规则。
第五步:反编译,再和原工程对
IL 能说明行为,读起来仍然费劲。ilspycmd 11.1 把两个 exe 反编译成 C# 工程。REA 没有做这一步。
反编译结果和后来拿到的原工程,在规则上是对齐的。原工程是四个文件,用 csc /langversion:5 编译:
Program.csWindowRepair.csRTOProfileStore.csGeometryTests.cs
还原工程按类型拆开了。局部变量名是反编译器起的,源码注释没有保留。ProfileSession 里两处跳到 finally 的 goto,改成了和原来 continue 相同的分支:查不到键名或读不到位置时记一条诊断,并关掉复制出来的句柄。
有一处目标框架必须改。反编译器先写成 net40,但 RepairForm 使用 Task.Run,.NET Framework 4.0 没有这个 API。改成 net48 之后,主程序编译通过。测试工程留在 net40,也能通过。重新编译的 exe 不会和原来的文件逐字节相同。

图 4. 对照的是同一对 exe。哈希相同,几何测试的输出也相同。
测试实际跑了什么
原目录和还原工程各跑了一次 GeometryTests:
PASS: 16 geometry and RECT boundary checks.两边退出码都是 0。这 16 项包括:窗口在主屏上方、修正后必须可见、能放下时保持 1440×737、正常位置保持不动、左副屏和上副屏的负坐标、标题不可见、只露出 10 像素、边框小幅越界、极端整数、损坏尺寸、过大窗口、小工作区、负坐标主屏,以及 16 字节往返和错误长度拒绝。
这证明几何和矩形编解码在这些输入上一致。它没有证明注册表写入、窗口移动和完整界面在一台正在运行的 RTO 上行为一致。那一部分只做了源码对照:访问值、备份文件名、报错文案和「先备份再修改」的顺序,和原文件一致。
调查时容易看错的地方
方法体锚点会漏掉中间的算术和按位运算。第一次只看锚点时,把 IsModel 理解成「必须带可见样式位」。把 and 和随后的比较补全以后,条件和原码一致:样式位 0x40000000 要等于 0。
另一个常数也容易贴错指令。1048576 是句柄表的起始字节数,也就是 1 MB。RegOpenCurrentUser 的访问值是 1。两条指令离得近,过滤输出时不能把邻近的 ldc.i4 算到另一次调用上。
所以 REA 的摘要适合定位,不适合代替完整指令。数字结论要回到方法体字节,并用证据里的 SHA-256 确认没有取错偏移。
可以怎么复述这次 REA
如果只记操作顺序,就是这六步:
用文件哈希固定样本。 看容器格式。托管程序走元数据,不先打开原生反编译器。 用类型、方法和入口记号建立地图。 用用户字符串和 P/Invoke 名字提出行为假设。 只对还没回答的问题反汇编方法体,并核对 IL 哈希。 需要可读源码时再反编译;有原工程或测试时,用它们核对,而不是用反编译器的变量名证明自己正确。
证据编号、覆盖范围和「没有执行」要留在结论旁边。页面翻完,只说明这一页的行翻完了,不说明程序在用户机器上一定这样运行。















































































