技術文章

HotPDF:以工作處理程序隔離 PDF 影像編解碼器

HotPDF 能把三種風險最高的 PDF 影像篩選器,也就是 DCTDecode、JPXDecode 與 JBIG2Decode,改在一個獨立、生命週期短暫的工作處理程序中解碼,而不是在你的應用程式內部解碼。啟用這項功能的屬性是 CodecIsolationMode,實際效果是:一個原本會讓 VCL 應用程式當掉的畸形 JPEG 2000 位元流,現在只會殺死一個可拋棄的子處理程序,主控端則回報一個狀態碼,繼續運作

這項差異在 PDF 實際來源最容易派上用場:上傳表單、郵件閘道、掃描裝置、合作夥伴的 FTP 投遞點。你無法掌控這些位元組的內容,而影像編解碼器正是歷史上損害最集中的地方

為什麼一張壞圖能拖垮整個應用程式?

因為在 PDF 讀取器中,影像編解碼器是唯一一個要在幾乎沒有結構檢查可依靠的情況下、對攻擊者可控資料執行複雜狀態機的部分。當位元組抵達 JPEG 2000 或 JBIG2 解碼器時,交互參照表早已解析完成、物件已被解析、篩選器鏈也已展開,剩下的就是一段原始位元流,其中記載著多少個圖塊、多少個色彩分量、每個樣本多少位元。這裡出現一個錯誤數字,不會只是剖析錯誤,而是緊湊解碼迴圈中的一次錯誤配置大小,或是一次越界索引

預算限制有幫助,你也應該已經有這些限制。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 強制執行的,所以即使主控端被強制終止,工作處理程序也會隨著 Job 一起結束

第三道上限是輸出上限,它是推導出來的,而不是直接設定的。HotPDF 會根據要求的區域,或是根據預期的影像幾何資訊(24 位元輸出時為寬乘以高再乘以三)計算出所需的位元組數,若設定了預算,再把這個值夾限到 DecodeBudgetBytes。若解碼器回報了一個看似合理的標頭,卻試圖輸出遠超過該幾何資訊所允許的像素量,就會被這塊記憶體對映本身擋下來,主控端會看到 cwsOutputLimit。這正是隔離層與解碼預算彼此互補的原因:預算定義了一張影像被允許多大,而隔離邊界則確保一個關於大小的謊言,不會變成你自己處理程序中的越界寫入

這在強化的接收路徑中的位置

處理程序隔離是防禦鏈中最外層的一環,這條鏈從更早之前就已經開始。結構性限制在剖析階段就會拒收不合理的文件。篩選器預算限制展開量。隔離則圍堵住撐過前兩道防線的內容。對於進到影像層的文件而言,值得先弄清楚你實際面對的是哪一種編解碼器,因為 JPXDecode 處理JBIG2 符號字典 的失敗特徵差異相當大,尤其 JBIG2 還帶有跨頁的全域區段,天真的逐張影像沙箱設計會把它弄壞

代價是誠實的,也值得明講:每隔離一張影像就啟動一個處理程序會增加毫秒級的開銷,一份有數百頁掃描內容的文件會明顯感受到這一點。要對照它換來了什麼再做取捨。在無人值守整夜運行的批次轉換器上,吞吐量的損失是察覺不到的,而崩潰圍堵正是整個功能的核心價值。在開啟使用者已經信任的文件的互動式檢視器上,cimDisabledcimAutomatic 才是合理的預設值。這個模式只是一個普通屬性,因此沒有什麼能阻止你在執行階段依文件類別分別選擇

HotPDF 把隔離層、解碼預算與結構性剖析器限制,全部整合成一個原生 VCL 元件,供 Delphi 與 C++Builder 使用,除了工作處理程序執行檔本身之外,不需要部署其他外部執行環境。完整 API 文件與試用版請參見 HotPDF Delphi PDF 元件頁面