技術記事

HotPDFでPDF画像コーデックをワーカープロセスに隔離

HotPDFは、PDF画像フィルタの中でも特に危険な3種類、DCTDecode、JPXDecode、JBIG2Decodeを、アプリケーション内部ではなく短命な別ワーカープロセスの内部でデコードできます。これを有効にするプロパティがCodecIsolationModeで、実際の効果としては、以前ならVCLアプリケーションをクラッシュさせていたであろう不正なJPEG 2000コードストリームが、今では使い捨ての子プロセスを道連れにするだけで済み、ホスト側はステータスコードを報告してそのまま処理を続けられます

この違いが最も重要になるのは、実際にPDFが届く経路、たとえばアップロードフォーム、メールゲートウェイ、スキャン機器、取引先のFTPドロップです。そこを流れるバイト列は自分でコントロールできず、そして画像コーデックこそが、歴史的に被害の温床になってきた場所です

1枚の不正な画像がアプリケーション全体を巻き込んで落ちるのはなぜか

なぜなら、画像コーデックはPDFリーダーの中でも唯一、攻撃者が制御可能なデータに対して複雑なステートマシンを走らせる部分でありながら、頼りにできる構造的チェックがほとんど残っていない箇所だからです。バイト列がJPEG 2000やJBIG2のデコーダーに到達する時点で、クロスリファレンステーブルはすでに解析済み、オブジェクトはすでに解決済み、フィルタチェーンもすでに展開済みであり、残っているのは、タイル数、コンポーネント数、サンプルあたりのビット数を語る生のコードストリームだけです。そこにある不正な数値は、パースエラーではありません。タイトなデコードループの内部での、不正な確保サイズや範囲外インデックスなのです

バイト予算による制限は役立ちますし、すでに設定しているべきものです。HotPDFはDecodeBudgetBytesDocumentDecodeBudgetBytesで展開量を制限し、DecodeFilterLimitDecodePipelineDepthLimitでフィルタチェーンを制限します。これらの上限の背景にある考え方はネストしたフィルタとPDF爆弾に対する有界デコードで扱っています。しかし、バイト予算が答えられるのは「どれだけの出力を許可するか」という1つの問いだけです。デコーダーが何の出力も生成しないまま異常終了した場合に何が起こるかは、答えられません。デコードループ内でのアクセス違反は、拒否できるようなポリシー違反ではありません。それはプロセスレベルの出来事であり、プロセスレベルの出来事を確実に封じ込める唯一の方法は、別のプロセスにすることです

HotPDFが隔離するもの、隔離しないもの

HotPDFが隔離するのは、HPDFCodecIsolationユニットでhckDCThckJPXhckJBIG2として列挙されている、ちょうど3種類のコーデックだけです。それ以外、Flate、LZW、RunLength、ASCII85、CCITTはプロセス内にとどまります。これらのデコーダーは予算で十分に制限できるほど単純であり、興味深い障害が発生する場所ではないからです

転送方式は意図的に狭く絞られています。ホストは1つの有界な共有メモリマッピングを確保し、固定長の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の3つの値は、「ワーカーがまったく実行できない場合にどうするか」という1つの問いに対する3通りの異なる答えを表しています。cimDisabledは隔離を完全にスキップし、3.x以前と同じくプロセス内でデコードします。デフォルトのcimAutomaticはワーカーの使用を試み、ワーカーの実行ファイルが見つからない、または起動できない場合には静かにプロセス内デコードへフォールバックし、これはステータスcwsUnavailableとして報告されます。cimRequiredはそのフォールバックを拒否します。ワーカーが利用できない場合、そのデコードは処理済みかつ失敗として扱われるため、信頼できないコードストリームが自分のアドレス空間に到達することは決してありません

選ぶ基準は利便性ではなく脅威モデルです。ユーザーがすでにディスク上に持っているドキュメントを開くだけのデスクトップビューアなら、cimAutomaticで問題ありません。ワーカーが見つからない場合は製品が壊れるのではなく従来の挙動に降格するだけだからです。インターネットから受け取ったファイルを解析する取り込みサービスはcimRequiredで稼働させるべきです。隔離層が静かに外れてしまうデプロイミスは、まさに、問題になるまで誰も気づかない類の退行だからです。ここには非対称性がある点に注意してください。フォールバックが発生するのはcwsUnavailableのときだけです。起動した後にクラッシュした、タイムアウトした、あるいは上限に達したワーカーは、どちらのモードでもデコード失敗であり、静かにプロセス内で再試行されることは決してありません

THPDFCodecWorkerStatusから判定結果を読み取る

GetLastCodecWorkerInfoは直近の隔離デコードの結果を返し、そのステータス列挙型は、単なる「画像に失敗しました」という汎用的なログ行ではなく、実際の運用判断を導けるだけの具体性を備えています。値はcwsNotRuncwsSucceededcwsUnavailablecwsLaunchFailedcwsTimedOutcwsCrashedcwsDecodeFailedcwsProtocolErrorcwsOutputLimitです

これらは3つのグループとして捉えてください。デプロイの問題はcwsUnavailablecwsLaunchFailedです。誰かがワーカーなしでリリースしてしまったか、アンチウイルス製品がプロセス作成をブロックしています。ドキュメントの問題はcwsDecodeFailedcwsOutputLimitです。ファイルが不正な形式か、ポリシーの許容範囲より大きく、拒否するのが正しい対応です。興味深いのはcwsTimedOutcwsCrashedのグループです。これらは、以前ならホストプロセスをハングさせるか強制終了させていたはずの出来事だからです。それが発生した場合、付随するProcessIdExitCodeElapsedMillisecondsの各フィールドがあれば、Windowsエラー報告のエントリと照合し、それが1つの顧客ファイルによる病的なケースなのか、誰かが探りを入れてきているのかを判断できます

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;

実際に効いてくる上限値

隔離デコードのすべてに、3つの独立した上限が適用され、どれが発動したのかを把握しておくだけで、半日分の推測作業を省けます。CodecWorkerTimeoutMillisecondsのデフォルトは10,000で、1から600,000の範囲で検証されます。範囲外の値を指定すると、静かにクランプされるのではなく例外が発生します。CodecWorkerMemoryLimitBytesのデフォルトは536,870,912バイトで、制限なしを意味するゼロか、少なくとも67,108,864バイトのいずれかでなければなりません。それより小さい上限では現実的なデコーダーのワーキングセットを保持できず、すべてのドキュメントで失敗してしまうからです。メモリ上限はkill-on-closeセマンティクスを持つWindows Job Objectによって強制されるため、ホストが不意に終了した場合でも、ワーカーはそのジョブと運命を共にして終了します

3つ目の上限は出力制限で、これは設定するものではなく導出されるものです。HotPDFは、要求された領域から、あるいは想定される画像のジオメトリ(24ビット出力なら幅×高さ×3)から必要なバイト数を計算し、予算が設定されている場合はその値をDecodeBudgetBytesまで切り詰めます。もっともらしいヘッダーを報告しておきながら、ジオメトリが許す量をはるかに超えるピクセルを出力しようとするデコーダーは、マッピング自体によって止められ、ホスト側にはcwsOutputLimitが返されます。だからこそ、隔離層とデコード予算は互いを補完し合う関係にあります。予算は画像がどれだけ大きくてよいかを定義し、隔離境界は、そのサイズについての虚偽申告が自分のプロセス内での境界外書き込みに変わることがないよう保証します

堅牢化された取り込み経路の中でこれが果たす役割

プロセス隔離は、はるか手前から始まる防御チェーンの最も外側の層です。構造的な制限は、パース時点でありえないドキュメントを拒否します。フィルタ予算は展開量を制限します。隔離は、その両方を生き延びたものを封じ込めます。画像レイヤーに到達したドキュメントについては、実際にどのコーデックを動かしているのかを把握しておく価値があります。JPXDecodeの処理JBIG2シンボル辞書とでは障害のプロファイルがまったく異なり、特にJBIG2はページをまたぐグローバルセグメントを持つため、素朴な画像単位のサンドボックスでは壊れてしまいます

コストは正直に述べておく価値があります。隔離画像1枚ごとにプロセスを起動するとミリ秒単位の負荷が加わり、スキャンページが数百枚あるドキュメントではそれが体感できるほどになります。そのコストは、それによって得られるものと比較して評価してください。夜間に無人で走るバッチコンバーターであれば、スループットの低下は目に見えず、クラッシュの封じ込めこそが目的そのものです。ユーザーがすでに信頼しているドキュメントを開くインタラクティブなビューアであれば、cimDisabledcimAutomaticが妥当なデフォルトです。このモードは単なるプロパティなので、実行時にドキュメントの種類ごとに選び分けることも自由にできます

HotPDFは、隔離層、デコード予算、構造的なパーサー制限を、Delphi/C++Builder向けの1つのネイティブVCLコンポーネントとして提供しており、ワーカー実行ファイル自体を除けば、デプロイが必要な外部ランタイムはありません。完全なAPIドキュメントと評価版はHotPDF Delphi PDFコンポーネントページで入手できます