“签名有效”回答的是某个规定范围的内容是否通过完整性检查,不自动回答运行时选用了哪一份程序表示。VBA 项目同时保存压缩源码与编译缓存;如果两者语义不同,签名覆盖范围和执行选择就必须分开核查。
这里以 2020 年的 Word/VBA 对照文档为例。静态视图显示源码与 P-Code 分歧,但缺少完整 Office 构建号、文件摘要和签名验证日志,因此不据此宣称所有 Office 版本都会执行缓存,或把某次成功执行归因于宏策略失效。
模块流保存两份程序表示
模块流起始部分是 PerformanceCache,后面是 CompressedSourceCode。两段长度都是变量;MODULEOFFSET 给出源码开始的位置,而不是一个固定的全局偏移。
| 区域 | 范围 | 含义 |
|---|---|---|
| PerformanceCache | [0, MODULEOFFSET) |
与实现、版本相关的缓存 |
| CompressedSourceCode | [MODULEOFFSET, EOF) |
按规定算法压缩的 VBA 源码 |
Microsoft 的格式规范明确将缓存描述为实现相关、版本相关,并要求格式读取者忽略它。这个互操作要求与某个 Office 实现实际选择什么执行表示,是不同层次的问题;运行行为要靠具体环境的记录证明。MS-OVBA 模块流
_VBA_PROJECT、__SRP_* 等位置也涉及编译状态。缓存不是跨版本稳定的独立可执行文件,只改一个模块的几处字节,可能引发缓存失效和源码重编译。
contents hash 并不等于整个文件的哈希
MS-OVBA 将 contents hash 定义为 VBA Storage 中规定信息子集的摘要。以 ContentNormalizedData 的模块处理部分为例,它读取 CompressedSourceCode 并解压,随后做规定的行处理;这不是把模块流从头到尾原样送进哈希函数。Contents Hashes
flowchart TD A["CompressedSourceCode"] --> B["解压为源码文本"] B --> C["按规定处理换行"] C --> D["跳过以 Attribute 开头的行"] D --> E["追加至规范化缓冲区"] E --> F["参与 contents hash"]
图中只概括这一版本算法的模块源码部分,不是完整签名实现;项目名、常量、引用等还有各自的处理规则。Attribute 判断忽略大小写,也不应直接套用到采用不同规范化规则的签名版本。Content Normalized Data
由此需要分别证明两件事:一是某次缓存变化没有改变实际被签名的输入,二是运行时接受并消费了变化后的缓存。前者是完整性覆盖问题,后者是执行选择问题,单独成立一个都不足以证明行为分歧。
把源码与 P-Code 放在同一条证据链上
对照文档 example-08b.docm 的源码提取结果保留了消息框逻辑。a3 是该文件的流选择器,应先枚举再使用,别把它当成所有 DOCM 的固定位置。
$ oledump.py -s a3 -v example-08b.docm
Attribute VB_Name = "Module1"
Sub autoopen()
MsgBox "Hello"
End Sub而 pcodedmp 的输出包含另一种行为。下面保留模块长度、字符串和调用名,不包含本机工具安装路径:
$ pcodedmp example-08b.docm | tail
Module streams:
VBA/ThisDocument - 1060 bytes
VBA/Module1 - 1400 bytes
Line #0:
FuncDefn (Sub autoopen())
Line #1:
LitStr 0x0004 "calc"
ArgsCall Shell 0x0001
Line #2:
EndSub两种表示的差别可以直接列出来:
| 观察层 | 可见内容 | 能支持的结论 |
|---|---|---|
| 源码 | MsgBox "Hello" |
解压出的程序文本调用消息框 |
| 缓存 | LitStr "calc"、ArgsCall Shell |
反汇编出的 P-Code 包含启动外部程序的调用 |
| 运行时 | 需另行采集 | 决定最终执行了哪一份表示 |
只查看 VBA 编辑器或只运行源码提取工具,会漏掉第二行;只看 P-Code,也可能误判一个已经失效、最终被源码重编译替代的缓存。保存、编辑或更换 Office 环境都可能改变这个选择,检查顺序本身应记录在案。
这些输出支持静态语义不一致,不构成计算器已启动或数字签名仍有效的单独证明。后两项应分别留下运行记录与签名验证结果。
用单字节对照减少变量
为了只观察表示选择,可以让两条路径都调用消息框,而分别显示 Hello 与 Hallo。下面是示意字节序列的离线检查:它不定位真实文档,不修改缓存,也不计算签名。
prefix0x00–0x01string0x02–0x06
before = bytes.fromhex("05 00 48 65 6c 6c 6f")
after = bytes.fromhex("05 00 48 61 6c 6c 6f")
changed = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
assert len(before) == len(after) == 7
assert changed == [3]
assert before[2:].decode("ascii") == "Hello"
assert after[2:].decode("ascii") == "Hallo"
print("PASS: one changed byte at offset 3; Hello -> Hallo")$ python verify_string_diff.py
PASS: one changed byte at offset 3; Hello -> Hallo变化发生在片段偏移 3,不是某个 DOCM 文件的绝对偏移。实验若要进入真实缓存层,先证明候选字节属于目标字符串,而非压缩源码、其他模块或无关数据;这个模型本身没有完成那一步。
复核矩阵必须包含四种独立状态
把签名有效、证书受信任、宏获准执行和缓存被采用混成一个布尔值,会让失败与成功都失去解释力。
| 文档或环境 | 固定条件 | 需要观察 |
|---|---|---|
| 原始已签名文档 | 同一 Office 构建与策略 | 源码、P-Code、签名与行为基线 |
| 仅缓存差异的副本 | 源码与签名材料保持可比 | 覆盖范围及执行表示是否分离 |
| 触发重编译的副本 | 记录触发方式,分别保存文件 | 缓存被替换后是否回到源码语义 |
| 另一构建或位数的环境 | 文档输入相同 | 缓存兼容性如何影响结果 |
每个单元记录文件哈希、Office 产品及完整构建号、32/64 位、证书链、宏策略、签名方案版本、验证状态和实际显示内容。状态栏上的一个签名徽标不替代这些记录;出现提示、缓存被重建或宏根本没有进入执行路径时,也应保留负面结果。
后续产品边界与检查建议
Microsoft 后来提供了 V3 VBA 签名和对应升级说明,包括重新签名以及仅信任 V3 签名的策略。兼容性默认设置可能仍接受旧签名,所以“已经升级 Office”与“所有文档已经采用 V3 并落实策略”不是同一件事。Microsoft KB5000676
这份 2020 年对照材料的具体构建号缺失,不把它直接归入某个 CVE,也不据此评价任意当前版本。实际审查应确认部署版本、签名格式、缓存处理和宏策略,而不是复用一个无版本边界的结论。
可靠的文档审阅应并列保存源码、缓存反汇编与执行选择证据。更一般的审查问题是:完整性校验绑定的是原始输入,还是运行时实际消费的派生对象? 先确定被保护的对象,再解释签名“有效”的含义。