~/posts/binary/vba-signature-source-cache-divergence.md

VBA 签名覆盖范围与编译缓存的执行分歧

同一份 Word 文档的源码显示 Hello,P-Code 却出现 calc 与 Shell。沿模块流和 contents hash 分清签名覆盖、缓存采纳与宏执行策略,并设计单字节对照实验及 V3 签名检查。

date[31:24]
read[23:16]
6 分钟
cat[15:8]
二进制
目录
  1. 0x00模块流保存两份程序表示
  2. 0x01contents hash 并不等于整个文件的哈希
  3. 0x02把源码与 P-Code 放在同一条证据链上
  4. 0x03用单字节对照减少变量
  5. 0x04复核矩阵必须包含四种独立状态
  6. 0x05后续产品边界与检查建议
  7. 0x06参考资料

“签名有效”回答的是某个规定范围的内容是否通过完整性检查,不自动回答运行时选用了哪一份程序表示。VBA 项目同时保存压缩源码与编译缓存;如果两者语义不同,签名覆盖范围和执行选择就必须分开核查。

这里以 2020 年的 Word/VBA 对照文档为例。静态视图显示源码与 P-Code 分歧,但缺少完整 Office 构建号、文件摘要和签名验证日志,因此不据此宣称所有 Office 版本都会执行缓存,或把某次成功执行归因于宏策略失效。

模块流保存两份程序表示

模块流起始部分是 PerformanceCache,后面是 CompressedSourceCode。两段长度都是变量;MODULEOFFSET 给出源码开始的位置,而不是一个固定的全局偏移。

模块流的逻辑布局2 行
区域 范围 含义
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 的输出包含另一种行为。下面保留模块长度、字符串和调用名,不包含本机工具安装路径:

P-Code 视图(末尾节选)
$ 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

两种表示的差别可以直接列出来:

同一模块的两种语义3 行
观察层 可见内容 能支持的结论
源码 MsgBox "Hello" 解压出的程序文本调用消息框
缓存 LitStr "calc"、ArgsCall Shell 反汇编出的 P-Code 包含启动外部程序的调用
运行时 需另行采集 决定最终执行了哪一份表示

只查看 VBA 编辑器或只运行源码提取工具,会漏掉第二行;只看 P-Code,也可能误判一个已经失效、最终被源码重编译替代的缓存。保存、编辑或更换 Office 环境都可能改变这个选择,检查顺序本身应记录在案。

这些输出支持静态语义不一致,不构成计算器已启动或数字签名仍有效的单独证明。后两项应分别留下运行记录与签名验证结果。

用单字节对照减少变量

为了只观察表示选择,可以让两条路径都调用消息框,而分别显示 Hello 与 Hallo。下面是示意字节序列的离线检查:它不定位真实文档,不修改缓存,也不计算签名。

对照输入片段
00000000050048656C6C6F
  1. prefix0x00–0x01
  2. string0x02–0x06
verify_string_diff.pypython
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 文件的绝对偏移。实验若要进入真实缓存层,先证明候选字节属于目标字符串,而非压缩源码、其他模块或无关数据;这个模型本身没有完成那一步。

复核矩阵必须包含四种独立状态

把签名有效、证书受信任、宏获准执行和缓存被采用混成一个布尔值,会让失败与成功都失去解释力。

最小对照矩阵4 行
文档或环境 固定条件 需要观察
原始已签名文档 同一 Office 构建与策略 源码、P-Code、签名与行为基线
仅缓存差异的副本 源码与签名材料保持可比 覆盖范围及执行表示是否分离
触发重编译的副本 记录触发方式,分别保存文件 缓存被替换后是否回到源码语义
另一构建或位数的环境 文档输入相同 缓存兼容性如何影响结果

每个单元记录文件哈希、Office 产品及完整构建号、32/64 位、证书链、宏策略、签名方案版本、验证状态和实际显示内容。状态栏上的一个签名徽标不替代这些记录;出现提示、缓存被重建或宏根本没有进入执行路径时,也应保留负面结果。

后续产品边界与检查建议

Microsoft 后来提供了 V3 VBA 签名和对应升级说明,包括重新签名以及仅信任 V3 签名的策略。兼容性默认设置可能仍接受旧签名,所以“已经升级 Office”与“所有文档已经采用 V3 并落实策略”不是同一件事。Microsoft KB5000676

这份 2020 年对照材料的具体构建号缺失,不把它直接归入某个 CVE,也不据此评价任意当前版本。实际审查应确认部署版本、签名格式、缓存处理和宏策略,而不是复用一个无版本边界的结论。

可靠的文档审阅应并列保存源码、缓存反汇编与执行选择证据。更一般的审查问题是:完整性校验绑定的是原始输入,还是运行时实际消费的派生对象? 先确定被保护的对象,再解释签名“有效”的含义。

参考资料

NORMAL~/posts/binary/vba-signature-source-cache-divergence.md§--
0%zh-CN