Tekninen artikkeli

Muistiturvallinen (Memory-Safe) PDF-jäsennys: Puolustautuminen haitallisia (Malicious) asiakirjoja vastaan

Asiakirjojen vastaanottoputki (intake pipeline) hyväksyy tiedostoja (files), jotka vieraat (strangers) ovat kirjoittaneet. Laskut, skannaukset, verkkolomakkeen liitteet (attachments from a web form): jokainen väittää (claims to) olevansa PDF ja kantaa mukanaan satoja numeroita, joiden (your) perusteella jäsentimesi odotetaan toimivan. Virtojen pituudet (Stream lengths), kuvien mitat (dimensions), tavusiirtymät (byte offsets), objektiviittaukset — jokaisen on (was) valinnut kuka (whoever) tahansa tiedoston tuottanut (produced the file), ja (and a) katkaistu lataus (truncated upload) tai (or a) tahallisesti (deliberately) viallinen asiakirja laittaa lopulta yhden (one of) noista numeroista paikkaan, jossa (where it) se tekee tuhoa (damage). Ero jäsentimen, joka (that) selviää (survives) tuosta tiedostosta, ja (and) jäsentimen välillä, joka kaatuu (crashes), tai jatkaa (keeps) toimintaansa korruptoituneella (corrupted) muistilla, on pieni joukko (small set of) tapoja (habits), jotka (that do) eivät riipu mistään tietystä PDF-kirjastosta

Näillä tavoilla (habits) on yksi yhteinen lähtökohta (premise): tiedostosta luettu (read from) arvo (value) on väite (claim), ei (not a) mittaus (measurement). Siitä tulee (becomes) käyttökelpoinen (usable) vasta, kun (after) se on tarkistettu (checked against) jotakin (something the) sellaista vasten, mitä jäsennin (parser) itse (itself) on mitannut — tiedoston (file) todellinen koko (real size), todellinen (real) tavumäärä (number of bytes), jonka dekooderi tuotti (produced), tai rekursion todellinen syvyys (depth). Seuraavassa on tämä (this) lähtökohta sovellettuna (applied to the) paikkoihin, joissa asiakirjajäsentimet (document parsers) todellisuudessa menevät rikki (break)

Ilmoitettu pituus on väite (claim), ei mittaus

Yksinkertaisin yhteensopimattomuus (mismatch) on virran pituus. PDF-virtaobjekti (stream object) ilmoittaa (declares its) tavumääränsä (byte count in the) /Length-avaimessa, ja todellinen data istuu (sits between the) stream- ja endstream-avainsanojen (keywords) välissä. Mikään ei pakota näitä kahta olemaan samaa mieltä (agree). Katkaistu (truncated) tiedosto sisältää vähemmän todellisia (real) tavuja (bytes) kuin ilmoitettu (declared) määrä; rikkinäisen generaattorin (generator) luoma tiedosto (file) voi ilmoittaa pituuden, joka ulottuu tiedoston lopun (end of the file or) yli (past) tai viereiseen objektiin. Tee varaus (Allocate from the) ilmoitetun (declared) arvon (value) perusteella ja kopioi endstream-kohtaan (until) asti, niin puskuri (buffer) ylivuotaa (overrun); lue tismalleen ilmoitettu määrä (count) tarkistamatta saatavuutta, niin (and you) kävelet ulos tiedoston (file) lopusta. Anna ilmoitetun (declared) arvon ohjata varausta (allocation) vasta, kun se on rajoitettu (clamping it against the) mitattua etäisyyttä vasten (measured distance to the) datan (data) loppuun, ja käsittele (treat a) erimielisyyttä (disagreement as a) päätöksentekopisteenä (decision point) — korjaa skannaamalla (scanning for) endstream tai (or) hylkää virta (reject the stream) — älä koskaan (never) asiana, jota (as something to) uskoa hiljaa

Kuvaparametrit, jotka (that) kuvaavat (describe a) suuremman rasterin (bigger raster) kuin mitä (than you) varasit (allocated)

Kuvavirrat nostavat panoksia (raise the stakes because), koska kaksi itsenäistä numerosarjaa (sets of numbers) kuvaa samoja pikseleitä. Kuvasanakirja (image dictionary) kantaa arvoja /Width ja /Height, ja rasteripuskurit (raster buffers) mitoitetaan (are usually sized) yleensä (from) niiden perusteella. Dekoodaussuodatin (decode filter) kantaa (carries its) oman (own) geometriansa: CCITTFaxDecode ottaa /Columns, /Rows ja /K DecodeParms-parametristaan, jossa /K valitsee Group 3 - tai Group 4 -järjestelmän ja dekooderi tuottaa (emits) (Columns + 7) div 8 tavua skannausriviä (scanline) kohden. Tiedosto, joka (that) ilmoittaa /Width 100 mutta (but) antaa (hands the) suodattimelle /Columns 1728 — oletusarvon (the default) — saa dekooderin tuottamaan yli (over) kuusitoista kertaa enemmän tavuja riviä kohden (per row) kuin puskuri odottaa, ja (and the) ylivuoto laskeutuu yhden skannausrivin (one scanline at a time in) kerrallaan mihin (whatever) tahansa, mikä (sits after the) sijaitsee (allocation) varauksen (after) jälkeen. Kun /Rows puuttuu, dekooderi suorittaa (runs until the data says stop), kunnes (so) data (bound the) käskee lopettaa, joten rajoita myös rivien määrää (row count). DCTDecode-suodattimella on (has the) sama sauma (seam): JPEG-data kantaa oman leveytensä (width) ja (and) korkeutensa (height) SOF-merkissään, eikä mikään velvoita niitä vastaamaan sanakirjaa (dictionary)

Puolustava (defensive) sääntö on mekaaninen (mechanical): laske odotettu rasterin (raster) koko (size from the) validoiduista dekoodausparametreista — suodattimen omista /Columns- ja /Rows-arvoista (for CCITT) CCITT:lle, SOF-mitoista DCT:lle — tarkista se (it against your) rajojasi vasten, tee varaus (allocate from it) sen perusteella ja varmista (verify) dekoodauksen (decode) aikana, että tuloste (output) ei koskaan (never) ylitä (runs past the) varausta (allocation). Kun sanakirja ja (and) suodatin (filter) ovat eri mieltä (disagree) geometriasta, sovita ne yhteen (reconcile them or) tai hylkää (reject the) kuva (image). Mitä jäsennin ei saa koskaan (never) tehdä (do), on (is) mitoittaa puskuri yhden numerosarjan (one set of numbers) perusteella ja antaa (let the) dekooderin (decoder) suorittaa asioita (run at the) toisen sarjan perusteella (other)

Delphin (Delphi) aritmetiikka ja varausten (allocation) sudenkuopat

Kolme (Three) Delphi-käyttäytymistä (behaviors) heikentää (undermine) jopa (even a) jäsennintä, joka aikoo (intends to) validoida (validate). Ensimmäinen on 32-bittinen kertolasku: Delphi arvioi (evaluates the) kahden (two) Integer-operandin tulon 32-bittisenä kohteen (destination's width) leveydestä (regardless of the) riippumatta, joten Width * Height * BytesPerPixel voi kiertyä ympäri (wrap even), vaikka jokainen tekijä (factor) läpäisisi oman järkevyystarkistuksensa (sanity check). 30000 x 30000 skannaus kolmella tavulla per pikseli on 2,7 miljardia tavua, mikä kiertyy (wraps) negatiiviseksi (negative in) etumerkillisessä (signed) 32-bittisessä aritmetiikassa; hieman (slightly) erilaiset (different) tekijät (factors) kiertyvät pieneen (small) positiiviseen pituuteen, joka varaa (allocates and) ja alimitoittaa (undersizes the) puskurin (buffer). Pakota koko lauseke (expression) leveäksi muuntamalla (cast) ensimmäinen operandi — Size := Int64(Width) * Height * BytesPerPixel — ja vertaa (compare against an) sitä sitten (then) selkeään (explicit) ylärajaan (cap before) ennen (anything reaches) kuin (SetLength) mikään (before anything reaches) saavuttaa SetLength-kutsun

Toinen (second) on rajatarkistus (range checking). Delphin (Delphi's default) oletusarvoinen (release configuration) julkaisuasetus toimitetaan se pois päältä kytkettynä (ships with it off), joten tiedostodatasta laskettu alueen ylittävä indeksi (out-of-range index) ei aiheuta poikkeusta (raise) — se lukee tai kirjoittaa taulukon viereistä (adjacent to the) muistia. Kytke (Turn) se (it back) takaisin päälle {$R+}-direktiivillä (ja {$Q+} aritmeettista ylivuotoa (arithmetic overflow) varten) jokaisen sellaisen yksikön (unit) alussa (at the top of), joka (that) indeksoi (indexes with) tiedostosta johdetuilla arvoilla. Kustannus (cost) on (is) mittaamaton (unmeasurable next to the) verrattuna siihen I/O-määrään, jonka jäsennin tekee joka tapauksessa, ja se (it) muuntaa hiljaisen korruption kiinniotettavaksi (catchable) ERangeError-poikkeukseksi

Kolmas on TMemoryStream.SetSize tiedoston toimittamalla (file-supplied) Int64-arvolla. Nykyisellä (On a current) RTL:llä se (it) varaa kaiken (allocates whatever the), mitä tiedosto (file) pyysi, joten yksittäinen (single) virta (stream), joka väittää olevansa (claiming) neljä gigatavua (gigabytes), muuttuu (becomes an) muistin loppumisen (out-of-memory) aiheuttamaksi epäonnistumiseksi kesken vastaanoton (mid-intake). Vanhemmilla (On older) RTL-versioilla, joissa (where) SetSize ottaa (takes a) Longint-arvon, arvo (value) kavennetaan (is silently narrowed) ensin hiljaa (first): ilmoitettu $100000010 muuttuu (becomes) 16:ksi, varaus onnistuu (succeeds) ja todellisen datan (real data) kirjoitus (write of the) jatkuu paljon sen yli. Validoi jokainen (every) koko (size against the) mitattua (measured source) lähteen (size and a) kokoa ja kovaa ylärajaa (hard cap before) vasten (before any) ennen kuin mikään varauskutsu näkee sen

Siirtymät (Offsets that), jotka osoittavat (point) tiedoston ulkopuolelle (outside the file)

Ristiviittaustaulukko (cross-reference table) kartoittaa objektinumerot (object numbers to) absoluuttisiin tavusiirtymiin (absolute byte offsets), ja jäsennin (parser) hakeutuu (seeks) minne tahansa (wherever) se osoittaakin (points). Vioittuneessa (damaged) tai haitallisessa (hostile) tiedostossa (file those) nämä siirtymät laskeutuvat (offsets land) tiedoston (file or) lopun yli (past the end of the) tai (inside) liittymättömien rakenteiden sisälle. TStream tekee epäonnistumisesta (failure) hiljaisen (quiet): Position-ominaisuuden (Position) asettaminen (setting) yli (beyond) Size-ominaisuuden ei ole virhe (error), ja (and a) pelkkä Read yli lopun (past the end simply) palauttaa (returns fewer) yksinkertaisesti vähemmän (bytes than) tavuja (requested) kuin pyydettiin, joten koodi (code that skips the count check), joka ohittaa (keeps) määrän (count) tarkistuksen (check), jatkaa vanhentuneiden tavujen jäsentämistä (parsing stale bytes) edellisestä (from the previous) objektista (object). Puolustus (defense) on (is a) pullonkaula (chokepoint) — yksi (one) apufunktio (helper through which), jonka (every) kautta (every file-driven) jokainen tiedosto-ohjattu haku ja luku (read passes) kulkee, validoiden siirtymän ja määrän (count against the) mitattua (measured file) tiedostokokoa (size before the) vasten (before the stream moves) ennen kuin (stream moves) virta (stream) liikkuu (moves)

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // mikään yksittäinen objekti ei saa ylittää 64 Mt

type
  EPdfBoundsError = class(Exception);

// Jokainen (Every) tiedosto-ohjattu haku (seek) ja luku (read) menee tästä (goes through here) läpi. Siirtymä (Offset) ja (and) Määrä (Count) ovat (are)
// tiedoston toimittamia väitteitä (file-supplied claims); Source.Size on (is the) mittaus (measurement they must fit), johon (measurement they must fit) niiden on (they must fit) mahduttava (they must fit).
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'objektin laajuus %d+%d ylittää tiedoston koon %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

Reititä (Route) ristiviittaussiirtymät, virtojen (stream) laajuudet ja upotettujen tiedostojen luvut sen läpi, jolloin (and a bad) huono siirtymä muuttuu (becomes a) siistiksi (clean rejection that) hylkäykseksi (rejection), joka nimeää numerot (numbers instead of an) sen sijaan, että se aiheuttaisi pääsyrikkomuksen kolme (three) kutsua myöhemmin (later)

Syklit (Cycles) ja (and) syvyys (depth in the) objektiverkossa (object graph)

PDF on verkko (graph), ei puu. Mikä tahansa (Any) arvo (value) voi (may) olla epäsuora (indirect reference) viittaus, viittaus voi ratketa toiseksi viittaukseksi — /Length 12 0 R, jossa (where object) objekti 12 sisältää 13 0 R — eikä mikään (and nothing) estä ketjua (chain from) sulkeutumasta takaisin itseensä. Selvittäjä (resolver that), joka (that follows) seuraa (references naively) viittauksia naiivisti, käyttää rekursiota (recurses until the), kunnes (until the native) natiivi pino on ehtynyt (exhausted, and stack), ja (and stack exhaustion) pinon ehtyminen (exhaustion is not something you) ei ole asia (is not something you catch), jonka (catch) voit ottaa (you catch) kiinni; se (it) lopettaa (ends the) prosessin. Syvälle (Deeply) sisäkkäiset (nested arrays and) taulukot (arrays and dictionaries) ja sanakirjat (dictionaries) saavuttavat saman lopun (end) ilman (without any) minkäänlaista sykliä (cycle at all)

Käytä (Use) kahta suojusta (two guards) yhdessä: nimenomainen syvyyslaskuri (depth counter bounds the) rajoittaa "rehellisen mutta (honest-but-deep) syvän" tapauksen rajalle (limit no), jota mikään (limit no legitimate) legitiimi tiedosto (file approaches) ei (approaches) lähesty (approaches), ja vierailtujen (visited) joukko (set catches a) nappaa aidon (genuine cycle on its) syklin (cycle) sen (its) toisella (second) vierailulla (visit), muuttaen sen tarkaksi (turning it into a precise), raportoitavaksi (reportable) virheeksi rajatripin (limit trip) sijaan

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // paljon syvempi kuin mikään legitiimi viittausketju

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // merkityksellinen kun Kind = pvReference
    // ... payload-kentät jäljellä oleville tyypeille
  end;

// LoadObject on (is your own routine) oma rutiinisi (routine): se etsii (looks up the) xref-siirtymän
// ObjNumber-arvolle (ObjNumber), lukee objektin (object with) ReadBounded-kutsulla ja jasentää (parses it) sen (it).
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('viittausketju (reference chain) ylittää syvyysrajan (depth limit)');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'kehäviittaus (circular reference) objektin %d kautta', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // esim. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // sisarukset (siblings may legally share) voivat laillisesti jakaa (legally share this object) tämän objektin
  end;
end;

Purkaminen on vahvistin (amplifier)

Muutama (A few) kilotavu FlateDecode-syötettä voi (can) paisua (inflate to) gigatavuiksi; yleiskäyttöinen (general-purpose compression) pakkaus palkitsee (rewards) toistuvaa (repetitive plaintext) selkokieltä, ja (and an) hyökkääjä (attacker can make it) voi tehdä siitä maksimaalisen (maximally) toistuvaa. Rajoita (Cap the) jokaisen (each) virran (stream at what its) purettu koko (inflated size of each stream at what its) sellaiseksi (at what its consumer can), mitä sen kuluttaja voi uskottavasti tarvita, ja pidä toista asiakirjakohtaista budjettia (second per-document budget): viisisataa virtaa, joista kukin on hieman (just under the) virtakohtaisen (per-stream) rajan (cap) alapuolella (under), kuluttaa (exhaust memory as surely as) muistin (memory) yhtä varmasti kuin yksi jättimäinen virta. Tarkistus (check belongs inside the) kuuluu purkusilmukan sisälle, laskien (counting output bytes) tulostetavuja sitä mukaa kun niitä tuotetaan, ja keskeyttäen ylityksestä (aborting on breach), ei silmukan jälkeen (after the loop when the), kun (when the) muisti on (is already) jo (already) käytetty. Asiakirjan (document) budjetti (budget), joka ilmaistaan monikertana pakatun (compressed) tiedoston (file size) koosta (size), toimii hyvin, koska legitiimit asiakirjat ryhmittyvät kauas niiden suhteiden (ratios) alapuolelle (far below the), jotka kustomoitu (crafted) virta saavuttaa (reaches)

Puolustus (Defense in) syvyydessä (depth) omien yksiköidesi (units) ulkopuolella (beyond)

Samat (same) vikaluokat (defect classes) elävät kirjastojen sisällä. Kaksi tapaustutkimusta (case studies on this blog) tässä blogissa käy läpi todellisia tapauksia (instances): kokonaislukujen (integer) ympärikiertymiset (wraps), rajoittamaton rekursio (unbounded recursion) ja alustamattomat (uninitialized) puskurit, jotka (closed in a) suljettiin (closed in a) natiivissa Pascal-moottorissa (engine in) artikkelissa Pascal PDF-jäsentimen kovettaminen haitallisia tiedostoja vastaan (Hardening a Pascal PDF Parser Against Malicious Files), sekä (and the) kutsukäytäntö- (calling-convention), kokonaislukuleveys- (integer-width) ja (and ownership hazards of) omistajuusriskit (hazards) C-moottorin (binding a C engine in) sitomisessa (binding a C engine in) artikkelissa PDFium-komponenttisidonnan kovettaminen (Hardening a PDFium Component Binding). Aitoja epäluotettavia (genuinely untrusted) vastaanottoja (intake) varten — julkinen (public upload form) latauslomake, todentamaton postilaatikko — suorita myös (also) jäsennys- (parse) ja dekoodaustyö erillisessä alhaisten (low-privilege) oikeuksien (privilege) prosessissa (process), jotta tiedosto, joka (that) kumoaa (defeats every) jokaisen (every) prosessin sisäisen (in-process) suojauksen (guard), aiheuttaa vain epäonnistuneen työn kaatuneen palvelun sijaan (instead of a downed service)

Ennakkotarkastuslista (preflight checklist)

Ennen kuin (Before the) seuraava koontiversio (build ships) julkaistaan, käy (walk the parser against this) jäsennin (parser) läpi tätä listaa vasten (this list): jokainen (every stream buffer) virtapuskuri on (buffer) mitoitettu (sized from a) rajoitetusta (clamped length rather than the) pituudesta ilmoitetun sijaan (rather than the declared one); jokainen rasteri on mitoitettu validoiduista dekoodausparametreista ja tarkistettu dekooderin tulostetta vasten (against decoder output); jokainen mittojen (dimension) tulo (product) on arvioitu (evaluated in) Int64-arvona (evaluated in Int64 and) ja (and) verrattu selkeään (explicit cap) ylärajaan (cap); {$R+} on (active in every) aktiivinen (active in every) jokaisessa yksikössä (unit that), joka indeksoi tiedostosta johdetuilla (derived) arvoilla; jokainen (every) haku (seek) on rajatarkistettu (bounds-checked against the) mitattua (measured file) tiedostokokoa (size) vasten; jokaisen viittauksen selvittämisen syvyys (resolution depth-limited and) on rajoitettu (depth-limited and) ja syklit (cycle-checked) tarkistettu; jokainen (every inflation loop counting output) purkusilmukka (loop) laskee tulosteen virtakohtaisia (per-stream and) ja (and) asiakirjakohtaisia (per-document) budjetteja (budgets) vasten. Mikään näistä (None of these checks) tarkistuksista (checks) ei (costs measurable time) maksa mitattavaa (measurable time on a) aikaa (time) legitiimillä (legitimate document) asiakirjalla (document, and), ja (and each converts memory corruption) jokainen (each) muuntaa (converts memory corruption into a) muistin (memory corruption into a) korruption (corruption into a) siistiksi (clean, loggable rejection), lokitettavaksi hylkäykseksi (rejection)

Huomautus: losLabin HotPDF Component, PDFlibPas Delphi PDF Library ja PDFium Component soveltavat (apply these) näitä (these) rajatarkistuksia (bounds checks), syvyysrajoituksia (depth limits) ja (and) laajennusrajoituksia (expansion caps) sisäisesti (internally), joten (so an) niiden varaan rakennettu (built on them starts from a) vastaanottoputki aloittaa (starts from a hardened baseline) karkaistulta (hardened) perusviivalta (baseline)