Tehnički članak

Rekonstrukcija oštećenih PDF xref tabela: Delphi recovery sken

Kada je cross-reference tabela PDF-a neupotrebljiva, popravka je da se ona potpuno ignoriše i ponovo izgradi iz tela fajla. PDF Library for Delphi Delphi PDF Library to radi jednim prolazom token skenera koji beleži svako pravo zaglavlje indirektnog objekta koje vidi, zatim oporavlja rečnik trejlera i prosleđuje rekonstruisanu tabelu običnom loaderu

Popravni procesni lanac PDF Library for Delphi kad je tabela unakrsnih referenci oštećena: učitač prelazi na jednopročlasni scan oporavka, a čisto ponovno čuvanje proizvodi svež xref
Indeks je ono što je omanulo, ne bajtovi: rezervni sken ponovo gradi pomerke objekata i upotrebljiv trailer, učitavač nastavlja normalno, i ponovno čuvanje emituje čist xref

Šta prvo pukne kad je PDF oštećen

Cross-reference tabela je najkrhkiji deo PDF-a, jer je jedini deo koji čuva apsolutne bajt-ofsete. ISO 32000-1 §7.5.4 definiše te stavke kao desetocifrene ofsete od početka fajla, a §7.5.5 stavlja ključnu reč startxref blizu kraja, gde pokazuje na samu tabelu. Svaki od tih brojeva postaje nevažeći usled bilo koje izmene koja pomera bajtove. FTP sesija koja je radila u tekstualnom modu i prevela CRLF, prekinut download, sektor koji je otkazao na deljenom disku, batch alat koji je dodao sadržaj bez ispravnog pisanja inkrementalnog ažuriranja: svi oni ostavljaju podatke objekata savršeno čitljivim, a indeks kako pokazuje na smeće

Zato je „fajl je oštećen i popravlja se" tako čest dijalog. Bajtovi su gotovo uvek i dalje tu. Ono što nedostaje je mapa. Rekonstrukcija zato nije forenzički oporavak izgubljenih podataka, već ponovna izgradnja indeksa koji se može izvesti iz tela, i uspeva mnogo češće nego što korisnici očekuju, jer skup sadržaj — stabla stranica, fontovi i slike — ostaje netaknut

Zašto skeniranje za N 0 obj nalazi lažna poklapanja?

Naivna rekonstrukcija pretražuje sirove bajtove po šablonu „ceo broj, ceo broj, obj" i beleži svaki pogodak. Nalazi previše. PDF je kontejnerski format, a tri regiona fajla su neprozirna za gramatiku objekata: komentari (§7.2), stringovi (§7.3.4) i podaci tokova (§7.3.8). Bilo koji od njih može sadržati bajtove koji se čitaju tačno kao zaglavlje objekta, a nijedan od njih to nije. Naslov u literal stringu, zaostali debug komentar, ili dva megabajta Flate ili DCT izlaza — svi će rado proizvesti nešto što izgleda kao 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, nije objekat
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // komentar, nije objekat
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { dva megabajta kompresovanih bajtova koji sadrže sekvencu bajtova
      99 0 obj i, dalje u nizu, kompletan endstream }
    'endstream'#10 +
    'endobj'#10;

Svaka lažna stavka košta dvostruko. Zagađuje rekonstruisanu tabelu brojem objekta koji ne postoji, i može zaseniti pravi objekat sa istim brojem koji se pojavljuje kasnije u fajlu. PDF Library for Delphi zato uopšte ne radi poklapanje po šablonu. Tokenizuje, što znači da uvek zna da li su bajtovi ispod kursora kod ili sadržaj, a sadržaj se preskače bez ikakvog tumačenja

Jedan prolaz kroz automat stanja preko blokova od 64 KiB

PDF Library for Delphi skenira ceo fajl tačno jednom, u blokovima od 64 KiB, sa automatom stanja izgrađenim na pravilima tokena iz ISO 32000-1 §7.2 i sintaksi indirektnog objekta iz §7.3.10. Token se završava na belini ili na jednom od graničnih karaktera, a zaglavlje objekta se beleži samo kada je viđena kompletna sekvenca pozitivnog broja objekta, nenegativnog broja generacije, i gole ključne reči obj. Zabeleženi ofset je početak tokena broja objekta, na šta cross-reference stavka i mora da pokazuje, a ne pozicija ključne reči obj

Mapa bajtova koja pokazuje kako naivna pretraga zaglavlja objekata nalazi tri fantomska pogotka unutar stringa, komentara i podataka toka, dok token skener PDF Library for Delphi beleži samo prava zaglavlja
Stringovi, komentari i bajtovi toka mogu savršeno oponašati zaglavlja objekata; skener zato tokenizuje umesto da upoređuje obrasce, beležeći samo kompletne N G obj sekvence
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;

Bitan detalj je da stanje tokena i stanje stringa prežive granicu bloka. Zaglavlje koje se proteže preko linije od 65536 bajtova i dalje se prepoznaje, jer parcijalni token, čekajući par celih brojeva i zastavice unutar-stringa svi prelaze u sledeći blok. Baferi su fiksni: 64 KiB za sken, 32 bajta za najduži token koji uopšte može biti bitan, a jedini nizovi koji rastu sa fajlom su liste broja objekta, broja generacije i 64-bitnog ofseta, koje su proporcionalne stvarnom broju objekata, a ne veličini fajla. U praksi sken izdaje sekvencijalna čitanja i najviše dva eksplicitna seek-a preko celog dokumenta, što ga i čini upotrebljivim na inputima od nekoliko stotina megabajta o kojima govori članak o direktnom pristupu pri spajanju i deljenju

Zašto se tok ne sme pouzdati da se završava na endstream?

Zato što su podaci toka proizvoljni bajtovi, a proizvoljni bajtovi mogu slučajno da sriču endstream. Tok koji počinje posle ključne reči stream mora se preskočiti kao neproziran podatak sve dok se zaista ne završi, ali prvo pojavljivanje zatvarajuće ključne reči je samo kandidat. PDF Library for Delphi ovo rešava zahtevajući potvrdu: token endstream prihvata se kao stvarni kraj toka samo kada je sledeći token koji nije belina samostalan endobj, sekvenca koju §7.3.8 zahteva oko stream objekta. Slučajan pogodak unutar kompresovanih podataka gotovo nikad nema taj nastavak, pa skener ostaje unutar toka i nastavlja. Dva manja pravila su podjednako bitna. Ključna reč stream ulazi u stanje toka samo kada je gola ključna reč, tako da name objekat poput /stream u rečniku nikad ne aktivira to stanje. A token obj ili trailer se uvažava samo kada token nije prekoračio ograničenje od 32 bajta i nije počeo kosom crtom. Bez ta dva čuvara, rečnik resursa sa pogrešnim imenima ključeva bio bi dovoljan da izbaci sken sa koloseka, što je tačno klasa neprijateljskog inputa pokrivena u belešci o bezbednom parsiranju nepoverljivih PDF-ova

Petlja odlučivanja koja potvrđuje pravi kraj PDF toka tokom xref oporavka u Delphi-ju: endstream token se prihvata samo kad iza njega sledi samostalan endobj
Kandidat za endstream traži potvrdu u sledećem usamljenom endobj; provere gole ključne reči, 32 bajta i kose crte sprečavaju nameštene rečnike da skrenu skeniranje s puta

Pronalaženje pravog kraja rečnika trejlera

Oporavak objekata je samo polovina posla, jer loaderu i dalje treba trejler da bi pronašao /Root. PDF Library for Delphi pamti poslednjih 64 pozicije ključne reči trailer pronađene tokom skena i validira ih unazad, počev od najnovije, tako da najnoviji upotrebljiv trejler pobeđuje, a zalutala ključna reč koju ne prati rečnik jednostavno ne prođe validaciju i pada na prethodnog kandidata. Svaki kandidat se čita sa ograničenjem od 1 MiB, a kraj rečnika se locira praćenjem ugnežđene dubine << i >>, zajedno sa escape sekvencama literal stringova, heksadecimalnim stringovima i komentarima

// Naivni čitač koji staje na prvom '>>' skraćuje ovaj trejler,
// a fiksni prozor od 2048 bajtova ga može preseći napola kod velikog
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Praćenje dubine nije akademsko pitanje. Skraćen trejler koji izgubi /Encrypt pretvara oporavljiv enkriptovan dokument u onaj koji se ne može otvoriti, a gubitak /Info ili prilagođenog podrečnika tiho odbacuje metapodatke od kojih neki sistem dalje u lancu možda zavisi. Ako je fajl enkriptovan, oporavljeni trejler je ono što omogućava normalnu putanju kredencijala da se izvrši, a semantika ponovnih pokušaja je ista ona opisana u članku o učitavanju enkriptovanih dokumenata

Šta rekonstrukcija ne može da vam vrati

Rekonstrukcija je najbolji mogući pokušaj, i biti iskren o njenim granicama deo je isporuke te funkcije. Tri slučaja potpuno propadaju. Objekti upakovani unutar tokova objekata (§7.5.7) nisu pojedinačno vidljivi bajt skeniranju, pa ako kontejner preživi, ali njegov cross-reference tok (§7.5.8) ne preživi, objekti koje sadrži nisu indeksirani rekonstrukcijom. Fajl čije je telo zapravo bilo oštećeno, a ne samo pogrešno indeksirano, proizvešće zaglavlja čiji se sadržaj više ne parsira. A fajl bez oporavljive ključne reči trailer i bez čitljivog kataloga nema na šta da usidri stablo dokumenta, bez obzira koliko je zaglavlja objekata pronađeno

Duplirani brojevi objekata su zanimljiv srednji slučaj. Inkrementalno ažuriran fajl legitimno sadrži nekoliko generacija istog broja objekta, a preživeli cross-reference lanac je jedini zapis o tome koja je trenutno važeća. Rekonstrukcija taj lanac nema, pa beleži svako zaglavlje koje vidi po redosledu u fajlu i naknadno razrešava po broju objekta. Obično kasnija revizija pobeđuje, što je obično ispravno, ali dokument koji je ažuriran, a zatim delimično vraćen unazad, može se vratiti suptilno drugačiji od onoga što je opisivao originalni xref. Linearizovani fajlovi nose istu opasku iz suprotnog smera: raspored prve strane i hint tabele postaju besmisleni čim se indeks regeneriše, pa popravljen fajl treba tretirati kao običan, nelinearizovan 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');   // upisuje čistu xref tabelu
    end;
  finally
    Pdf.Free;
  end;
end;

Fallback je automatski: PDF Library for Delphi pokreće sirovi sken kad god se cross-reference lanac ne može pročitati, a takođe i kada svaka stavka u upotrebi tvrdi da je ofset nula, što je potpis tabele koja je zapisana, ali nikada popunjena. GetDocumentRepaired vraća 1 kada je ta putanja izvršena, i vredi je logovati, a ne ignorisati, jer dokument koji se učitao kroz rekonstrukciju treba ponovo sačuvati u čist fajl umesto ostaviti u pipelineu kao da se ništa nije desilo. Čuvanje piše svežu, konzistentnu cross-reference tabelu, što je najjeftinija moguća popravka za svakog potrošača dalje u lancu

Putanja rekonstrukcije, zastavica GetDocumentRepaired i streaming loader prikazan ovde deo su PDF Library for Delphi Delphi PDF Library, zajedno sa API-jima za parsiranje, renderovanje i potpisivanje pokrivenim na drugim mestima na ovom blogu