ドキュメントの取り込みパイプラインは、見知らぬ人が作成したファイルを受け入れます。請求書、スキャン、Webフォームからの添付ファイルなど、それぞれがPDFであると主張し、パーサーが処理することを想定した何百もの数値を保持しています。ストリーム長、画像の寸法、バイトオフセット、オブジェクト参照など、すべてはファイルを作成した人物によって選択されたものであり、不完全なアップロードや意図的に不正な形式のドキュメントは、最終的にそれらの数値の1つを損害を与える場所に配置します。そのファイルを乗り切るパーサーと、クラッシュするかメモリが破損したまま実行を続けるパーサーとの違いは、特定のPDFライブラリに依存しない小さな習慣の集まりにあります
これらの習慣は1つの前提を共有しています。それは、ファイルから読み取られた値は「主張」であり、「測定値」ではないということです。その値は、パーサー自身が測定したもの(ファイルの実際のサイズ、デコーダーが生成した実際のバイト数、再帰の実際の深さ)と照らし合わせてチェックされた後にのみ使用可能になります。以下は、ドキュメントパーサーが実際に壊れる場所にその前提を適用したものです
宣言された長さは主張であり、測定値ではない
最も単純な不一致はストリームの長さです。PDFのストリームオブジェクトは /Length キーでそのバイト数を宣言し、実際のデータは stream と endstream キーワードの間にあります。この2つを一致させる強制力はありません。切り捨てられたファイルは、宣言されたカウントよりも少ない実際のバイト数を保持します。壊れたジェネレーターからのファイルは、ファイルの終わりを過ぎたり、隣接するオブジェクトに到達するような長さを宣言する可能性があります。宣言された値から割り当てて endstream までコピーすると、バッファがオーバーランします。可用性を確認せずに宣言されたカウントを正確に読み取ると、ファイルの終わりに達してしまいます。データの終わりまでの測定された距離に対してクランプした後にのみ、宣言された値に割り当てを促させます。不一致は、endstream をスキャンして修復するか、ストリームを拒否するかの決定ポイントとして扱い、決して暗黙のうちに信じるべきものではありません
割り当てたよりも大きなラスターを記述する画像パラメーター
画像ストリームは、2つの独立した数値セットが同じピクセルを記述するため、リスクが高まります。画像辞書には /Width と /Height があり、通常、ラスターバッファのサイズはこれらから決定されます。デコードフィルターは独自のジオメトリを保持します。CCITTFaxDecode は DecodeParms から /Columns、/Rows、/K を取得します。ここで /K はGroup 3またはGroup 4のスキームを選択し、デコーダーはスキャンラインごとに (Columns + 7) div 8 バイトを出力します。/Width 100 を宣言しているのにフィルターにデフォルトの /Columns 1728 を渡すファイルは、デコーダーにバッファが想定する行あたりのバイト数の16倍以上を生成させ、オーバーフローは割り当ての後の場所にスキャンラインごとに1回ずつ着地します。/Rows がない場合、デコーダーはデータが停止を指示するまで実行されるため、行数も制限します。DCTDecode にも同じ継ぎ目があります。JPEGデータはSOFマーカーに独自の幅と高さを保持しており、それらが辞書と一致する義務はありません
防御ルールは機械的です。検証されたデコードパラメーター(CCITTの場合はフィルター独自の /Columns と /Rows、DCTの場合はSOFの寸法)から予想されるラスターサイズを計算し、制限に照らし合わせてチェックし、そこから割り当て、デコード中に出力が割り当てを超えて実行されないことを確認します。ジオメトリについて辞書とフィルターの意見が一致しない場合は、それらを調整するか、画像を拒否します。パーサーが絶対にやってはいけないことは、一方の数値セットからバッファのサイズを決定し、もう一方の数値セットでデコーダーを実行させることです
Delphiの算術と割り当ての落とし穴
Delphiの3つの動作は、検証を意図したパーサーでさえも弱体化させます。1つ目は32ビットの乗算です。Delphiは、ターゲットの幅に関係なく、2つの Integer オペランドの積を32ビットで評価するため、各要素が独自のサニティチェックに合格しても Width * Height * BytesPerPixel がラップする可能性があります。1ピクセルあたり3バイトで30000×30000のスキャンを行うと27億バイトになり、符号付き32ビット演算では負にラップします。係数が少し異なると、小さな正の長さにラップされ、バッファが小さく割り当てられてしまいます。最初のオペランドをキャストして(Size := Int64(Width) * Height * BytesPerPixel)、式全体を強制的に幅広くし、何かが SetLength に到達する前に明示的な上限と比較してください
2つ目は範囲チェックです。Delphiのデフォルトのリリース構成ではオフになっているため、ファイルデータから計算された範囲外のインデックスは例外を発生させず、配列に隣接するメモリの読み取りまたは書き込みを行います。ファイル由来の値でインデックスを作成するすべてのユニットの先頭で {$R+}(および算術オーバーフローの場合は {$Q+})を使用して元に戻します。パーサーがとにかく行うI/Oの横でコストは測定不可能であり、暗黙の破損をキャッチ可能な ERangeError に変換します
3つ目は、ファイルから提供された Int64 を伴う TMemoryStream.SetSize です。現在のRTLでは、ファイルが要求したものを何でも割り当てるため、4ギガバイトを要求する単一のストリームは、取り込みの途中でメモリ不足のエラーになります。SetSize が Longint を取る古いRTLでは、値は最初に静かに縮小されます。宣言された $100000010 は16になり、割り当ては成功しますが、実際のデータの書き込みはそれをはるかに超えて実行されます。割り当ての呼び出しで表示される前に、すべてのサイズを、測定されたソースサイズとハードキャップに照らし合わせて検証してください
ファイル外を指すオフセット
相互参照(クロスリファレンス)テーブルは、オブジェクト番号を絶対バイトオフセットにマッピングし、パーサーはそれが指す場所をシークします。破損したファイルや悪意のあるファイルでは、これらのオフセットはファイルの終わりを過ぎたり、無関係な構造の内部に着地したりします。TStream はエラーを静かにします。Position を Size より大きく設定してもエラーにはならず、終わりを過ぎて単純な Read を行うと要求されたよりも少ないバイト数が返されるだけなので、カウントチェックをスキップするコードは前のオブジェクトの古いバイトを解析し続けます。防御はチョークポイントです。ファイル主導のすべてのシークと読み取りが通過する1つのヘルパーであり、ストリームが移動する前に、測定されたファイルサイズに対してオフセットとカウントを検証します
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB
type
EPdfBoundsError = class(Exception);
// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
相互参照オフセット、ストリームエクステント、および埋め込みファイルの読み取りをこれにルーティングすると、不正なオフセットは、3回の呼び出し後のアクセス違反ではなく、数値を指定するクリーンな拒否になります
オブジェクトグラフのサイクルと深さ
PDFはツリーではなくグラフです。任意の値は間接参照である可能性があり、参照は別の参照に解決される可能性があります(例:/Length 12 0 R で、オブジェクト12が 13 0 R を保持する)。また、チェーンがそれ自身に戻って閉じるのを防ぐものは何もありません。参照をたどるリゾルバは、ネイティブスタックが枯渇するまで素朴に再帰します。スタックの枯渇はキャッチできるものではなく、プロセスを終了させます。深くネストされた配列と辞書は、サイクルがなくても同じ終焉を迎えます
2つのガードを組み合わせて使用します。明示的な深さカウンターは、正当なファイルが近づくことのない限界で「誠実だが深い」ケースを制限し、訪問済みセットは2回目の訪問で真のサイクルを捕捉し、制限を超えたトリップではなく、正確で報告可能なエラーに変えます
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // meaningful when Kind = pvReference
// ... payload fields for the remaining kinds
end;
// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // siblings may legally share this object
end;
end;
解凍は増幅器である
数キロバイトの FlateDecode 入力がギガバイトに膨れ上がる可能性があります。汎用圧縮は反復的な平文に有利に働き、攻撃者はそれを最大限に反復させることができます。各ストリームの膨張サイズを、コンシューマーがもっともらしく必要とする値に制限し、2つ目のドキュメントごとの予算を維持します。ストリームごとの上限をわずかに下回る500のストリームは、1つの巨大なストリームと確実に同じようにメモリを使い果たします。チェックはインフレーション(膨張)ループ内に属し、生成された出力バイトをカウントし、メモリがすでに消費されたループ後ではなく、違反時に中止します。細工されたストリームが到達する比率を正当なドキュメントのクラスターがはるかに下回るため、圧縮ファイルサイズの倍数として表されるドキュメント予算はうまく機能します
自身のユニットを超えた多層防御
同じ欠陥クラスがライブラリ内に存在します。このブログの2つのケーススタディでは、実際の事例を説明しています。悪意のあるファイルに対するPascal PDFパーサーの強化 でネイティブのPascalエンジンで閉じられた整数のラップ、無制限の再帰、初期化されていないバッファと、PDFiumコンポーネントバインディングの強化 でCエンジンをバインドする際の呼び出し規約、整数幅、所有権の危険性です。公開アップロードフォームや認証されていないメールボックスなど、純粋に信頼できない取り込みの場合、解析とデコードの作業を別の低特権プロセスで実行し、すべてのインプロセスガードを打ち破るファイルが、ダウンしたサービスではなく、失敗したジョブのコストで済むようにします
プリフライトチェックリスト
次のビルドが出荷される前に、このリストに対してパーサーを確認してください。宣言された長さではなく、クランプされた長さからサイズ設定されたすべてのストリームバッファ。検証されたデコーダーパラメーターからサイズ設定され、デコーダー出力に対してチェックされたすべてのラスター。Int64 で評価され、明示的な上限と比較されたすべての寸法の積。ファイル由来の値でインデックスを作成するすべてのユニットでアクティブな {$R+}。測定されたファイルサイズに対して境界チェックされたすべてのシーク。深さが制限され、サイクルチェックされたすべての参照解決。ストリームごとおよびドキュメントごとの予算に対して出力をカウントするすべてのインフレーションループ。正当なドキュメントでは、これらのチェックのいずれも測定可能な時間を費やさず、それぞれがメモリの破損をクリーンでログに記録可能な拒否に変換します
注: losLab の HotPDF コンポーネント、PDFlibPas Delphi PDF Library、および PDFium コンポーネント は、これらの境界チェック、深さ制限、および拡張上限を内部で適用するため、それらに基づいて構築された取り込みパイプラインは強化されたベースラインから始まります