Technical Article

PDFium-VCL-sidoksen karkaisu: ABI- ja muistiturvallisuus

Pascal-sidos C-kirjaston päällä näyttää tavalliselta Pascalilta. Kutsut metodia, saat tietueen takaisin, vapautat varaamasi muistin. Ongelmana on, että PDFium on C- ja C++-kirjasto, jolla on oma kutsukäytäntönsä, omat kokonaislukuleveytensä ja omat sääntönsä siitä, kuka omistaa muistin ja kuka vapauttaa sen. Mikään näistä ei ylitä kielirajaa itsestään. Jokainen näistä sopimuksista on määriteltävä käsin Pascal-esittelyissä, ja yksikin väärä sana muuttaa puhtaalta näyttävän kutsun pinon korruptoitumiseksi, katkaistuksi siirtymäksi tai kaksinkertaiseksi vapauttamiseksi. PDFium VCL -sidoksen v1.61.0 auditointi paljasti yhden kunkin tyyppisen vian. Ne ovat läpikäymisen arvoisia, koska ne eivät koske vain tätä sidosta. Ne ovat pysyviä vaaroja kääriessä mitä tahansa C-rajapintaa Delphiin tai Lazarukseen.

cdecl on osa funktiotyyppiä, ei koriste

PDFium on käännettyä C-koodia. Win32-alustalla sen viennit ja, mikä tärkeämpää, sen kutsumat takaisinkutsut käyttävät cdecl-kutsukäytäntöä. cdecl-käytännössä kutsuja siivoaa pinon kutsun palautuessa. Delphin natiivi oletus on register, ja Win32 C-standardi takaisinkutsuille on joissakin kirjastoissa stdcall, jossa kutsuttu taho siivoaa pinon. Kun rakenne välittää PDFiumille funktio-osoittimen ja unohdat cdecl-määritteen kyseisen osoittimen tyypistä, osapuolet ovat eri mieltä siitä, kuka säätää pino-osoitinta. Molemmat korjaavat sen tai ei kumpikaan, ja pino-osoitin siirtyy argumenttien koon verran jokaisella kutsulla.

Syy siihen, miksi tämä vika on vaikea löytää, on se, että vahinko on epälokaali. Korruptoitunut kutsu palautuu ja näyttää hyvältä. Virheellinen kohdistus näkyy myöhemmin jossakin täysin liittymättömässä funktiossa, jonka kehys sijaitsee nyt muutaman tavun verran pielessä olevassa pino-osoittimessa, ja se ilmenee virheellisenä lukuna, virheellisenä paluuosoitteena tai kaatumisena, jonka pinonjäljitys ei osoita lähellekään sitä takaisinkutsukutsua, jonka todella teit väärin. Lomakkeiden täyttö on klassinen paikka, jossa tämä iskee, koska lomakkeiden täyttörajapinta on tietue täynnä takaisinkutsuja, joita PDFium kutsuu. Yksi niistä, FFI_OpenFile, välittää PDFiumille funktion, jota se kutsuu ulkoisen tiedoston avaamiseksi, esiteltynä muodossa function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Lopussa oleva cdecl on kopioimisen arvoinen kohta. Jätä se pois, ja koodi kääntyy silti, linkittyy silti ja toimii aina siihen asti, kunnes PDFium kutsuu funktiota. Kutsukäytäntö kuuluu itse funktiotyyppiin. Se ei ole valinnainen koriste, eikä kääntäjä varoita sen puuttumisesta, koska tavallinen funktiotyyppi on täysin laillinen Pascal-tyyppi. Ainoa puolustus on käsitellä kutsukäytäntöä pakollisena kenttänä jokaisessa tuodussa allekirjoituksessa ja jokaisessa ulospäin välitettävässä takaisinkutsussa.

size_t on osoittimen levyinen, ja FPC Win64 -alustalla se tarkoittaa 64 bittiä

Toinen vika on kokonaisluvun leveyden yhteensopimattomuus, joka esiintyy vain yhdellä kohdealustalla. C-kielen size_t on määritelty tarpeeksi leveäksi pitämään sisällään minkä tahansa olion koon, mikä 64-bittisellä alustalla tarkoittaa 64-bittistä etumerkitöntä kokonaislukua. PDFiumin progressiivisen latauksen rajapinnat käyttävät size_t-tavusiirtymiä. Saatavuuden tarjoajan FX_FILEAVAIL-tietue sisältää IsDataAvail-takaisinkutsun, jota PDFium kutsuu siirtymällä ja koolla, ja FX_DOWNLOADHINTS-tietueen AddSegment-takaisinkutsu vastaanottaa saman. Molemmat parametrit ovat tyyppiä size_t.

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

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

Jos esittelet nämä siirtymät 32-bittisenä tyyppinä, sidos toimii Win32- ja Delphi Win64 -alustoilla, mutta rikkoutuu hiljaisesti FPC- ja Lazarus Win64 -alustoilla. Syy on hienovaraisuus. FPC Win64 -alustalla NativeUInt on aito osoittimen levyinen 64-bittinen tyyppi, ja size_t on sen alias. Sidoksessa on tyyppiosiossa kommentti, joka varoittaa nimenomaan varjostamasta NativeUInt-tyyppiä FPC:ssä, koska sen määritteleminen uudelleen 32-bittiseksi aliakseksi pakottaisi size_t-tyypin 32-bittiseksi ja korruptoisi jokaisen kirjastolle välitetyn tai sen kirjoittaman size_t-parametrin. 32-bittiseen parametriin saapuva 64-bittinen siirtymä menettää yläpuoliskonsa. Pienellä tiedostolla jokainen siirtymä mahtuu 32 bittiin ja mikään ei ole vialla. Suurella tiedostolla heti, kun siirtymä ylittää neljän gigatavun rajan, katkaistu arvo osoittaa aivan muualle, PDFium kysyy, onko väärä tavualue saatavilla, ja progressiivinen lataus pysähtyy tai lukee roskatietoa. Vika on näkymätön, kunnes tiedosto on tarpeeksi suuri ja kohdealusta on se, jossa size_t todella laajeni.

Pascal-poikkeus ei saa koskaan purkautua C-kehyksen läpi

Kolmas luokka koskee poikkeusmallia, jota C-kielellä ei ole. Kun PDFium kutsuu jotakin takaisinkutsuistasi, Pascal-koodisi suoritetaan C- ja C++-kehysten pinossa, joka ei tiedä mitään Delphin poikkeusmekanismista. Jos takaisinkutsusi nostaa poikkeuksen ja antaa sen levitä, se purkaa pinon kehysten läpi, joita ei koskaan rakennettu purettaviksi. PDFiumin oma siivous ei ajaudu, sen sisäiset invariantit jäävät puoliksi päivitetyiksi, ja prosessi on nyt tilassa, jota kirjasto ei koskaan ennakoinut. Sopimus näille takaisinkutsuille on paluukoodi, ei poikkeus.

Kaksi takaisinkutsua tekee tästä konkreettisen. FPDF_FILEWRITE on nielu, johon PDFium kirjoittaa tallennetun dokumentin, ja FPDF_FILEACCESS on lähde, josta se lukee syötedokumentin. Molemmat on toteutettu tässä Delphi-luokan TStream päälle, ja molemmat voivat epäonnistua samalla tavalla kuin mikä tahansa virta epäonnistuu: levy täyttyy, virta suljetaan allasi, lukutoiminto ylittää lopun. Kirjoitustakaisinkutsu käärii virtakirjoituksen ja muuttaa minkä tahansa epäonnistumisen PDFiumin virhekoodiksi sen sijaan, että se antaisi sen karata.

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 ilmoittaa nollasta vastaamaan FPDF_FILEACCESS-sopimusta sen sijaan, että se nostaisi poikkeuksen rajan yli. Paljas except ilman uudelleennostoa näyttää väärältä Pascal-ohjelmoijalle, joka on koulutettu olemaan koskaan nielemättä poikkeuksia, ja tavallisessa Pascalissa se on väärin. ABI-rajapinnalla se on oikea muoto, koska ainoa turvallinen arvo välitettäväksi takaisin C-kutsujalle on tilakoodi, jonka se osaa tulkita. Epäonnistuminen etenee silti, mutta vain paluuarvon kautta, ja kirjaston yläpuolella oleva kutsuva koodi tuo sen pintaan nimellä EPdfError, kun hallinta on palannut aidan Pascal-puolelle.

Kaksinkertainen vapauttaminen piileskelee virhepolulla

Neljäs vika koskee omistajuutta. Kirjasto avaa PDFium-dokumenttikahvan, ja se on suljettava täsmälleen kerran funktiolla FPDF_CloseDocument. Vaarana on virhepolku, joka vapauttaa kahvan, jonka myös toinen siivousrutiini omistaa. Kuvittele rutiinia, joka luo käärioliot, määrittää sille vasta avatun dokumenttikahvan ja tekee sitten lisää asetuksia, jotka voivat epäonnistua. Jos asennus heittää poikkeuksen, aikaisen paluun käsittelijä, joka kutsuu FPDF_CloseDocument-funktiota raakahahmolle, sulkee sen, ja sitten kääriolion oma tuhoaja sulkee sen uudelleen, kun olio vapautetaan. Kahva vapautetaan kahdesti, mikä on määrittelemätöntä käyttäytymistä ja todennäköinen kaatuminen.

Auditointi löysi tämän asemointityylistä tuontipolusta, joka rakentaa TPdf-olion jo auki olevan kahvan ympärille. Korjaus on tehdä omistajuuden siirrosta ainoa totuuden lähde. Kun kahva on määritetty kääreen kenttään, kääre omistaa sen, ja ainoa siivous virhepolulla on kääreen vapauttaminen. Kääreen tuhoaja kutsuu FPDF_CloseDocument-funktiota puolestasi, joten toinen nimenomainen sulkeminen vapauttaisi saman dokumentin kahdesti. Korjattu virheenkäsittelijä vapauttaa olion ja nostaa poikkeuksen uudelleen, ja sulkemiseen on 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;

Hallinnoidut tietueet ja kirjasto täynnä vientejä vaativat molemmat nimenomaisen purkamisen

Viimeinen luokka koskee muistia, jota kääntäjä hallitsee puolestasi ja jonka C-kielen tapa hiljaisesti korruptoi. Monet tämän sidoksen apufunktioista palauttavat tietueen, joka sisältää WideString-tyypin tai dynaamisen taulukon. Nämä ovat viitelaskettuja kenttiä, ja kääntäjä tuottaa piilotettua kirjanpitoa niiden määrien ylläpitämiseksi. C-kielestä siirtynyt vaisto on tyhjentää uusi tietue komennolla FillChar(Result, SizeOf(Result), 0). Se leimaa nollia tietueen sisällä olevan hallinnoidun viitteen päälle vähentämättä sitä ensin. Kääntäjä käyttää uudelleen yhtä piilotettua väliaikaismuuttujaa funktion tulokselle silmukan iteraatioiden yli, joten toisella iteraatiolla FillChar kirjoittaa yli elävän merkkijono-osoittimen, jota ei koskaan vapautettu, ja merkkijono, johon se osoitti, vuotaa. Kutsu funktiota silmukassa tuhannen annotaation yli, ja vuodat tuhat merkkijonoa.

Korjaus on anna kielen tyhjentää tietue tavalla, jonka se osaa, eli Default(T)-funktiolla, joka vapauttaa minkä tahansa hallinnoidun kentän ennen sen 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);

Tähän liittyvä omistajuusongelma elää kirjaston latausrajalla. Tämä sidos selvittää useita satoja funktio-osoittimia PDFium-DLL-tiedostosta GetProcAddress-funktiolla LoadLibrary-kutsun jälkeen. Jos yksi vaadittu vienti puuttuu, osittain sidottu tila on vaarallinen: kymmenet osoittimet ovat kelvollisia, loput ovat nil tai vanhentuneita, ja mikä tahansa myöhempi kutsu jonkin niistä kautta hyppää moduuliin, joka saattaa jo olla poistettu muistista. Sidos käsittelee tämän poistamalla kirjaston muistista ja ajamalla täyden ClearAllBindings-rutiinin, joka palauttaa jokaisen tuodun osoittimen takaisin nil-arvoksi aina, kun vaadittu vienti epäonnistuu. Tämän jälkeen mikään funktio-osoitin ei roiku muistista poistetussa moduulissa, ja myöhempi kutsu epäonnistuu puhtaasti nil-osoittimen tarkistukseen sen sijaan, että se haarautuisi vapautettuun koodiin.

Kääre on paikka, jossa neljä sopimusta määritellään käsin uudelleen

Mikään näistä viidestä viasta ei ole eksoottinen. Ne ovat C-rajapinnan päällä olevan ohuen Pascal-kerroksen ennustettavia epäonnistumistiloja, ja ne ryhmittyvät, koska kyseinen kerros on juuri se paikka, jossa neljä erillistä sopimusta on määriteltävä uudelleen. Kutsukäytännön on oltava cdecl jokaisessa takaisinkutsussa. Kokonaisluvun leveyden on vastattava size_t-tyyppiä sillä yhdellä kohdealustalla, jossa se todella laajenee. Poikkeusmalli on muutettava paluukoodeiksi jokaisessa takaisinkutsussa, joka ylittää Pascal-rajan. Jokaisen kahvan ja hallinnoidun kentän omistajuus on määriteltävä kerran ja sitä on noudatettava jokaisella polulla, mukaan lukien ne virhepolut, joita kukaan ei kokeile ennen tuotantoa. Jos unohdat minkä tahansa näistä, saat vian, jonka oire näkyy kaukana sen syystä, mikä tekee tästä kategoriasta kalliin. Auditoinnin arvo ei ollut niinkään yksittäisissä korjauksissa, vaan kunkin näistä käsittelemisessä omana kurinalaisena tarkistuksena koko sidoksen laajuudella.

If you want to see the binding doing real work rather than guarding its edges, the render-cache and zoom techniques in our note on render-cache and zoom performance show the rendering path, and the cross-compiler walkthrough in building a Lazarus and FPC viewer is the place the Win64 size_t behavior described here actually matters. Both build on the same memory-safety and ABI work that ships in the PDFium Component for Delphi, Lazarus, and C++Builder, alongside the rendering, text-extraction, and form APIs covered elsewhere on this blog.