技术文章

HotPDF:在工作进程中隔离 PDF 图像编解码器

HotPDF 可以把三种风险最高的 PDF 图像过滤器——DCTDecode、JPXDecode 和 JBIG2Decode——放到一个独立的短生命周期工作进程中解码,而不是在你的应用程序内部解码。启用这项功能的属性是 CodecIsolationMode,实际效果是:一段畸形的 JPEG 2000 码流原本会让你的 VCL 应用崩溃,现在只会杀死一个可丢弃的子进程,主进程报告状态码后继续运行

这种差异在 PDF 实际来源的场景中最为重要:上传表单、邮件网关、扫描设备、合作伙伴的 FTP 投递目录。你无法控制这些字节,而图像编解码器正是历史上损失最集中的地方

为什么一张损坏的图像会拖垮整个应用程序?

因为图像编解码器是 PDF 阅读器中唯一一个要在攻击者可控数据上运行复杂状态机、且几乎没有结构性校验可以兜底的部分。字节到达 JPEG 2000 或 JBIG2 解码器时,交叉引用表已经解析完毕,对象已经解析出来,过滤器链也已经展开,剩下的只是一段原始码流,其中记录着多少个 tile、多少个分量、每个采样多少位。这里出现一个错误的数字不是解析错误,而是一个错误的分配大小,或者是紧凑解码循环内部的一个越界索引

预算限制确实有帮助,你也应该已经用上了。HotPDF 用 DecodeBudgetBytesDocumentDecodeBudgetBytes 限制膨胀,用 DecodeFilterLimitDecodePipelineDepthLimit 限制过滤器链;这些上限背后的考量在嵌套过滤器与 PDF 炸弹的有界解码一文中有详细说明。但字节预算只能回答一个问题:允许多少输出。它无法回答解码器在产生任何输出之前就出错时会发生什么。解码循环内部的一次访问违例不是你可以拒绝的策略违规,而是一个进程级事件,而进程级事件唯一可靠的遏制手段就是换一个进程

HotPDF 隔离了什么,又没有隔离什么

HotPDF 只隔离三种编解码器,在 HPDFCodecIsolation 单元中枚举为 hckDCThckJPXhckJBIG2。其余的——Flate、LZW、RunLength、ASCII85、CCITT——仍然留在进程内,因为这些解码器足够简单,用预算就能约束住,也不是有意思的故障通常发生的地方

传输通道被有意设计得很窄。主进程分配一块有界的共享内存映射,写入一个固定的 THPDFCodecSharedHeader,加上压缩输入数据和任何 JBIG2 全局段,启动工作进程,然后等待。工作进程把解码后的像素写回同一块映射,并设置一个状态字。这里没有管道协议会失步,没有序列化格式可供模糊测试,头部携带一个魔数和版本号,因此版本不匹配的工作进程二进制文件会被直接拒绝,而不是被错误解读

uses
  HPDFDoc, HPDFCodecIsolation;

var
  Pdf: THotPDF;
  Info: THPDFCodecWorkerInfo;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    // 失败关闭:这些编解码器绝不在进程内解码
    Pdf.CodecIsolationMode := cimRequired;
    Pdf.CodecWorkerExecutable := 'HotPDFCodecWorker.exe';
    Pdf.CodecWorkerTimeoutMilliseconds := 5000;       // 1..600000
    Pdf.CodecWorkerMemoryLimitBytes := 268435456;     // 0 或 >= 64 MiB
    Pdf.DecodeBudgetBytes := 134217728;

    if Pdf.LoadFromFile('untrusted-upload.pdf') = 1 then
      if Pdf.GetLoadedImageCount > 0 then
      begin
        Bmp := Pdf.ExtractLoadedImage(0);
        try
          if Pdf.GetLastCodecWorkerInfo(Info) then
            LogCodecOutcome(Info);
        finally
          Bmp.Free;
        end;
      end;
  finally
    Pdf.Free;
  end;
end;

CodecWorkerExecutable 留空,HotPDF 会在你自己可执行文件旁边解析工作进程,也就是 ParamStr(0) 所在目录下的 HotPDFCodecWorker.exe。如果你的部署把工作进程放在别处,就要显式设置这个值;该值会经过 ExpandFileName 展开,所以相对路径是相对于当前目录而不是应用程序目录来解析的,这在服务程序里通常不是你想要的行为

自动还是必须:你更愿意接受哪种失败方式?

THPDFCodecIsolationMode 的三个取值,对同一个问题给出了三种不同的答案:当工作进程完全无法运行时应该怎么办。cimDisabled 完全跳过隔离,在进程内解码,这是 3.x 之前的行为。默认值 cimAutomatic 会尝试启动工作进程,如果工作进程可执行文件缺失或无法启动,就静默回退到进程内解码,此时状态报告为 cwsUnavailablecimRequired 拒绝这种回退:工作进程不可用时,该次解码会被标记为已处理但失败,因此任何不受信任的码流都不会进入你的地址空间

应该按威胁模型来选择,而不是图方便。桌面查看器打开用户磁盘上已有的文档时,用 cimAutomatic 就够了,工作进程缺失时会退化为经典行为,而不会导致产品直接不可用。从互联网解析文件的接入服务应该运行 cimRequired,因为一次悄悄丢掉隔离层的部署失误,正是那种在出事之前没人会注意到的回归问题。注意这里的不对称性:只有 cwsUnavailable 会触发回退。工作进程启动后崩溃、超时或触碰到某个上限,在两种模式下都是解码失败,绝不会静默地在进程内重试

从 THPDFCodecWorkerStatus 读取判定结果

GetLastCodecWorkerInfo 返回最近一次隔离解码的结果,状态枚举足够具体,可以驱动真正的运维决策,而不只是一行笼统的"图像失败"日志。取值包括 cwsNotRuncwsSucceededcwsUnavailablecwsLaunchFailedcwsTimedOutcwsCrashedcwsDecodeFailedcwsProtocolErrorcwsOutputLimit

可以把它们分成三组来看。部署问题是 cwsUnavailablecwsLaunchFailed:有人部署时漏带了工作进程,或者杀毒软件挡住了进程创建。文档问题是 cwsDecodeFailedcwsOutputLimit:文件格式有误,或者超出了你的策略允许的大小,拒绝它就是正确答案。真正有意思的一组是 cwsTimedOutcwsCrashed,因为这些正是以前会挂死或杀死主进程的事件。发生这种情况时,随附的 ProcessIdExitCodeElapsedMilliseconds 字段足以让你与 Windows 错误报告条目做关联,判断这是某个客户文件本身有问题,还是有人在探测你的系统

procedure LogCodecOutcome(const Info: THPDFCodecWorkerInfo);
begin
  case Info.Status of
    cwsSucceeded:
      ; // 无需报告
    cwsUnavailable, cwsLaunchFailed:
      Alert('Codec worker not deployed: ' + Info.ErrorMessage);
    cwsTimedOut, cwsCrashed:
      Quarantine(Format('pid %d exit %d after %d ms',
        [Info.ProcessId, Info.ExitCode, Info.ElapsedMilliseconds]));
  else
    RejectDocument(Info.ErrorMessage);
  end;
end;

真正起作用的限制

每一次隔离解码都会同时受到三道独立上限的约束,弄清楚是哪一道触发了,能省下一下午的猜测时间。CodecWorkerTimeoutMilliseconds 默认值为 10,000,取值范围校验为 1 到 600,000;超出范围会直接抛出异常,而不是静默钳制。CodecWorkerMemoryLimitBytes 默认值为 536,870,912 字节,取值必须为零(表示不限制)或至少 67,108,864 字节,因为更小的上限装不下一个真实解码器的工作集,会导致每个文档都失败。这个内存上限由带 kill-on-close 语义的 Windows Job Object 强制执行,因此即使主进程被突然终止,工作进程也会随作业一起终结

第三道上限是输出限制,它是推导出来的,而不是配置出来的。HotPDF 根据请求的区域,或者根据预期的图像几何尺寸(对 24 位输出来说是宽乘高乘三),计算所需字节数,如果设置了预算,再把这个值钳制到 DecodeBudgetBytes 以内。一个报告了看似合理的头部信息、随后却试图输出远超几何尺寸允许的像素数量的解码器,会被这块内存映射本身拦下,主进程会看到 cwsOutputLimit。这正是隔离层和解码预算互补的原因:预算定义了一张图像被允许有多大,而隔离边界确保关于这个大小的谎言不会变成你进程内的越界写入

在加固的接入路径中处于什么位置

进程隔离是一条防线的最外层,这条防线其实从更早的地方就已经开始。结构性限制在解析阶段拒绝不合理的文档,过滤器预算约束膨胀,隔离层则兜住能挺过前两关的东西。对于到达图像层的文档,值得弄清楚自己实际用的是哪种编解码器,因为JPXDecode 处理JBIG2 符号字典的故障特征截然不同,JBIG2 尤其携带跨页的全局段,一个简单粗暴的按图像沙箱化方案会把它弄坏

代价要老实说清楚:每隔离一张图像就启动一个进程,会增加若干毫秒的开销,一份有几百个扫描页的文档会感受到这一点。要把这个代价和它换来的东西放在一起权衡。对于夜间无人值守运行的批量转换器来说,吞吐量的损失可以忽略不计,而崩溃遏制才是重点所在。对于打开用户已经信任的文档的交互式查看器来说,cimDisabledcimAutomatic 是合理的默认选择。这个模式只是一个普通属性,没有什么能阻止你在运行时按文档类别分别选择

HotPDF 把隔离层、解码预算和结构性解析限制作为一个原生 VCL 组件提供给 Delphi 和 C++Builder,除了工作进程可执行文件本身之外无需部署任何外部运行时。完整的 API 文档和试用版可以在 HotPDF Delphi PDF 组件页面获取