Tekninen artikkeli

PDF-purkupommit Delphissä: HotPDF-suodatinketjun budjetit

20 kilotavun PDF, joka lukitsee palveluprosessin siihen asti kunnes OOM-tappaja hoitaa sen, ei ole vika koodissasi, se on purkupommi. HotPDF, natiivi VCL PDF-komponentti Delphille ja C++Builderille, rajaa tällaisen DecodeBudgetBytes-asetuksella, joka on suodatinketjukohtainen katto, joka on oletuksena 268435456 tavua ja veloittaa jokaisen purkuvaiheen yhdestä jaetusta budjetista

20 kilotavun tiedosto, joka söi työprosessin

Tapauksen muoto on aina sama. Pikkukuvia renderöivä jonotyöntekijä poimii latauksen, käytössä oleva muisti nousee yli 12 gigatavun alle kahdessa sekunnissa, ja prosessi katoaa ilman pinojälkeä. Tiedosto on 20 kilotavua. Siinä on yksi sivu, yksi sisältövirta, ja /Filter-taulukko, jossa on viisi merkintää. Jokainen nimi tuossa taulukossa on suodatin, jonka spesifikaatio määrittelee, jokainen vaihe purkautuu ilman virhettä, eikä tiedostossa ole mitään väärin muodostettua. Se tekee tästä syöteluokasta hankalan: ei ole korruptoitunutta tavua hylättäväksi

Tämä ei ole sama ongelma kuin yksittäisen suodattimen purkaminen oikein. LZWDecode:n ja /DecodeParms-ennustajan oikeaan saaminen on oma aiheensa, käsitelty artikkelissa LZW, ennustajat ja DecodeParms ladatuilla asiakirjoilla. Tässä jokainen dekooderi on jo oikein. Vika on se, mitä oikeat dekooderit tekevät, kun ajat viisi niistä peräkkäin eikä kukaan laske kokonaismäärää. ISO 32000-1 §7.4 on selvä, että /Filter voi olla yksi nimi tai nimien taulukko, ja että taulukko sovelletaan järjestyksessä, ensimmäinen merkintä ensin. Se ei sano mitään siitä, kuinka paljon vaihe saa laajentaa syötteensä, eikä mitään ketjun kokonaissummasta. ASCIIHexDecode-vaihe suunnilleen puolittaa syötteensä, mikä kuulostaa vaarattomalta. FlateDecode-vaihe nollatavujen juoksun yli saavuttaa suhteita tuhansissa. Ketjuta ne, ja aritmetiikka on kertautuvaa: 20 kilotavusta tulee 20 megatavua josta tulee 20 gigatavua, ja jokainen yksittäinen vaihe on laillisen virran yhdenmukainen purku

Miksi suodatinkohtainen raja ei pysäytä purkupommia?

Koska suodatinkohtainen raja viritetään uudelleen jokaisella /Filter-taulukon alkiolla. Viiden vaiheen ketju 256 MiB:n vaihekohtaisen katon alla valtuuttaa 1,25 GiB, ja viimeinen vaihe alkaa silti täysin tuoreella sallimalla riippumatta siitä, mitä neljä sitä edeltänyttä tuottivat. Raja pannaan täytäntöön rehellisesti eikä se rajoita mitään olennaista. HotPDFillä oli täsmälleen tuo muoto ennen versiota v2.447.0, ja sillä oli toinenkin aukko sen vierellä. LZW-purkaja kantoi MaxOutputBytes-katon ja kuvan ennustajapolku laski omat rivinsä, joten nuo kaksi olivat paikallisesti rajattuja. FlateDecode, ASCIIHexDecode, ASCII85Decode ja RunLengthDecode eivät kantaneet lainkaan kattoa: jokainen kirjoitti TMemoryStream:iin, kunnes syöte loppui tai muistinvaraaja luovutti. Vihamielisellä ketjulla oli siis kaksi tietä läpi. Se saattoi käyttää täysin vartioimatonta suodatinta, tai se saattoi käyttää vartioituja ja yksinkertaisesti lisätä niitä enemmän

On kolmaskin yksityiskohta, jonka naiivi korjaus ohittaa. Luku, josta kannattaa välittää, ei ole lopullisen puretun tulosteen koko. Se on huippu, ja huippu asuu yleensä välipuskurissa. Ketju, joka päättyy vaatimattomaan 4 megatavun sisältövirtaan, voi varata 8 gigatavua vaiheessa kolme ja antaa takaisin jotain, joka näyttää täysin kohtuulliselta. Tuloksen pituuden tarkistaminen jälkikäteen ei kerro mitään varauksesta, joka tappoi prosessin

Yksi budjettiseurain per suodatinketju

Korjaus HotPDF v2.447.0:ssa on saada kirjanpito kattamaan ketju vaiheen sijaan. Jokainen suodatinketju rakentaa yhden THPDFDecodeBudgetTracker:n, ja jokainen dekooderi kirjoittaa THPDFBudgetWriteStream:n läpi, joka kääriytyy oikean kohteen ympärille. Kääre kutsuu Budget.Consume(Count):tä ennen kuin se välittää yhtäkään tavua, joten kieltäytyminen tapahtuu kun kohdevirta on vielä vanhassa koossaan. Tuo järjestys on koko pointti: tarkistus, joka tehdään sen jälkeen kun puskuri on jo kasvanut, on diagnoosi, ei puolustus

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

Paikalliset katot eivät kadonneet, niistä tuli jaetun budjetin heijastumia. LZW-vaihe asettaa nyt Decoder.MaxOutputBytes := Budget.RemainingBytes, joten sen yksityinen katto on mitä tahansa ketjulla on jäljellä riippumattoman sallimuksen sijaan. Kuvan ennustajavaihe avautuu BeginFilter:llä ja veloittaa rivivaatimuksensa Consume:n kautta ennen varaamista, mikä tarkoittaa, että ennustajan tuloste laskutetaan samasta budjetista kuin sitä ruokkineet yleiset suodattimet. Tämä on merkityksellistä erityisesti kuvapolulla, jossa suodatinketju ja ennustaja ovat yhden operaation kaksi puoliskoa, kuten käsitellään artikkelissa kuvien erottaminen ladatuista asiakirjoista niiden purkusuodattimien kautta

Mitä kutsuja näkee, kun budjetti kieltäytyy?

Pinon pohjalla kieltäytyminen nostaa EHPDFDecodeBudgetError:n. Sen yläpuolella vastaus riippuu sopimuksesta, joka kutsutulla API:lla jo oli. Korkean tason lukumetodit, jotka raportoivat epäonnistumisen False:n tai nil:n kautta, jatkavat täsmälleen sitä, koska dokumentoidun totuusarvotuloksen muuttaminen poikkeukseksi rikkoisi kutsujat, jotka jo käsittelivät väärin muodostettua syötettä oikein. Ladatun sivun sisältöpolku on tarkoituksellinen poikkeus: se nostaa EHPDFDecodeBudgetError:n uudelleen sen sijaan, että antaisi katkaistun sisältövirran renderöityä sivuna, joka vain tuli tyhjänä ulos. Tuo suunnittelu tarkoittaa, että paljas False on itsessään epäselvä, joten budjetti julkaisee diagnostiikkatietueen sen rinnalla: THotPDF.GetLastDecodeBudgetInfo palauttaa instanssin viimeksi purkaman ketjun tilan

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

Lue nuo kentät yhdessä, ja ne erottavat kaksi hyökkäysmuotoa toisistaan. Kun PeakStageBytes on lähellä DecodedBytes:iä, yksi vaihe teki kaiken vahingon, ja katsot yhtä korkean suhteen suodatinta. Kun PeakStageBytes on pieni murto-osa DecodedBytes:istä ja FilterCount on korkea, mikään yksittäinen vaihe ei ollut kohtuuton, ja ketju kasautui katon yli, mikä on juuri se tapaus, jota suodatinkohtainen raja ei näe. Yksi varoitus kannattaa kirjoittaa käsittelijääsi: GetLastDecodeBudgetInfo palauttaa False:n, kunnes instanssi on purkanut vähintään yhden suodattimen, joten False siitä ei ole todiste siitä, että asiakirja oli puhdas

Mistä budjetti nollautuu, ja milloin nolla on rehellinen vastaus

DecodeBudgetBytes rajaa yhden virtaketjun, ei yhtä asiakirjaa, ja tuo raja on tarkoituksellinen mutta helppo lukea väärin. Jokainen sisältövirta, jokainen upotettu tiedosto, jokainen ristiviittausvirta ja jokainen objektivirta alkaa tuoreella 256 MiB:llä. 4 000-sivuisella asiakirjalla on siis 4 000 itsenäistä mahdollisuutta kuluttaa koko katto, ja objektivirrat kertaannuttavat määrää edelleen, koska jokainen niistä on itsessään pakattu säilö, joka pitää sisällään monta objektia, kuten kuvataan muistiinpanoissa objektivirroista ja asteittaisista päivityksistä. Jos todellinen vaatimuksesi on raja koko prosessin muistille, tämä ominaisuus on yksi syöte siihen, ei koko asia, ja sen pitäisi istua työtason tai säilötason katon takana

Nolla tarkoittaa rajoittamatonta, ja se on legitiimi asetus eikä pakotie. Aseta se, kun omistat syötteen: arkiston uudelleenkäsittelyputki asiakirjoille, jotka oma järjestelmäsi tuotti, tai rasterointivaihe, jossa yksi 600 dpi:n väriskannausketju todella tarvitsee enemmän kuin mikä tahansa katto, jonka olisit mukava kovakoodata. Negatiiviset arvot hylätään heti ERangeError:lla, koska negatiivisella budjetilla ei ole johdonmukaista merkitystä ja sen hiljainen leikkaaminen piilottaisi konfiguraatiovirheen

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

Luvun valitseminen ansaitsee enemmän huolellisuutta kuin sille yleensä annetaan, koska liian alhaiseksi asetettu budjetti on itseaiheutettu käyttökatkos. Aja olemassa oleva korpuksesi oletusarvolla, tallenna PeakStageBytes ja DecodedBytes jokaiselle ketjulle, ja aseta katto havaitun maksimin yläpuolelle todellisella liikkumavaralla. Pyöreä luku, joka valittiin, koska se kuulosti turvalliselta, hylkää legitiimin suuren skannauksen pahimpana mahdollisena hetkenä, ja epäonnistuminen näyttää lokeissasi täsmälleen hyökkäykseltä

Kopio, jota ei enää tapahdu

Jokaisen vaiheen reitittäminen budjettikääreen läpi osoittautui tekevän ketjusta halvemman kalliimman sijaan. Kun virralla on suodattimia, ensimmäinen vaihe lukee nyt lähdevirtaa suoraan sen sijaan, että kopioisi koodatut tavut ensin luonnospuskuriin, ja siitä eteenpäin vain kaksi puskuria on elossa kerralla: nykyinen syöte ja kirjoitettava vaiheen tuloste. Raaka kopio säilyy kahdessa tapauksessa, jotka tarvitsevat sitä, nimittäin virrassa, jossa ei ole lainkaan suodattimia, ja kuvassa, jossa kutsuja haluaa viimeisen koodauksen säilytettävän, koska molemmat antavat takaisin virran, jonka kutsuja omistaa ja jota voi hakea itsenäisesti. Tämän koodin vartioimaton versio varasi enemmän ja rajasi vähemmän, mikä on tavallinen suhde näiden kahden välillä. Silti kannattaa sanoa suoraan: mikään tästä ei tee mielivaltaisesta PDF:stä turvallista ladattavaa. Se sulkee yhden erityisen ja erittäin halvan palvelunestohyökkäysvektorin, sen, jossa pieni tiedosto ostaa suuren varauksen sisäkkäisten suodattimien kautta. Kokonaislukujen ylivuoto tavukirjanpidossa on vartioitu erikseen, ja laajempi kysymys vihamielisten asiakirjojen jäsentämisestä luottamatta niiden sisäisiin siirtymiin on eri kurinalaisuuslaji. Purkubudjetti on yksi raja monista, ja sen arvo on siinä, että se on se, jonka voit asettaa yhdestä ominaisuudesta ennen kuin kosket tiedostoon

Ketjukohtainen budjetti, sen diagnostiikkatietue ja ladatun asiakirjan purkupolut, joita se suojaa, toimitetaan kaikki osana itse komponenttia, ilman ulkoista purkuriippuvuutta konfiguroitavaksi tai paikattavaksi. Jos arvioit, miten rajata epäluotettu PDF-syöte Delphi- tai C++Builder-palvelun sisällä, HotPDF Delphi PDF -komponentin sivu listaa ladatun asiakirjan työkalupaketin, johon nämä rajat pätevät