Műszaki cikk

CMS-aláírások elemzése Delphiben: DER-határok és OID-ívek

A PDF Library for Delphi (PDFlibPas) a PDF-aláírásban tárolt tanúsítványokat a /Contents-ban lakó CMS SignedData tisztán DER-bejárással nyeri ki, CryptoAPI nélkül. A v3.539.10 óta minden egymásba ágyazott olvasást a szülőeleme határol, a CMS utáni nulla padding a CMS által deklarált hossznál vágódik le, és az objektumazonosítók az összefésült első szubidentifikátorukat base-128-ban kódolják. A határszabály és az OID-javítás is olyan kódot váltott fel, ami hibádobás nélkül produkált rossz válaszokat, a padding-szabály pedig megakadályozza, hogy a szigorúbb olvasó elutasítsa a valódi aláírásokat

Az olvasó oldal fontosabb, mint amennyire látszik. A long-term validation eszköznek ki kell nyernie az aláíró tanúsítványát és a kibocsátóit egy meglévő aláírásból, mielőtt visszavonási adatot tudna szerezni, egy audit riportnak meg kell mondania, ki írt alá, a Linuxon fordított Lazarus buildnek pedig nincs mire támaszkodnia Windows üzenetfüggvények terén. Egy ebben a pozícióban ülő parser rossz bemeneten ritkán bukik el hangosan. A fájó hibamód az, amikor a tanúsítványszámláló beleszámol egy szomszéd bájtjaiba, az aláíróját egy rossz mezőhöz hasonlítják, vagy egy OID csendben másik OID-dá változik. Erre épülő aláírás-feldolgozó lánc magabiztosan jelent értelmetlenségeket

Az aláíró tanúsítványok kiolvasása egy aláírt PDF-ből

Öt TPDFlib metódus fedi le az olvasó oldalt, és mindegyik átveszi az InputFile, Password, FieldName paramétereket: minden hívás csak olvasásra nyitja meg a fájlt, válaszol, majd bezárja. A GetSignatureEmbeddedCertificateCount és a GetSignatureEmbeddedCertificateDER a tanúsítványhalmazt sorolja fel kódolási sorrendben, a GetSignatureSignerCertificateDER azt a tanúsítványt adja vissza, amiből egy adott SignerInfo született, a GetSignatureCertificateChainLength / GetSignatureCertificateChainDER páros pedig ettől az aláírótól indul el az aláírás által hordozott legtávolabbi kibocsátó felé. Az indexek nullától számolnak. Az eredményt AnsiString-ben tartsd, ezért adja vissza őket a library is így: egy string-en vagy TStrings-en áteresztett DER blob karakterkészlet-konverzión megy át, és sérülten jön vissza

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;

Két dologra kell figyelni ebben a kimenetben. A 0-s darabszám nem diagnózis: hiányzó mező, rossz jelszó, nem DER blob, és egy SignedData, ami egyszerűen elhagyja az opcionális tanúsítványhalmazt, mind 0-t vagy üres stringet ad vissza, ezért a szám mellé írd be a mezőnevet. És az a lánc sem hiba, ami önmagát kibocsátó tanúsítvány előtt ér véget. A láncépítő csak az aláírásba ágyazott tanúsítványokat használja, így a maradék kibocsátókat azokon a címeken kell behúzni, amiket a GetCertificateIssuerURLs jelent

A /Contents mekkora része valójában a CMS?

Csak az a prefix tartozik a CMS-hez, amit a külső SEQUENCE deklarál, és a PLTrimCMSPadding minden mást levág utána. Az aláíró már a CMS létezése előtt lefoglalja a /Contents hex stringet, mert az ISO 32000-1 §12.8.1-e szerinti /ByteRange-et előbb rögzíteni kell, így a hely bőven van méretezve, és a fel nem használt farok nullákból áll. A PLTrimCMSPadding kiolvassa az első TLV-t, megköveteli a $30 taget, és az elem végéig adja vissza a bájtokat; ami nem jól formált SEQUENCE-zel indul, az üresen jön vissza. A felső szint az egyetlen hely, ahol a feleslegesen ottmaradt bájtok legálisak, és ez a különbség a következő szakasz szempontjából is fontos: egy szigorú „az elemnek a teljes buffert el kell fogyasztania" szabály minden valós aláírást elutasítana, egy minden mélységben engedékeny szabály viszont hagyná, hogy az egymásba ágyazott mezők olyan bájtokat olvassanak, amik nem az övéik

A PDFlibPas PLTrimCMSPadding kiolvassa a lefoglalt /Contents hex string első TLV-jét, megköveteli a $30 taget, és a külső SEQUENCE által deklarált hossznál vágja le a nulla paddinget; üres eredményt ad, ha a buffer nem jól formált SEQUENCE-zel indul
A felesleges farokbájtok csak a felső szinten legálisak, ahol a lefoglalt helynek a /ByteRange miatt rögzítve kell maradnia — a mélyebb olvasások ehelyett a szülőhöz kötött szabályt kapják

Miért kell egy DER olvasónak a szülő végoffsetje?

Egy egymásba ágyazott elem csak akkor érvényes, ha a szülőjén belül ér véget, és ezt a buffer végéhez viszonyított ellenőrzés nem bizonyítja. A PDFlibASN1 alacsony szintű DERReadTLV-je minden elemet a teljes stringhez mér, ami a legkülső objektumnál helyes, minden attól lejjebbihez képest rossz. Képzelj el egy SignerInfo-t, aminek az issuerAndSerialNumber-je 40 bájtot deklarál, miközben a benne lakó issuer Name 60-at állít. Minden bájt még mindig a bufferben van, így egy bufferhez kötött olvasó elfogadja a Name-et, a sorozatszámot a következő digest algoritmusából olvassa ki, majd ezt a párt hasonlítja a beágyazott tanúsítványokhoz. A v3.539.10 előtt a CMS-bejáró pontosan így olvasott. A javítás egy kis wrapper, ami minden olvasásba beleviszi a szülő végpozícióját

A PDFlibPas minden egymásba ágyazott DER-olvasást a szülőeleméhez köt: a 40 bájtos issuerAndSerialNumber-en belüli 60 bájtos issuer Name-et a régi, bufferhez kötött DERReadTLV elfogadja, majd a sorozatszámot a digestAlgorithm-ból olvassa ki, miközben a ReadTLVWithin minden ParentEnden túl végződő elemet elutasít
A bufferben maradni memóriabiztonság, a szülőn belül maradni helyesség — a PDFlibPas minden CMS szinten átvezeti a szülő végoffsetjét, így egy ellenséges hossz nem kölcsönözhet szomszéd bájtokat
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // a szülőn belül nem maradt semmi: nem indít olvasást
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // az Offset most az elemen eggyel túl ül; nem mehet át a szülőn
  Result := Offset <= ParentEnd;
end;

// minden szint feljegyzi a saját végét, és lefelé adja:
//   OuterEnd    := a ContentInfo vége         (RFC 5652 3. szakasz)
//   ExplicitEnd := a content [0] EXPLICIT vége
//   ContentEnd  := a SignedData vége          (RFC 5652 5.1. szakasz)
//   SignerEnd / InnerEnd a SignerInfo-hoz és az issuerAndSerialNumber-hoz

A PDFlibCMSRead unit ezeknek a végeknek a menetét mostantól végigviszi a ContentInfón, a [0] EXPLICIT csomagoláson, a signerInfos-ig tartó SignedData mezőkön, a SignerIdentifier mindkét alakján, az issuerAndSerialNumber-en és a [0] subjectKeyIdentifier-en (RFC 5652 §5.3), valamint az aláíró párosítása során minden beágyazott tanúsítványból kiolvasott tbsCertificate mezőkön. A tanúsítványhalmazon és a signerInfos halmazon belül egy olyan elem, ami túlcsúszik a halmaz végén, megállítja a ciklust: a PLExtractCMSCertificates visszaadja azokat a tanúsítványokat, amiket már elfogadott, és sosem ragasztja az utolsóra a következő crls vagy signerInfos bájtokat. Az issuer-and-serial párosításhoz mindkét fele kell, mert egy sorozatszám csak egy kibocsátón belül egyedi

Miért lett a 2.999.3-ból 1.15.3?

Egy OID első két íve egyetlen szubidentifikátorrá áll össze, nem egy bájttá, és ez a szubidentifikátor ugyanúgy base-128-ban kódolódik, mint a többi ív. Az X.690 §8.19.4-e 40 * arc1 + arc2-ként definiálja; a korábbi DER_OID ezt az értéket Byte(...)-tal írta ki, ami csak 127-ig, a 2.47 értékéig helyes. A 2.999 esetén az összeg 1079, a bájtos cast 55-et tart meg, az 55 pedig 1.15-ként dekódolódik, így az azonosító csendben a fa egy másik ágát nevezi meg. A 128 és 255 közti értékek máshogy buknak el: egy bájtot bocsátanak ki bekapcsolt folytatási bittel, ami lenyeli a következő ívet. A legtöbb PKI azonosító (1.2.840..., 2.5.29..., 0.4.0...) sose ér a határig, ezért maradhatott észrevétlen a hiba; a joint-iso-itu-t ívek 2.48-tól felfelé igen. A DER_OID az aláírt attribútumok kódolóját és a DERFindExtensionByOID matcherét, valamint a SignedData content-type ellenőrzését is kiszolgálja, így egy rossz kódolás egyszerre törte el az írást és a keresést

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.
A PDFlibPas DER_OID-je az első két OID ívet 40 * arc1 + arc2 alapján fésüli össze, és az összeget UInt64-ben kódolja base-128-ban, így a 2.999.3-ból 06 03 88 37 03 lesz, miközben a régi Byte cast megtartotta az 55-öt, és csendben 1.15.3-ként dekódolta az azonosítót
A legtöbb PKI ív sose ér a határig, ezért maradhatott fent a hiba — a joint-iso-itu-t ívek 2.48-tól felfelé két bájtot igényelnek, a teszt pedig a 2.47-et és a 2.48-at tartja a két oldalon

Az összefésült érték szándékosan UInt64-ben lakik. A DER_OID az íveket Int64-be parseolja, így egy legális második ív Int64.MaxValue nagyságú is lehet, és a 80 hozzáadása arc1 = 2 esetén túlcsordít egy előjeles 64 bites egészre. A UInt64 átbírja a Int64.MaxValue + 80-ot csordulás nélkül, és a tíz bájtos segédbuffer befogadja azokat a tíz 7 bites csoportot, amik egy 64 bites értéknek kellenek. Megőrzésre érdemes tesztvektorok a határ két oldalán állók: a 2.47-nek egy bájton kell maradnia, a 2.48-nak kettővé kell válnia

Mit garantál az olvasó oldali CMS-bejáró?

A PDFlibCMSRead a struktúrát garantálja és semmi mást: olyan bájtokat ad vissza, amik pontosan ott ülnek, ahová az RFC 5652 teszi őket, és egyetlen aláírást, digestet vagy érvényességi időszakot sem ellenőriz. A bejáró csak DER-t fogad el, így a DERReadTLV elutasítja a határozatlan hosszakat és a több bájtos tag számokat, egy nem megfelelő aláírótól származó, BER-kódolású CMS pedig részleges találgatás helyett zérus tanúsítványt jelent. Az attribútumtanúsítványokat és a CertificateChoices többi alternatíváját kihagyja, mert semmi a lefolyásban nem tudja őket hasznosítani. A kriptográfiai ellenőrzés annál a kódnál marad, amié: ez indul a PAdES aláírás és ByteRange validáció Delphiben cikkben írt bájt-lefedettségi ellenőrzésekkel, és folytatódik a PDF aláírása után megváltozott tartalom osztályozásával

A tágabb tanulság minden bináris formátumra átvihető: a „bufferben van" memóriabiztonsági tulajdonság, a „szülőn belül van" helyességi tulajdonság, és egy parsernek mindkettő kell. Ugyanez a gondolkodásmód az ellenséges hosszakról fut végig a Pascal PDF parser rosszindulatú fájlok elleni megerősítésében. Az itt tárgyalt tanúsítványkinyerési, láncépítési és long-term validation API-k a Delphire, C++Builderre és Lazarusra szánt losLab PDF Library for Delphi részeként kerülnek kiadásra