技術記事

悪意のあるPKCS#12に対するDelphi PDF署名機能の堅牢化

PDF に署名するとき、署名キーは自分が管理するものだと思いがちです。自分が生成した .pfx ファイルの中に住み、自分が選んだパスワードで守られています。そのファイルを読むコードは、境界ではなく配管に感じられます。この直感は、証明書が自分のものではなくなった瞬間に裏切られます。ユーザーに任意の .pfx を選ばせるデスクトップツール、アップロードされた資格情報を受け入れるサーバー、ネットワーク越しに証明書を供給されるバッチ署名器。いずれも、署名バイトが 1 つ生まれる前に、攻撃者の影響を受けたバイトをパーサーへ渡しています。PKCS#12 リーダーは攻撃対象領域です。画像デコーダーやフォントローダーと同じ意味で

この記事では、すべての PKCS#12 リーダーが防御しなければならない 2 つの落とし穴をたどります。どちらも署名資格情報をインポートする経路にあり、HotPDF がそれらをどう閉じたかも述べます。どちらも奇抜なものではありません。固定幅整数を持つ言語で書かれたほぼすべてのバイナリパーサーを打つ、同じ根元の原因から来ます。ファイル由来の長さやカウントが、本来より 1 ステップ先まで信じられるというものです。片方は境界外読み取りに、もう片方は強制終了するまで帰ってこないプロセスにつながります

PBKDF2 反復回数の落とし穴を示す HotPDF の図。有界でない偽造の 40 億回のカウントなら CPU を釘付けにしますが、HPDFCrypt の下限と上限がこれを拒否します
1 億は正当なファイルのどれよりも上にあり、最悪のケースに上限を設けます。広くデコードし、有界にし、それから使う
ASN.1 長のラップ落とし穴の 2 つの経路(HotPDF)。32 ビットで加算された偽造の長さは素朴な境界チェックをすり抜けますが、HPDFASN1ParseNode は Int64 まで読んでこれを拒否します
狭いガードは、実値の切り詰められた幽霊と戦います。先に広く読めば、偽造の長さはどんな SetLength の前にも見えるようになります

バイトがたどる道

.pfx をインポートして文書に署名するのは 1 つの操作ではなく、短いパイプラインであり、各段階は攻撃者が書いたかもしれない何かをパースします。コンテナは RFC 7292 で定義される PKCS#12 構造、つまり秘密鍵を収める暗号化された shroud を包む AuthenticatedSafe バッグの巣です。読むということは、ASN.1 を歩き、パスワードから鍵を導出し、復号し、回収した RSA 鍵を署名を組み立てるコードへ渡すことです

HotPDF では、これらの段階は個別のユニットに対応します。PKCS#12 コンテナのロジックは HPDFPFX に住んでいます。それが触れるすべてのタグ、長さ、値は、HPDFASN1 の ASN.1 リーダーがデコードします。鍵導出と PBES2 復号は、PBKDF2HMACSHA256 と並んで HPDFCrypt に座っています。鍵が回収されると、HPDFRSA と HPDFCMS の CMS SignedData ビルダーが、PDF に埋め込まれる detached 署名へと変えます。チェーン全体を駆動する公開エントリーポイントは、1 つの呼び出しです

HotPDF における .pfx インポートのパイプライン図。攻撃者の影響を受けたバイトは、HPDFCrypt の鍵導出と HPDFRSA/HPDFCMS の署名の前に、HPDFPFX と HPDFASN1 を通ります
PKCS#12 リーダーは攻撃対象領域です。2 つのパーサーが不注意なら、下流の暗号は一度も意味を持ちません
// パイプライン全体を駆動する: プレースホルダー PDF をロードし、PFX をパースし、
// キーを導出し、CMS SignedData を組み立て、署名済み出力を書く
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // 署名が埋め込まれる
else
  // 署名は完了しなかった
;

signer.pfx のすべてのバイトは、暗号が何かを始める前に HPDFASN1 と HPDFPFX を流れます。この 2 つのユニットがファイルの主張に対して慎重でなければ、下流の暗号が意味を持つ機会は二度と来ません

落とし穴 1: ガードを追い越す ASN.1 の長さ

DER と BER の ASN.1 は、すべての要素をタグ、長さ、その数だけのコンテンツバイトとして符号化します。長さは、信じつつ検証すべきフィールドです。パーサーにどこまで読むかを告げ、しかもファイルを作った人が書いたからです。X.690 の §8.1.3 は 2 つの符号化を定義します。短形式は 0 から 127 の長さを 1 バイトに収めます。それより大きい何かに使われる長形式は、下位 7 ビットが後続の長さバイト数を与えるリードバイト 1 つを費やし、続くその数のビッグエンディアンのバイトが実際の値を運びます。したがって長さバイト 4 つで、4 ギガバイト近いコンテンツサイズを宣言できます

そのような値をデコードした後、パーサーは、コンテンツが実際にバッファの内側に収まることを、信じる前に確認しなければなりません。自然なチェックは、現在位置 + コンテンツ長がデータの終端を超えないことを確認することです。位置、コンテンツ長、合計をすべて 32 ビット符号付き整数に保持する素直な書き方では、そのガードは壊れています

// trap: 符号付き 32-bit 演算。ContentLen が MaxInt 付近だと、
// Pos + ContentLen は負の値へオーバーフローし、比較は false になって、
// 約 2 GB の偽造長がそのまま通ってしまう
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

問題は加算であって比較ではありません。ContentLen が MaxInt(2147483647)に近いとき、Pos + ContentLen は符号付き 32 ビット範囲をオーバーフローし、負の数へ wrap します。負の合計は Total より大きくなることはないため、ガードはすべて正常と報告し、パーサーは、バッファが含んでいない約 2 ギガバイトのコンテンツ長で先へ進みます。次に起こるのが被害です。リーダーは主張された長さのバッファを確保し、そこへコピーします。ソースから読む SetLength とそれに続く Move です。ソースには数百バイトしか残っていないため、コピーは入力の終端を大きく越えて読みます。最善でもクラッシュする境界外読み取りであり、最悪では、隣接するプロセスメモリがパース結果へ漏れます

唯一正しいガードは、比較の前に中間の和を広げることです。そうすれば加算は、計算される型の範囲をオーバーフローできません。HotPDF は両オペランドを Int64 へ昇格させます

// 正しい形: 加算の前に両オペランドを Int64 へ拡張するため、
// 和は wrap できない。偽造された 2 GB の長さは境界チェックで弾かれる
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64 は 2 つの 32 ビット値の和を損失なしで保持するため、比較は実値を見て、偽造された長さを拒否します。ContentLen の別の非負チェックは、デコードされた値が自力で負に着地する対応するケースを閉じます。HotPDF では、このガードは HPDFASN1ParseNode、つまり他のすべてのヘルパーが土台にするノードを生む関数の中に住んでいます。HPDFASN1Content は SetLength と Move をノードのコンテンツ長から直接サイズするため、弱いガードを通過したノードは、そこから取られるすべての読み取りを汚染します。デコードの時点で境界を強制することこそ、その上のヘルパーを安全にしているのです

落とし穴 2: 武器として使われる PBKDF2 反復回数

2 つ目の落とし穴はメモリエラーではなく、ファイルが自分の CPU にどれだけ働けと言ってくるかの問題です。PKCS#12 は鍵素材を PBES2 で守ります。PKCS#5 のパスワードベーススキームで、RFC 8018 に規定されたものです。PBES2 は鍵導出関数(ここでは HMAC-SHA-256 付きの PBKDF2)を走らせ、それから暗号(ここでは AES-256-CBC)を走らせます。PBKDF2 は反復回数を受け取り、その回数はファイルが運ぶパラメーターです。その存在意義は遅いことです。反復が増えるほど各パスワード推測のコストが上がり、オフライン攻撃者には好都合だからです。RFC 8018 の §4.2 は、大きい回数ほどセキュリティに良いと明言し、意図的に天井を設けていません

その開放性は、自分がファイルを生成したときには問題ありません。攻撃者が生成したときには武器です。反復回数は攻撃者制御の作業係数であり、攻撃者制御の作業係数はアルゴリズム的計算量の DoS です。偽造された .pfx は、数十億の反復回数を符号化できます。それを律儀に読み、その回数の HMAC-SHA-256 ラウンドを PBKDF2 に実行させるパーサーは、1 つの供給ファイルで、分単位か時間単位で帰ってこないループへプロセスを消し込みます。1 リクエスト 1 資格情報を処理する署名サーバーでは、1 つの細工されたアップロードがワーカーを止めます

このカウントは、CPU を回す前にラップアラウンドを悪化させます。反復値はファイルの中では ASN.1 INTEGER として住んでおり、固定幅を持ちません。一方 PBKDF2 が最終的に消費するフィールドは 32 ビットの Integer です。INTEGER をそのフィールドへ直にデコードすると、大きい値は切り詰められ、符号ビットに着地するように細工された値は負の数や無関係な小さな数として戻ってきます。仕事の大きさすら、ファイルが頼んだように見えたものではなくなります。HotPDF は値を全幅で読み、狭める前に有界にします

// 反復回数をまず Int64 として読み、常識的な帯域へクランプする。
// PBKDF2 が使う 32-bit の Iterations フィールドへ狭めるより前に
LIter := HPDFASN1ToInteger(Data, Node);          // Int64 を返す
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // 安全: すでに有界

Int64 へ読むということは、デコードされた値が実値であり、切り詰められた幽霊ではないということです。下限は、ゼロと負のカウントを拒否します。鍵導出にとって無意味なものです。上限の 1 億は、今日の正当な PKCS#12 ファイルが使う数万から数十万の反復より十分上にあり、同時に最悪のケースを、有界で生き残れる量の仕事へ押さえます。値がその帯域を通った後にだけ 32 ビットのフィールドへ狭められるため、切り詰めはもう誰も驚かせません。HotPDF では、このクランプは ParsePBES2Params に住んでいます。PBKDF2 のパラメーターが PBKDF2HMACSHA256 へ向かう途中でデコードされる場所です

なぜ 2 つのガードが同じガードなのか

2 つの落とし穴は違って見えます。片方はバッファオーバーラン、もう片方はハングしたプロセスです。しかし同じ間違いです。どちらのケースでも、信頼できないファイル由来の数が、現実との照合の前に、固定幅の型へ 1 ステップ早く運ばれます。長さは境界テストの前に 32 ビットで加算され、反復回数は範囲テストの前に 32 ビットへ狭められます。両方とも、同じ規律に従います。全幅でデコードし、実際の限界と照合し、それから狭める。中間の Int64 はスタイルの選択ではありません。攻撃者が実際に書いた値をガードが見える唯一の幅です。オーバーフローする境界は境界ではなく、天井のないカウントはパラメーターではなく、自分の CPU への遠隔スロットルです

署名パイプラインのための実務指針

狭い教訓は、信頼できない証明書入力を、他の信頼できないアップロードと同じように検証することです。受け入れる .pfx のサイズに上限を設けてください。正当なものはキロバイトであり、メガバイトではありません。パースの失敗は、ユーザーへスタックトレースを見せるべきエラーではなく、日常的な拒否された入力として扱います。サーバーで署名するなら、インポートを、止まったワーカーがサービス全体を引きずり落とせない場所で実行し、操作にタイムアウトを掛けてください。予想外に高価なファイルは、反復上限だけでなくウォールクロックでも有界にされます

広い教訓は証明書の先へ届きます。パーサーの堅牢化は、1 つのユニットへの 1 回限りの監査ではなく、ライブラリが自分の書いていないバイトを読むすべての場所の性質です。PDF ライブラリは、信頼できないソースからたくさんパースします。文書に埋め込まれたフォント、半ダースのコーデックの画像、ストリームフィルター、そして署名経路上の証明書。それぞれが攻撃対象領域であり、それぞれのすべての長さとすべてのカウントに、同じ疑いを向けるに値します。HotPDF は、インポートと署名の経路を、ここで述べた堅牢化された HPDFASN1、HPDFPFX、HPDFCrypt、HPDFCMS ユニットの上に築いているため、渡された資格情報は、来歴がどこであれ、信じられるようになる前に防御的にパースされます

これらのチェックが守る署名ワークフローは、Delphi における PAdES 電子署名の解説で端から端まで扱っています。同じ防御姿勢を文書暗号化へ適用したもの、このコードベースを共有する AES-256 鍵経路を含む話は、AES-256 暗号化とセキュリティの記事で述べています。すべては、このブログの別の記事で扱うロード、編集、暗号化、署名の API と並んで、Delphi と C++Builder 向けの HotPDF Delphi Component の一部として出荷されます