技術記事

PDFium ComponentのDocMDP:Widget /Pが隠したページ編集

v3.126.2より前のPDFium Componentビルドでは、TPdf.AnalyzeSignatureRevisionsが、実際のページコンテンツ編集をDocMDP P=3の下で許可された注釈変更として判定できました。リビジョンのロールグラフが、署名ウィジェットのページへの/P逆参照を所有権として扱っていたからです。v3.126.2から、PDFium Componentはナビゲーションエッジを所有ペイロードのエッジから分離するので、ページコンテンツはページコンテンツのままです。この修正の背後にあるバグ報告は、紙の上では無害に見えます。注釈を許す認証済み契約書があり、取引先がインクリメンタルセーブを1つ足し、アナライザーはそれ以降の変更はすべて許可されていると答える。そして誰かが描画結果のページを差分し、2ページ目の支払金額が違うことに気づく

この記事は、署名後リビジョン変更分析の概観の続編で、攻撃者の視点から書いています。リビジョンの再構築とDocMDP判定の基礎は省略し、オブジェクトグラフへ直行します。所有権がどうモデル化されていたか、なぜエッジの向きがセキュリティの判定を決めるのか、v3.126.2で何が変わったのか、そして自分の受け入れロジックをどう監査するか

なぜページ編集がDocMDP P=3の下で注釈変更として通ったのか

ページ編集が通ったのは、古いロールグラフが辞書内のすべての間接参照を、参照先が参照元に属するものとして辿っていたからです。そして署名ウィジェットは自分のページを指し戻します。注釈辞書は、自分が載っているページオブジェクトへの間接参照/Pを持ちます(ISO 32000-1 §12.5.2)。このエントリーはナビゲーションのヒントです。ウィジェットがページを所有するのではなく、ページが/Annots配列を通じてウィジェットを所有します

アナライザーは後の変更を判定する前に、各オブジェクトにロールビットの集合を割り当てます。ページ、注釈、フォーム、検証材料です。ルートオブジェクトは自分の辞書からロールを得て、ロールは参照先すべてへ広がります。古い伝播では、連鎖は次のように進みました:

  1. 署名ウィジェットは/FT /Sig付きの/Subtype /Widgetなので、注釈ロールを得ます
  2. ウィジェットの/Pが注釈ロールをページ辞書へ押し込みます。ページ辞書はすでにページロールを持っています
  3. ページは両方のロールを/Contentsと/Resourcesへ、そして/Parent経由でPagesツリーの上へ、さらにすべての兄弟ページへ広げます
  4. << /Length 812 >>のようなコンテンツストリーム辞書は/Typeを持たないので、分類器はロールビットへフォールバックし、ページロールより先に注釈ロールをチェックしました
v3.126.2より前のDocMDPロールグラフを示すPDFium Componentの図。署名ウィジェットの/P逆参照が注釈ロールをページ辞書へ押し込み、ページが/Contents経由でそれを/Typeエントリーのないコンテンツストリームへ広げ、分類器はprckAnnotationを出し、P=3判定はprdAllowedを返します
v3.126.2より前は、ロールグラフがすべての間接参照を所有権として扱いました。だからウィジェットの/Pエントリーが注釈ロールをページへ押し込み、本物のページ編集が許可された注釈変更としてアナライザーから出ていったのです

そのため改変されたコンテンツストリームはprckAnnotationとして出ていきました。ISO 32000-1 §12.8.2.2の下では、DocMDP P=3は注釈変更を許すので、判定はprdAllowedとなり、レポートはprasAllowedへ集約されます。同じファイルはP=2では拒否されましたが、偶然にすぎません。P=2は注釈変更を禁じるので、誤ラベルのストリームが間違った理由で拒まれたのです。固定4パスの伝播ループが2つ目の弱点を足していました。間接配列を通じて届くペイロードや、オブジェクト番号が逆に走る長い連鎖を通じて届くペイロードは、ロールをまったく受け取らないことがありました

なぜ署名検証器はオブジェクトの所有者を尋ねねばならないのか

署名検証器がオブジェクトの所有者を尋ねねばならないのは、PDFのインクリメンタル更新(ISO 32000-1 §7.5.6)が、既存のオブジェクト番号を再定義するリビジョンの追記を誰にでも許し、再定義された本体は自分が何であるかを名乗らないからです。署名は自分のリビジョンのバイトだけをカバーするので、検証は通り続けます。だから署名後の改変に対するすべての防御は、変更された各オブジェクトをそれを使う構造へ対応付け、署名者がその構造の変更を許したかを尋ねることに依存します

公表されている複数の攻撃クラスは、まさにこの隙間で働きます。インクリメンタルセーブ攻撃は、ページコンテンツを差し替えるリビジョンを追記し、検証器が署名済みバイト範囲しかチェックしないことに賭けます。シャドウ攻撃は、署名前に隠しコンテンツを仕込み、署名後に小さく無害そうな変更で活性化します。認証済みドキュメントへの攻撃は、P=2とP=3が一部の後続編集を明示的に許す事実を悪用し、禁じられた編集を許された編集に見せかけます。/Type /Annotのようなラベルで、あるいはたまたま届く参照経路でオブジェクトを分類する検証器は、3つ目のクラスに晒されます。攻撃者に必要なのは、禁じられた構造へ届く許可された構造を1つ見つけることだけです

だから問うべきは、どのオブジェクトが変わったかではなく、誰がそれを所有しているかです。/Contents経由でページから届くコンテンツストリームは、他に何が指していようがページコンテンツです。/P経由でページを指し戻す注釈が語るのは、注釈がどこに住むかであって、何を所有するかではありません

PDFium Component v3.126.2は所有権をどうモデル化するのか

PDFium Component v3.126.2は逆参照をナビゲーションとして扱い、ロール伝播から締め出します。そしてどのキーをナビゲーションと数えるかは、キー名だけからではなく、それを保持する辞書の構造的ロールから決めます。次の表は、所有権を運ばなくなったナビゲーションキーのまとめです

所有者辞書ナビゲーションとして扱うキー仕様の参照箇所
PageまたはPagesノード/Parent、/Kids、/AnnotsISO 32000-1 §7.7.3
注釈またはウィジェット/PISO 32000-1 §12.5.2
ウィジェットまたはフィールド辞書/ParentISO 32000-1 §12.7.3
PDFium Component v3.126.2のオブジェクトグラフ。ページと注釈のロールを広げる/Contentsや/Annotsのような所有ペイロードエッジを、ロールを運ばないウィジェット/Pのようなナビゲーションエッジから分離し、所有者辞書ごとのナビゲーションキーを示し、偽造された/Typeの下でもコンテンツストリームにはprckPageContentが保たれます
v3.126.2は逆参照をロール伝播から締め出します。ロールは本物の所有権だけを通って旅するので、コンテンツストリームはページコンテンツのまま、/Pのヒントは何も決めません

キー名でのグローバルなフィルタリングは、新しい穴を作ったはずです。フォントやXObjectのリソースは/P、/Parent、/Annotsという名前を持てます。そして/Pエントリーを伝播から落とした/Resources辞書は、攻撃者に無害なリソース名の後ろへページ所有のXObjectを隠させます。v3.126.2では、ナビゲーションフィルターは、所有する辞書が実際にページ、Pagesノード、注釈、ウィジェット、フィールドであるときにだけ適用されます。それらの辞書がナビゲーションキーを重複して持つ場合、たとえばウィジェットに/Pが2つある場合、アナライザーはビューアーがどちらのコピーを使うかを推測しません。ロール構築が失敗し、署名はIndeterminateになります

残りの再ラベリング経路を閉じるルールがさらにいくつかあります:

  • Pagesノードはそれ自体がページロールのルートなので、Pagesツリーから継承されたリソース(ISO 32000-1 §7.7.3.4)は、子ページからの/Parent辿りではなく、本物の所有権を通ってページコンテキストに入ります
  • 注釈ロールやフォームロールが、カタログ、Pagesノード、ページ、注釈、フィールド辞書へ届いたら、そこで止まります。これらの構造オブジェクトは自分のロールを確立するので、入ってくるペイロードロールが上書きしてはならないからです
  • 分類の間、ページロールは権威です。ページ所有のオブジェクトは、後のリビジョンが偽造した/FTや/Type /Annotラベルで書き換えたり、アピアランスストリームと共有したりしても、prckPageContentのままです
  • フィールドや注釈のアピアランスとしてだけ使われるForm XObjectは、フォームか注釈のカテゴリーを保ちます。だからフォーム記入後の普通のアピアランス再生成は、通常の許可ルールの下で判定され続けます
  • 自分の/FTを持たないウィジェットは、/Parent連鎖を通じて継承されたフィールドタイプを解決します。解決できない連鎖は、注釈へデフォルトする代わりにロール構築を失敗させます
  • すべての後続リビジョンからのロールビットは、被覆リビジョンのロールへ統合されます。だから後の更新が、ストリームをまず切り離してから編集するやり方で、先のページ所有関係を消すことはできません

固定パス数の代わりに不動点で

v3.126.2のロール到達可能性は、オブジェクトが新しいロールビットを得なくなるまで反復するワークキューとして走ります。連鎖の深さやオブジェクト番号に関係なく、これは真の不動点です。/Contents配列が自分自身のオブジェクトとして保存されているような間接配列も辿られます。各オブジェクトが得られる個別のロールビットは最大4つなので、キューはオブジェクト番号ごとに4エントリーで上限が付きます。この予算を超えるとprrResourceLimitExceededを上げます。フリーオブジェクトへの参照、世代の不一致、壊れたオブジェクトヘッダーはprrMalformedRevisionChainを、圧縮オブジェクトストリーム内のペイロードはprrCompressedObjectUnresolvedを上げます。これらの失敗はすべてprasIndeterminateで終わり、許可の判定で終わることは決してありません。そして被覆リビジョンのロール構築中に失敗が起きた場合、署名はChangesをまったく報告しません

次のルーチンは、この分析を生き延びるページコンテンツ編集を一覧します。prckPageContentの変更がprdAllowedと判定されることは決してありません。DocMDP P=1、2、3はそれをprdDisallowedにし、DocMDPなしの署名はprdSuspiciousと判定します

uses
  SysUtils, TypInfo, PDFium, FPdfPades;

procedure ListPageContentEdits(const FileName: string);
const
  ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  Sig: TPadesSignatureRevisionAnalysis;
  Change: TPadesRevisionObjectChange;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;   // レコード。解放すべきものはない
    for i := 0 to High(Report.Signatures) do
    begin
      Sig := Report.Signatures[i];
      if not (prrPageContentChanged in Sig.Risks) then
        Continue;
      Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
        [Sig.SignatureIndex, Sig.DocMdpPermission]));
      for j := 0 to High(Sig.Changes) do
      begin
        Change := Sig.Changes[j];
        if Change.Kind <> prckPageContent then
          Continue;
        Writeln(Format('  revision %d  object %d %d R  %s%s',
          [Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
           GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
           ShadowTag[Change.IsAuthoritative]]));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

FieldMDPと注釈が1つのオブジェクトを共有したら何が起きるのか

フィールドの/Vと注釈の/Contentsが同じ間接オブジェクトを指すとき、v3.126.2は、その変更が注釈編集と分類されてもFieldMDPのロックを有効のまま保ちます。シナリオは手で簡単に作れます。署名者がTotalフィールドをFieldMDPでロックし(ISO 32000-1 §12.8.2.4)、攻撃者がテキスト注釈の/Contentsに、フィールド値を保持するのと同じ文字列オブジェクトを参照させるのです。P=3の下では注釈編集は許可されているので、修正前はその共有文字列の書き換えが、許可の判定でロックされたフィールド値を変えていました

オブジェクトは今、注釈とフォームの両ロールを運び、署名がFieldMDPトランスフォームを持つときは、注釈の判定がフォーム側を再チェックします:

  • P=2の下では注釈変更は丸ごと不許可です。以前とまったく同じです
  • FieldMDP Allなら、すべてのフィールドがロックされるので、共有の変更はprdDisallowedです
  • FieldMDP IncludeかExcludeなら、アナライザーは共有スカラーを1つのフィールド名まで追跡できないので、判定は当て推量ではなくprdIndeterminateです
  • FieldMDPがなければ、P=3の注釈ルールが適用され、変更は許可のままです
PDFium ComponentのFieldMDP判定の図。ロックされたTotalフィールドの/Vと注釈の/Contentsが同じ間接オブジェクトを参照し、DocMDP P=2、FieldMDP All、FieldMDP IncludeまたはExclude、FieldMDPなしの上で分岐し、同じ共有編集に対してprdDisallowed、prdIndeterminate、prdAllowedの判定へ至ります
1つの間接オブジェクトが注釈とフォームの両ロールを運ぶとき、注釈の判定はFieldMDPロックを再チェックします。だから同じ編集が、許可から不許可、Indeterminateまで振れ幅を持ちます

ゲートコードにとって大事なレポーティングのディテールが1つ。共有ケースはKind = prckAnnotation、Decision = prdIndeterminateとして報告され、prrFieldMdpUnresolvedがリスク集合に加わるのは、フォームフィールドと分類された変更だけです。prrFieldMdpUnresolvedを探してStatusを無視するゲートは、このケースを完全に見逃します

Delphiコードはリビジョン分析でどうフェイルクローズすべきか

Delphiコードが署名済みドキュメントを受け入れるのは、分析ステータスがprasNoLaterChangesかprasAllowedで、構造的リスクが存在しないときだけにすべきです。そしてprasIndeterminateとprasSuspiciousは、ログに取って通す警告ではなく、信頼できないものとして扱います。Indeterminateは、アナライザーが後続リビジョンが許可されていたと証明できなかったことを意味します。攻撃者にとって、自分のコードが通すなら、確実にIndeterminateを生む入力は、Allowedを生む入力と同じくらい有用です。グローバルのAnalyzePadesSignatureRevisionsは任意のTStreamを受け取り、位置0から読みます。ドキュメントを描画する必要のないアップロードハンドラーに向いた形です

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // 重複定義はStatusを下げずに記録される
  BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
    prrResourceLimitExceeded];

function SignedRevisionsAcceptable(const FileName: string;
  out Reason: string): Boolean;
var
  Source: TFileStream;
  Report: TPadesRevisionAnalysisReport;
begin
  Result := False;
  Reason := '';
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Report := AnalyzePadesSignatureRevisions(Source);
  finally
    Source.Free;
  end;
  if Report.SignatureCount = 0 then
  begin
    Reason := 'no signature anchors the analysis';
    Exit;
  end;
  if Report.Risks * BlockingRisks <> [] then
  begin
    Reason := 'structural risk in the revision chain';
    Exit;
  end;
  case Report.Status of
    prasNoLaterChanges, prasAllowed:
      Result := True;
  else
    // prasIndeterminateとprasSuspiciousは拒否であって警告ではない
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

2つの境界を、はっきり言っておく価値があります。TPadesRevisionAnalysisReportはCMSの完全性や証明書の信頼については何も語りません。だからこのゲートは、暗号と信頼の検証の隣に置くのであって、その代わりではありません。そして正しい所有権グラフは、P=3をすべてのワークフローに対して安全にはしません。P=3は本当に注釈を許すので、不透明なアピアランスを持つ注釈は、1つのコンテンツストリームにも触れずに署名済みテキストを覆えます。認証済みドキュメントがレビュー用コピーではなく契約書なら、P=2で認証するか、許可された注釈変更を人間へ回してください。次のヘルパーのように:

function AllowedAnnotationEditsUnderP3(
  const Report: TPadesRevisionAnalysisReport): Integer;
var
  i, j: Integer;
begin
  Result := 0;
  for i := 0 to High(Report.Signatures) do
    if Report.Signatures[i].DocMdpPermission = 3 then
      for j := 0 to High(Report.Signatures[i].Changes) do
        if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
           (Report.Signatures[i].Changes[j].Decision = prdAllowed) then
          Inc(Result);
end;

署名リビジョン監査チェックリスト

このリストで、自分の検証パイプラインが晒されていたか、そして今フェイルクローズするかを確認してください:

  • v3.126.2より前のPDFium Componentビルドは、DocMDP P=3ドキュメントのページコンテンツ編集にprasAllowedを報告できました。古いビルドが受け入れた認証済みP=3ファイルにはTPdf.AnalyzeSignatureRevisionsを再実行してください
  • フィールド値と注釈が間接オブジェクトを共有しているかもしれない、FieldMDPロック付きのP=3ドキュメントを再チェックしてください
  • 受け入れるのはprasNoLaterChangesとprasAllowedだけにします。prasIndeterminateとprasSuspiciousは信頼できないものとして扱います
  • Report.StatusだけでなくReport.Risksもテストします。prrDuplicateObjectDefinitionはそれ自体ではステータスを変えないからです
  • 署名ステータスがIndeterminateのとき、空のChanges配列をクリーンな結果として読まないでください。失敗したロール構築は変更を報告しません
  • FieldMDPの問題を捕捉する目的でprrFieldMdpUnresolvedだけに頼らないでください。共有注釈のケースは判定とステータスを通してしか表面化しません
  • P=3の下での許可された注釈変更に、自分のワークフローで人間のレビューが必要か決めてください
  • 分析するのは元ファイルのバイトです。SaveAsで書き直されたドキュメントは、リビジョン連鎖をもう含んでいません

リビジョン分析は署名チェックの1層です。辞書とベースラインレベルにはPDFデジタル署名とPAdESレベルの検査を、JavaScript、起動アクション、埋め込みファイルにはより広いPDFセキュリティリスク監査と組み合わせてください。ここで述べたTPdf.AnalyzeSignatureRevisions、AnalyzePadesSignatureRevisions、所有権を意識したロールグラフは、Delphi、C++Builder、Lazarus向けのPDFium Componentに同梱されます