起因
翻旧文件时找到一对 2018 年的易语言程序:一个叫「主程序」,一个叫「检查程序」。下面是主程序的源码,12 行 —— 整篇文章前半的结论全从这 12 行长出来,而检查程序那 10 行我是到第六节才把它解出来的:
.版本 2
.程序集 程序集1
.子程序 _启动子程序, 整数型, , 本子程序在程序启动后最先执行
' 病毒主程序
运行 (“shutdown -s -t 10000”, 真, #隐藏窗口)
.判断循环首 (1 = 1)
运行 (“shutdown -s -t 3”, 真, #隐藏窗口)
.如果真 (进程_是否存在 (“looking.exe”) = 假)
写到文件 (“C:\Windows\looking.exe”, #图片1)
运行 (“C:\Windows\looking.exe”, 真, #隐藏窗口)
.如果真结束
.判断循环尾 ()
返回 (0)
逻辑一眼看完:先挂一个 10000 秒(≈2.8 小时)之后的关机,然后进死循环 —— 每一圈重新挂一个 3 秒后关机,并且检查 looking.exe 还在不在,不在就把自己带的 #图片1 写成 C:\Windows\looking.exe 再拉起来。
真正奇怪的是体积:12 行程序,工程文件一个 7,445,030 字节、一个 8,001,908 字节,编译出来的 exe 一个 7.37 MB、一个 7.93 MB。而它们内部那部分"程序本身",肉眼可见就是几百 KB 的量。
多出来的 7 MB 是什么?
一、不动手运行,先量长度
我全程没有运行任何一个二进制,也没有让它们在磁盘上落地过。第一步只做一件事:在容器里找出所有 PE 头。
不能只搜 MZ —— 7 MB 随机数据里 MZ 会撞出上百次。要按结构规则验:
i = d.find(b'MZ', pos)
e_lfanew = struct.unpack_from('<I', d, i + 0x3C)[0] # DOS 头里那个"PE 头在哪"
if 0x40 <= e_lfanew <= 0x400 and d[i+e_lfanew:i+e_lfanew+4] == b'PE\x00\x00':
... # 才算一个真 PE
e_lfanew 必须落在 0x40–0x400 之间(DOS 存根的实际长度范围),且那个位置真是 PE\0\0。这一条过滤把噪声基本清空。
结果:
主程序.e 7,445,030 字节 → 检出 10 个 PE
检查程序.e 8,001,908 字节 → 检出 11 个 PE
两个都是 i386(0x14C)、PE32(0x10B)、4 个节:.text 401408 / .rdata / .data 61440 / .rsrc 24576。
注意这四个数:.text、.data、.rsrc 的大小在 21 个检出结果里一个字节都没变,变的只有 .rdata。也就是说这 21 个 PE 是同一个程序的 21 次构建,而每次构建只有资源段在长。
二、家谱:按构建时间戳排出来
PE 头里有个时间戳字段(链接器写进去的构建时刻)。把它当构建日志用,21 个 PE 立刻排成一条链:
| 代 | 构建时刻 (UTC) | 声明长度 | md5 前 8 位 |
|---|---|---|---|
| 1 | 07-01 11:29:09 | 557,056 | 68d5be5a |
| 2 | 07-01 11:37:43 | 2,363,392 | 817637e3 |
| 3 | 07-01 11:38:37 | 2,920,448 | a4b38d98 |
| 4 | 07-01 11:38:50 | 3,477,504 | e43ea573 |
| 5 | 07-01 11:39:24 | 4,034,560 | 258a26c7 |
| 6 | 07-01 11:39:36 | 4,591,616 | fbd72960 |
| 7 | 07-01 12:06:51 | 5,148,672 | 691d8115 |
| 8 | 07-01 12:07:05 | 5,705,728 | 5509dee6 |
| 9 | 07-03 02:07:22 | 6,262,784 | 3299432e |
| 10 | 07-03 02:08:02 | 6,819,840 | 1f9346fb |
| 11 | 07-03 02:08:17 | 7,376,896 | 04249c6e |
这条链在两个容器里都存在,而且每一代的 md5 完全相同。 主程序.e 装的是 1–10 代,检查程序.e 装的是 1–11 代。第 11 代 04249c6e… 和盘上那个 exe 的整文件 md5 一模一样 —— 也就是说它不是"类似的东西",它就是那个文件本体。
两夜,11 次构建。07-01 那晚 8 次(11:29 → 12:07,中间 11:37–11:39 那四分钟里连做 5 次),07-03 凌晨 3 次,最后两次相隔 40 秒。
然后看相邻两代的长度差:
557,056 → 2,363,392 差 1,806,336
2,363,392 → 2,920,448 差 557,056
2,920,448 → 3,477,504 差 557,056
3,477,504 → 4,034,560 差 557,056
… 一直到 …
6,262,784 → 6,819,840 差 557,056
6,819,840 → 7,376,896 差 557,056
从第 3 代起是严格等差数列,公差 557,056 = 第 1 代自己的整文件大小。
等差而不是等比 —— 这一点很关键,它直接把结构定死了:
size(n) = size(n-1) + 固定自身开销
= size(n-1) + size(第1代)
如果是"每一代把上一代整个塞进去,而上一代自己也塞了它上一代",体积会爆炸成指数。实际是线性,说明程序自身的代码量恒定(.text/.data/.rsrc 三个数从头到尾没变),每次构建只是往资源段里再塞一个固定的东西 —— 而那个东西的大小正好等于第一代裸程序。
用一句话讲:每次重编,它带的载荷是上一代;而它自己那部分永远是 557 KB。
三、验证而不是猜:字节全等
上面还是推论。要坐实"第 n 代里含第 n−1 代",就去看偏移:
第 2代 含 第1代 相对偏移 0x64385 区间内含=True 字节全等=True
第 3代 含 第2代 相对偏移 0x643ae 区间内含=True 字节全等=True
…
第10代 含 第9代 相对偏移 0x6447a 区间内含=True 字节全等=True
第11代 含 第10代 相对偏移 0x64451 区间内含=True 字节全等=True
三层验证,每层都能证伪:
- 区间内含 —— 内层的
[偏移, 偏移+声明长度)必须完全落在外层的区间里。 - 字节全等 —— 把外层按那个相对偏移切出来的片段和内层逐字节比,必须一模一样。
- 相对偏移几乎不动 —— 全部在
0x643ae–0x6447e这 2KB 窗口内。载荷在资源段里的位置是固定的,所以每次构建它都在同一格。
“字节全等"那一步是整篇里最值钱的动作。 等差数列能暗示嵌套,但它也能被别的解释骗过去(比如共同模板、同一个压缩过的公共库、或者我数错了节大小)。把内层片段真的切出来逐字节比一次,11 次全 True,解释空间就只剩一个。
四、#图片1 就是那个 exe
源码里那句 写到文件 (“C:\Windows\looking.exe”, #图片1) 说的是"把图片资源 #图片1 写到 C 盘”。
扫容器时顺手看了资源名的偏移,和它紧挨着的下一个字节:
主程序.e "图片1" @ 0x993e 第一个 PE 头 @ 0x9949 相差 11 字节
检查程序.e "图片1" @ 0x993e 第一个 PE 头 @ 0x9949 相差 11 字节
资源名和 PE 头只差一个资源表头的距离。所谓"图片1"就是一整个可执行文件 —— 易语言的资源槽只按"名字 + 字节"存,你把 exe 塞进图片槽,它就当图片存,写到文件 又原样吐出来。
所以这 12 行程序没有任何"下载"或"网络"动作,它的载荷是自己带进来的:每次构建前,把上一次编译出来的 exe 拖进图片资源槽。
五、两个程序互相当对方的载荷
这是整件事最漂亮的地方。归属靠一条简单规则就能定死:一个容器里最新的那代载荷,等于「它自己编译出来的那个 exe」的前一代(因为构建时塞进去的是上一版)。于是:
| 容器里最新的载荷 | 所以它的编译产物是 | 盘上实证 | |
|---|---|---|---|
| 主程序.e | 第 10 代 1f9346fb | 第 11 代 | task.exe,整文件 md5 04249c6e… 与第 11 代全等 ✓ |
| 检查程序.e | 第 11 代(= task.exe) | 第 12 代 | 检查程序.exe,构建时刻 02:08:34,比第 11 代晚 17 秒 ✓ |
两个工程在同一条构建链上交替往前推 —— 检查程序.e 里装的东西,就是主程序编译出来的那个 exe。
而源码里主程序守护的名字是 looking.exe,它自己的产物叫 task.exe —— 两个名字,两个进程。这就是"互相复活"而不是"自我复制":A 死了 B 把 A 写回来,B 死了 A 把 B 写回来,而且每一边资源槽里都真的带着对方的身体。
另一边的源码我后来自己解了出来(怎么解的见第六节),一共 10 行,而且一行关机调用都没有:
.判断循环首 (1 = 1)
.如果真 (进程_是否存在 (“task.exe”) = 假)
写到文件 (“C:\Windows\task.exe”, #图片1)
运行 (“C:\Windows\task.exe”, 假, )
.如果真结束
.判断循环尾 ()
它是纯看守:发现 task.exe 不在,就把自己那份资源补到 C:\Windows\task.exe 再拉起来。杀机器那件事它完全不参与 —— 主程序负责杀,它负责让主程序杀不死。
两边还不对称,而不对称正是要害。 看 运行 的第二个参数(是否等待):
| 拉起对方时 | 效果 | |
|---|---|---|
| 主程序 | 运行("…looking.exe", 真, #隐藏窗口) | 等待 —— 一拉起来就卡在这一行,自己的循环停住 |
| 检查程序 | 运行("…task.exe", 假, ) | 不等 —— 立刻回到循环顶部继续轮询 |
一个 join、一个 poll。主程序平时根本不轮询,它阻塞在看守进程身上,等对方死了才醒过来重新投放;检查程序则一直转。合起来是"任何时刻都至少有一个醒着在看"。另外主程序拉起子进程带了 #隐藏窗口,检查程序拉 task.exe 时没带 —— 所以被复活的主程序会露一个窗口出来。2018 年的我 either 没打算藏,或者没藏住。
旁边一个 5 行的批处理把这个设计说得更清楚:
@echo off
:start
taskkill /im task.exe /f
taskkill /im shutdown.exe /f
shutdown /a
ping 127.0.0.1 -n 2 >nul
goto start
它必须是个死循环。 单次 taskkill + shutdown /a 救不了:进程被杀的那一瞬间,另一个进程正在它的循环里检查"那个人还在不在",一发现不在就立刻把资源写回 C:\Windows 再拉起来。而 shutdown /a 也只能取消一次 —— 循环里那句 shutdown -s -t 3 每一圈都会重新挂一个 3 秒后的关机,所以你取消得比它重挂更快才行。这就是为什么解毒方案是"每 2 秒杀一次,一直杀",而不是"杀一次"。
顺带解释源码第一行为什么在循环之外:shutdown -s -t 10000 先挂一个 2.8 小时后的定时关机。这是第二层保险 —— 就算循环被掐断、两个进程都被杀掉,机器上还留着一个没人取消的 10000 秒定时。要真活下来,除了跑那个死循环,还得自己记得 shutdown /a 一次。
六、两套互不相干的方法,落在同一组字节上
上面全部结论都是只量长度得来的。到这里其实还有一件事可以做:直接把源码解出来,看它和我推的是不是同一回事。
工具是 e-packager(C++,MIT,win32 便携包,我先核了它的 sha256 与 release 公布的 digest 一致)。一个命令,1.2 秒,零告警:
e-packager unpack 检查程序.e out/chk
产出是 src/*.txt + 窗体 XML + 资源 + AGENTS.md。控制流是真回来了 —— 不是只回来名字:.判断循环首、.如果真、.否则、嵌套、字符串字面量、标签2.标题 = “0次” 这种属性赋值都在。上面那 10 行就是这么读到的。
有一个前提值得记:它需要一个易语言目录去加载 .fne 支持库(校验条件是同时存在 lib、static_lib、ecom)。不给的话结构照样解得出来,但命令名会退化成 _Lib0Cmd36 这种占位符。 我机器上 IDE 装在 D:\1\e(有 lib、static_lib),而模块在旁边的 D:\1\ecom,两者不同目录 —— 所以我没去动任何原目录,而是在自己的工作目录里用三个 junction 拼了一个满足校验的假目录出来。
然后是关键的一步。它会把资源槽里的东西原样吐成文件(out/chk/image/图片1.bin)。而这正好独立检验了我第五节那张归属表:
| 我只量长度预测的那一代 | 工具解出的 图片1.bin | |
|---|---|---|
| 检查程序 | 第 11 代,04249c6e… | 7,376,896 字节,md5 04249c6e7a503169ad05632092e553d7 ✓ |
| 主程序 | 第 10 代,1f9346fb… | 6,819,840 字节,md5 1f9346fbb0c596a218662ecacb24b4cc ✓ |
字节数和 md5 逐位对上。 我这边一次都没运行、没反汇编,只做了"扫 PE 头 → 读声明长度 → 按时间戳排序 → 做差";它那边是完全独立的另一套代码。两套方法落在同一组字节上给出同一个答案,这才是我敢把第五节写成的原因。
两处工具痕迹要提醒,照抄会把工具的记号抄进源码:它输出的文本常量带自己的标记(“#e2txt_text#C:\\Windows\\task.exe”),库命令带 krnln:: 前缀 —— IDE 里都不是这么显示的。另外 unpack 会把载荷落成 .bin,也就是真的往磁盘写了可执行文件;我全程只读不跑,但这一步产生了文件,得知道。
(顺带修正我早前写过的话:.e 里"命令序列和操作数是二进制记录,扫不出来"——对我那套字节扫描成立,对格式本身不成立。有在维护的反编译器能把控制流还原成文本,我的八篇里那句话该按这个改。)
七、这套读法可以搬走的东西
① 量长度是免费的元数据。 一个文件里出现 N 个合法 PE 头时,第一件事不是抽出来看代码,而是把它们的声明长度排个序做差。等差 → 线性套娃(外层固定开销 + 内层整体);等比/无规律 → 多半是资源包或加壳;全都一样大 → 是同类数据的多个副本而不是嵌套。这次三个数(.text 401408 / .data 61440 / .rsrc 24576)21 次构建一动不动,光这一点就够说明"是同一个程序反复重编",不需要任何反汇编。
② PE 时间戳要反过来当构建日志用。 平时我们都嫌它不准(可改、可复用),但在这里它给出的是行为序列:11 次构建、分布在两夜、07-01 那晚 11:37 到 11:39 三分钟内连做 5 次(典型的"改一行编一次试一次"节奏),07-03 最后两次间隔 40 秒(收尾)。这是任何注释和提交记录都给不出的东西。
③ 识别"互相守护"只需要回答一个问题:谁把另一个二进制塞进了自己的资源里。 双进程守护、服务自愈、看门狗重启 —— 正经软件里这套叫 watchdog,实现方式从注册系统服务到 systemd 依赖都有;但它的最小形态就是这里这 12 行:一个 进程_是否存在 判断加一次 写到文件。认出来之后,反制方式也跟着变 —— 你要杀的不是一棵树,是两个正在互相看着对方的进程,所以要么同时杀、要么循环杀。这是那次我唯一做对的一件事:先想清楚了"杀不干净",才写出那个死循环 bat。
④ 全程不用运行。 这条是我现在做分析的习惯:所有结论都能从字节、偏移、长度、哈希、时间戳拿到时,就不要碰执行。它同时也让这篇有得写 —— 那 7 MB 里装的是能直接把机器摁关机循环的东西,我一次都没让它跑过。
⑤ 一句话的自我修正。 我原先写".e 里命令序列是二进制记录,扫不出来",那是对我那套方法的描述,我把它当成对这个格式的描述发出去了。这次两者都验了:扫字节能拿到归属和哈希,反编译器能拿到控制流,两者互为检查 —— 用哪个不重要,重要的是别让其中一套的结论冒充另一套。
八、这个仓不放二进制
按我自己定的口径:样本和样本的中间产物不进 git、不进文章。 这次尤其必要 —— 这两个工程文件不是惰性源码容器,它们各自带着 6.8 MB / 7.4 MB 的可执行载荷,打开就能跑。
所以仓里只放:
- 两份源码的文字版(主程序 12 行、检查程序 10 行)
- 那个 5 行的解毒批处理
- 我用来重建家谱的扫描脚本(找 PE 头、按时间戳排序、做长度差、验字节包含与全等)
- 家谱表本身:每代的偏移、声明长度、构建时间戳、md5
.e 和三个 exe 一个都不放。哈希和偏移足够别人复现全部结论,也足够别人认出"我这块盘上曾经有过 11 代"。