Tekninen artikkeli

PDF-sanakirjojen turvallinen jäsentäminen Delphissä: nimitunnisteet

Alimerkkijonohaut, kuten Pos('/Length', Dict), ovat väärä työkalu PDF-sanakirjan lukemiseen, koska PDF-nimiavaimet jakavat yhteisiä etuliitteitä: /Length on etuliite avaimelle /Length1, ja /Encrypt on etuliite avaimelle /EncryptMetadata. ISO 32000-1 §7.3.5 määrittelee nimen tunnisteeksi, joka päättyy vain erottimeen tai tyhjämerkkiin, joten avain katsotaan löytyneeksi vain, kun sen jälkeinen tavu on jokin näistä merkeistä. Sanakirjan lukija, joka ohittaa tämän yhden tarkistuksen, lukee lopulta väärän arvon täysin kelvollisesta tiedostosta

Virhe, joka opetti meille tämän, ei näyttänyt lainkaan leksikaalisen jäsentimen ongelmalta. Yhteensopivuusskannaus alkoi raportoida FontFile-virtaa vaurioituneeksi: pakattu fonttiohjelma oli vain katkelma, joka katkesi kesken taulukon. Tiedosto avautui hienosti kaikissa katseluohjelmissa. Levyllä olevat virtatiedot olivat ehjiä. Perussyy oli yhdessä rivissä jaetussa sanakirjan lukijassamme: Pos('/Length', ...) oli täsmännyt avaimeen /Length1, joka on standardi avain FontFile-virtasanakirjoissa ISO 32000-1 -standardin taulukon 127 mukaan, ja lukija otti /Length1:n jälkeisen kokonaisluvun virran pituudeksi. Tämän nimenomaisen tiedoston kirjoittaja sattui sarjallistamaan avaimen /Length1 ennen avainta /Length, mikä on täysin sallittua, koska sanakirjan merkinnät ovat järjestämättömiä pykälän §7.3.7 mukaan. Virta typistyi virheelliseen tavumäärään, ja jokainen sitä kuluttanut jatkotarkistus epäonnistui hiljaisesti

Miksi alimerkkijonojen täsmäytys rikkoo PDF-sanakirjan jäsennyksen?

Alimerkkijonojen täsmäytys rikkoutuu, koska PDF:n nimiavaruus on täynnä tarkoituksellisia etuliiteperheitä ja koska sanakirjan merkintöjen järjestystä ei ole määritelty. ISO 32000-1:n taulukko 127 määrittelee avaimet /Length1, /Length2 ja /Length3 upotetuille fonttivirroille, jotka kaikki sijaitsevat saman sanakirjan /Length-avaimen vieressä. Salaussanakirja parittaa tiedoston lopussa (trailer) olevan avaimen /Encrypt sen sisällä olevan avaimen /EncryptMetadata kanssa. Lyhyemmät avaimet ovat vielä pahempia: haku, joka on rakennettu muodossa Pos('/' + Key, ...), missä Key = 'N', osuu helposti avaimeen /Name tai /Nums. Mikään näistä törmäyksistä ei edellytä virheellistä tiedostoa. Kirjoittaja, joka järjestää avaimen /Length1 ennen avainta /Length, noudattaa täysin standardia, mikä tarkoittaa, että alimerkkijonovirhe ei ole puute vaurioituneen syötteen sietokyvyssä – se on virhe kelvollisen syötteen oikeellisuuden käsittelyssä

Virhetila on myös hiljaista tyyppiä. Väärä /Length ei aiheuta poikkeusta; se vain antaa sinulle lyhyemmän tai pidemmän tavulohkon kuin mitä virta todellisuudessa vie. Jos tämä lohko syötetään fontin osajoukon tarkistukseen, CMap-jäsennykseen tai metatietojen skannaukseen, kuluttaja näkee roskatietoa eikä yleensä raportoi mitään, koska puolikas zlib-virta ei yksinkertaisesti purkaudu ja koodi jatkaa eteenpäin. Toimitimme juuri tämän tyyppisen virheen ja korjasimme sen jaetun lukijamme versiossa v2.14.3 sen jälkeen, kun ISO 32000-1 -standardin kohtien §7.2–§7.3 pykäläkohtainen auditointi merkitsi jokaisen Pos-tyylisen avainhaun epäilyttäväksi

Mitä ISO 32000-1 §7.3.5 todella määrittelee nimen olevan

Kohta 7.3.5 on lyhyt ja täsmällinen: nimiobjekti on vinoviiva (solidus) ja sitä seuraava sarja tavallisia merkkejä, ja tunniste päättyy ensimmäiseen erottimeen tai tyhjämerkkiin. Erottimet ovat kahdeksan sulkumerkkiä sekä vinoviiva ja prosenttimerkki – ( ) < > [ ] { } / % – ja tyhjämerkkejä ovat nolla, sarkain, rivinvaihto, sivunvaihto, vaununpalautus ja välilyönti (§7.2.2–§7.2.3). Tämä päättymissääntö on koko jutun ydin. /Length1 ei ole '/Length, jota seuraa 1'; se on yksi, jakamaton tunniste, aivan kuten LengthOne and Length ovat eri tunnisteita Pascalissa. Mikä tahansa lukija, joka etsii avaimia raakojen tavujen perusteella, toteuttaa leksikaalisen jäsentimen uudelleen siten, että päättymissääntö on poistettu

Tässä on virheen muoto pelkistettynä olennaiseen. Tämä versio kääntyy, läpäisee testit sellaisia tiedostoja vastaan, joiden kirjoittajat järjestävät avaimen /Length ensimmäiseksi, ja korruptoi virrat sellaisten kirjoittajien kohdalla, jotka eivät tee niin

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Koko tunnisteen täsmäytys: tarkista tavu avaimen jälkeen

Oikea predikaatti seuraa suoraan kohdasta §7.3.5: mahdollinen osuma on todellinen avain vain, jos heti sen jälkeinen merkki on erotin, tyhjämerkki tai puskurin loppu. Kaikki muu on pidempää nimeä, joka vain jakaa etuliitteen, joten hakua on jatkettava sen ohi eikä luovutettava. Korjaus lukijassamme korvasi jokaisen raa'an Pos-haun yhdellä jaetulla rutiinilla, joka perustuu tähän sääntöön

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

Kaksi yksityiskohtaa tässä silmukassa on tärkeitä. Ensinnäkin se jatkaa etsimistä sen sijaan, että palauttaisi virheen ensimmäisessä etuliitteen törmäyksessä, koska /Length1 120 /Length 4076 on laillinen järjestys ja todellinen avain on vasta edessäpäin. Toiseksi puskurin loppu lasketaan päätemerkiksi, koska sanakirjan katkelma voi laillisesti päättyä heti nimen jälkeen. Hienovaraisempi kohta, joka kannattaa auditoida omassa koodissasi: sama sääntö pätee haun vasemmalla puolella, jos hakumerkkijonosi ei sisällä vinoviivaa, muuten Pos('Length', ...) voi osua avaimen /PieceLength sisään. Hakumerkkijonon ankkurointi alkavalla /-merkillä, kuten yllä, hoitaa vasemman reunan, koska / on itsessään erotin, joka päättää edellisen tunnisteen

Miten haitallinen PDF voi muuttaa jäsenninvirheen gigatavun varaukseksi?

Virheellinen tai haitallinen tiedosto paisuttaa nämä leksikaaliset virheet resurssien ehtymiseksi, koska sanakirjan kokonaisluvut ohjaavat usein muistinvarausten kokoa. Auditointimme löysi juuri tämän muotoisen ketjun objektivirtojen laajennuksessa. ObjStm-sanakirjan /N-merkintä kertoo, kuinka monta pakattua objektia virta sisältää, ja laajennuskoodi kutsui SetLength-aliohjelmaa sen mukaan mitoitetulle taulukolle. Kokonaislukujäsennin jätti kuitenkin out-parametrin koskemattomaksi epäonnistuessaan, vaikka palautti sen – joten ei-numeerinen /N välitti SetLength-kutsulle alustamattoman pinon arvon. Roska positiivinen kokonaisluku siellä tarkoittaa gigatavujen muistinvarauspyyntöä, jonka käynnistää muutaman tavun virheellinen syöte, pelkästään skannattaessa dokumenttia, johon et ole edes vielä luottanut

Korjauksessa oli kaksi toisistaan riippumatonta osaa, ja molemmat ovat yleispäteviä. Jäsennin palauttaa nyt virheestä eksplisiittisen 0:n, ei koskaan alustamatonta muistia. Eikä kuluttaja enää luota avaimeen /N ilman tarkistusta: ObjStm-otsikkoalue ennen avainta /First tallentaa kokonaislukuparin – objektin numeron ja siirtymän – jokaiselle pakatulle objektille, ja jokainen pari vie vähintään neljä tavua erottimet mukaan lukien. Mikä tahansa /N-arvo, joka on suurempi kuin FirstVal div 4 + 1, on siten fyysisesti mahdoton ilmoitetulle otsikkokoolle, ja se hylätään ennen kuin mitään varausta tapahtuu. Rajatarkistus maksaa yhden vertailun ja se saadaan tiedoista, jotka ovat jo käytössä. Tämä on kaava, jota kannattaa etsiä: raja, jonka tiedosto itse todistaa, ei mikään mielivaltainen vakio

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Kaksi muuta ylärajatarkistusta täydentävät lukijamme puolustuskehää, ja molemmat toimitettiin versiossa v2.12.0. Virran lukija hylkää minkä tahansa avaimen /Length, joka on suurempi kuin koko tiedosto, ennen kuin varaa tulospuskurin – virta ei voi olla suurempi kuin säiliö, jossa se elää, joten tarkistuksessa ei ole vääriä positiivisia. Ja pakauksen purkupolku rajoittaa puretun tulosteen 256 megatavuun (MiB), mikä estää klassisen zlib-pommin, jossa muutaman kilotavun syöte laajenee rajattomasti. Raja on antelias mille tahansa todelliselle PDF-virralle, mutta pitää pahimman tapauksen hallittavana. Teema kaikissa kolmessa on sama: jokainen tiedoston ilmoittama koko on väite, ja jäsennin vahvistaa jokaisen väitteen sellaista vastaan, mitä se voi mitata, ennen kuin varaa sille muistia. Sama auditointiasenne pätee yhtä kerrosta alempana sidosten rajapinnassa, jota käsitellään artikkelissa PDFium-rajapinnan (ABI) vahvistamisesta ja muistiturvallisuudesta Delphissä

Missä koko tunnisteen sääntö ei riitä

Rehellisyyden nimissä on syytä mainita rajat, jotta et luota yllä olevaan rutiiniin liikaa. Koko tunnisteen täsmäytys korjaa avaimen tunnistamisen, mutta suora tavuhaku sanakirjan alueelta ei edelleenkään pysty kertomaan, sijaitseeko osuma sisäkkäisen sanakirjan, kirjaimellisen merkkijonon vai kommentin sisällä – FindDictKey sivukohteessa voi osua avaimeen sen /Resources-alisanakirjassa, jos annat sille liain laajan alueen. Lukijamme rajaa alueen ensin yhteen objektin runkoon ja käsittelee merkkijono- ja kommenttiyhteyksiä erillisenä, edelleen avoimena auditointikohteena. Alimerkkijonojen turvallisuus on vain yksi askelma tikkailla, ei koko tikkaat: ristiviittausten johdonmukaisuus on oma tieteenalansa, jota käsitellään artikkelissa objektien ja xref-virtojen validoinnista, ja laajempi uhkaluettelo dokumenteille, joita et ole itse luonut, on artikkelissa PDF-tietoturvariskien auditoinnista

Jos ylläpidät käsin kirjoitettua sanakirjan lukijaa Delphissä tai Lazaruksessa, tämän tapauksen tarkistuslista on lyhyt. Hae (grep) kaikki Pos('/ koodikannasta ja ohjaa osumat yhden koko tunnisteen apuohjelman läpi. Luettele etuliiteperheet, joihin avaimesi osallistuvat – /Length, /Encrypt, /N ja /Type vastaan /Type1 esiintyvät kaikki todellisissa tiedostoissa. Käy sitten läpi jokainen kokonaisluku, joka päätyy SetLength-, GetMem- tai kopiointisilmuun, ja kysy, mikä sitä rajoittaa: tiedoston koko, otsikosta johdettu yläraja vai ei mikään. Tässä kuvattu jäsennyskerros on perustana PDFium-komponentillemme, jossa tavutason lukija ja renderöintimoottori ristiintarkistavat toisensa jokaisessa käsittelemässään dokumentissa