Technical Article

强化 PDFium VCL 绑定:ABI 与内存安全性

C 库上的 Pascal 绑定读起来像普通的 Pascal。你调用一个方法,得到一个记录返回,释放你分配的内容。麻烦在于 PDFium 是一个 C 和 C++ 库,它有自己的调用约定、自己的整数宽度以及自己关于谁拥有内存和谁释放内存的规则。这些契约本身不会自动跨越语言边界。每一个契约都必须在 Pascal 声明中手动重新声明,而一个错误的词就会将看起来干净的调用变成堆栈损坏、截断的偏移量或双重释放。对 PDFium VCL 绑定进行的 v1.61.0 审计发现了每种类型的一个缺陷。它们值得一读,因为它们不是该绑定所特有的。它们是将任何 C API 封装到 Delphi 或 Lazarus 中的常见风险。

cdecl 是函数类型的一部分,而不是修饰物

PDFium 是已编译的 C。在 Win32 上,它的导出项,更重要的是它调用的回调函数,使用的是 cdecl 调用约定。在 cdecl 下,调用者在调用返回后清理堆栈。Delphi 原生的默认约定是 register,而某些库中回调函数的 Win32 C 标准是 stdcall(其中由被调用者进行清理)。当一个结构向 PDFium 传递一个函数指针而你忘记了该指针类型上的 cdecl 时,双方就会在谁调整堆栈指针的问题上产生分歧。要么双方都调整,要么都不调整,堆栈指针在每次调用时都会偏离参数的大小。

这个缺陷难以被发现的原因是其破坏是非局部的。损坏的调用返回后看起来很正常。错位会稍后显现,出现在某些无关的函数中,该函数的栈帧现在栈顶指针偏移了几个字节的堆栈指针上,这表现为野指针读取、错误的返回地址,或者是在回溯指向的地方与你实际搞错的回调函数毫不相关的崩溃。表单填充是这方面最容易出问题的地方,因为表单填充接口是一个充满了 PDFium 调用的回调函数的记录(record)。其中之一是 FFI_OpenFile,它向 PDFium 传递一个用来打开外部文件的函数,声明为 function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl。末尾的 cdecl 是值得注意的重点。去掉它,代码仍然可以编译、链接,并且运行良好,直到 PDFium 调用该函数为止。该调用约定属于函数类型本身。它不是可选的修饰,当它缺失时,编译器不会警告你,因为普通函数类型是一个完全合法的 Pascal 类型。唯一的防御手段是将调用约定视为每个导入签名以及你传递出去的每个回调函数的必填字段。

size_t 是指针宽度的,在 FPC Win64 上这意味着 64 位

第二个缺陷是仅在某个目标平台上出现的整数宽度不匹配。C 语言的 size_t 被定义为足够宽以容纳任何对象大小,这在 64 位平台上意味着 64 位无符号整数。PDFium 的 渐进式加载接口以 size_t 字节偏移量进行交流。可用性提供程序的 FX_FILEAVAIL 记录携带一个 IsDataAvail 回调,PDFium 会使用偏移量和大小来调用它,而 FX_DOWNLOADHINTS 记录的 AddSegment 回调也接收相同的内容。这两个参数都是 size_t

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

如果你将这些偏移量声明为 32 位类型,该绑定在 Win32 和 Delphi Win64 上工作正常,然后在 FPC 和 Lazarus Win64 上会静默损坏。原因很微妙。在 FPC Win64 上, NativeUInt 是一个真正的指针宽度 64 位类型,并且 size_t 被别名指向它。绑定在类型部分中有一条注释,明确警告不要在 FPC 上遮蔽(shadow)NativeUInt,因为在此处将其重新定义为 32 位别名会强制将 size_t 设为 32 位,并破坏传递给该库或由该库写入的每个 size_t 参数。到达 32 位参数的 64 位偏移量会丢失其高半部分。对于小文件,每个偏移量都适合 32 位,没有问题。对于大文件,一旦偏移量越过 4 GB 线,截断的值就会指向完全不同的地方,PDFium 会询问错误的字节范围是否可用,并且渐进式加载会陷入停滞或读取垃圾数据。该缺陷直到文件足够大且目标平台是 size_t 确实变宽的那个平台时才可见。

Pascal 异常绝不能通过 C 栈帧进行展开

第三类是关于异常模型的,而 C 语言没有这个模型。当 PDFium 调用你的一个回调函数时,你的 Pascal 代码是在对 Delphi 异常机制一无所知的 C 和 C++ 栈帧堆栈中运行的。如果你的回调函数抛出异常并允许异常传播,它将通过从未被设计为可以展开的栈帧进行展开。PDFium 自身的清理工作不会运行,其内部不变量被置于半更新状态,进程现在处于该库从未预料到的状态。这些回调的约定是一个返回码,而不是异常。

两个回调使这一点更加明确。FPDF_FILEWRITE 是 PDFium 写入已保存文档的接收器,而 FPDF_FILEACCESS 是它从中读取输入文档的源。这两者在此处都是基于 Delphi 的 TStream 实现的,并且这两者都会像任何流一样失败:磁盘填满、流在下方关闭、读取超出末尾。写入回调封装了其流写入,并将任何失败转换为 PDFium 的失败代码,而不是让其逃逸。

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

读取端也是如此:失败的读取报告零以符合 FPDF_FILEACCESS 约定,而不是跨越边界抛出异常。对于被训练为永远不要吞掉异常的 Pascal 程序员来说,没有重新抛出异常的空 except 看起来是错误的,在普通的 Pascal 中确实也是错误的。但是在 ABI 边界处,它是正确的形式,因为交还给 C 调用者唯一的安全值是它知道如何解释的状态码。失败仍然会传播,只是通过返回值传播,一旦控制权回到 Pascal 侧,库上方的调用代码就会将其显现为 EPdfError

双重释放隐藏在错误路径上

第四个缺陷是所有权问题。PDFium 文档句柄由库打开,并且必须通过 FPDF_CloseDocument 精确关闭一次。危险在于一个错误路径释放了一个另一个清理也拥有的句柄。设想一个例程,它创建一个包装器对象,将一个新鲜打开的文档句柄分配给它,然后进行可能失败的更多设置。如果设置抛出异常,在原始句柄上调用 FPDF_CloseDocument 的早期返回处理程序将关闭它,然后包装器对象在释放时,其自身的析构函数将再次关闭它。该句柄被释放了两次,这是未定义行为,很可能会导致崩溃。

审计在 imposition 风格的导入路径上发现了这一点,该路径围绕一个已经打开的句柄构建 TPdf。修复方法是将所有权转移作为单一的可信数据源。一旦句柄分配给了包装器的字段,包装器就拥有了它,错误路径上唯一的清理就是释放包装器。包装器的析构函数会为你调用 FPDF_CloseDocument,因此第二次显式关闭将双重释放相同的文档。修正后的错误处理程序会释放对象并重新抛出异常,从而只有一条通往关闭的路径。

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

托管记录和布满导出项的库都需要显式卸载清理

最后一类是关于编译器代表你管理的内存的,C 语言的习惯会悄悄地破坏它。该绑定的许多助手函数都返回包含 WideString 或动态数组的记录(record)。这些是引用计数的字段,编译器发出隐藏的簿记以维持它们的计数。从 C 语言带过来的直觉是用 FillChar(Result, SizeOf(Result), 0) 来清理新记录。这会在记录内的托管引用上盖上零,而没有先递减它。编译器在循环迭代中为函数结果重用一个隐藏的临时变量,因此在第二次迭代中,FillChar 覆盖了一个从未释放的活动字符串指针,导致它指向的字符串发生泄露。在循环中对一千个批注调用该函数,你就会泄露一千个字符串。

修复方法是让语言以它知道的方式使用 Default(T) 清理记录,这会在将记录归零之前释放任何托管字段。

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

所有权方面相关的一个问题存在于库加载边界。在 LoadLibrary 之后,此绑定使用 GetProcAddress 从 PDFium DLL 中解析了数百个函数指针。如果缺少一个必需的导出项,部分绑定的状态是危险的:几十个指针是有效的,其余的是 nil 或陈旧的,任何后来通过其中之一进行的调用都会跳转到可能已经被卸载的模块中。绑定通过在必需导出项无法解析时卸载该库并运行完整的 ClearAllBindings 将每个导入指针重置为 nil 来处理此问题。在此之后,没有函数指针悬空指向已卸载的模块,后来的调用会因为 nil 指针检查而干净地失败,而不是分支跳转到已释放的代码中。

包装器是手动重新声明四个契约的地方

这五个缺陷没有一个是少见的。它们是 C API 之上薄薄的 Pascal 层可以预见的失败模式,它们之所以聚集在一起,是因为该层恰好是必须重新声明四个独立契约的地方。每个回调函数上的调用约定都必须拼写为 cdecl。在整数宽度实际变宽的一个目标平台上,它必须匹配 size_t。在每个跨越 Pascal 边界的回调中,必须将异常模型转换为返回码。每个句柄和每个托管字段的所有权都必须声明一次,并在每条路径上服从,包括直到生产环境才有人运行的错误路径。错过任何一个,你都会得到一个其症状显现在远离其原因之处的缺陷,这就是为什么这一类缺陷排查成本高昂的原因。审计的价值不仅在于任何单一的修复,更在于将这些作为其自身的规则在整个绑定中进行检查。

如果你想看到绑定在做实际工作而不是保护其边缘,我们关于渲染缓存和缩放性能的笔记中展示了渲染路径,而构建 Lazarus 和 FPC 查看器的交叉编译器指南则是这里描述的 Win64 size_t 行为实际起作用的地方。两者都基于 Delphi、Lazarus 和 C++Builder 的 PDFium Component 中提供的相同内存安全和 ABI 工作,同时还包括本博客其他地方介绍的渲染、文本提取和表单 API。