技術記事

悪意のあるファイルに対するPascal PDFパーサーの堅牢化

PDFは、単に「開く」だけのものではありません。実行される「小さなプログラム」です。埋め込まれたすべてのフォントは文字構成命令(charstrings)を待つスタックベースのインタプリタであり、すべての画像はファイルが指定した幅、高さ、ビット深度の各フィールドを供給されるデコーダであり、すべてのストリームはファイルが設定したパラメータを持つフィルタでラップされて届きます。これらの数値はいずれも自身で制御したものではありません。ファイルを生成した何者かに由来するものであり、実際の運用では顧客の請求書や送信元不明の添付ファイルなどです。これらのバイトデータをピクセルやグリフに変換するデコーダこそがアタックサーフェスであり、そこにある入力を信頼してしまうパーサーは、不正なファイルが1つあるだけでクラッシュやそれ以上の被害を引き起こします

PDF Library for Delphiは、フォントプログラム(TrueType、Type1、CFF、CMapテーブル)、画像デコーダ(PNG、GIF、TIFF、JBIG2、CCITT Group 3/4)、ストリームフィルタ(LZW、ASCII85、Flate予測器)にわたって、デコードパス全体を敵対的なものとして扱います。以下で述べるのは、そのパスが塞がなければならない5つのメモリ安全性の罠です。それぞれには、それを可能にするDelphi固有の挙動が絡んでおり、同じ形は信頼できない入力をパースするあらゆるPascalコードにも現れます

PDF Library for Delphi: Pascal PDF パーサーの攻撃対象面マップ。フォントインタープリター、画像デコーダー、ストリームフィルターへ供給し、5 つのガードされたトラップクラスに至る
幅、奥行き、オフセット、長さ、フィルターフラグはすべて作成者側から来るため、フォント・画像・フィルターの各デコーダーこそ、ハードニングパスが敵対的なものとして扱う攻撃面になります

サイズ不足のバッファを生成する整数オーバーフロー

画像デコーダにおける古典的なメモリ安全性のバグは、寸法(ディメンション)の乗算がラップアラウンドすることです。デコーダは幅、高さ、コンポーネント数、およびビット深度を読み取り、それらを掛け合わせて出力サイズを決定し、そのバイト数を割り当てた上で、実際の寸法で画像を書き込みます。この乗算が32ビット演算で行われると、個々の係数が適切な範囲内であっても、積がラップアラウンドして小さな値になってしまうことがあり、割り当て自体は成功するものの非常に小さな領域しか確保されず、デコード処理がその末尾を超えて書き込んでしまいます。これはCWE-190(整数オーバーフロー)であり、一歩進むとヒープ境界外書き込み(CWE-787)を引き起こします

共有画像パスで各寸法を65535にクランプするだけでは、独自にサイズを計算するスタンドアロンのデコーダに対しては不十分です。ByteCount * FHeightのような「行バイト数×高さ」の式や、FWidth * Components * BitDepthのようなピクセルごとの式は、両方のオペランドが32ビット整数であれば、結果を代入する変数の幅に関わらず、Delphiでは32ビットの積になります。幅60000、高さ60000は大きなスキャンとして十分あり得る値ですが、バイト単位のその積は符号付き32ビットの範囲を超え、長さは小さな値として出てきます。同じ罠が、BitsPerComponent * Colors * Columnsのような予測器のストライドにも潜んでいます

防御は、式全体が64ビットで評価されるように少なくとも1つのオペランドをInt64にしておき、MaxIntと比較して、型を縮小してSetLengthを呼び出すより前にファイルを拒否することです

PDF Library for Delphi: 並列比較。ラップした 32 ビット寸法積が過小割り当てと範囲外書き込みを引き起こす場合と、Int64 拡幅チェックがファイルを拒否する場合
バイト数の積を Int64 で評価すれば、パーサーは MaxInt と比較し、SetLength が嘘のバッファーを確保する前に敵対的な寸法を拒否できます
// 書き込んだ後ではなく、割り当てる前に拒否します。
// 積を Int64 で評価し、32 ビットでラップしないようにします。
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // 敵対的または対応不能な寸法。画像を拒否します
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という比較は到達可能であり、ループは終了します。境界チェックがValue > MaxValueOfType(Value)の形をしており、オペランドがちょうどその最大値の型である場合、その条件は定数偽になります。型を広げるか、発火できるよう最大値との等値をテストしてください

ホットパスで無効化されていた範囲チェック

範囲チェック(range checking)を有効にすると、Delphiはすべての配列および文字列のインデックスに対して境界チェックを挿入します。これは、範囲外のインデックスがキャッチ可能なERangeErrorを発生させるか、あるいはそのインデックスが構造体に属さないメモリを読み書きしてしまうかの決定的な違いとなります。ホットパス(頻繁に実行される経路)では、ローカルの{$R-}ディレクティブを使用してこれを無効にすることがありますが、それはインデックスが完全に信頼できる場合に限られます

フォントインタプリタが依存するリストアクセサTPDFlibStringList.Getは、まさにそのようなパスです。Windows上では範囲チェックを無効にしてコンパイルされ、バッキングストアを直接インデックス指定するため、範囲外のインデックスはエラーにはならず、生のメモリアクセスになります。インデックスが常に有効であれば問題ありませんが、インデックスがファイルに起因するCFFやType2のcharstringインタプリタの内部では問題になります。空のスタックからオペランドを取り出すcharstringはマイナス1のインデックスを生成し、グリフ数に対して1つずれたグリフ識別子は末尾より1つ先のスロットをインデックス指定します。チェックされなければ、どちらもキャッチ可能な例外ではなく本物の境界外アクセスになり、スロットは参照カウント方式のAnsiString値を保持しているため、不正な読み取りは文字列の参照カウントをも破損させる可能性があります

PDF Library for Delphiは、そのホットパスでは範囲チェックをオフのままにし、代わりにインデックスが妥当であることを証明可能にします。オペランドスタックの先頭を取る前にインタプリタはスタックが空でないことを確認し、すべてのインデックスガードは、オフバイワンを許してしまう「以下」ではなく、カウントに対する厳密な「未満」です。このディレクティブは境界の責任をコンパイラからあなたに移します。取り除かれた検証は、すべてのエントリポイントで手作業で戻さなければなりません

charstringインタプリタにおける無限再帰

Type2のcharstringはサブルーチンを呼び出すことができ、サブルーチン自体も別のサブルーチンを呼び出せるcharstringであるため、ローカルおよびグローバルのサブルーチン呼び出し演算子により、ファイルは呼び出しの深さを制御できます。直接的またはループを介して自身を呼び出すサブルーチンは、ネイティブスタックが枯渇してプロセスが停止するまで無限に再帰します。これは制御されていない再帰(CWE-674)です

Type1インタプリタは、呼び出し深度カウンターと天井PLType1MaxCallDepthでこれを防ぎ、それ以上降りることを拒否します。これはType1仕様自体が名指ししている深度制限を反映したものです。罠は、後から書かれた構造的に似たインタプリタ、たとえばType2のものは、そのガードを勝手に引き継がないという点です。自分自身の番号を呼び出すサブルーチンを含む手作りフォントは、欠けたチェックを素通りしてスタックオーバーフローに達します

// 両方の charstring インタプリタに必要な深度ガードの形です。
// ネストされた呼び出し全体で深度を追跡し、それ以上の再帰を拒否します。
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // 敵対的な自己参照サブルーチン。深く降りるのを止めます
// ...サブルーチンを解釈し、戻るときに Dec(CallDepth) します

PDF Library for Delphiは、Type2のパスにも兄弟のType1と同じ制限深度を与えています。フォントのサブルーチンであれ、ネストされた配列であれ、クロスリファレンスチェーンであれ、攻撃者が制御する構造の上のあらゆる再帰下降には、入力が引き上げられない深度の天井が必要です

出力に漏れ出る未初期化メモリ

最も微妙な罠は、ヒープの内容を復号済み出力へ漏らすものです。原因は忘れがちなSetLengthの性質にあります。SetLengthでAnsiStringを拡張すると、Delphiはバイトを割り当てるもののゼロにはしないため、新しい領域には、そのヒープメモリに以前入っていたものが入ります。すべてのバイトがその後書き込まれるなら何の問題もありません。どこかの経路がバッファの一部を書き残したまま、それをデータとして返すと、古いバイトが結果に便乗して出ていきます。これはCWE-457、未初期化メモリの使用であり、結果が信頼境界を越えると情報漏えいになります

AES-CBCの復号パスはまさにここが刺さる場所です。出力バッファをSetLengthで確保し、暗号文を一度に16バイトのブロックずつ処理すると、暗号文の長さが16の倍数でないとき——攻撃者が選べる長さです——末尾の半端ブロックは決して書き込まれず、その最後のバイト列はSetLengthが残したヒープの内容を抱えたまま、ドキュメントオブジェクトの復号済み平文として返っていきます。対策は2つのガードで、どちらか1つでは足りません。PDF Library for Delphiの復号エントリポイントは、長さがブロックサイズの倍数でない暗号文を拒否します。さらにバックストップとして、出力は使用前にFillCharでクリアされ、領域を書き損ねた経路はどんなにでもヒープの残骸ではなくゼロを返します

PDF Library for Delphi の図。AES-CBC 復号において、エントリ長チェックと FillChar バッファークリアが経路を守らないと、終端処理されていない末尾ブロックが未初期化ヒープバイトを残す
2 つの独立したガードが漏えいを塞ぎます。長さが 16 バイト境界から外れる暗号文を拒否することと、出力を事前にクリアして、書き漏らしがあってもヒープの残骸ではなくゼロが返ることです

5つの罠に共通するもの

5つの罠は異なるバグですが、韻を踏んでいます。積をラップさせる整数幅、ガードを定数偽に釘付けにするフィールド型、インデックスが安全でなくなった場所でオフにされた範囲チェック、底のない再帰、そして言語がゼロにすることを拒んだバッファです。いずれの場合もDelphiは定義どおりに動いています。ラップする算術、沈黙する縮小、オフにできる範囲チェック、組み込み制限のない再帰、初期化しない割り当て——言語がそれを提供しているからです。それが契約であり、Pascalパーサは、ファイルが制御するすべての境界で4つの事柄——整数幅、範囲チェック、再帰深度、バッファ初期化——を手作業で管理することで、この契約を満たします

DelphiとC++Builder向けのエンジンであるPDF Library for Delphiは、デコードパスを5つの罠すべてから守ります。ファイルが自分は保護されていると主張するしかたまで作業が及ぶなら、姉妹編のメモである暗号化と権限の監査とPDF/AとPDF/UAのプリフライトが、同じパーサの解析側をカバーしています。そのすべては、このブログの他の記事で扱うロード、レンダリング、署名のAPIと並んで、PDF Library for Delphi Delphi PDF Libraryに同梱されています