技術記事

DelphiでのCMS署名解析:DER親境界とOIDのエンコード

PDF Library for Delphi(PDFlibPas)は、/Contentsに格納されたCMS SignedDataを素のDERウォークでたどって、PDF署名内の証明書を取り出します。CryptoAPIは一切使いません。v3.539.10からは、ネストした読み取りがすべて親要素で区切られ、CMSの後ろのゼロ埋めはCMSが宣言する長さで切り落とされ、オブジェクト識別子は合成した最初のサブ識別子をbase-128でエンコードします。境界ルールとOID修正は、どちらもエラーを上げずに間違った答えを出していたコードの置き換えです。パディングのルールは、より厳格になったリーダーが実在の署名を拒否しないためのものです

読み取り側は見た目以上に重要です。長期検証ツールは失効データを取得する前に、既存の署名から署名者証明書とその発行者を取り出さなければなりません。監査レポートは誰が署名したかを答えなければならず、Linux上のLazarusビルドには頼れるWindowsメッセージ関数がありません。その立場のパーサーが不正入力でクラッシュすることはめったにありません。痛いのは、隣の要素のバイトまで数に含まれた証明書カウント、間違ったフィールドに対して行われた署名者マッチ、それから気付かないうちに別のOIDへ化けたOIDです。その上に築いた署名パイプラインは、自信たっぷりにデタラメを報告します

署名済みPDFから署名者証明書を取り出す

読み取り側は5つのTPDFlibメソッドが担っており、いずれもInputFile, Password, FieldNameを取ります。呼び出しごとにファイルを読み取り専用で開き、答えを返して、また閉じるのです。GetSignatureEmbeddedCertificateCountとGetSignatureEmbeddedCertificateDERはエンコード順に証明書の集合を列挙し、GetSignatureSignerCertificateDERは指定したSignerInfoを生成した証明書を返し、GetSignatureCertificateChainLength/GetSignatureCertificateChainDERはその署名者から、署名自体が運んでいる最も遠い発行者へとたどります。インデックスは0ベースです。結果はAnsiStringで受けてください。ライブラリがそう返すのには理由があります。stringやTStringsを経由させたDERの塊は文字コード変換を通って、戻ってきたときには壊れています

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

この出力には気を付ける点が2つあります。カウント0は診断ではありません。フィールドの欠落、パスワードの間違い、DERでないデータ、オプションの証明書集合を単に省いたSignedDataは、どれも0や空文字列で返ってきます。数字の隣にはフィールド名をログに残しましょう。自己発行証明書の手前で終わるチェーンも、エラーではありません。チェーンビルダーが使うのは署名に埋め込まれた証明書だけで、残りの発行者はGetCertificateIssuerURLsが報告するアドレスから取得するしかないのです

/Contentsのうち、本当にCMSなのはどの部分か

CMSに属するのは、外側のSEQUENCEが宣言する接頭部だけであり、PLTrimCMSPaddingはその後ろをすべて切り捨てます。署名者はCMSが存在する前に/Contentsの16進文字列を予約します。ISO 32000-1 §12.8.1で定められた/ByteRangeを先に確定させなければならないためで、スロットは大きめに確保され、未使用の末尾はゼロのままです。PLTrimCMSPaddingは最初のTLVを読み、タグ$30を要求し、その要素の終わりまでのバイトを返します。整形されたSEQUENCEで始まらないものは、空で返ります。末尾に余分なバイトが許されるのは、この最上位だけです。この区別は次のセクションで効いてきます。「要素はバッファー全体を消費しなければならない」という厳格ルールでは、実在する署名をことごとく拒否してしまいます。逆にあらゆる深さへ緩いルールを適用すると、ネストしたフィールドが自分のものではないバイトを読めてしまいます

PDFlibPasのPLTrimCMSPaddingは予約された/Contents 16進文字列の最初のTLVを読み、タグ$30を要求し、外側のSEQUENCEが宣言する長さでゼロ埋めを切り落とします。整形されたSEQUENCEで始まらないバッファーは空の結果を返します
末尾の余分なバイトが許されるのは最上位だけです。/ByteRangeのために予約スロットを固定しておく必要があるためで、それより深い読み取りには親境界ルールが適用されます

DERリーダーに親の終了オフセットが必要な理由

ネストした要素は、親の内側で終わって初めて妥当です。バッファーの終わりとの突き合わせでは、それを証明できません。PDFlibASN1の低レベルDERReadTLVは各要素を文字列全体に対して区切ります。これは最外オブジェクトには正しい検査ですが、その下のすべてに対しては間違った検査です。SignerInfoのissuerAndSerialNumberが40バイトを宣言しているのに、内側のissuer Nameが60バイトを主張している状況を想像してください。どのバイトもバッファー内にあるので、バッファー区切りのリーダーはNameを受け入れ、直後のダイジェストアルゴリズムからシリアル番号を読み出し、そのペアを埋め込み証明書と照合します。v3.539.10より前のCMSウォーカーは、まさにその読み方をしていました。修正は、親の終了位置をすべての読み取りへ運ぶ小さなラッパーです

PDFlibPasはネストしたDER読み取りをすべて親要素で区切ります。40バイトのissuerAndSerialNumber内にある60バイトのissuer Nameは、旧来のバッファー区切りDERReadTLVでは受け入れられ、続いてdigestAlgorithmからシリアル番号が読み出されます。ReadTLVWithinはParentEndを超えて終わる要素を拒否します
バッファー内はメモリ安全性の話、親の内側は正しさの話です。PDFlibPasは親の終了オフセットをCMSの全レベルへ渡すので、悪意ある長さが隣のバイトを借りることはできません
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // 親の内側に残りがない:読み取りを開始しない
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offsetは今や要素の1つ先。親を越えてはならない
  Result := Offset <= ParentEnd;
end;

// 各レベルは自分の終端を記録して下へ渡す:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

ユニットのPDFlibCMSReadは今や、これらの終端をContentInfo、[0] EXPLICITラッパー、signerInfosまでのSignedDataフィールド、issuerAndSerialNumberと[0] subjectKeyIdentifierの両形式があるSignerIdentifier(RFC 5652 §5.3)、そして署名者を照合するときに各埋め込み証明書から読むtbsCertificateフィールドへと通しています。証明書集合とsignerInfos集合の内側では、集合の終わりを越えて伸びる要素がループを止めます。PLExtractCMSCertificatesはそれまでに受け入れた証明書を返し、後続のcrlsやsignerInfosのバイトを最後の1つに継ぎ足すことはしません。issuer-and-serialの照合は両方の半分を要求します。シリアル番号は1つの発行者の中でしか一意でないからです

なぜ2.999.3が1.15.3になったのか

OIDの最初の2つのアークは、1バイトではなく1つのサブ識別子へ合成されます。そのサブ識別子は他のアークと同じくbase-128でエンコードされます。X.690 §8.19.4はこれを40 * arc1 + arc2と定めています。旧来のDER_OIDはこの値をByte(...)で書き出していました。正しいのは127まで、つまり2.47の値までです。2.999なら合計は1079で、バイトキャストは55を残し、55は1.15としてデコードされます。識別子は音もなく木の別の枝を名乗ります。128から255の値は別の形で壊れ、継続ビットを立てた1バイトを出力して次のアークを飲み込みます。PKIの識別子のほとんど(1.2.840...、2.5.29...、0.4.0...)は境界に届かず、それがこのバグが生き延びた理由です。2.48以上のjoint-iso-itu-tアークは届きます。DER_OIDはsigned attributesのエンコーダーと、DERFindExtensionByOIDのマッチャーおよびSignedDataのcontent-type検査の両方に使われるため、誤ったエンコードは書き込みと参照の双方を壊しました

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
PDFlibPasのDER_OIDはOIDの最初の2つのアークを40 * arc1 + arc2として合成し、その合計をUInt64でbase-128エンコードします。2.999.3は06 03 88 37 03になりますが、旧来のByteキャストは55を残し、識別子は黙って1.15.3としてデコードされていました
PKIのアークのほとんどは境界に届かないので、このバグは生き延びました。2.48以上のjoint-iso-itu-tアークは2バイトを要し、テストは2.47と2.48を境界の両側に置いています

合成した値をUInt64に持たせるのは、意図的なことです。DER_OIDはアークをInt64へパースするので、2番目のアークは正当な範囲でInt64.MaxValueまであり得ます。arc1 = 2の分の80を足すと、符号付き64ビット整数はオーバーフローします。UInt64はInt64.MaxValue + 80をラップせずに運び、10バイトのスクラッチバッファーは64ビット値に必要な10個の7ビットグループを収めます。取っておく価値のあるテストベクターは、境界の両側にあるものです。2.47は1バイトのままであること、2.48は2バイトになることを確かめます

読み取り側のCMSウォーカーが保証するもの

PDFlibCMSReadが保証するのは構造だけです。RFC 5652が指示する位置にあるバイトを返しますが、署名もダイジェストも有効期間も検証しません。ウォーカーが受け入れるのはDERだけです。DERReadTLVは不定長とマルチバイトのタグ番号を拒否するので、仕様に従わない署名者が作ったBERエンコードのCMSは、部分的な推測ではなく証明書ゼロとして報告されます。属性証明書とCertificateChoicesの他の選択肢は、下流で使えるものがないためスキップします。暗号検証はそれを司るコードの仕事です。その始まりはDelphiでのPAdES署名とByteRange検証で述べたバイトカバレッジ検査で、続きはPDF署名後の変更内容の分類です

より広い教訓は、どんなバイナリフォーマットにも通じます。「バッファー内」はメモリ安全性の性質、「親の内側」は正しさの性質で、パーサーには両方が要ります。敵意ある長さへの同じ考え方は、PascalのPDFパーサーを悪意あるファイルに対して堅牢化するでも一貫しています。ここで扱った証明書抽出、チェーン構築、長期検証のAPIは、Delphi、C++Builder、Lazarus向けのlosLab PDF Library for Delphiに同梱されます