PDFは、単に「開く」だけのものではありません。実行される「小さなプログラム」です。埋め込まれたすべてのフォントは文字構成命令(charstrings)を待つスタックベースのインタプリタであり、すべての画像はファイルが指定した幅、高さ、ビット深度の各フィールドを供給されるデコーダであり、すべてのストリームはファイルが設定したパラメータを持つフィルタでラップされて届きます。これらの数値はいずれも自身で制御したものではありません。ファイルを生成した何者かに由来するものであり、実際の運用では顧客の請求書や送信元不明の添付ファイルなどです。これらのバイトデータをピクセルやグリフに変換するデコーダこそがアタックサーフェスであり、そこにある入力を信頼してしまうパーサーは、不正なファイルが1つあるだけでクラッシュやそれ以上の被害を引き起こします。
PDFlibPasは、フォントプログラム(TrueType、Type1、CFF、CMapテーブル)、画像デコーダ(PNG、GIF、TIFF、JBIG2、CCITT Group 3/4)、およびストリームフィルタ(LZW、ASCII85、Flate予測器)にわたり、デコードパス全体を「敵対的(hostile)」なものとして扱う堅牢化プロセスを経ました。以下に示すのは、それが閉鎖した5つの欠陥カテゴリであり、それぞれがその原因となったDelphi独自の挙動に基づいています。これらは現在のリリースで修正されており、信頼できない入力を解析するあらゆるPascalコードに同様のパターンが現れます。
サイズ不足のバッファを生成する整数オーバーフロー
画像デコーダにおける古典的なメモリ安全性のバグは、寸法(ディメンション)の乗算がラップアラウンドすることです。デコーダは幅、高さ、コンポーネント数、およびビット深度を読み取り、それらを掛け合わせて出力サイズを決定し、そのバイト数を割り当てた上で、実際の寸法で画像を書き込みます。この乗算が32ビット演算で行われると、個々の係数が適切な範囲内であっても、積がラップアラウンドして小さな値になってしまうことがあり、割り当て自体は成功するものの非常に小さな領域しか確保されず、デコード処理がその末尾を超えて書き込んでしまいます。これはCWE-190(整数オーバーフロー)であり、一歩進むとヒープ境界外書き込み(CWE-787)を引き起こします。
共有画像パスは各寸法をすでに65535にクランプ(制限)していましたが、独立したデコーダがすべてその制限を継承していたわけではありません。ByteCount * FHeightのような「行バイト数×高さ」の式や、FWidth * Components * BitDepthのような「ピクセルごとの処理」の式は、結果を代入する変数の幅に関わらず、両方のオペランドが32ビット整数の場合、Delphiでは32ビットの積になります。大きなスキャンの場合、幅と高さがそれぞれ60,000であることは十分にあり得ますが、バイト単位のその積は符号付き32ビット整数の範囲を超えてオーバーフローし、長さが小さな値として計算されます。同じ罠は、ZLib予測器のストライド(歩幅)であるBitsPerComponent * Colors * Columnsにも潜んでいました。
修正は、式全体が64ビットで評価されるように、少なくとも1つのオペランドをInt64にし、MaxIntと比較してファイルを拒否した上で、再び型を縮小してSetLengthを呼び出すようにすることです。
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
これが一般的な問題ではなくDelphi独自の問題である理由は、警告なしの縮小(silent narrowing)にあります。大きすぎる式を32ビットの宛先に代入することは、デフォルトでコンパイラが警告を出さない合法な変換であり、インデックスとして実際に使用される前に発生したラップアラウンドは、範囲チェックでも検出されません。積を32ビットのままにしておくと、言語は静かに、デコード処理がアクセスしようとしている実際のメモリサイズとは異なる「嘘の長さ」を返します。
ガードの起動を不可能にするフィールド型
TIFFファイルはイメージファイルディレクトリ(IFD)のチェーンであり、それぞれが次のディレクトリオフセットを保持しています。悪意のあるファイルは、そのチェーンを自身に戻るように指し示すことができ、停止条件なしでそれを巡回するリーダーは無限ループに陥ります。これは、攻撃者が制御する入力によって引き起こされる無限ループ(CWE-835)であり、防御策は、正当なファイルでは決して到達しない制限を超えたときに停止するカウンターを設けることです。
ページカウンターはWordとして宣言されており、Delphiでは0から65535を保持します。ループには「ページ数が65535を超えたら停止する」という形式の終了ガードが設けられていましたが、これはオペランドと閾値が同じ上限を共有していることに気づくまでは正しいように見えます。Word型は決して65535より大きくなることはないため、比較処理は構造的に常に偽になります。カウンターが65535に達すると、次のインクリメントで0に戻ってしまい、ガードは上限を超える値を決して検出できず、ループするIFDチェーンはリーダーを回転させ続けます。
修正は、ガードがカウンターが実際に保持できる値を表現できるように、フィールドを拡張することでした。TPDFTIFF.FPageCountをIntegerとして宣言したことで、同じFPageCount > 65535の比較が可能になり、ループは終了し、公開されているPageCountプロパティの型も呼び出し側を壊すことなく一致するように変更されました。境界チェックがValue > MaxValueOfType(Value)の形をしており、オペランドがすでにその最大値の型である場合、その条件は常に偽になります。型を広げるか、最大値との等価性をテストしてガードが起動するようにしてください。
ホットパスで無効化されていた範囲チェック
範囲チェック(range checking)を有効にすると、Delphiはすべての配列および文字列のインデックスに対して境界チェックを挿入します。これは、範囲外のインデックスがキャッチ可能なERangeErrorを発生させるか、あるいはそのインデックスが構造体に属さないメモリを読み書きしてしまうかの決定的な違いとなります。ホットパス(頻繁に実行される経路)では、ローカルの{$R-}ディレクティブを使用してこれを無効にすることがありますが、それはインデックスが完全に信頼できる場合に限られます。
フォントインタプリタが依存するリストアクセサTPDFlibStringList.Getは、まさにそのようなパスです。Windows上では範囲チェックを無効にしてコンパイルされ、バッキングストアを直接インデックス指定するため、範囲外のインデックスはエラーにはならず、生のメモリアクセスになります。インデックスが常に有効であれば問題ありませんが、インデックスがファイルに起因するCFFやType2のcharstringインタプリタの内部では問題になります。空のスタックからオペランドを取り出すcharstringはマイナス1のインデックスを生成し、グリフ数に対して1つずれたグリフ識別子は末尾より1つ先のスタックをインデックス指定します。範囲チェックが無効になっていると、どちらもキャッチ可能な例外ではなく本物の境界外アクセスになり、スロットは参照カウント方式のAnsiString値を保持しているため、不正な読み取りは文字列の参照カウントをも破損させる可能性があります。
堅牢化プロセスでは、ホットパスの範囲チェックを再び有効にすることはしませんでした。まず、インデックスが確実に有効であることを検証可能にしました。インタプリタは、オペランドスタックの最上位を取得する前にスタックが空でないことを確認し、すべてのインデックスガードは、1つのズレを許容してしまう「以下(less-than-or-equal)」ではなく、カウントに対する厳密な「未満(less-than)」として記述されました。このディレクティブは、境界チェックの責任をコンパイラから開発者に移すため、削除された検証処理はすべてのエントリポイントにおいて手動で元に戻す必要があります。
charstringインタプリタにおける無限再帰
Type2のcharstringはサブルーチンを呼び出すことができ、サブルーチン自体も別のサブルーチンを呼び出せるcharstringであるため、ローカルおよびグローバルのサブルーチン呼び出し演算子により、ファイルは呼び出しの深さを制御できます。直接的またはループを介して自身を呼び出すサブルーチンは、ネイティブスタックが枯渇してプロセスが停止するまで無限に再帰します。これは制御されていない再帰(CWE-674)です。
Type1インタプリタはすでにこれを防ぐ手段を備えていました。呼び出し深度カウンターと上限値PLType1MaxCallDepthを保持しており、それを超える深さへの移行を拒否します。これはType1仕様自体が定めている深度制限を反映しています。後から追加され構造的に類似しているType2インタプリタには同じガードが含まれておらず、自身の番号を呼び出すサブルーチンを持つ手動構築されたフォントは、欠落しているチェックを通過してスタックオーバーフローに直結していました。
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
修正は、Type2パスに、その兄弟であるType1がすでに持っていた制限深度を与えることでした。フォントのサブルーチン、ネストされた配列、あるいは相互参照チェーンなど、攻撃者が制御する構造に対するあらゆる再帰的下行(recursive descent)には、入力データによって引き上げることのできない深度の天井が必要です。
未初期化メモリの使用による情報リーク
最も微細な欠陥は、復号された出力にヒープの内容がリークしていたことであり、原因は忘れがちなSetLengthの性質にあります。SetLengthを使用してAnsiStringを拡張すると、Delphiはバイトを割り当てますがそれらをゼロクリアはしないため、新しい領域には以前ヒープメモリにあったデータが保持されます。すべてのバイトが後から書き込まれれば問題にはなりませんが、一部のバッファが未書き込みのまま放置され、それがデータとして返されると、古いバイトデータが結果として持ち出されます。これは未初期化メモリの使用(CWE-457)であり、結果が信頼の境界を超えると、情報リーク(情報漏えい)につながります。
AES-CBC復号パスはまさにこの問題に直面しました。出力バッファはSetLengthでサイズ指定され、デクリプタは暗号文を一度に1つの16バイトブロックずつ処理していました。暗号文の長さが16の倍数ではない(攻撃者が選択可能な)場合、末尾の不完全なブロックは書き込まれず、その最後の数バイトはSetLengthが残したヒープの内容を維持したまま、バッファはドキュメントオブジェクトの復号されたプレーンテキストとして返されていました。対策は2つのガードであり、どちらか一方だけでは不十分です。すなわち、復号のエントリポイントは長さがブロックサイズの倍数ではない暗号文を拒否するようになり、バックストップとして、使用前に出力バッファをFillCharでクリアして、書き込みに失敗した領域はヒープの残骸ではなくゼロを返すようにしました。
堅牢化プロセスが残したもの
5つの欠陥は異なるバグですが、共通の性質を持っています。積をラップアラウンドさせる整数幅、ガードを常に偽にするフィールド型、インデックスが安全でなくなった場所で無効化された範囲チェック、天井のない再帰、および言語がクリアを拒否したバッファです。いずれのケースでもDelphiは定義通りの処理を行いました。なぜなら言語仕様として、ラップアラウンドする演算、警告なしの縮小、オフにできる範囲チェック、組み込み制限のない再帰、および初期化を行わないメモリ割り当てを提供しているからです。これが契約であり、Pascalパーサーは、ファイルが制御するすべての境界において手動で4つの項目(整数の幅、範囲チェック、再帰深度、バッファの初期化)を管理することで、この契約に対処します。
これらの欠陥は、DelphiおよびC++Builder向けのエンジンである現在のPDFlibPasリリースで解消されています。ファイル保護方式の検出も関わっている場合、暗号化と権限の監査に関するメモやPDF/AおよびPDF/UAプリフライトに関するメモが同じパーサーの解析側をカバーしており、そのすべてが、このブログの他の場所で扱われているロード、レンダリング、および署名APIと並んで、PDFlibPas Delphi PDF Libraryに同梱されています。