技術記事

Delphiで埋め込みPDF用PDFiumバイト範囲読み込み

オーバーフローチェックがオフのとき、両オペランドがDelphiのInteger値なら、2000000000 + 2000000000は-294,967,296と評価されます。この数字が雑学でなくなるのは、コンテナパーサーがレコードヘッダーからオフセットと長さを読み、埋め込みPDFがバッファに収まるか確かめるためにそれらを足した瞬間です。和は負へラップし、Length(Data)との比較は通り、次の行は配列の末尾から約2ギガバイト先のバイトのアドレスを取ります。大きなバッファの途中からPDFを読み込むには、決して足し算をしない範囲チェックと、文書を開いた後のバイトの所有者が誰かへの明確な答えが要ります

本記事は、すでにRAM上にあり、別の何かの中の既知のオフセットに座っている文書を扱います。メモリに載せきれない文書は別の問題で、PDFium VCLで大きなPDFを必要時にストリーミングで述べられているように、ディスクからのブロック引き出しで処理されます。最終セクションが境界線を引きます

既知のオフセットにある埋め込みPDF

メールストアはメッセージ本文と添付を1つのレコードに保持します。アーカイブコンテナはマニフェスト、数枚の画像、PDFを連結します。独自のワイヤプロトコルは長さ接頭辞付きヘッダーの後ろに文書をframeします。レコードが40 MB、PDFがバイト1,182,336から始まって312 KB続くとします。慣習的なコードはCopy(Data, Index, Count)でスライスを切り出し、そのスライスを、さらに自分のバッファへコピーするローダーへ渡します。コンテナも数えると、同じ312 KBが3回存在し、メールボックスのスキャンはメッセージごとにそれを繰り返します

PDFium Componentはウィンドウを直接アドレスします。TPdfはLoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean = True)を宣言します。Indexはゼロ始まりのバイト位置、Countはバイト長です。このメソッドは2つの数を検証し、1つのポインタを計算し、ポインタオーバーロードLoadDocument(Data: Pointer; Size: NativeUInt; Buffered: Boolean)へ転送します。こちらはFPDF_LoadMemDocument64経由で文書を開きます。配列全体の形式LoadDocument(const Data: TBytes; Buffered: Boolean)は、バイト範囲形式への0とLength(Data)を使った1行の呼び出しであり、すべてのTBytesロードは同じ検証を通ります

Delphi でコンテナ内の埋め込み PDF 向け旧二重コピー経路と、ウィンドウをインプレースでアドレス指定する PDFium Component バイトレンジ LoadDocument オーバーロードの比較図
Copy でスライスしてからロードすると、同じ 312 KB のウィンドウが 2 回コピーされます。バイトレンジ呼び出しはウィンドウをその場でアドレスし、Buffered がそもそも何かをコピーするかどうかを選びます

コードの中では、スライスはただ消えます。LocateEmbeddedPdfはあなた自身のコンテナパーサーの代役です

uses
  System.SysUtils, System.IOUtils, PDFium;

procedure PreviewEmbedded(const RecordFile: string);
var
  Frame: TBytes;           // コンテナレコード全体、数十メガバイト
  Offset, Size: Integer;
  Pdf: TPdf;
begin
  Frame := TFile.ReadAllBytes(RecordFile);
  LocateEmbeddedPdf(Frame, Offset, Size);   // 自分のパーサー、コンポーネントの一部ではない

  Pdf := TPdf.Create(nil);
  try
    // Copy(Frame, Offset, Size) は不要。ウィンドウはその場でアドレスされる
    Pdf.LoadDocument(Frame, Offset, Size);  // Buffered の既定値は True
    Frame := nil;  // ここでは問題ない。コピーされたのは PDF のウィンドウだけ
    Writeln('Pages: ', Pdf.PageCount);
  finally
    Pdf.Free;      // 文書を閉じる
  end;
end;

グローバルのEnableV8EngineがまだFalseで、PDFiumライブラリがまだロードされていないとき、メモリローダーは入力バイトをXFAマーカーについて走査し、見つかればEnableV8Engineをオンに切り替えます。V8対応のDLLがロードされるようにするためです。バイト範囲呼び出しでは、その走査はウィンドウだけを見ます。同じレコードの別の場所にあるマニフェストや2つ目の文書がそれを引き起こすことはありません

Index + Count はなぜ境界チェックをオーバーフローさせるのか?

IndexとCountがどちらも32ビット符号付きInteger値であり、大きな正の値を2つ足しても、大きな正の結果が確実に得られるとは限らないためです。オフセットと長さがファイルヘッダーから来る場所では、攻撃者や壊れたレコードが両方を支配します。同じ罠が、それらの数を先に計算するあなた自身のコンテナパーサーの中にも待ち構えています

// 自分のコンテナパーサーのためのヘルパー(提案であり、コンポーネントの一部ではない)
function IsValidWindow(const Data: TBytes; Index, Count: Integer): Boolean;
begin
  // 誤り:Index + Count は Integer として評価され、負へラップし得る
  //   Result := (Index >= 0) and (Index + Count <= Length(Data));

  // 正しい:符号を拒否し、Index を単独で上限設定し、それから Count を
  // 残りと比較する。唯一の算術はラップできない減算
  Result := (Index >= 0) and (Count >= 0) and
            (Index <= Length(Data)) and
            (Count <= Length(Data) - Index);
end;

失敗するケースを追ってみましょう。Index = 2000000000、Count = 2000000000のとき、本当の和は40億ですが、32ビット符号付き算術はそれを-294,967,296として保存します。この値はどんなLength(Data)よりも快適に下なので、誤ったチェックは通り、@Data[Index]は2ギガバイトの長さを付けたまま、配列のはるか外を指します。クラッシュは良い方の結末です。悪い方は、たまたまそこに住んでいたプロセスメモリを、PDFiumがパースし始めることです

正しい順序は決して足し算をしません。負の値は、何かがインデックスされる前に拒否されます。Indexは次にLength(Data)単独に対して上限設定され、これがLength(Data) - Indexを非負のIntegerに保証します。それからようやくCountがその残りと比較されます。Delphiは既定でandを短絡評価({$B-})するため、後の項は前の項が成立したときにだけ走ります。安全網として{$Q+}に頼らないでください。リリースビルドはオーバーフローチェックをオフにしたまま日常的に出荷されますし、オンにしても、メモリ安全性の穴を、検証ルーチンの途中から逃げ出すEIntOverflowに置き換えたにすぎません

オーバーフローする Index + Count 境界チェックと、Delphi で PDFium バイトレンジロードのメモリ安全性を保つ減算専用検証順の対比フロー図
先に加算すると MaxInt を越えてラップし、ガードが無力化します。符号の拒否と項の上限設定を、ラップなしの 1 回の減算の前に行えば、すべての中間値が表現可能に保たれます

バイト範囲のLoadDocumentは同じ4つのテストを同じ順序で実行し、失敗した規則を名指すメッセージ付きのEPdfErrorを上げます。"PDF byte range index cannot be negative"、"PDF byte range count cannot be negative"、"PDF byte range index exceeds data length"、"PDF byte range exceeds data length"。4バイトの配列でどれでも再現できます。Index = 5, Count = 0は3つ目のメッセージを、Index = 2, Count = 3は4つ目を引き起こし、すべての拒否の後もPdf.ActiveはFalseのままです。ネイティブ境界を越える信頼できない長さへの同じ扱いは、DelphiにおけるPDFium VCL ABIとメモリ安全性の強化でより広く扱われています

長さゼロのウィンドウも正当な呼び出しである

空のウィンドウはnilポインタを渡します。@Data[Index]は、検証が受け入れるすべてのIndexに対して正当な式とは限らないからです。Index = Length(Data)とCount = 0の組み合わせは、バッファの末尾の整形式の空ウィンドウであり、空のTBytesは、要素ゼロを持たない配列の上のIndex = 0です。どちらのケースでもアドレスを取ると、末尾を読み過ぎたり、nilの動的配列を参照したりします。範囲チェックがオンなら、ローダーが走るより前にDelphiがERangeErrorを上げます

そこでバイト範囲オーバーロードは分岐します。Count = 0はnilを生み、その他のカウントは@Data[Index]を生みます。ポインタオーバーロードはnilをサイズゼロと一緒のときだけ受け入れ、PDFiumは空のバッファを開くことを辞め、呼び出しはPDFiumの最後のエラーテキスト、あるいはPDFiumが何も報告しないときは"Cannot load PDF document"を運ぶ、普通のEPdfErrorで終わります。ゼロバイトの添付を主張するヘッダーは、他の悪い入力と同じ捕まえられる例外を受け取ります。パーサーの中で、メッセージにレコード番号を付けて拒否するほうが、サポートログではまだ読みやすいものです

Bufferedがバイトの所有者を決める

Bufferedは、この呼び出しより長く続く結果を持つ、ここでは唯一のパラメーターです。Buffered = Trueのとき、コンポーネントは選ばれたウィンドウを、ウィンドウだけを、自分のバッファへコピーしてからロードします。40 MBの例では312 KBがコピーされ、コンテナは放置されます。LoadDocumentが返った後は、コンテナをすぐに解放、再利用、上書きして構いません

Buffered = Falseのとき、@Data[Index]はFPDF_LoadMemDocument64へ直行し、PDFiumは文書が開いている間ずっと、そのポインタ経由で読み続けます。バッキングTBytes全体が借用されます。Active := Falseを設定するかTPdfを解放するまで、生きたまま、未変更でなければなりません。重要なのは配列全体で、ウィンドウだけではありません。Delphiの動的配列は1つのブロックとして参照カウントされるため、コードのどこかで最後の参照を落とすと、PDFiumがまだ読んでいるメモリが解放されます。SetLengthも同じくらい致命的です。再割り当てはブロックを動かせます

2つのDelphiの細部が、見た目より間違えやすくしています。動的配列は文字列と違ってcopy-on-writeを持ちません。したがってOther := FFrame; Other[0] := 0;は、PDFiumがまさにパースしているバイトそのものへ書き込みます。そして、ネットワークコードが次のフレームに再利用する受信バッファは、開いている文書の下で上書きされます。関数の結果をPdf.LoadDocument(ReadRecord(Id), Offset, Size, False)のように素通りで渡すと、配列は呼び出しルーチンと一緒に死ぬ隠れた一時オブジェクトにだけ保持され、文書は生き残ります

Delphi でバイトレンジから埋め込み PDF をロードする際の 2 つの Buffered モードのタイムライン図。PDF ウィンドウのみコピーしてコンテナを即解放するか、UnloadDocument が走るまで基盤 TBytes 全体を借りるか
Buffered = True はウィンドウだけをコピーするためコンテナーは使い捨てにでき、Buffered = False は文書が閉じられるまでバッキング配列全体を借りたままにします

スレッドをまたぐもの、再利用可能なバッファの背後に座るもの、ロードしたルーチンより長く生きるものには、Buffered = Trueを保ってください。追加コストはPDF自身のサイズのコピー1回で、呼び出しが返った瞬間に40 MBのレコードを落とせる自由を買えます。借用が価値を持つのは、コンテナがすでにセッション全体で保持する長寿命のフィールドであるときだけです。ユーザーが添付をページ送りしている間、アーカイブレコードを開いたままにするビューアーなどです。その場合、所有権を1つのクラスの中で明示してください

type
  TFrameSession = class
  private
    FFrame: TBytes;   // FPdf が文書を開いている間、バッキングストレージを所有する
    FPdf: TPdf;
  public
    constructor Create(const AFrame: TBytes);
    destructor Destroy; override;
    procedure OpenEmbedded(Offset, Size: Integer);
  end;

constructor TFrameSession.Create(const AFrame: TBytes);
begin
  inherited Create;
  FFrame := AFrame;   // 配列を共有する。以後、他の誰も書いてはいけない
  FPdf := TPdf.Create(nil);
end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  FPdf.Active := False;                            // まず以前のウィンドウを閉じる
  FPdf.LoadDocument(FFrame, Offset, Size, False);  // FFrame はこの文書より長く生きなければならない
end;

destructor TFrameSession.Destroy;
begin
  FPdf.Active := False;  // 1. 借りを解放する
  FPdf.Free;
  FFrame := nil;         // 2. ここで初めてストレージを解放してよい
  inherited;
end;

コンストラクターは呼び出し側の配列をコピーではなく共有するため、呼び出し側は書き込みを止めなければなりません。それを約束できないなら、コンストラクターにCopy(AFrame)を渡してください。コピー1回に戻ります。ロードごとではなくセッションごとに1回です

ウィンドウが %PDF- から始まっていることの確認

数バイトずれたウィンドウは微妙な失敗です。文書はまだ開くかもしれないからです。ISO 32000-1 §7.5.4はすべてのcross-referenceエントリを、ファイル先頭からのバイトオフセットとして定義し、§7.5.2は%PDF-ヘッダーを先頭に置きます。パーサーが接頭辞を数え違えてウィンドウが8バイト早く始まると、それらのオフセットは8バイトずれます。PDFiumは先頭1024バイトの中でヘッダーを探し、先行するゴミを補償します。遅く始まるウィンドウや早く終わるウィンドウは、見つかったオブジェクトマーカーからcross-referenceテーブルを再構築させるかもしれません。どちらにしてもページ数は正しく見えます。それでもパーサーのオフセットは間違っており、そのレコードの次の読み手は、もう少し厳しいかもしれません

オーバーロードLoadDocument(const Data: TBytes; Index, Count: Integer; const Options: TPdfLoadOptions; out Report: TPdfLoadReport; Buffered: Boolean = True)はそれを見えるようにします。Report.HeaderOffsetは、ウィンドウの先頭1024バイトの中で%PDF-が見つかった位置、見つからなければ-1です。Report.UsedRecoveryは、PDFiumがcross-referenceテーブルを有効として受け入れなかったときにTrueになります。レポート内のすべてのオフセットは、コンテナではなくウィンドウの最初のバイトから数えます。短すぎるCountは末尾の%%EOFを切り落とし、TPdfLoadOptions.Default(plmStrict)はRequireFinalEndOfFileMarkerをオンにするため、厳格モードはそのウィンドウを丸ごと拒否します

var
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default;   // plmCompatible
  Pdf.LoadDocument(Frame, Offset, Size, Options, Report, True);

  // LogWarning は自分のロギングルーチン
  if Report.HeaderOffset > 0 then
    LogWarning(Format('Window starts %d bytes before %%PDF-, check Offset',
      [Report.HeaderOffset]))
  else if Report.HeaderOffset < 0 then
    LogWarning('No %PDF- header in the first 1024 bytes of the window');

  if Report.UsedRecovery then
    LogWarning('Cross-reference table was rebuilt, check Offset and Size');
end;

これらのフィールドの背後にある監査には、借用ロードにとって重要なコストがあります。ウィンドウがOptions.AuditByteLimit(既定で256 MB)以内に収まる場合、監査はウィンドウの一時コピーを読むため、レポート付きのBuffered = Falseは、ロードの間、コピーなしではなくなります。AuditByteLimitを0にすると監査とコピーが省かれ、HeaderOffsetは-1のままです。コンテナパーサーを開発している間と、新しいレコードソースに対しては、監査付きの形式で走らせ、オフセットを信頼できるようになったら素のオーバーロードへ切り替えてください。レポートの残りは、PDFiumロードレポートでPDFロード失敗を捉えるが扱います

バイト範囲が間違ったツールであるとき

バイト範囲オーバーロードは、コンテナ全体がすでにメモリ上にあることを前提とし、CountがIntegerであるため、1つのウィンドウはMaxIntバイトを超えられません。ディスク上の6 GBアーカイブや、巻き戻せないソケットはこの形に収まらず、中のウィンドウをアドレスするためだけに全部をTBytesへ読むのは目的に反します。そのような場合、LoadCustomDocument(AStream: TStream; AOwnsStream: Boolean)が、シーク可能なストリームからPDFiumに必要時にブロックを引かせます。すべてのSeekにPDFの開始オフセットを加える小さなTStream派生クラスは、埋め込み文書を位置ゼロから始まるものとして提示します。オンデマンドストリーミングの記事がそれを示します

もう1つの不一致は、まだ素のPDFではないコンテンツです。埋め込みバイトが圧縮されている、コンテナによって暗号化されている、別のエンコーディングに包まれているなら、変換済みのコピーは避けられず、その新しい配列を既定のBuffered = Trueでロードするのが素直な答えです。バイト範囲が力を発揮するのは1つの形だけです。連続した、未変更のPDFバイトが、すでにメモリ上に、足し算なしで検証済みのオフセットに座っている形です

バイト範囲呼び出しは、同じTPdfコンポーネントの中で、ファイル、ストリーム、生ポインタ、オンデマンドロードの隣に座っています。PDFium Component製品ページが、対応するDelphiとC++Builderのバージョン、ライセンスオプション、ローディングAPIの残りを一覧にしています