Teknisk artikkel

Gjenoppbygg skadede PDF-xref-tabeller: Delphi-gjenoppretting

Når en PDF-kryssreferansetabell er ubrukelig, er løsningen å ignorere den fullstendig og bygge den opp igjen fra filens kropp. PDFlibPas Delphi PDF Library gjør dette med en enkelt-pass tokenskanner som registrerer hvert eneste ekte indirekte objekthode den ser, gjenoppretter deretter trailer-ordboken og gir den rekonstruerte tabellen til den vanlige lasteren

Hva knekker først når en PDF er skadet

Kryssreferansetabellen er den mest skjøre delen av en PDF, fordi den er den eneste delen som lagrer absolutte byteoffset. ISO 32000-1 §7.5.4 definerer disse oppføringene som titalls-siffer offset fra filens start, og §7.5.5 plasserer startxref-nøkkelordet nær slutten, pekende på selve tabellen. Hvert eneste av disse tallene ugyldiggjøres av enhver redigering som forskyver byte. En FTP-økt som kjørte i tekstmodus og oversatte CRLF, en avbrutt nedlasting, en sektor som gikk dårlig på en delt disk, et batch-verktøy som la til uten å skrive en inkrementell oppdatering korrekt: alle disse etterlater objektdataene fullt lesbare og indeksen pekende på søppel

Det er derfor «filen er skadet og blir reparert» er en så vanlig dialogboks. Bytene er nesten alltid fortsatt der. Det som er borte, er kartet. Rekonstruksjon er derfor ikke rettsmedisinsk gjenoppretting av tapte data, det er en gjenoppbygging av en indeks som kan utledes fra kroppen, og den lykkes langt oftere enn brukere forventer, fordi det kostbare innholdet, sidetrær og fonter og bilder, er urørt

Hvorfor finner skanning etter N 0 obj falske treff?

En naiv gjenoppbygging søker i de rå bytene etter mønsteret «heltall, heltall, obj» og registrerer hvert treff. Den finner for mye. PDF er et containerformat, og tre regioner i en fil er ugjennomsiktige for objektgrammatikken: kommentarer (§7.2), strenger (§7.3.4) og strømdata (§7.3.8). Alle disse kan inneholde byte som leses akkurat som et objekthode, og ingen av dem er et objekthode. En bildetekst i en literal streng, en gjenglemt debug-kommentar, eller to megabyte med Flate- eller DCT-utdata vil alle glatt produsere noe som ser ut som 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;

Hver falsk oppføring koster dobbelt. Den forurenser den gjenoppbygde tabellen med et objektnummer som ikke finnes, og den kan overskygge et ekte objekt med samme nummer som dukker opp lenger ute i filen. PDFlibPas mønstergjenkjenner derfor overhodet ikke. Den tokeniserer, noe som betyr at den alltid vet om bytene under markøren er kode eller nyttelast, og nyttelast hoppes over uten noen gang å bli tolket

En enkelt-pass tilstandsmaskin over 64 KiB-blokker

PDFlibPas skanner hele filen nøyaktig én gang, i 64 KiB-blokker, med en tilstandsmaskin bygget på tokenreglene i ISO 32000-1 §7.2 og den indirekte objektsyntaksen i §7.3.10. Et token slutter ved mellomrom eller ved ett av skilletegnene, og et objekthode registreres bare når en komplett sekvens av et positivt objektnummer, et ikke-negativt generasjonsnummer og et bart obj-nøkkelord er observert. Det registrerte offsetet er starten på objektnummer-tokenet, som er det en kryssreferanseoppføring må peke på, ikke posisjonen til obj-nøkkelordet

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;

Det viktige detaljet er at token-tilstanden og strengtilstanden overlever en blokkgrense. Et hode som strekker seg over 65536-byte-linjen, gjenkjennes fortsatt, fordi det delvise tokenet, det ventende heltallsparet og in-string-flaggene alle føres videre inn i neste blokk. Bufferne er faste: 64 KiB for skanningen, 32 byte for det lengste tokenet som overhodet kan bety noe, og de eneste arrayene som vokser med filen, er listene for objektnummer, generasjonsnummer og 64-bits offset, som er proporsjonale med det reelle objektantallet fremfor filstørrelsen. I praksis utfører skanningen sekvensielle lesinger og maksimalt to eksplisitte seek over hele dokumentet, noe som er det som gjør den levedyktig på de flerhundre-megabyte-store inputfilene omtalt i artikkelen om direkte tilgang ved sammenslåing og deling

Hvorfor kan ikke en strøm stoles på å slutte ved endstream?

Fordi strømdata er vilkårlige byte, og vilkårlige byte kan ved en tilfeldighet stave endstream. En strøm som begynner etter stream-nøkkelordet må hoppes over som ugjennomsiktig data helt til den genuint slutter, men den første forekomsten av avslutningsnøkkelordet er bare en kandidat. PDFlibPas løser dette ved å kreve bekreftelse: et endstream-token aksepteres som strømmens reelle slutt bare når det neste ikke-mellomrom-tokenet er en frittstående endobj, sekvensen §7.3.8 krever rundt et strømobjekt. Et tilfeldig treff inne i komprimert data har nesten aldri den oppfølgeren, så skanneren blir værende inne i strømmen og fortsetter. To mindre regler betyr like mye. stream-nøkkelordet går bare inn i strømtilstand når det er et bart nøkkelord, så et navneobjekt som /stream i en ordbok utløser det aldri. Og et obj- eller trailer-token honoreres bare når tokenet ikke overskred 32-byte-grensen og ikke begynte med en solidus. Uten disse to vaktene ville en ressursordbok med feil nøkkelnavn vært nok til å spore av skanningen, og det er nettopp den klassen av fiendtlig input som dekkes i notatene om trygg parsing av upålitelige PDF-er

Å finne trailer-ordbokens virkelige slutt

Å gjenopprette objektene er bare halve jobben, fordi lasteren fortsatt trenger en trailer for å finne /Root. PDFlibPas husker de siste 64 trailer-nøkkelord-posisjonene funnet under skanningen og validerer dem bakover, den nyeste først, slik at den nyeste brukbare traileren vinner, og et villedet nøkkelord som ikke etterfølges av en ordbok, feiler ganske enkelt valideringen og faller gjennom til forrige kandidat. Hver kandidat leses med en grense på 1 MiB, og ordbokens slutt lokaliseres ved å spore nøstet <<- og >>-dybde sammen med literal streng-escape, heksadesimale strenger og kommentarer

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

Dybdesporing er ikke akademisk. En avkuttet trailer som mister /Encrypt, gjør et gjenopprettbart kryptert dokument til et ikke-åpnbart et, og å miste /Info eller en egendefinert underordbok forkaster stille metadata et system lenger nede i kjeden kan være avhengig av. Hvis filen er kryptert, er den gjenopprettede traileren det som lar den vanlige legitimasjonsstien kjøre, og gjenprøvingssemantikken er den samme som beskrevet i artikkelen om lasting av krypterte dokumenter

Hva rekonstruksjon ikke kan gi deg tilbake

Rekonstruksjon er et beste forsøk, og å være ærlig om grensene er en del av å levere den. Tre tilfeller feiler rett ut. Objekter pakket inne i objektstrømmer (§7.5.7) er ikke individuelt synlige for en byteskanning, så hvis en beholder overlever men kryssreferansestrømmen dens (§7.5.8) ikke gjør det, blir objektene den holder ikke indeksert av gjenoppbyggingen. En fil hvis kropp faktisk ble skadet, fremfor bare feilindeksert, vil produsere hoder hvis innhold ikke lenger parses. Og en fil uten noe gjenopprettbart trailer-nøkkelord og ingen lesbar katalog har ingenting å forankre et dokumenttre til, uansett hvor mange objekthoder som ble funnet

Duplikate objektnumre er det interessante mellomtilfellet. En inkrementelt oppdatert fil inneholder legitimt flere generasjoner av samme objektnummer, og den overlevende kryssreferansekjeden er det eneste registeret over hvilken som var gjeldende. En gjenoppbygging har ikke den kjeden, så den registrerer hvert hode den ser i filrekkefølge og løser opp etter objektnummer etterpå. Vanligvis vinner den seneste revisjonen, som vanligvis er riktig, men et dokument som ble oppdatert og deretter delvis rullet tilbake, kan komme tilbake subtilt annerledes enn det den opprinnelige xref-en beskrev. Lineariserte filer bærer det samme forbeholdet fra motsatt retning: førstesideoppsettet og hint-tabellene er meningsløse når indeksen regenereres, så en reparert fil bør behandles som et vanlig, ikke-linearisert dokument

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;

Fallbacken er automatisk: PDFlibPas kjører rå-skanningen når kryssreferansekjeden ikke kan leses, og også når hver i-bruk-oppføring hevder offset null, som er kjennetegnet på en tabell som ble skrevet men aldri fylt ut. GetDocumentRepaired returnerer 1 når den stien kjørte, og det er verdt å logge fremfor å ignorere, fordi et dokument som ble lastet gjennom rekonstruksjon bør lagres på nytt til en ren fil fremfor å bli liggende i en pipeline som om ingenting hadde skjedd. Å lagre det skriver en fersk, konsistent kryssreferansetabell, som er den billigste mulige fiksen for enhver konsument lenger nede i kjeden

Rekonstruksjonsstien, GetDocumentRepaired-flagget og strømmelasteren vist her er en del av PDFlibPas Delphi PDF Library, sammen med parsing-, rendering- og signerings-API-ene dekket andre steder på denne bloggen