Technical Article

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

PDFに署名するとき、署名キーは自分でコントロールするものだと考えがちです。それは自身が生成し、自身で設定したパスワードで保護された.pfxファイルの中にあります。そのファイルを読み取るコードは、境界ではなく単なるパイプラインのように思えます。しかし、証明書があなた自身のものでなくなった瞬間、その直感は誤りとなります。ユーザーに任意の.pfxを選択させるデスクトップツール、アップロードされた資格情報を受け入れるサーバー、ネットワーク経由で証明書を受け取るバッチ署名機能などはすべて、署名の処理が開始される前に、攻撃者の影響下にあるバイトデータをパーサーに渡すことになります。PKCS#12リーダーは、画像デコーダやフォントローダーと同様に、アタックサーフェス(攻撃対象領域)なのです。

本記事では、署名資格情報をインポートするパスに存在していた、2つの実際の欠陥について詳しく説明します。どちらも特殊なものではありません。固定幅の整数型を持つ言語で書かれたほぼすべてのバイナリパーサーが直面するのと同じ根本原因、すなわち、ファイルから得られた長さやカウント値を必要以上に信頼してしまうことに起因します。一方は境界外読み取り(out-of-bounds read)を引き起こし、もう一方は強制終了するまでプロセスがハングアップする原因となります。

バイトデータの流れ

ドキュメントに署名するために.pfxをインポートする処理は、単一の操作ではなく短いパイプラインであり、各ステージは攻撃者が作成した可能性のあるデータを解析します。コンテナはRFC 7292で定義されているPKCS#12構造であり、秘密鍵を保持する暗号化されたシュラウドを包むAuthenticatedSafeバッグのネストです。これを読み取ることは、ASN.1をたどり、パスワードから鍵を導出し、復号し、復元されたRSA鍵を署名を構築するコードに渡すことを意味します。

HotPDFでは、これらのステージは個別のユニットにマッピングされています。PKCS#12コンテナのロジックはHPDFPFXにあります。それが処理するすべてのタグ、長さ、および値は、HPDFASN1内のASN.1リーダーによってデコードされます。鍵の導出とPBES2復号は、PBKDF2HMACSHA256とともにHPDFCrypt内にあります。鍵が復元されると、HPDFRSAおよびHPDFCMS内のCMS SignedDataビルダーが、それをPDFに埋め込まれる分離署名(detached signature)に変換します。チェーン全体を動かす公開エントリポイントは、1つの呼び出しです。

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

暗号処理が行われる前に、signer.pfxのすべてのバイトがHPDFASN1HPDFPFXを流れます。これら2つのユニットがファイルに記載されている値に対して警戒を怠ると、下流の暗号処理が力を発揮する機会は訪れません。

欠陥1:ガードをすり抜けてラップアラウンドするASN.1の長さ

DERおよびBERにおけるASN.1は、すべての要素をタグ、長さ、およびその長さ分のコンテンツバイトとしてエンコードします。長さフィールドは「信頼しつつも検証」しなければならないフィールドです。なぜなら、それがパーサーにどこまで読み取るかを指示し、その値はファイルを生成した何者かによって書き込まれたものだからです。X.690 §8.1.3は2つのエンコーディングを定義しています。ショートフォームは、0から127の長さを1バイトにパックします。それ以上の大きさに使用されるロングフォームは、1つのリードバイトを使用し、その下位7ビットが続く長さバイトの数を示し、その後にビッグエンディアン形式で実際の値が続きます。したがって、4バイトの長さフィールドは、4ギガバイトに近いコンテンツサイズを宣言できます。

このような値をデコードした後、パーサーはその値を信頼する前に、コンテンツが実際にバッファ内に収まっているかをチェックする必要があります。自然なチェックは、現在の位置にコンテンツの長さを加えた値がデータの末尾を超えないことを確認することです。現在位置、コンテンツの長さ、合計サイズがすべて32ビットの符号付き整数で保持されている一般的な書き方では、そのガードは機能しません。

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

問題は比較ではなく、加算にあります。ContentLenMaxInt(2147483647)に近い場合、Pos + ContentLenは符号付き32ビット整数の範囲をオーバーフローし、負の数に回り込みます(ラップアラウンド)。負の合計値がTotalより大きくなることはないため、ガードは問題ないと判定し、バッファ内に存在しない約2ギガバイトのコンテンツ長でパーサーを処理させます。その後に発生するのが実害です。リーダーは要求された長さのバッファを割り当ててコピーを実行します(SetLengthの後にソースから読み取るMoveを実行)。ソースには数百バイトしか残っていないため、コピー処理は入力の末尾をはるかに超えて読み取りを行います。これは、最良の場合でもクラッシュし、最悪の場合は隣接するプロセスメモリが解析処理にリークする、境界外読み取りを引き起こします。

唯一の正しいガードは、比較の前に中間の加算結果を拡張し、計算元の型で加算がオーバーフローしないようにすることです。修正では、両方のオペランドをInt64に昇格させます。

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
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は、RFC 8018で規定されているPKCS#5のパスワードベースのスキームであるPBES2で鍵データを保護します。PBES2は鍵導出関数(ここではHMAC-SHA-256を使用したPBKDF2)を実行し、次に暗号(ここではAES-256-CBC)を実行します。PBKDF2は反復回数を受け取り、そのカウントはファイル内に保持されるパラメータです。反復回数を多くする本来の目的は、処理を遅くすることにあります。反復が多いほどパスワードの推測にかかるコストが増加するため、オフライン攻撃に対して有利になります。RFC 8018 §4.2は、セキュリティのためにはカウント値が大きいほど良いと明記しており、意図的に上限を設定していません。

そのオープンさは、自身でファイルを生成した場合は問題ありません。しかし、攻撃者が生成した場合には武器になります。反復回数は攻撃者制御のワークファクター(計算負荷)であり、攻撃者制御のワークファクターはアルゴリズム複雑性サービス拒否(DoS)を引き起こします。偽装された.pfxは、数十億という反復回数をエンコードできます。パーサーはそれを忠実に読み取り、その回数分HMAC-SHA-256のPBKDF2を呼び出すため、プロセスは提供された1つのファイルに対して数分から数時間も返ってこないループに陥ります。リクエストごとに1つの資格情報を処理する署名サーバーでは、巧妙に作成された1つのアップロードがワーカーをストールさせます。

このカウント値は、CPUをスピンさせる前にラップアラウンドを悪化させます。反復値はファイル内では固定幅のないASN.1 INTEGERとして存在しますが、PBKDF2が最終的に消費するフィールドは32ビットのIntegerです。INTEGERを直接そのフィールドにデコードすると、大きな値が切り捨てられ、符号ビットに一致するように作られた値は負の値や無関係な小さな数として返されるため、処理の大きささえもファイルが要求したように見えるものとは異なってしまいます。修正案では、値をフル幅で読み取り、切り捨てる前に範囲を制限します。

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns 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);                    // safe: already bounded

Int64への読み込みは、デコードされた値が切り捨てられたゴーストではなく、実際の値であることを意味します。下限は、鍵導出において無意味なゼロや負のカウントを拒否します。上限である1億は、現在数万から数十万回の反復を使用する一般的なPKCS#12ファイルよりも十分に大きく設定しつつ、最悪のシナリオでも許容可能な計算量に制限します。値がその範囲を通過した後に初めて32ビットフィールドに切り捨てられるため、予期せぬ切り捨てが発生することはありません。HotPDFでは、このクランプ処理はPBKDF2HMACSHA256に進む過程でPBKDF2パラメータがデコードされるParsePBES2Paramsに組み込まれています。

なぜ両方の修正が同じ解決策になるのか

一方はバッファオーバーラン、もう一方はプロセスのハングアップと、2つの欠陥は異なるように見えますが、本質的には同じ誤りです。どちらの場合も、信頼できないファイルから得た数値が、実際に検証される前に1歩早く固定幅の型に代入されていました。長さは境界テストの前に32ビットで加算され、反復回数は範囲テストの前に32ビットに切り詰められていました。どちらも同じ原則に従います。すなわち、フル幅でデコードし、実際の制限に照らしてチェックし、その後で型を縮小します。中間のInt64はスタイルの選択肢ではなく、攻撃者が実際に書き込んだ値をガード処理が認識できる唯一の幅です。オーバーフローする境界は境界ではなく、天井のないカウント値はパラメータではなく、自身のCPUに対する外部からのスロットル(負荷制御)になってしまいます。

署名パイプラインにおける実践的なガイダンス

直接的な教訓は、信頼できない証明書の入力を、信頼できないアップロードファイルを検証するのと同様に検証することです。正当な.pfxファイルは数キロバイトであり、メガバイト単位にはならないため、受け入れるファイルのサイズを制限します。解析の失敗は日常的な「拒否された入力」として扱い、ユーザーにスタックトレースを表示するようなエラーとして扱うべきではありません。サーバー側で署名を行う場合は、ストールしたワーカーがサービス全体を巻き添えにしない場所でインポートを実行し、反復回数の上限に加えて実時間(ウォールクロック)でのタイムアウトを設定して、想定外に重いファイルを制限します。

より広い教訓は、証明書だけに留まりません。パーサーの堅牢化は、特定のユニットの単発の監査ではなく、ライブラリが自身で作成していないバイトデータを読み取るすべての場所に適用される性質のものです。PDFライブラリは、ドキュメントに埋め込まれたフォント、半ダースものコーデックの画像、ストリームフィルタ、および署名パスにおける証明書など、信頼できないソースから多くのデータを解析します。これらはすべてアタックサーフェスであり、すべての長さやカウント値に対して同様の疑いを持つ必要があります。HotPDFは、インポートおよび署名パスをここで解説した堅牢化されたユニット(HPDFASN1HPDFPFXHPDFCryptHPDFCMS)上に構築し、提供された資格情報がどこから来たものであっても、信頼する前に防御的に解析されるようにしています。

これらのチェックが保護する署名ワークフローは、DelphiにおけるPAdESデジタル署名のチュートリアルで包括的にカバーされています。また、このコードベースを共有するAES-256キーパスを含む、ドキュメント暗号化に適用された同様の防御体制については、AES-256暗号化とセキュリティに関する記事で説明されています。そのすべてが、このブログの他の場所で扱われている読み込み、編集、暗号化、および署名APIと並んで、DelphiおよびC++Builder向けのHotPDF Componentの一部として提供されています。