Tekninen artikkeli

ZIP EOCD -validointi epäluotetuille XLSX-tiedostoille Delphissä

Xlsx-tiedosto on ZIP-arkisto, eikä ZIP:llä ole yhtä auktoritatiivista sisällysluetteloa. HotXLS Excel Library Delphille ja C++Builderille kohtelee tuota epäselvyyttä hyökkäyspintana: sen keskushakemiston loppuosan jäsentäjä hyväksyy ehdokastietueen vasta, kun neljä itsenäistä ristiintarkistusta ovat samaa mieltä, joten ZIP-kommenttiin piilotettu väärennetty hakemisto ei koskaan voita

Skenaario, joka tekee tästä konkreettisen, on arkinen. Palvelin hyväksyy taulukkolaskentalatauksia asiakkailta. Tiedosto läpäisee virustorjuntaskannauksen, kirjoitetaan spool-hakemistoon, ja Delphi-palvelusi avaa sen poimiakseen kolme saraketta. Kaikki näyttää kunnossa olevan, paitsi että skanneri ja jäsentäjäsi eivät olleet samaa mieltä siitä, mitä arkisto sisälsi. Skanneri luetteloi yhden joukon jäseniä; latauksesi luetteloi eri joukon samoista tavuista. Kumpikaan niistä ei ole viallinen tavallisessa mielessä. Ne vain ratkaisivat epäselvyyden ZIP-muodossa kahteen eri suuntaan, ja hyökkääjä valitsi tavut niin, että näin kävisi

Missä totuus ZIP-arkistosta oikeasti asuu?

Se asuu aivan lopussa, 22-tavuisessa rakenteessa nimeltä keskushakemiston loppuosan tietue. ZIP-tiedostoa ei lueta alusta loppuun: jokainen jäsen kantaa paikallisen tiedosto-otsikon välittömästi ennen pakattua dataansa, mutta auktoritatiivinen hakemisto on keskushakemisto, joukko tietueita lähellä loppua, joka nimeää jokaisen merkinnän ja antaa sen paikallisen otsikon siirtymän. Löytääksesi keskushakemiston sinun täytyy ensin löytää EOCD, koska EOCD on se, mikä sanoo, mistä hakemisto alkaa ja kuinka monta tietuetta se pitää sisällään. HotXLS mallintaa sen TEndOfCentralDirectoryRecord:na, jonka kentät kuvautuvat yksi yhteen levyllä olevaan asetteluun: FDiskNumber siirtymässä 4, FStartDisk siirtymässä 6, FThisDiskEntries siirtymässä 8, FTotalEntries siirtymässä 10, FSizeOfCD siirtymässä 12, FOffsetOfStartCD siirtymässä 16, ja FCommentLen siirtymässä 20. Tuo summa on FMinSize, laskettu konstruktorissa muodossa 4*3 + 5*2. Sen jälkeen tulee arkiston kommentti, jopa 65535 tavua mielivaltaista sisältöä, mikä tekee FMaxSize:sta 65557 ja tarkoittaa, ettei tietue ole kiinteässä positiossa. Sitä täytyy lähteä etsimään

Miksi taaksepäin skannaaminen EOCD-allekirjoitusta varten ei riitä?

Koska neljä tavua, joita skannaat, PK\005\006, voivat laillisesti esiintyä arkistokommentin sisällä, pakatun datan sisällä, tai toisessa EOCD:ssä, jonka hyökkääjä liitti tarkoituksella. Jäsentäjä, joka pysähtyy ensimmäiseen allekirjoitukseen, jonka se kohtaa kävellessään taaksepäin, on triviaalisti ohjattavissa: sijoita houkutin-EOCD lähelle häntää, ja naiivi jäsentäjä seuraa sitä, kun taas jäsentäjä, joka skannaa eri järjestyksessä, tai joka kohtelee tiedoston viimeistä allekirjoitusta kanonisena, seuraa oikeaa. Tämä on ZIP-epäselvyyshyökkäysten perhe, ja sen tuotto on täsmälleen yllä kuvattu jako, jossa skannausmoottori ja kuluttava sovellus näkevät eri merkintäjoukot yhdestä tiedostosta

TEndOfCentralDirectoryRecord.Parse todella skannaa taaksepäin. Se asettaa startscan:in viimeiseen tavuun, rajaa endscan:in arvoon lsize - FMaxSize tai nollaan, ja kävelee ikkunan läpi 256-tavuisilla puskureilla, jotka menevät päällekkäin kolmella tavulla, joten allekirjoitus, joka hajoaa puskurin rajan yli, ei koskaan jää huomaamatta. Ero on siinä, mitä tapahtuu osumassa. Allekirjoituksen löytäminen tuottaa vain Candidate-siirtymän. HotXLS lukee sitten 22 tavua tuosta siirtymästä, jäsentää ne ReadEOCD:llä ja vaatii tulosten kenttien olevan sisäisesti johdonmukaisia tiedoston kanssa, jota ne väittävät kuvaavansa, ennen kuin FOffsetEOCD edes asetetaan

Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
  SetLength(RecordBuf, FMinSize);
  inputstream.Position := Candidate;
  if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
  begin
    ReadEOCD(RecordBuf[0], 0);
    if (Candidate + FMinSize + FCommentLen = lsize) and
       (FDiskNumber = 0) and (FStartDisk = 0) and
       (FThisDiskEntries = FTotalEntries) and
       (Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
    begin
      FOffsetEOCD := Candidate;
      Result := FOffsetEOCD;
      Exit;
    end;
  end;
end;

Lue predikaatti neljänä erillisenä väitteenä, jotka väärennöksen täytyy täyttää samanaikaisesti. Candidate + FMinSize + FCommentLen = lsize vaatii, että ilmoitettu kommentin pituus ulottuu täsmälleen tiedoston loppuun, mikä on se, mikä tappaa houkutin-kommentissa-tempun: kommentin sisään haudattu väärä EOCD ei voi myös selittää jokaista tavua itsensä jälkeen. FDiskNumber = 0 ja FStartDisk = 0 hylkäävät monilevylaajennuskentät, joita mikään xlsx ei ole koskaan laillisesti käyttänyt ja jotka esiintyvät käsintehdyissä arkistoissa vain hämmentääkseen. FThisDiskEntries = FTotalEntries hylkää jaetun-lukumäärän-tempun, jossa yksi jäsentäjä mitoittaa silmukkansa yhdestä kentästä ja toinen jäsentäjä toisesta. Ja Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate vaatii keskushakemiston päättyvän täsmälleen siihen, mistä EOCD alkaa, joten hakemistoa ei voi osoittaa johonkin toisaalla tiedostossa olevaan liittymättömään blobiin. Int64-tyyppimuunnos viimeisessä on merkityksellinen: molemmat operandit ovat 32-bittisiä, ja ilman laajennusta käsintehty pari voisi kiertyä ympäri ja täyttää testin aritmeettisesti osoittaen ei-mihinkään järkevään

Paikallisten otsikoiden täytyy täsmätä keskushakemiston kanssa

EOCD-tarkistukset kiinnittävät, mikä hakemisto on auktoritatiivinen; ne eivät vielä takaa, että hakemisto kertoo totuuden yksittäisistä jäsenistä. Jokainen merkintä kuvataan kahdesti ZIP-tiedostossa, kerran keskeisesti ja kerran paikallisessa otsikossaan, eikä mikään muodossa pakota kahta kuvausta täsmäämään, joten lukija, joka luottaa keskushakemistoon, ja lukija, joka luottaa paikallisiin otsikoihin, voivat erottaa eri sisällön yhdestä arkistosta. TZipEntry.ParseLocalHeader sulkee tuon aukon jäsentämällä paikallisen otsikon kohdassa FCdFile.LocalFileHeaderOffset ja vertaamalla kahta kopiota kenttä kentältä, palauttaen erillisen negatiivisen koodin jokaiselle erimielisyystyypille: kanonisoidun merkinnän nimen, pakkausmenetelmän, yleiskäyttöiset bittiliput, ja, kun datankuvauslippu on tyhjä, CRC32:n ja molemmat koot. Kun tuo lippu on asetettu, paikalliset kopiot voivat olla nollia, koska todelliset arvot asuvat perässä tulevassa kuvauksessa, mutta minkä tahansa nollasta poikkeavan paikallisen arvon täytyy silti täsmätä. Lopullinen tarkistus hylkää merkinnät, joiden data ulottuisi tiedoston lopun yli, verraten Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize):ta inputstream.Size:ä vasten. Mikä tahansa epäonnistuminen leviää ulos TCentralDirectory.Parse:sta ei-1-tuloksena, ja TZipArchive.OpenArchive muuttaa sen viestiksi Can't open zip archive, sen sijaan että antaisi sinulle puoliksi luotetun arkisto-objektin. Kun tarvitset vain tietää, mitä arkkeja tiedosto sisältää, tuon validoinnin ajaminen ennen täyttä jäsennystä on halpaa, ja kevyt arkkitarkastuspolku antaa sinulle täsmälleen sen materialisoimatta solutietoja

Mitä tapahtuu, kun itse tavut valehtelevat?

Rakenteellinen yhteisymmärrys ei silti kerro mitään hyötykuormasta, joten HotXLS käärii jokaisen merkintävirran TZipVerifiedStream:iin, joka pakottaa ilmoitetun koon ja CRC32:n samalla kun kutsuja lukee. Tämä ei ole tarkoituksella jälkikäteinen tarkistus: purkupommi, jonka ilmoitettu purettu koko on 4 kilotavua mutta joka paisuu gigatavuiksi, pysäytetään 4 kilotavun kohdalla, ei vahingon jälkeen. Kääre rajaa jokaisen luvun jäljellä oleviin ilmoitettuihin tavuihin, nostaa poikkeuksen ZIP entry ended before its declared size, jos lähde loppuu kesken aikaisin, luotaa yhtä ylimääräistä tavua valmistuessaan ja nostaa poikkeuksen ZIP entry exceeds its declared size, jos jotain on jäljellä, ja vertaa lopulta juoksevaa CRC32:ta VerifyComplete:ssa, nostaen poikkeuksen ZIP entry uncompressed size mismatch tai ZIP entry CRC32 mismatch

if Count > 0 then
begin
  Result := FSource.Read(Buffer, Count);
  if Result <= 0 then
    raise Exception.Create('ZIP entry ended before its declared size');
  FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
  Inc(FPosition, Result);
end
else
  Result := 0;

if FPosition = FExpectedSize then
begin
  if FSource.Read(Probe, 1) <> 0 then
    raise Exception.Create('ZIP entry exceeds its declared size');
  VerifyComplete;
end;

Yksi seuraus kannattaa suunnitella etukäteen. Virta on suunnittelultaan vain eteenpäin; Seek mihinkään muuhun kuin nykyiseen positioon nostaa poikkeuksen ZIP entry stream is forward-only, yhdellä myönnytyksellä soEnd:lle nollasiirtymällä, jotta kokokyselyt silti toimivat. Se on oikea kompromissi epäluotetulle syötteelle, koska virta, jonka voi kelata takaisin, on virta, jonka CRC-kirjanpidon voi voittaa, mutta se tarkoittaa, että kuluttajakoodi, joka odottaa haettavaa virtaa, tarvitsee oman puskurinsa. Sama vain-eteenpäin-kuri tukee suoratoistavaa suoraa lukijaa, joka on API, jota käyttää silloin, kun ladattu työkirja on tarpeeksi suuri, ettet halua sitä lainkaan muistissa

Resurssirajat ennen varausta, ei sen jälkeen

Kolme vakiota tiedostossa lxZipArchive rajaavat, mitä yksi arkisto voi pyytää prosessia tekemään, ja TZipEntries.Add soveltaa niitä samalla kun keskushakemistoa vielä luetaan, ennen kuin yhtäkään tavua merkintädatasta on kosketettu. ZipMaxEntryUncompressedSize rajaa yhden jäsenen 1 GiB:iin, ZipMaxTotalUncompressedSize rajaa arkiston 4 GiB:iin, ja ZipMaxCompressionRatio, joka on 10000, hylkää minkä tahansa deflate-pakatun merkinnän, jonka ilmoitettu laajennus ylittää kymmenentuhatkertaisen, sekä rappeutuneen tapauksen, jossa nollasta poikkeava purettu koko on parina nollapituisen pakatun koon kanssa. Merkintöjen nimet kulkevat CanonicalZipEntryName:n läpi samassa kutsussa, joka hylkää upotetut NUL-merkit, kaksoispisteet ja minkä tahansa ..-polkusegmentin viestillä Invalid ZIP entry name, ja joka pienentää ja normalisoi segmentit, jotta kaksi merkintää, jotka eroavat vain kirjainkoossa tai redundanteissa erottimissa, törmäävät viestinä Duplicate ZIP entry name sen sijaan, että ne hiljaa varjostaisivat toisiaan

Syvä puolustus ZIP-kerroksen yläpuolella

ZIP-kerros on yksi useista tasoista, ja kuvio toistuu kaikkialla, missä HotXLS jäsentää hyökkääjän hallitsemaa rakennetta. Selvin esimerkki istuu BIFF-kaavajäsentäjässä: TXLSFormula.GetTranslated rekursoi tMemFunc-tokenien läpi, joten käsintehty rgce-tokenivirta vanhassa .xls-tiedostossa voi sisäkkäistyä mielivaltaisen syvälle ja kuluttaa pinon loppuun. Portti on vakio, MaxTranslateDepth = 256, valittu tunnetun ylätason faktan pohjalta eikä arvattu. Excel rajaa kaavan sisäkkäistyksen 64:ään, joten 256 jättää nelinkertaisen liikkumavaran eikä voi koskaan hylätä kaavaa, jonka todellinen taulukkolaskenta tuotti, samalla kun se silti lopettaa haitallisen virran riittävän kauan ennen kuin pino loppuu kesken

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

Huomaa, että portti palauttaa nil:n eikä nosta poikkeusta. Kaava, joka on liian syvä ollakseen aito, ei tuota syntaksipuuta, ympäröivä jäsennys jatkuu, ja työkirja silti latautuu. Tuo epäsymmetria on tarkoituksellinen ja kannattaa omaksua omissa rajoissasi: rajan, joka on olemassa pysäyttämään resurssien loppumisen, pitäisi rappeuttaa pienin mahdollinen yksikkö, ei keskeyttää asiakirjaa. Sama päättely pätee, kun laajennat laskentakerrosta, joten jos rekisteröit omat käsittelijäsi kaavamoottorin mukautetun funktio-API:n kautta, anna niille omat argumentti- ja rekursiorajat sen sijaan, että olettaisit kutsujan jo tarkistaneen

Mitä nämä tarkistukset eivät osta sinulle

Ole täsmällinen rajasta. Neljä EOCD-ristiintarkistusta tekevät arkiston hakemistosta yksiselitteisen, joten HotXLS ja mikä tahansa muu yhdenmukainen lukija ratkaisevat saman tiedoston samaan merkintäjoukkoon; ne eivät kerro mitään siitä, onko tuo merkintäjoukko harmiton. Paikallisen otsikon yhteensopivuus pysäyttää kahden-näkymän-tempun, ei haitallista hyötykuormaa, joka on johdonmukaisesti kuvattu. Varmennettu virta pysäyttää katkaisun, ylivuodon ja korruption, ei täydellisen hyvin muodostetun XML-osan, joka koodaa jotain, mitä et odottanut. Eikä mikään tästä kosketa makroja: rakenteellisesti moitteettoman työkirjan sisällä oleva VBA-projekti on silti VBA-projekti, ja päätös säilyttää, riisua tai hylätä se kuuluu käytäntökerrokseesi, ei ZIP-lukijalle

Se, minkä saat vastineeksi, on puhdas epäonnistumisraja. Epäluotettu xlsx joko avautuu yhtenä yksiselitteisenä arkistona, jonka jäsenet täsmäävät ilmoitettuihin kokoihinsa ja tarkistussummiinsa, tai se nostaa poikkeuksen viestillä, joka nimeää tietyn invariantin, jonka se rikkoi, ja palvelusi voi karanteenisoida poikkeuksen perusteella arvaamisen sijaan. ZIP-lukija ja sen yläpuolella olevat jäsentäjätasot toimitetaan osana HotXLS Excel Componenttia Delphille ja C++Builderille, joka ei tarvitse Exceliä eikä OLE-automaatiota jäsentävällä koneella, ja tuo puuttuminen on itsessään merkittävä pienennys siihen, mitä ladattu tiedosto voi saavuttaa