当你对 PDF 进行签名时,你通常认为签名密钥是处于你控制之下的。它存在于你生成的 .pfx 文件中,并由你选择 the 密码保护。读取该文件的代码感觉像是管道,而不是边界。但一旦证书不再属于你,这种直觉就是错误的。允许用户选择任意 .pfx 的桌面工具、接受上传凭据的服务器、通过网络获取证书的批量签名器,都会在生成单个签名字节之前,将受攻击者影响的字节传递给解析器。PKCS#12 读取器是攻击面,这与图像解码器或字体加载器是攻击面的道理相同。
本文将介绍该读取器中存在的两个真实缺陷,均位于导入签名凭据的路径中。它们都不罕见。两者都源自几乎影响所有用固定宽度整数语言编写的二进制解析器的根本原因:来自文件的长度或计数被过度信任了。一个导致越界读取(out-of-bounds read),另一个导致进程挂起直到你将其杀死。
字节流向何处
导入 .pfx 以对文档进行签名并非单一操作,而是一个简短的流水线,每个阶段都会解析攻击者可能编写的内容。容器是 RFC 7292 中定义的 PKCS#12 结构,它是包裹在保存私钥的加密护罩周围的 AuthenticatedSafe 包的嵌套。读取它意味着遍历 ASN.1、从密码派生密钥、解密,然后将恢复的 RSA 密钥传递给构建签名的代码。
在 HotPDF 中,这些阶段映射到不同的单元。PKCS#12 容器逻辑存在于 HPDFPFX 中。它触及的每个标签、长度和值都由 HPDFASN1 中的 ASN.1 读取器解码。密钥派生和 PBES2 解密与 PBKDF2HMACSHA256 一起位于 HPDFCrypt 中。当密钥恢复后,HPDFRSA 和 HPDFCMS 中的 CMS SignedData 构建器会将其转换为嵌入在 PDF 中的分离式签名。驱动整个链的公共入口点只需一次调用。
// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
'signer.pfx', 'p@ssw0rd') then
// signature embedded
else
// signing did not complete
;
在发生任何密码学操作之前,signer.pfx 的每个字节都会流经 HPDFASN1 和 HPDFPFX。如果这两个单元对文件所声明的内容不够谨慎,下游的密码学就根本没有机会发挥作用。
缺陷一:越过防护边界的 ASN.1 长度回绕
DER 和 BER 中的 ASN.1 将每个元素编码为标签、长度以及相应字节数的内容。长度是你必须“信任但要验证”的字段,因为它告诉解析器要读取多远,并且它是由生成文件的人编写的。X.690 §8.1.3 定义了两种编码。短格式将 0 到 127 的长度打包到单个字节中。用于更大内容的长格式则占用一个前导字节,其低 7 位给出随后的长度字节数,然后相应数量的大端字节携带实际值。因此,四个长度字节可以声明接近 4 GB 的内容大小。
在解码此值之后,解析器必须在信任它之前检查内容是否确实适合缓冲区。自然的检查是确认当前位置加上内容长度不会越过数据的末尾。如果用显而易见的方式编写,将当前位置、内容长度和总数都保存在 32 位有符号整数中,那么这种防护将失效:
// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
raise EHPDFASN1Error.Create('content overruns buffer');
问题在于加法,而不在于比较。当 ContentLen 接近 MaxInt (2147483647) 时,Pos + ContentLen 会溢出有符号的 32 位范围并回绕为负数。负的和永远不会大于 Total,因此防护机制报告一切正常,并允许解析器继续处理缓冲区并不包含的约 2 GB 的内容长度。接下来发生的就是破坏:读取器为该声明的长度分配一个缓冲区并复制到其中(先是 SetLength 然后是 Move 读取源数据)。源数据只剩下几百字节,因此复制会读取到远远超出输入末尾的地方,这种越界读取在最好的情况下是崩溃,在最坏的情况下会将相邻的进程内存泄露到解析过程中。
唯一正确的防护是在比较之前加宽中间和,使加法不会溢出其计算所在的类型。修复方法是将两个操作数都提升为 Int64:
// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
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 解码到该字段中,大值会被截断,而设计为落在符号位上的值会作为负数或某些无关的小数字返回,因此甚至工作量的大小也已不再是文件似乎要求的样子。修复方法是读取全宽的值,并在收窄之前对其进行界定:
// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node); // returns 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); // safe: already bounded
读取到 Int64 意味着解码后的值是真实值,而不是它被截断后的幽灵。下界拒绝零和负计数,这对于密钥派生来说是没有意义的。上界(一亿)远高于任何合法的 PKCS#12 文件(如今使用数万到数十万次迭代),同时将最坏情况限制在可承受的有限工作量内。只有在值通过该范围后,才被收窄为 32 位字段,因此截断不再会出乎意料。在 HotPDF 中,此夹紧(clamp)限制位于 ParsePBES2Params 中,在此处,PBKDF2 参数在流向 PBKDF2HMACSHA256 的途中被解码。
为什么两种修复是同一种修复
这两个缺陷看起来不同,一个是缓冲区溢出,一个是进程挂起,但它们是同一个错误。在每种情况下,来自未授权文件的数字都在与其真实情况核对之前,过早地被导入到固定宽度类型中。长度在边界测试之前以 32 位相加;迭代次数在范围测试之前收窄为 32 位。两者都屈从于同一套规则:全宽解码,针对真实限制进行检查,然后才收窄。中间的 Int64 不是风格选择,它是防护机制能够看到攻击者实际写入的值的唯一宽度。会溢出的边界不是边界,没有上限 of 计数不是参数,它是对你自身 CPU 的远程限制。
签名流水线的实用指南
直接的教训是以验证任何不受信任上传的方式来验证不受信任的证书输入。限制你接受的 .pfx 的大小,因为合法的 .pfx 是千字节级别,而不是兆字节级别。将解析失败视为常规的被拒输入,而不是值得向用户展示堆栈轨迹的错误。如果你在服务器上进行签名,请在卡住的工作线程无法摧毁服务的地方运行导入,并为该操作设置超时,以便通过实际时钟以及迭代限制来约束异常耗费资源的文件。
更广泛的教训超出了证书本身。解析器强化不是对单个单元的一次性审计,它是你库中读取非自身写入字节的每个地方的属性。PDF 库从不受信任的源解析了大量内容:文档中嵌入的字体、采用半数编解码器的图像、流过滤器,以及在签名路径上的证书。其中的每一个都是攻击面,每一个都值得对每个长度和每个计数保持相同的怀疑。HotPDF 将导入和签名路径构建在此处介绍的强化的 HPDFASN1、HPDFPFX、HPDFCrypt 和 HPDFCMS 单元之上,以便无论你传递给它的凭据来自何处,都在它被信任之前进行防御性解析。
这些检查所保护的签名工作流已在我们关于 Delphi 中 PAdES 数字签名的指南中端到端地涵盖,而应用于文档加密(包括共享此代码库的 AES-256 密钥路径)的相同防御姿态,已在关于 AES-256 加密和安全性的文章中描述。所有这些都作为 Delphi 和 C++Builder 的 HotPDF Component 的一部分提供,同时还包括本博客其他地方介绍的加载、编辑、加密和签名 API。