Technical Article

PDFium VCLバインディングの堅牢化:ABIとメモリの安全性

CライブラリをラップしたPascalバインディングは、普通のPascalコードのように読めます。メソッドを呼び出し、レコードを受け取り、割り当てたメモリを解放します。しかし問題は、PDFiumが独自の呼び出し規約、独自の整数幅、およびメモリの所有者と解放者に関する独自のルールを持つCおよびC++ライブラリである点です。これらの契約は、言語の境界を自動的に超えることはありません。これらすべての契約をPascalの宣言で手動で再定義しなければならず、たった1語の誤りが、一見きれいに見える呼び出しをスタック破損、オフセットの切り捨て、あるいは二重解放(double free)に変えてしまいます。PDFium VCLバインディングのv1.61.0監査において、それぞれの種類の欠陥が1つずつ見つかりました。これらはこのバインディングに限った話ではないため、確認する価値があります。これらは、DelphiやLazarusでC APIをラップする際に常に存在するリスクなのです。

cdeclは関数型の一部であり、単なる装飾ではない

PDFiumはコンパイルされたCです。Win32上では、そのエクスポート関数や、さらに重要なこととして、それが呼び出すコールバックはcdecl呼び出し規約を使用します。cdeclでは、呼び出し元(caller)が関数から戻った後にスタックをクリーンアップします。Delphiのネイティブのデフォルトはregister防策であり、一部のライブラリにおけるコールバックのWin32 C標準はstdcallで、この場合は呼び出された側(callee)がクリーンアップを行います。構造体がPDFiumに関数ポインタを渡し、そのポインタの型にcdeclを付け忘れると、スタックポインタをどちらが調整するかについて両者の意見が食い違います。両方が修正を行うか、あるいはどちらも行わないため、呼び出しのたびに引数のサイズ分スタックポインタがずれていきます。

この欠陥の発見が困難な理由は、スタック破損の影響がローカルに留まらないためです。破損した呼び出しは正常に返り、問題がないように見えます。ズレは後になって、数バイトずれたスタックポインタ上にフレームが配置された無関係な関数で発生し、不正な読み取り、不正なリターンアドレス、あるいは実際に誤っていたコールバックから遠く離れた場所を指すバックトレースを伴うクラッシュとして現れます。フォーム入力(Form-fill)は、この問題が典型的に発生する場所です。フォーム入力インターフェースは、PDFiumがコールバックする関数で満たされたレコードだからです。その1つであるFFI_OpenFileは、外部ファイルを開くために呼び出す関数をPDFiumに渡しますが、これはfunction(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdeclのように宣言されています。末尾のcdeclが重要です。これを取り除いても、コードはコンパイルされ、リンクされ、PDFiumが関数を呼び出す直前まで正常に動作します。呼び出し規約は関数型そのものに属しています。それは任意のオプションではなく、プレーンな関数型も完全に合法なPascalの型であるため、コンパイル時に警告されることもありません。唯一の防御策は、呼び出し規約を、インポートするすべてのシグネチャおよび外部に渡すすべてのコールバックの「必須フィールド」として扱うことです。

size_tはポインタ幅であり、FPC Win64では64ビットを意味する

2つ目の欠陥は、特定のターゲットでのみ発生する整数幅の不一致です。Cのsize_tは、任意のオブジェクトサイズを保持できる幅として定義されており、64ビットプラットフォームでは64ビットの符号なし整数を意味します。PDFiumのプログレッシブロード(段階的読み込み)インターフェースは、size_tのバイトオフセットで通信します。可用性プロバイダのFX_FILEAVAILレコードは、PDFiumがオフセットとサイズを指定して呼び出すIsDataAvailコールバックを保持しており、FX_DOWNLOADHINTSレコードのAddSegmentコールバックも同様のデータを受け取ります。どちらのパラメータもsize_tです。

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

これらのオフセットを32ビット型として宣言すると、バインディングはWin32およびDelphi Win64では動作しますが、FPCおよびLazarus Win64では警告なしに動作を停止します。原因は微妙なものです。FPC Win64において、NativeUIntは本物のポインタ幅の64ビット型であり、size_tはそのエイリアスです。バインディングの型セクションには、FPC上でNativeUIntをシャドウイング(再定義)しないよう警告するコメントが記載されています。なぜなら、そこで32ビットのエイリアスに再定義してしまうと、size_tが32ビットに強制され、ライブラリとの間で受け渡しまたは書き込まれるすべてのsize_tパラメータが破損するためです。32ビットパラメータに到達した64ビットオフセットは、上位半分を失います。ファイルが小さい場合、すべてのオフセットが32ビットに収まるため問題は発生しません。大きいファイルの場合、オフセットが4ギガバイトのラインを超えた瞬間に切り捨てられた値はまったく異なる場所を指し、PDFiumは誤ったバイト範囲が利用可能かどうかを尋ねるため、プログレッシブロードがストールするか、ゴミデータを読み込むことになります。この欠陥は、ファイルが十分に大きくなり、size_tが実際に拡張されるターゲット上で動作させるまで表面化しません。

Pascalの例外はCのフレームをアンワインドしてはならない

3つ目のカテゴリは、Cには存在しない例外モデルに関するものです。PDFiumがコールバックを呼び出すと、PascalコードはDelphiの例外処理機構を全く知らないCおよびC++のスタックフレームの内部で実行されます。コールバックが例外を発生させ、それを伝播させると、例外のアンワインドに対応していないフレームを通じてアンワインド(unwind)が行われます。PDFium自身のクリーンアップ処理は実行されず、内部のインバリアント(不変条件)は半分更新されたまま放置され、プロセスはライブラリが想定していない状態になります。これらのコールバックの契約はリターンコードであり、例外ではありません。

2つのコールバックがこれを具体化します。FPDF_FILEWRITEはPDFiumが保存されたドキュメントを書き込むシンクであり、FPDF_FILEACCESSは入力ドキュメントを読み取るソースです。どちらもここではDelphiのTStream上で実装されており、ディスクがいっぱいになる、ストリームが途中で閉じられる、読み取りが末尾を超えるなど、あらゆるストリームと同様に失敗する可能性があります。書き込みコールバックは、ストリームの書き込み処理をラップし、エラーをエスケープさせるのではなくPDFiumの失敗コードに変換します。

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

読み取り側も同様です。境界を超えて例外を発生させる代わりに、FPDF_FILEACCESSの契約に合わせて、失敗した読み取りはゼロを報告します。再スローを行わない素のexceptは、例外を飲み込まないよう訓練されたPascalプログラマにとっては誤っているように見えますし、通常のPascalコードにおいては確かに誤りです。しかし、ABI境界においてはこれが正しい形状です。なぜなら、Cの呼び出し側に返せる唯一の安全な値は、解釈可能なステータスコードだからです。エラーはリターンコードを通じて伝播し、制御がPascal側に戻った段階で、ライブラリの上位の呼び出しコードがそれをEPdfErrorとして表面化させます。

エラーパスに潜む二重解放

4つ目の欠陥は所有権に関するものです。PDFiumドキュメントハンドルはライブラリによって開かれ、FPDF_CloseDocumentによって正確に一度だけ閉じられる必要があります。危険なのは、別のクリーンアップ処理も所有しているハンドルをエラーパスが解放してしまうことです。ラッパーオブジェクトを作成し、新しく開いたドキュメントハンドルを割り当て、さらに失敗する可能性のあるセットアップを実行するルーチンを想像してください。セットアップ処理が例外をスローした場合、生のハンドルに対してFPDF_CloseDocumentを呼び出す早期リターンハンドラがそれを閉じ、その後、オブジェクトが解放される際にラッパーオブジェクト自身のデストラクタが再びそれを閉じることになります。ハンドルが2回解放されることは未定義の動作であり、クラッシュを引き起こする可能性が非常に高いです。

監査において、すでに開いているハンドルの周囲にTPdfを構築するインポジション形式のインポートパスでこの問題が発見されました。修正は、所有権の移転を「唯一の真実のソース」にすることです。ハンドルがラッパーのフィールドに割り当てられると、ラッパーがそれを所有し、エラーパスにおける唯一のクリーンアップはラッパーを解放することだけになります。ラッパーのデストラクタが代わりにFPDF_CloseDocumentを呼び出すため、2回目の明示的なクローズ処理は同じドキュメントの二重解放を引き起こします。修正されたエラーハンドラはオブジェクトを解放して再発生(re-raise)させ、クローズに至るパスを正確に1つにします。

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

管理レコードとエクスポートでいっぱいのライブラリは、どちらも明示的な破棄が必要

最後のカテゴリは、コンパイラが自動管理するメモリに関するもので、Cの癖によって静かに破損させられる可能性があります。このバインディングのヘルパー関数の多くは、WideStringや動的配列を含むレコードを返します。これらは参照カウント方式のフィールドであり、コンパイラはカウントを維持するための隠れた管理処理を出力します。Cから引き継いだ本能により、新しいレコードをFillChar(Result, SizeOf(Result), 0)でクリアしてしまいがちですが、これはレコード内の管理対象参照をデクリメントすることなく、単にゼロで上書きしてしまいます。コンパイラは、ループの反復処理にわたって関数結果のために1つの隠れた一時変数を再利用するため、2回目の反復処理でFillCharが解放されていないアクティブな文字列ポインタを上書きし、その結果、指し示されていた文字列がリークします。関数をループ内で呼び出して1,000個の注釈を処理すると、1,000個の文字列をリークすることになります。

修正は、言語自体にレコードをクリアさせることで、管理対象フィールドをゼロにする前に解放するDefault(T)を使用します。

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

関連する所有権の問題は、ライブラリの読み込み境界にも存在します。このバインディングは、LoadLibraryの後にGetProcAddressを使用して、PDFium DLLから数百の関数ポインタを解決します。必須のエクスポート関数が1つでも不足している場合、部分的にバインドされた状態は危険です。数十のポインタは有効ですが、残りはnilか古いデータのままであり、後でそれらを経由して呼び出しを行うと、すでにアンロードされたモジュールにジャンプする可能性があります。バインディングは、必須のエクスポートの解決に失敗した場合、ライブラリをアンロードし、インポートされたすべてのポインタをnilにリセットする完全なClearAllBindingsを実行することでこれに対処します。これにより、アンロードされたモジュールに関数ポインタがぶら下がる(ダングリングする)ことがなくなり、後続の呼び出しは、解放されたコードに分岐するのではなく、nilポインタチェックで安全に失敗します。

ラッパーは4つの契約を手動で再定義する場所

これら5つの欠陥はいずれも特殊なものではありません。C APIの上に構築された薄いPascalレイヤで予想される失敗モードであり、そのレイヤこそが、4つの独立した契約を再定義しなければならない場所だからこそ集中して発生します。すべてのコールバックにおいて呼び出し規約をcdeclと指定しなければなりません。整数幅は、実際に拡張されるターゲットにおいてsize_tと一致しなければなりません。例外モデルは、Pascalの外部に出るすべてのコールバックにおいてリターンコードに変換されなければなりません。すべてのハンドルとすべての管理対象フィールドの所有権は、本番環境までテストされないエラーパスを含め、すべてのパスで明確にして遵守されなければなりません。どれか1つでも見落とすと、原因から遠く離れた場所に症状が現れる欠陥となり、これがこのカテゴリの対応コストを高くする要因です。今回の監査の価値は、単一の修正にあるのではなく、これらの各項目をバインディング全体でチェックすべき固有の規律として扱ったことにあります。

エッジの防御ではなく、バインディングが実際に動作している様子を確認したい場合は、レンダリングキャッシュとズームのパフォーマンスに関するメモでレンダリングパスが示されており、LazarusおよびFPCビューアの構築に関するクロスコンパイラのチュートリアルは、ここで説明したWin64 size_tの挙動が実際に重要となる場所です。どちらも、このブログの他の場所で扱われているレンダリング、テキスト抽出、およびフォームAPIと並んで、Delphi、Lazarus、およびC++Builder向けのPDFium Componentで提供されている、ここで解説したメモリの安全性およびABIの取り組みをベースにしています。