HotPDF 可以把三种风险最高的 PDF 图像过滤器——DCTDecode、JPXDecode 和 JBIG2Decode——放到一个独立的短生命周期工作进程中解码,而不是在你的应用程序内部解码。启用这项功能的属性是 CodecIsolationMode,实际效果是:一段畸形的 JPEG 2000 码流原本会让你的 VCL 应用崩溃,现在只会杀死一个可丢弃的子进程,主进程报告状态码后继续运行
这种差异在 PDF 实际来源的场景中最为重要:上传表单、邮件网关、扫描设备、合作伙伴的 FTP 投递目录。你无法控制这些字节,而图像编解码器正是历史上损失最集中的地方
为什么一张损坏的图像会拖垮整个应用程序?
因为图像编解码器是 PDF 阅读器中唯一一个要在攻击者可控数据上运行复杂状态机、且几乎没有结构性校验可以兜底的部分。字节到达 JPEG 2000 或 JBIG2 解码器时,交叉引用表已经解析完毕,对象已经解析出来,过滤器链也已经展开,剩下的只是一段原始码流,其中记录着多少个 tile、多少个分量、每个采样多少位。这里出现一个错误的数字不是解析错误,而是一个错误的分配大小,或者是紧凑解码循环内部的一个越界索引
预算限制确实有帮助,你也应该已经用上了。HotPDF 用 DecodeBudgetBytes 和 DocumentDecodeBudgetBytes 限制膨胀,用 DecodeFilterLimit 和 DecodePipelineDepthLimit 限制过滤器链;这些上限背后的考量在嵌套过滤器与 PDF 炸弹的有界解码一文中有详细说明。但字节预算只能回答一个问题:允许多少输出。它无法回答解码器在产生任何输出之前就出错时会发生什么。解码循环内部的一次访问违例不是你可以拒绝的策略违规,而是一个进程级事件,而进程级事件唯一可靠的遏制手段就是换一个进程
HotPDF 隔离了什么,又没有隔离什么
HotPDF 只隔离三种编解码器,在 HPDFCodecIsolation 单元中枚举为 hckDCT、hckJPX 和 hckJBIG2。其余的——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 会尝试启动工作进程,如果工作进程可执行文件缺失或无法启动,就静默回退到进程内解码,此时状态报告为 cwsUnavailable。cimRequired 拒绝这种回退:工作进程不可用时,该次解码会被标记为已处理但失败,因此任何不受信任的码流都不会进入你的地址空间
应该按威胁模型来选择,而不是图方便。桌面查看器打开用户磁盘上已有的文档时,用 cimAutomatic 就够了,工作进程缺失时会退化为经典行为,而不会导致产品直接不可用。从互联网解析文件的接入服务应该运行 cimRequired,因为一次悄悄丢掉隔离层的部署失误,正是那种在出事之前没人会注意到的回归问题。注意这里的不对称性:只有 cwsUnavailable 会触发回退。工作进程启动后崩溃、超时或触碰到某个上限,在两种模式下都是解码失败,绝不会静默地在进程内重试
从 THPDFCodecWorkerStatus 读取判定结果
GetLastCodecWorkerInfo 返回最近一次隔离解码的结果,状态枚举足够具体,可以驱动真正的运维决策,而不只是一行笼统的"图像失败"日志。取值包括 cwsNotRun、cwsSucceeded、cwsUnavailable、cwsLaunchFailed、cwsTimedOut、cwsCrashed、cwsDecodeFailed、cwsProtocolError 和 cwsOutputLimit
可以把它们分成三组来看。部署问题是 cwsUnavailable 和 cwsLaunchFailed:有人部署时漏带了工作进程,或者杀毒软件挡住了进程创建。文档问题是 cwsDecodeFailed 和 cwsOutputLimit:文件格式有误,或者超出了你的策略允许的大小,拒绝它就是正确答案。真正有意思的一组是 cwsTimedOut 和 cwsCrashed,因为这些正是以前会挂死或杀死主进程的事件。发生这种情况时,随附的 ProcessId、ExitCode 和 ElapsedMilliseconds 字段足以让你与 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 尤其携带跨页的全局段,一个简单粗暴的按图像沙箱化方案会把它弄坏
代价要老实说清楚:每隔离一张图像就启动一个进程,会增加若干毫秒的开销,一份有几百个扫描页的文档会感受到这一点。要把这个代价和它换来的东西放在一起权衡。对于夜间无人值守运行的批量转换器来说,吞吐量的损失可以忽略不计,而崩溃遏制才是重点所在。对于打开用户已经信任的文档的交互式查看器来说,cimDisabled 或 cimAutomatic 是合理的默认选择。这个模式只是一个普通属性,没有什么能阻止你在运行时按文档类别分别选择
HotPDF 把隔离层、解码预算和结构性解析限制作为一个原生 VCL 组件提供给 Delphi 和 C++Builder,除了工作进程可执行文件本身之外无需部署任何外部运行时。完整的 API 文档和试用版可以在 HotPDF Delphi PDF 组件页面获取