技术文章

强化 Delphi PDF 签名器以抵御恶意 PKCS#12

签 PDF 的时候,你通常把签名密钥当成自己掌控的东西。它躺在一个你生成的 .pfx 文件里,由你选的密码保护,读这个文件的代码感觉像管道,而不是边界。一旦证书不再属于你,这个直觉就错了。允许用户挑任意 .pfx 的桌面工具、接受上传凭据的服务器、从网络喂证书的批量签名器,都在产出第一个签名字节之前,就把受攻击者影响的字节递给了解析器。PKCS#12 读取器是攻击面,和图像解码器、字体加载器是攻击面是同一个道理

本文走一遍每个 PKCS#12 读取器都必须防住的两个陷阱——都在导入签名凭据的路径上——以及 HotPDF 如何关掉它们。两个都不稀奇,都源自同一个根因:凡是拿固定宽度整数写的二进制解析器几乎都躲不开——文件里的长度或计数被多信任了一步。一个导致越界读取,另一个让进程挂死到你去杀它

HotPDF 示意图:PBKDF2 迭代次数陷阱:无上界的伪造四十亿轮计数会拖死 CPU,而 HPDFCrypt 里的下界与上界会将其拒收
一亿高于任何合法文件,同时封住最坏情况 — 先宽解码,再设界,然后才使用
HotPDF:ASN.1 长度回绕陷阱的两条路:32 位加法中伪造的长度溜过天真的边界检查,而 HPDFASN1ParseNode 以 Int64 读取并将其拒收
窄防护校验的只是真实值被截断的幽灵;先按宽类型读取,伪造的长度在任何 SetLength 之前就现形

字节走过的路

导入 .pfx 去签一份文档不是单个操作,而是一小段流水线,每一级都在解析攻击者可能写过的东西。容器是 RFC 7292 定义的 PKCS#12 结构,一层 AuthenticatedSafe 包裹着存私钥的加密护罩。读它意味着走 ASN.1、从密码派生密钥、解密,然后把还原出来的 RSA 密钥交给构建签名的代码

在 HotPDF 里这些阶段落在不同的单元。PKCS#12 容器逻辑在 HPDFPFX。它碰到的每个标签、长度和值都由 HPDFASN1 里的 ASN.1 读取器解码。密钥派生和 PBES2 解密在 HPDFCrypt,与 PBKDF2HMACSHA256 作伴。密钥还原后,HPDFRSA 和 HPDFCMS 里的 CMS SignedData 构建器把它变成嵌进 PDF 的分离签名。驱动整条链的公开入口是一次调用

HotPDF 中 .pfx 导入管道:受攻击者影响的字节先经 HPDFPFX 与 HPDFASN1,再进入 HPDFCrypt 密钥派生和 HPDFRSA/HPDFCMS 签名
PKCS#12 读取器本身就是攻击面——两个解析器一旦大意,下游的密码学再强也无济于事
// 驱动完整流水线:加载占位 PDF、解析 PFX、
// 派生密钥、构建 CMS SignedData、写出签名输出。
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // 签名已嵌入
else
  // 签名未完成
;

signer.pfx 的每个字节在任何密码学发生之前都流经 HPDFASN1 和 HPDFPFX。这两个单元若对文件的声明不够提防,下游的密码学根本轮不到发挥

陷阱一:回绕越过防护的 ASN.1 长度

DER 和 BER 里的 ASN.1 把每个元素编码成标签、长度和相应数量的内容字节。长度是那个你必须“信任但要验证”的字段:它告诉解析器读多远,而它是由文件的产出者写的。X.690 §8.1.3 定义了两种编码。短格式把 0 到 127 的长度塞进单个字节。长格式用于更大的值,花一个前导字节,其低 7 位给出后续长度字节的个数,然后那么多大端字节承载实际值。四个长度字节因此能声明接近 4 GB 的内容大小

解码出这样的值之后,解析器得先确认内容确实装得进缓冲区,才能信它。自然的检查是确认当前位置加内容长度不超过数据末尾。按最直白的方式写——位置、内容长度、总量都放 32 位有符号整数——这个防护是坏的:

// 陷阱:有符号 32 位算术。ContentLen 接近 MaxInt 时,
// Pos + ContentLen 溢出成负值,比较结果为假,
// 伪造的约 2 GB 长度径直通过。
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

问题在加法,不在比较。ContentLen 接近 MaxInt(2147483647)时,Pos + ContentLen 溢出 32 位有符号范围,回绕成负数。负数永远不会大于 Total,于是防护报告一切正常,解析器拿着一个缓冲区根本装不下的约 2 GB 内容长度继续走。接下来发生的就是损害:读取器按声明的长度分配缓冲区往里拷贝——一次 SetLength 接一次从源读取的 Move。源只剩几百字节,拷贝远远读过了输入末尾,一次越界读取,好则崩溃,坏则把相邻的进程内存泄进解析结果

唯一正确的防护是在比较之前加宽中间和,让加法不可能溢出它所在的计算类型。HotPDF 把两个操作数都提升为 Int64:

// 正确做法:两个操作数在相加前都加宽为 Int64,总和
// 不会回绕。伪造的 2 GB 长度现在过不了边界检查。
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64 无损装下两个 32 位值的和,比较看到的是真实数字,伪造长度被拒。ContentLen 的独立非负检查堵上了另一个对称情形——解码值本身就是负数。在 HotPDF 里这个防护住在 HPDFASN1ParseNode,那个产出所有其他辅助函数都依赖的节点的函数。因为 HPDFASN1Content 直接按节点的内容长度定 SetLength 和 Move 的规模,一个混过弱防护的节点会毒化从它出发的每一次读取。在解码点强制边界,才是让上层辅助函数安全的原因

陷阱二:被当成武器的 PBKDF2 迭代次数

第二个陷阱不是内存错误,而是文件在指挥你的 CPU 干多重的活。PKCS#12 用 PBES2 保护密钥材料——PKCS#5 的基于口令的方案,RFC 8018 规定。PBES2 先跑一个密钥派生函数,这里是带 HMAC-SHA-256 的 PBKDF2,然后一个密码,这里是 AES-256-CBC。PBKDF2 吃一个迭代次数,而这个次数是文件携带的参数。它的全部意义就是慢:迭代越多,每次口令猜测越贵,对离线攻击者是好事。RFC 8018 §4.2 明确说更大的计数对安全更好,并且刻意不设上限

文件是你生成的时候,这份开放没问题。文件是攻击者生成的时候,它就是武器。迭代次数是一个攻击者控制的工作因子,而攻击者控制的工作因子就是算法复杂度拒绝服务。伪造的 .pfx 可以把迭代次数编到几十亿;解析器尽职地读它、为这么多轮 HMAC-SHA-256 调 PBKDF2,进程就消失在一个几小时都不会返回的循环里——只凭你提交的一份文件。在一台每个请求处理一份凭据的签名服务器上,一次精心构造的上传就能拖住一个工作线程

这个计数在让 CPU 空转之前,先让回绕更糟。迭代值在文件里是没有固定宽度的 ASN.1 INTEGER,而 PBKDF2 最终消费的字段是 32 位 Integer。把 INTEGER 直接解到那个字段,大值被截断,而一个精心构造、落在符号位上的值会以负数或某个不相干的小数回来,于是连工作量的大小都不再是文件看起来的请求。HotPDF 以全宽读取该值,在收窄之前先设界:

// 先把迭代次数读成 Int64,再收敛到合理区间,
// 然后才收窄进 PBKDF2 使用的 32 位 Iterations 字段。
LIter := HPDFASN1ToInteger(Data, Node);          // 返回 Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // 安全:已经设界

读进 Int64 意味着解码出的值是真实值,不是它被截断的幽灵。下界拒收零和负数——对密钥派生来说毫无意义。上界一亿,远高于今天任何合法 PKCS#12 文件(它们用几万到十几万次迭代),同时把最坏情况封顶到有限的、扛得住的工作量。只有过了这个区间,值才被收窄进 32 位字段,截断从此不再能惊到任何人。在 HotPDF 里这个夹紧住在 ParsePBES2Params,PBKDF2 参数在通往 PBKDF2HMACSHA256 的路上在那里解码

为什么两个防护是同一个防护

两个陷阱看着不同——一个是缓冲区越界,一个是进程挂死——但它们是同一个错误。每种情形里,一个来自不受信文件的数字都被过早装进固定宽度类型,在对照现实检查之前。长度在边界测试前用 32 位相加;迭代次数在范围测试前收窄成 32 位。两者都败给同一条纪律:全宽解码,对照真实限制检查,然后才收窄。中间那个 Int64 不是风格选择,它是防护能看见攻击者实际写入之值的唯一宽度。会溢出的边界不是边界,没有上限的计数不是参数——它是架在你自己 CPU 上的远程油门

签名流水线的实用建议

窄的教训是:像验证任何不受信上传一样验证不受信的证书输入。给你接受的 .pfx 设大小上限,合法的是千字节级,不是兆字节级。把解析失败当作寻常的被拒输入,而不是值得给用户看堆栈跟踪的错误。在服务器上签名的话,把导入放在卡死一个工作线程也拖不垮服务的地方跑,并给操作加超时,让一份意外昂贵的文件既受迭代上限约束,也受挂钟时间约束

更宽的教训超出证书。解析器加固不是对某个单元的一次性审计,它是你的库每一处读取非自己所写字节的地方的属性。PDF 库从不受信来源解析大量东西:文档里嵌入的字体、六种编解码器的图像、流过滤器,以及签名路径上的证书。每一个都是攻击面,每一个都值得对每个长度、每个计数抱同样的怀疑。HotPDF 把导入和签名路径建在这里介绍的加固过的 HPDFASN1、HPDFPFX、HPDFCrypt 和 HPDFCMS 单元上,让你交出的凭据无论来自哪里,在被信任之前都先被防御性地解析

这些检查保护的签名工作流在Delphi 中 PAdES 数字签名演练一文里端到端讲过;同一套防御姿态用在文档加密上——包括共享这套代码库的 AES-256 密钥路径——见AES-256 加密与安全一文。这一切都作为面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 的一部分提供,与本博客其他文章介绍的加载、编辑、加密和签名 API 同在