Tekninen artikkeli

PDFium-komponenttisidoksen karkaisu: ABI ja muistiturvallisuus

Pascal-sidos (binding) C-kirjaston päällä lukee kuin tavallinen Pascal. Kutsut metodia, saat tietueen (record) takaisin, vapautat sen, minkä varasit (allocated). Ongelma on siinä, että PDFium on C- ja C++-kirjasto, jolla on oma kutsukäytäntönsä (calling convention), omat kokonaislukujen leveytensä (integer widths) ja omat sääntönsä siitä, kuka omistaa muistin ja kuka vapauttaa sen. Mikään niistä ei ylitä kielirajaa (language boundary) itsekseen. Jokainen näistä sopimuksista (contracts) on toistettava käsin Pascal-määrityksissä, ja yksikin väärä sana muuttaa siistiltä näyttävän kutsun pinokorruptioksi (stack corruption), katkaistuksi siirtymäksi (truncated offset) tai tuplavapautukseksi (double free). PDFium Component -sidoksen v1.61.0:n auditointi paljasti yhden jokaisen tyyppisen vian. Ne on syytä käydä läpi, koska ne eivät ole ominaisia vain tälle sidokselle. Ne ovat pysyviä vaaroja (standing hazards), kun mitä tahansa C-API:a kääritään Delphiin tai Lazarukseen

cdecl on osa funktiotyyppiä, ei koriste

PDFium on käännettyä C:tä (compiled C). Win32:lla sen viennit (exports) ja, mikä tärkeintä, sen kutsumat takaisinkutsut (callbacks) käyttävät cdecl-kutsukäytäntöä. cdecl:n alaisuudessa kutsuja (caller) siivoaa pinon (stack) sen jälkeen, kun kutsu palaa. Delphin natiivi oletus on register, ja Win32 C -standardi takaisinkutsuille on joissakin kirjastoissa stdcall, jossa kutsuttava (callee) siivoaa puolestaan pinon. Kun tietorakenne (structure) ojentaa PDFiumille funktio-osoittimen ja unohdat cdecl:n tuon osoittimen tyypistä, osapuolet ovat eri mieltä siitä, kuka säätää pino-osoitinta (stack pointer). Molemmat korjaavat sen, tai kumpikaan ei korjaa, ja pino-osoitin ajautuu (drifts) argumenttien koon verran jokaisella kutsukerralla

Syy, miksi tätä vikaa on vaikea löytää, on se, että vahinko on ei-lokaalia (non-local). Korruptoitunut kutsu palaa ja näyttää hienolta. Virheellinen kohdistus (misalignment) ilmenee myöhemmin, jossain täysin asiaan liittymättömässä funktiossa, jonka kehys (frame) istuu nyt pino-osoittimessa, joka on muutaman tavun pielessä, ja se ilmenee villinä lukuna (wild read), huonona paluuosoitteena (bad return address) tai kaatumisena, jonka pinojälki (backtrace) ei osoita lähellekään sitä takaisinkutsua (callback), jonka itse asiassa teit väärin. Lomakkeiden täyttö (form-fill) on klassinen paikka, jossa tämä puraisee, koska lomakkeen täyttöliittymä (form-fill interface) on tietue, joka on täynnä takaisinkutsuja, joihin PDFium soittaa takaisin (calls back into). Yksi niistä, FFI_OpenFile, ojentaa PDFiumille funktion, jota se kutsuu avatakseen ulkoisen tiedoston, ja joka on määritelty (declared) seuraavasti: function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Perässä laahaava (trailing) cdecl on kopioimisen arvoinen kohta. Pudota se, ja koodi silti kääntyy (compiles), silti linkittyy (links) ja silti ajaa aina siihen asti, kunnes PDFium kutsuu funktiota. Käytäntö (convention) kuuluu itse funktiotyyppiin. Se ei ole valinnaista sokeria (optional sugar), eikä kääntäjä (compiler) varoita sinua, kun se puuttuu, koska pelkkä funktiotyyppi on täysin laillinen Pascal-tyyppi. Ainoa puolustus on käsitellä kutsukäytäntöä (calling convention) pakollisena kenttänä jokaisessa tuodussa (imported) allekirjoituksessa ja jokaisessa ulospäin välittämässäsi takaisinkutsussa

size_t on osoittimen levyinen (pointer-width), ja FPC Win64:ssä se tarkoittaa 64 bittiä

Toinen vika on kokonaisluvun leveyden (integer-width) epäsuhta (mismatch), joka näkyy vain yhdellä kohteella (target). C:n size_t on määritelty tarpeeksi leveäksi pitämään sisällään minkä tahansa objektin koon, mikä 64-bittisellä alustalla tarkoittaa 64-bittistä etumerkitöntä kokonaislukua. PDFiumin progressiivisen latauksen käyttöliittymät puhuvat size_t tavusiirtyminä (byte offsets). Saatavuuden tarjoajan (availability provider) FX_FILEAVAIL-tietue kantaa IsDataAvail-takaisinkutsua, jota PDFium kutsuu offsetin ja koon kanssa, ja FX_DOWNLOADHINTS-tietueen AddSegment-takaisinkutsu vastaanottaa saman. Molemmat parametrit ovat size_t-tyyppiä

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Jos määrittelet (declare) nuo siirtymät 32-bittisenä tyyppinä, sidos (binding) toimii Win32:lla ja Delphi Win64:llä, ja rikkoutuu sitten hiljaa FPC:llä ja Lazarus Win64:llä. Syy on hienovarainen (subtle). FPC Win64:ssä NativeUInt on aito osoittimen levyinen (pointer-width) 64-bittinen tyyppi, ja size_t on aliasoitu (aliased) siihen. Sidoksella (binding) on tyyppiosiossa (type section) kommentti, joka varoittaa nimenomaan NativeUInt:n varjostamisesta (shadowing) FPC:ssä, koska sen uudelleenmäärittely 32-bittiseksi aliakseksi siellä pakottaisi size_t:n 32 bittiin ja korruptoisi jokaisen kirjastolle välitetyn tai sen kirjoittaman size_t-parametrin. 64-bittinen siirtymä, joka saapuu 32-bittiseen parametriin, menettää yläpuoliskonsa. Pienellä tiedostolla jokainen siirtymä (offset) mahtuu 32 bittiin eikä mikään ole vialla. Suurella tiedostolla heti kun siirtymä ylittää neljän gigatavun rajan, katkaistu arvo (truncated value) osoittaa jonnekin aivan muualle, PDFium kysyy, onko väärä tavualue saatavilla, ja progressiivinen lataus (progressive loading) jumittuu tai lukee roskaa. Vika on näkymätön, kunnes tiedosto on tarpeeksi iso ja kohde (target) on se, missä size_t todella leveni (widened)

Pascal-poikkeus (exception) ei saa koskaan purkautua (unwind) C-kehyksen (C frame) läpi

Kolmas luokka koskee poikkeusmallia (exception model), jota C:llä ei ole. Kun PDFium kutsuu jotakin takaisinkutsuasi, Pascal-koodisi ajetaan C- ja C++-kehysten pinon (stack of frames) sisällä, joka ei tiedä mitään Delphin poikkeuskoneistosta. Jos takaisinkutsusi nostaa poikkeuksen (raises) ja antaa poikkeuksen levitä (propagate), se purkautuu (unwinds) sellaisten kehysten (frames) läpi, joita ei koskaan rakennettu purkautumaan. PDFiumin oma siivous (cleanup) ei aja, sen sisäiset invariantit (internal invariants) jäävät puoliksi päivitetyiksi, ja prosessi on nyt tilassa, jota kirjasto ei koskaan ennakoinut. Näiden takaisinkutsujen sopimus (contract) on paluukoodi (return code), ei poikkeus

Kaksi takaisinkutsua tekee tämän konkreettiseksi. FPDF_FILEWRITE on nielu (sink), johon PDFium kirjoittaa tallennetun asiakirjan, ja FPDF_FILEACCESS on lähde, josta se lukee syöteasiakirjan. Molemmat on toteutettu tässä Delphi TStream -virran (stream) päälle, ja molemmat voivat epäonnistua samalla tavalla kuin mikä tahansa virta epäonnistuu: levy täyttyy, virta suljetaan altasi, luku jatkuu lopun yli. Kirjoitustakaisinkutsu (write callback) käärii (wraps) virtakirjoituksensa (stream write) ja muuttaa minkä tahansa epäonnistumisen PDFiumin epäonnistumiskoodiksi sen sijaan, että antaisi sen karata (escape)

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Lukupuoli tekee saman: epäonnistunut luku (failed read) raportoi nolla täyttääkseen FPDF_FILEACCESS-sopimuksen sen sijaan, että se nostaisi (raising) poikkeuksen rajan (boundary) yli. Paljas except ilman uudelleennostoa (re-raise) näyttää väärältä Pascal-ohjelmoijalle, joka on koulutettu olemaan koskaan nielemättä poikkeuksia, ja tavallisessa Pascalissa se onkin väärin. ABI-rajalla (ABI boundary) se on oikea muoto, koska ainoa turvallinen arvo palauttaa C-kutsujalle (C caller) on tilakoodi (status code), jonka se osaa tulkita. Epäonnistuminen leviää yhä, se tapahtuu vain paluuarvon kautta, ja kutsuva koodi kirjaston yläpuolella tuo sen esiin (surfaces it) EPdfError-virheenä (error) heti, kun hallinta on takaisin aidan Pascal-puolella

Tuplavapautus (double free) piiloutuu virhepolulle (error path)

Neljäs vika on omistajuus (ownership). Kirjasto avaa PDFium-asiakirjakahvan (document handle) ja se on suljettava täsmälleen kerran, FPDF_CloseDocument-kutsulla. Vaarana on virhepolku (error path), joka vapauttaa kahvan (handle), jonka myös toinen siivous (cleanup) omistaa. Kuvittele rutiini, joka luo kääreobjektin (wrapper object), määrittää vastikään avatun asiakirjakahvan sille ja tekee sitten lisää asennuksia (setup), jotka saattavat epäonnistua. Jos asennus heittää (throws), varhaisen palautuksen (early-return) käsittelijä, joka kutsuu FPDF_CloseDocument-metodia raakaan kahvaan (raw handle), sulkee sen, ja sitten kääreobjektin oma tuhoaja (destructor) sulkee sen uudelleen, kun objekti vapautetaan. Kahva vapautetaan kahdesti, mikä on määrittelemätöntä käyttäytymistä (undefined behavior) ja todennäköinen kaatuminen (crash)

Auditointi löysi tämän asemointityyppiseltä (imposition-style) tuontipolulta (import path), joka rakentaa TPdf:n jo avoimen kahvan (handle) ympärille. Korjaus on tehdä omistajuuden siirrosta (ownership transfer) ainoa totuuden lähde (single source of truth). Kun kahva on osoitettu (assigned) kääreen (wrapper) kenttään, kääre omistaa sen, ja ainoa siivous virhepolulla (error path) on kääreen vapauttaminen. Kääreen tuhoaja (destructor) kutsuu FPDF_CloseDocument-metodia puolestasi, joten toinen eksplisiittinen (explicit) sulkeminen tuplavapauttaisi (double-free) saman PDFium-asiakirjan. Korjattu virhekäsittelijä vapauttaa objektin ja nostaa uudelleen (re-raises), ja sulkemiseen on olemassa täsmälleen yksi polku

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Hallitut tietueet (managed records) ja kirjasto täynnä vientejä (exports) tarvitsevat molemmat eksplisiittisen (explicit) purkamisen (teardown)

Viimeinen luokka koskee muistia, jota kääntäjä (compiler) hallinnoi (manages) puolestasi, ja jota C-tapa (C habit) hiljaa korruptoi. Monet tämän sidoksen apufunktiot (helper functions) palauttavat tietueen (record), joka sisältää WideString-merkkijonon tai dynaamisen taulukon. Ne ovat viitelaskettuja (reference-counted) kenttiä, ja kääntäjä lähettää (emits) piilotettua kirjanpitoa ylläpitääkseen niiden laskureita. C:stä siirtynyt vaisto on tyhjentää (clear) tuore tietue (record) komennolla FillChar(Result, SizeOf(Result), 0). Se leimaa nollia tietueen sisällä olevan hallitun viittauksen (managed reference) päälle vähentämättä (decrementing) sitä ensin. Kääntäjä käyttää uudelleen (reuses) yhden piilotetun väliaikaismuuttujan (hidden temporary) funktiotulosta varten yli silmukan iteraatioiden (loop iterations), joten toisella iteraatiolla FillChar ylikirjoittaa (overwrites) elävän merkkijono-osoittimen, jota ei koskaan vapautettu (released), ja merkkijono, johon se osoitti, vuotaa (leaks). Kutsu funktiota silmukassa tuhannen huomautuksen (annotation) yli ja vuodat (leak) tuhat merkkijonoa

Korjaus on antaa kielen tyhjentää (clear) tietue (record) tavalla, jonka se osaa, komennolla Default(T), joka vapauttaa kaikki hallitut kentät (managed field) ennen niiden nollaamista

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Kirjaston latauksen (library-loading) rajalla asuu siihen liittyvä omistajuusongelma. Tämä sidos (binding) ratkaisee (resolves) useita satoja funktio-osoittimia (function pointers) ulos PDFium DLL:stä GetProcAddress-kutsulla LoadLibrary-kutsun jälkeen. Jos yksi vaadittu vienti (export) puuttuu, osittain sidottu tila (partially bound state) on vaarallinen: kymmenet osoittimet ovat kelvollisia, loput ovat nollia (nil) tai vanhentuneita (stale), ja mikä tahansa myöhempi kutsu yhden niistä kautta hyppää moduuliin, joka saattaa jo olla purettu (unloaded). Sidos (binding) käsittelee tämän purkamalla kirjaston (unloading the library) ja suorittamalla täyden ClearAllBindings-kutsun, joka palauttaa jokaisen tuodun osoittimen (imported pointer) takaisin nollaan (nil) aina, kun vaadittu vienti epäonnistuu ratkaisussa (fails to resolve). Sen jälkeen mikään funktio-osoitin ei roiku (dangles) puretussa moduulissa, ja myöhempi kutsu epäonnistuu siististi (cleanly) nolla-osoittimen tarkistuksella (nil-pointer check) sen sijaan, että se haarautuisi vapautettuun koodiin (freed code)

Kääre (wrapper) on paikka, jossa neljä sopimusta toistetaan käsin

Mikään näistä viidestä viasta ei ole eksoottinen. Ne ovat ohuiden Pascal-kerrosten ennustettavia vikatiloja (failure modes) C-API:n päällä, ja ne ryhmittyvät, koska tuo kerros on juuri se paikka, jossa neljä erillistä sopimusta on määriteltävä (re-declared) uudelleen. Kutsukäytäntö (calling convention) on kirjoitettava cdecl jokaiseen takaisinkutsuun (callback). Kokonaisluvun leveyden (integer width) on vastattava size_t-tyyppiä siinä yhdessä kohteessa (target), jossa se todella levenee. Poikkeusmalli (exception model) on muutettava paluukoodeiksi (return codes) jokaisessa takaisinkutsussa, joka ylittää Pascalin rajat. Jokaisen kahvan (handle) ja jokaisen hallitun kentän (managed field) omistajuus on todettava kerran ja sitä on noudatettava jokaisella polulla, mukaan lukien virhepolut (error paths), joita kukaan ei harjoita ennen tuotantoa. Jätä väliin yksikin, ja saat vian, jonka oire ilmenee kaukana sen syystä, mikä tekee tästä kategoriasta kalliin (expensive). Auditoinnin arvo (value) ei ollut niinkään missään yksittäisessä korjauksessa, vaan siinä, että kutakin näistä käsiteltiin omana kurinpitonaan (discipline), joka piti tarkistaa koko sidoksen (binding) poikki

Jos haluat nähdä sidoksen (binding) tekevän todellista työtä sen sijaan, että se vain vartioisi reunojaan (edges), renderöintivälimuisti- (render-cache) ja zoomaustekniikat (zoom techniques) artikkelissa pdfium-component-render-cache-zoom-performance.html, huomautus (note) render-cache- ja zoom-suorituskyvystä, näyttävät renderöintipolun, ja ristiinkääntäjän (cross-compiler) läpikäynti (walkthrough) artikkelissa pdfium-component-lazarus-fpc-viewer.html, Lazaruksen ja FPC-katseluohjelman rakentaminen, on paikka, jossa tässä kuvattu Win64:n size_t-käyttäytyminen todella merkitsee. Molemmat rakentuvat saman muistiturvallisuus- ja ABI-työn varaan, joka toimitetaan PDFium Component -komponentissa Delphiä, Lazarusta ja C++Builderia varten renderöinti-, tekstin poiminta- ja lomake-API:den ohella, joita käsitellään muualla tässä blogissa