Technical Article

Een Delphi PDF-ondertekenaar beveiligen tegen malafide PKCS#12

Wanneer u een PDF ondertekent, beschouwt u de ondertekeningssleutel meestal als iets dat u zelf beheert. Deze bevindt zich in een door u gegenereerd .pfx-bestand, beveiligd met een zelfgekozen wachtwoord. De code die dat bestand leest voelt als een technische koppeling, niet als een grens. Die intuïtie is onjuist zodra het certificaat niet meer van u is. Een desktoptool waarmee een gebruiker een willekeurige .pfx kan kiezen, een server die een geüpload certificaat accepteert, of een batch-ondertekenaar die certificaten via het netwerk ontvangt: ze sturen allemaal door de aanvaller beïnvloede bytes naar een parser voordat er ook maar één handtekeningbyte is gegenereerd. Een PKCS#12-lezer is een aanvalsoppervlak, net zoals een afbeeldingsdecoder of een lettertypeloader dat is.

Dit artikel behandelt twee werkelijke fouten die zich in die lezer bevonden, beide in het pad dat een ondertekeningscertificaat importeert. Geen van beide is exotisch. Beide komen voort uit dezelfde hoofdoorzaak die bijna elke binaire parser treft die is geschreven in een taal met gehele getallen van vaste breedte: een lengte of aantal uit het bestand wordt één stap verder vertrouwd dan zou moeten. De ene fout leidt tot een out-of-bounds-lezing, de andere tot een proces dat vastloopt totdat u het beëindigt.

Waar de bytes naartoe reizen

Het importeren van een .pfx om een document te ondertekenen is niet één enkele bewerking, maar een korte pijplijn. Elke fase daarin parseert gegevens die een aanvaller kan hebben geschreven. De container is een PKCS#12-structuur zoals gedefinieerd in RFC 7292, een nest van AuthenticatedSafe-bags gewikkeld rond een versleuteld omhulsel dat de privésleutel bevat. Het lezen ervan betekent het doorlopen van ASN.1, het afleiden van een sleutel uit het wachtwoord, het ontsleutelen en vervolgens het overhandigen van de herstelde RSA-sleutel aan de code die de handtekening opbouwt.

In HotPDF zijn die fasen ondergebracht in afzonderlijke units. De PKCS#12-containerlogica bevindt zich in HPDFPFX. Elke tag, lengte en waarde die wordt geraakt, wordt gedecodeerd door de ASN.1-lezer in HPDFASN1. Sleutelafleiding en de PBES2-ontsleuteling bevinden zich in HPDFCrypt naast PBKDF2HMACSHA256. Zodra de sleutel is hersteld, zetten HPDFRSA en de CMS SignedData-builder in HPDFCMS deze om in de losstaande handtekening die in de PDF wordt ingesloten. Het publieke ingangspunt dat de hele keten aanstuurt, is één enkele aanroep.

// 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
;

Elke byte van signer.pfx stroomt door HPDFASN1 en HPDFPFX voordat er enige cryptografie plaatsvindt. Als die twee units niet voorzichtig zijn met wat het bestand claimt, krijgt de cryptografie verderop in de pijplijn nooit de kans om van betekenis te zijn.

Fout één: een ASN.1-lengte die voorbij de beveiliging wrapt

ASN.1 in DER en BER codeert elk element als een tag, een lengte en dat aantal inhoudsbytes. De lengte is het veld dat u moet vertrouwen maar controleren, omdat het de parser vertelt tot hoever hij moet lezen, en het is geschreven door degene die het bestand heeft gemaakt. X.690 §8.1.3 definieert twee coderingen. De korte vorm pakt een lengte van 0 tot 127 in een enkele byte. De lange vorm, gebruikt voor alles wat groter is, gebruikt één beginbyte waarvan de laagste zeven bits het aantal daaropvolgende lengtebytes aangeven, waarna dat aantal big-endian bytes de werkelijke waarde bevat. Vier lengtebytes kunnen dus een inhoudsgrootte declareren die de vier gigabyte nadert.

Na het decoderen van zo'n waarde moet de parser controleren of de inhoud daadwerkelijk in de buffer past voordat hij deze vertrouwt. De logische controle is om te bevestigen dat de huidige positie plus de inhoudslengte de grens van de gegevens niet overschrijdt. Als dit op de voor de hand liggende manier is geschreven, waarbij de positie, de inhoudslengte en het totaal allemaal in 32-bits signed integers worden bewaard, faalt die controle:

// 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');

Het probleem zit in de optelling, niet in de vergelijking. Wanneer ContentLen dicht bij MaxInt (2147483647) ligt, veroorzaatz Pos + ContentLen een overflow in het signed 32-bits bereik en wrapt naar een negatief getal. Een negatieve som is nooit groter dan Total, dus de controle geeft aan dat alles in orde is en laat de parser doorgaan met een inhoudslengte van ongeveer twee gigabyte die de buffer helemaal niet bevat. Wat volgt is de schade: de lezer alloceert een buffer voor die geclaimde lengte en kopieert daarnaar met een SetLength gevolgd door een Move vanaf de bron. De bron bevat nog maar een paar honderd bytes, dus de kopieeractie leest ver voorbij het einde van de invoer. Dit veroorzaakt een out-of-bounds-lezing die in het gunstigste geval crasht en in het ongunstigste geval aangrenzend procesgeheugen lekt naar de parse.

De enige juiste beveiliging is het verbreden van de tussentijdse som vóór de vergelijking, zodat de optelling geen overflow kan veroorzaken in het type waarin deze wordt berekend. De oplossing converteert beide operanden naar 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');

Een Int64 bevat de som van twee 32-bits waarden zonder verlies, waardoor de vergelijking het werkelijke getal ziet en de verveelde lengte weigert. De afzonderlijke controle op een niet-negatieve ContentLen sluit het geval uit waarin een gedecodeerde waarde op zichzelf negatief uitvalt. In HotPDF bevindt deze beveiliging zich in HPDFASN1ParseNode, de functie die de node produceert waarop alle andere helpers bouwen. Omdat HPDFASN1Content zijn SetLength en Move rechtstreeks afstemt op de inhoudslengte van de node, zou een node die een slechte controle passeerde elke lezing die ervan werd genomen hebben vergiftigd. Het herstellen van de grens op het punt van decodering is wat de helpers daarboven veilig maakt.

Fout twee: een PBKDF2-iteratieteller ingezet als wapen

De tweede fout is geen geheugenfout, maar het bestand dat uw CPU vertelt hoe hard hij moet werken. PKCS#12 beveiligt zijn sleutelmateriaal met PBES2, de op wachtwoord gebaseerde methode uit PKCS#5, gespecificeerd in RFC 8018. PBES2 voert een sleutelafleidingsfunctie uit, in dit geval PBKDF2 met HMAC-SHA-256, en vervolgens een cijfer, in dit geval AES-256-CBC. PBKDF2 vereist een iteratieteller, en die teller is een parameter die in het bestand wordt meegestuurd. Het enige doel daarvan is traagheid: meer iteraties betekent dat elke wachtwoordpoging meer kost, wat effectief is tegen een offline aanvaller. RFC 8018 §4.2 is er expliciet over dat een hogere teller beter is voor de veiligheid, en stelt bewust geen limiet.

Die openheid is prima wanneer u het bestand zelf hebt gegenereerd. Het is echter een wapen als een aanvaller dat heeft gedaan. De iteratieteller is een door de aanvaller beheerde werkfactor, en zo'n factor leidt tot een denial of service op basis van algoritmische complexiteit. Een verveelde .pfx kan een iteratieteller in de miljarden bevatten; de parser leest deze plichtsgetrouw en roept PBKDF2 aan voor dat aantal HMAC-SHA-256-rondes, waardoor het proces verdwijnt in een lus die minuten of uren niet retourneert op basis van één enkel aangeleverd bestand. Op een ondertekeningsserver die één certificaat per verzoek verwerkt, legt één enkele gemanipuleerde upload een worker plat.

De teller verergert de wraparound nog voordat de CPU op volle toeren gaat draaien. De iteratiewaarde bevindt zich in het bestand als een ASN.1 INTEGER, die geen vaste breedte heeft, terwijl het veld dat PBKDF2 ultimately consumeert een 32-bits Integer is. Decodeer de INTEGER rechtstreeks in dat veld en een grote waarde wordt afgekapt. Een waarde die is ontworpen om op de sign-bit te landen, komt terug als negatief of als een willekeurig klein getal, waardoor zelfs de omvang van het werk niet meer overeenkomt met wat het bestand leek te vragen. De oplossing leest de waarde op volledige breedte en begrenst deze alvorens te versmallen:

// 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

Waarom beide oplossingen in feite dezelfde oplossing zijn

De twee fouten lijken verschillend — de ene is een bufferoverloop en de andere een vastgelopen proces — maar ze berusten op dezelfde fout. In beide gevallen werd een getal uit een onbetrouwbaar bestand een stap te vroeg in een type met vaste breedte geplaatst, voordat het was getoetst aan de realiteit. De lengte werd opgeteld in 32 bits vóór de grenstest; de iteratieteller werd versmald naar 32 bits vóór de bereiktest. Beide lossen op met dezelfde discipline: decodeer op volledige breedte, controleer tegen de werkelijke limiet en versmal pas daarna. De tussenstap via Int64 is geen cosmetische keuze, maar de enige breedte waarin de controle de waarde kan zien die de aanvaller daadwerkelijk heeft geschreven. Een grens die overloopt is geen grens, en een teller zonder plafond is geen parameter, maar een gashendel op afstand voor uw eigen CPU.

Praktische richtlijnen voor een ondertekeningspijplijn

De specifieke les is om onbetrouwbare certificaatinvoer te valideren zoals u elke onbetrouwbare upload zou valideren. Beperk de grootte van een geaccepteerde .pfx, aangezien een legitiem bestand kilobytes in beslag neemt, geen megabytes. Behandel een parseerfout als normale afgewezen invoer, niet als een fout die een stacktrace aan de gebruiker waard is. Als u op een server ondertekent, voer de import dan uit waar een vastgelopen worker de service niet kan platleggen, en bouw een time-out in rond de bewerking zodat een onverwacht zwaar bestand zowel door de kloktijd als door de iteratielimiet wordt begrensd.

De bredere les reikt verder dan certificaten. Het beveiligen van parsers is geen eenmalige audit van één unit, maar een eigenschap van elke plek waar uw bibliotheek bytes leest die hij niet zelf heeft geschreven. Een PDF-bibliotheek parseert veel uit onbetrouwbare bronnen: lettertypen die in een document zijn ingesloten, afbeeldingen in diverse codecs, stream-filters en, op het ondertekeningspad, certificaten. Elk daarvan vormt een aanvalsoppervlak en verdient hetzelfde wantrouwen bij elke lengte en elke teller. HotPDF bouwt het import- en ondertekeningspad op de beveiligde units HPDFASN1, HPDFPFX, HPDFCrypt en HPDFCMS die hier worden beschreven, zodat het certificaat dat u aanbiedt, van waar het ook komt, defensief wordt geparseerd voordat het wordt vertrouwd.

De ondertekeningsworkflow die door deze controles wordt beschermd, wordt van begin tot eind behandeld in onze handleiding over digitale PAdES-handtekeningen in Delphi. Dezelfde defensieve houding toegepast op documentversleuteling, inclusief het AES-256-sleutelpad dat deze codebase deelt, wordt beschreven in het artikel over AES-256-versleuteling en beveiliging. Dit alles wordt geleverd als onderdeel van de HotPDF Component voor Delphi en C++Builder, naast de laad-, bewerkings-, versleutelings- en ondertekenings-API's die elders op deze blog worden behandeld.