Technisch artikel

Beschadigde PDF Xref-Tabellen Herbouwen: Delphi Recovery Scan

Wanneer een PDF-cross-reference-tabel onbruikbaar is, is de oplossing om hem volledig te negeren en te herbouwen vanuit de bestandsinhoud. De PDFlibPas Delphi PDF Library doet dit met een single-pass tokenscanner die elke echte indirecte objectheader registreert die hij tegenkomt, en herstelt vervolgens het trailer-dictionary en geeft de gereconstrueerde tabel door aan de normale loader

Wat breekt er eerst wanneer een PDF beschadigd raakt?

De cross-reference-tabel is het meest kwetsbare onderdeel van een PDF, omdat het het enige onderdeel is dat absolute byte-offsets opslaat. ISO 32000-1 §7.5.4 definieert die entries als tiencijferige offsets vanaf het begin van het bestand, en §7.5.5 plaatst het startxref-sleutelwoord vlak voor het einde, wijzend naar de tabel zelf. Elk van die getallen wordt ongeldig door elke bewerking die bytes verschuift. Een FTP-sessie die in tekstmodus draaide en CRLF vertaalde, een afgebroken download, een sector die corrupt raakte op een gedeelde schijf, een batchtool die toevoegde zonder correct een incrementele update te schrijven: al deze scenario's laten de objectdata perfect leesbaar en de index wijzend naar rommel

Daarom is "het bestand is beschadigd en wordt hersteld" zo'n veelvoorkomende dialoog. De bytes zijn bijna altijd nog aanwezig. Wat verdwenen is, is de kaart. Reconstructie is dus geen forensisch herstel van verloren data, het is een herbouw van een index die uit de body afgeleid kan worden, en het slaagt veel vaker dan gebruikers verwachten, omdat de kostbare inhoud, paginabomen, fonts en afbeeldingen, ongemoeid blijft

Waarom levert scannen naar N 0 obj valse matches op?

Een naïeve herbouw doorzoekt de ruwe bytes op het patroon "geheeltal, geheeltal, obj" en registreert elke treffer. Dat vindt te veel. PDF is een containerformaat, en drie regio's van een bestand zijn ondoorzichtig voor de objectgrammatica: comments (§7.2), strings (§7.3.4), en stream-data (§7.3.8). Elk daarvan kan bytes bevatten die precies lezen als een objectheader, en geen daarvan is een objectheader. Een bijschrift in een literal string, een achtergebleven debugcomment, of twee megabytes aan Flate- of DCT-output produceren allemaal probleemloos iets dat lijkt op 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

Elke valse entry kost dubbel. Het vervuilt de herbouwde tabel met een objectnummer dat niet bestaat, en het kan een echt object met hetzelfde nummer verduisteren dat later in het bestand voorkomt. PDFlibPas doet daarom helemaal niet aan patroonmatching. Het tokeniseert, wat betekent dat het altijd weet of de bytes onder de cursor code zijn of payload, en payload wordt overgeslagen zonder ooit geïnterpreteerd te worden

Eén single-pass state machine over blokken van 64 KiB

PDFlibPas scant het hele bestand precies één keer, in blokken van 64 KiB, met een state machine gebouwd op de tokenregels van ISO 32000-1 §7.2 en de indirecte objectsyntaxis van §7.3.10. Een token eindigt bij witruimte of bij een van de scheidingstekens, en een objectheader wordt alleen geregistreerd wanneer een complete reeks van een positief objectnummer, een niet-negatief generatienummer, en een kaal obj-sleutelwoord is gezien. De geregistreerde offset is het begin van het objectnummer-token, wat is waar een cross-reference-entry naar moet wijzen, niet de positie van het obj-sleutelwoord

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

Het belangrijke detail is dat tokenstatus en stringstatus een blokgrens overleven. Een header die de grens van 65536 bytes overspant, wordt nog steeds herkend, omdat het gedeeltelijke token, het lopende integer-paar, en de in-string-vlaggen allemaal meegaan naar het volgende blok. De buffers zijn vast: 64 KiB voor de scan, 32 bytes voor het langste token dat ooit relevant kan zijn, en de enige arrays die meegroeien met het bestand zijn de lijsten met objectnummer, generatienummer en 64-bits offset, die evenredig zijn met het werkelijke objectaantal in plaats van met de bestandsgrootte. In de praktijk voert de scan sequentiële reads uit en hooguit twee expliciete seeks over het hele document, wat is wat de scan haalbaar maakt op de meerdere-honderden-megabytes-grote inputs die besproken worden in het artikel over direct-access merge en split

Waarom kan een stream niet vertrouwd worden om te eindigen bij endstream?

Omdat streamdata willekeurige bytes zijn, en willekeurige bytes toevallig endstream kunnen spellen. Een stream die begint na het stream-sleutelwoord moet als ondoorzichtige data worden overgeslagen totdat hij daadwerkelijk eindigt, maar de eerste voorkomst van het afsluitende sleutelwoord is slechts een kandidaat. PDFlibPas lost dit op door bevestiging te eisen: een endstream-token wordt alleen geaccepteerd als het echte einde van de stream wanneer het volgende niet-witruimte-token een op zichzelf staande endobj is, de volgorde die §7.3.8 vereist rond een stream-object. Een toevallige treffer binnen gecomprimeerde data heeft bijna nooit dat vervolg, dus blijft de scanner binnen de stream en gaat verder. Twee kleinere regels wegen even zwaar. Het stream-sleutelwoord activeert alleen streamstatus wanneer het een kaal sleutelwoord is, zodat een naamobject zoals /stream in een dictionary het nooit triggert. En een obj- of trailer-token wordt alleen gehonoreerd wanneer het token niet over de grens van 32 bytes ging en niet begon met een schuine streep. Zonder die twee beveiligingen zou een resource-dictionary met de verkeerde sleutelnamen genoeg zijn om de scan te ontsporen, wat precies de klasse van vijandige input is die behandeld wordt in de notities over het veilig parsen van niet-vertrouwde PDF's

Het echte einde van het trailer-dictionary vinden

Het herstellen van de objecten is slechts de helft van het werk, want de loader heeft nog steeds een trailer nodig om /Root te vinden. PDFlibPas onthoudt de laatste 64 trailer-sleutelwoordposities die tijdens de scan gevonden zijn en valideert ze achterwaarts, meest recente eerst, zodat de nieuwste bruikbare trailer wint en een verdwaald sleutelwoord dat niet gevolgd wordt door een dictionary simpelweg validatie faalt en doorvalt naar de vorige kandidaat. Elke kandidaat wordt gelezen met een maximum van 1 MiB, en het einde van het dictionary wordt gevonden door geneste <<- en >>-diepte bij te houden samen met literal-string-escapes, hexadecimale strings en comments

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Diepte-tracking is niet academisch. Een afgekapte trailer die /Encrypt kwijtraakt, verandert een herstelbaar versleuteld document in een onopenbaar document, en het kwijtraken van /Info of een aangepast sub-dictionary verwijdert stilzwijgend metadata waarvan een downstream systeem mogelijk afhankelijk is. Als het bestand versleuteld is, is de herstelde trailer wat het normale credential-pad laat draaien, en de retry-semantiek is dezelfde als beschreven in het artikel over het laden van versleutelde documenten

Wat reconstructie je niet kan teruggeven

Reconstructie is een best effort, en eerlijk zijn over de grenzen ervan maakt deel uit van het uitleveren ervan. Drie gevallen falen ronduit. Objecten verpakt binnen object streams (§7.5.7) zijn niet individueel zichtbaar voor een byte-scan, dus als een container overleeft maar zijn cross-reference stream (§7.5.8) niet, worden de objecten die hij bevat niet geïndexeerd door de herbouw. Een bestand waarvan de body daadwerkelijk beschadigd was, in plaats van alleen verkeerd geïndexeerd, produceert headers waarvan de inhoud niet meer parseert. En een bestand zonder herstelbaar trailer-sleutelwoord en zonder leesbare catalog heeft niets om een documentboom aan te verankeren, ongeacht hoeveel objectheaders gevonden werden

Duplicaat-objectnummers zijn het interessante tussengeval. Een incrementeel bijgewerkt bestand bevat legitiem meerdere generaties van hetzelfde objectnummer, en de overlevende cross-reference-keten is het enige record van welke actueel was. Een herbouw heeft die keten niet, dus registreert het elke header die het tegenkomt in bestandsvolgorde en lost daarna op via objectnummer. Meestal wint de latere revisie, wat meestal juist is, maar een document dat bijgewerkt en vervolgens gedeeltelijk teruggedraaid werd, kan subtiel anders terugkomen dan wat de originele xref beschreef. Gelineariseerde bestanden dragen dezelfde waarschuwing vanuit de andere richting: de eerste-pagina-lay-out en hint-tabellen zijn betekenisloos zodra de index geregenereerd is, dus een hersteld bestand moet behandeld worden als een gewoon, niet-gelineariseerd document

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

De fallback is automatisch: PDFlibPas voert de ruwe scan uit wanneer de cross-reference-keten niet gelezen kan worden, en ook wanneer elke in-gebruik-entry offset nul claimt, wat de signatuur is van een tabel die geschreven maar nooit ingevuld werd. GetDocumentRepaired retourneert 1 wanneer dat pad gedraaid heeft, en het is de moeite waard om te loggen in plaats van te negeren, want een document dat via reconstructie geladen is, moet opnieuw opgeslagen worden naar een schoon bestand in plaats van achtergelaten te worden in een pipeline alsof er niets gebeurd is. Het opslaan ervan schrijft een verse, consistente cross-reference-tabel, wat de goedkoopst mogelijke oplossing is voor elke downstream consument

Het reconstructiepad, de GetDocumentRepaired-vlag en de streaming loader die hier getoond worden, maken deel uit van de PDFlibPas Delphi PDF Library, naast de parsing-, rendering- en ondertekenings-API's die elders op deze blog behandeld worden