Súbor xlsx je ZIP archív, a ZIP nemá jediný autoritatívny obsah. HotXLS Excel Library pre Delphi a C++Builder túto nejednoznačnosť chápe ako útočnú plochu: jeho parser end of central directory prijme kandidátny záznam iba potom, čo sa zhodnú štyri nezávislé krížové kontroly, takže sfalšovaný adresár skrytý v ZIP komentári nikdy nevyhrá
Scenár, ktorý to robí konkrétnym, je banálny. Server prijíma uploady tabuliek od zákazníkov. Súbor prejde antivírusovým skenom, zapíše sa do spool adresára, a vaša Delphi služba ho otvorí, aby z neho vytiahla tri stĺpce. Všetko vyzerá v poriadku, až na to, že skener a váš parser sa nezhodli na tom, čo archív obsahoval. Skener vymenoval jednu množinu členov; váš loader vymenoval inú množinu z tých istých bajtov. Ani jeden z nich nemá chybu v bežnom zmysle. Jednoducho vyriešili nejednoznačnosť v ZIP formáte dvoma rôznymi smermi, a útočník zvolil bajty tak, aby to tak dopadlo
Kde skutočne žije pravda o ZIP archíve?
Žije na samom konci, v 22-bajtovej štruktúre zvanej end of central directory record. ZIP súbor sa nečíta odpredu dozadu: každý člen nesie lokálnu hlavičku súboru bezprostredne pred svojimi komprimovanými dátami, ale autoritatívnym indexom je centrálny adresár, sekvencia záznamov blízko konca, ktorá pomenúva každú položku a udáva offset jej lokálnej hlavičky. Aby ste našli centrálny adresár, musíte najprv nájsť EOCD, pretože EOCD je to, čo hovorí, kde adresár začína a koľko záznamov obsahuje. HotXLS ho modeluje ako TEndOfCentralDirectoryRecord, ktorého polia sa mapujú jedna k jednej na rozloženie na disku: FDiskNumber na offsete 4, FStartDisk na 6, FThisDiskEntries na 8, FTotalEntries na 10, FSizeOfCD na 12, FOffsetOfStartCD na 16, a FCommentLen na 20. Tento súčet je FMinSize, vypočítaný v konštruktore ako 4*3 + 5*2. Po ňom nasleduje komentár archívu, až 65535 bajtov ľubovoľného obsahu, čo robí FMaxSize 65557 a znamená, že záznam nie je na pevnej pozícii. Musíte ho ísť hľadať
Prečo nestačí skenovať dozadu na signatúru EOCD?
Pretože štyri bajty, ktoré hľadáte, PK\005\006, sa môžu legálne objaviť vnútri komentára archívu, vnútri komprimovaných dát, alebo vnútri druhého EOCD, ktorý útočník zámerne pripojil. Parser, ktorý sa zastaví pri prvej signatúre, na ktorú narazí pri prechode dozadu, je triviálne ovládateľný: umiestnite návnadu EOCD blízko konca a naivný parser ju nasleduje, zatiaľ čo parser, ktorý skenuje v inom poradí, alebo ktorý považuje poslednú signatúru v súbore za kanonickú, nasleduje tú skutočnú. Toto je rodina útokov na nejednoznačnosť ZIP, a jej odmenou je presne rozdelenie opísané vyššie, kde skenovací engine a spotrebúvajúca aplikácia vidia z jedného súboru odlišné množiny položiek
TEndOfCentralDirectoryRecord.Parse skutočne skenuje dozadu. Nastaví startscan na posledný bajt, oreže endscan na lsize - FMaxSize alebo nulu, a prechádza oknom v 256-bajtových bufferoch, ktoré sa prekrývajú o tri bajty, takže signatúra siahajúca cez hranicu buffera sa nikdy neprehliadne. Rozdiel je v tom, čo sa stane pri zásahu. Nájdenie signatúry vyprodukuje iba kandidátny offset. HotXLS potom prečíta 22 bajtov na tomto offsete, spracuje ich pomocou ReadEOCD, a vyžaduje, aby výsledné polia boli vnútorne konzistentné so súborom, ktorý tvrdia, že opisujú, skôr, než sa FOffsetEOCD vôbec priradí
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;
Čítajte predikát ako štyri samostatné tvrdenia, ktoré musí falzifikát splniť súčasne. Candidate + FMinSize + FCommentLen = lsize vyžaduje, aby deklarovaná dĺžka komentára siahala presne po koniec súboru, čo je to, čo zabíja trik s návnadou vo vnútri komentára: falošný EOCD zakopaný vnútri skutočného komentára nemôže zároveň vysvetliť každý bajt za sebou. FDiskNumber = 0 a FStartDisk = 0 odmietajú polia rozdelenia na viac diskov, ktoré žiadny xlsx nikdy legitímne nepoužil a ktoré v upravených archívoch existujú iba na zmätenie. FThisDiskEntries = FTotalEntries odmieta trik s rozdeleným počtom, kde jeden parser rozmeria svoju slučku podľa jedného poľa a iný podľa druhého. A Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate vyžaduje, aby centrálny adresár končil presne tam, kde EOCD začína, takže adresár nemôže ukazovať na nejaký nesúvisiaci blob inde v súbore. Kastovanie na Int64 pri tom poslednom je dôležité: oba operandy sú 32-bitové, a bez rozšírenia by upravená dvojica mohla pretiecť a aritmeticky splniť test, pričom by ukazovala nikam rozumne
Lokálne hlavičky sa musia zhodovať s centrálnym adresárom
Kontroly EOCD určujú, ktorý adresár je autoritatívny; ešte negarantujú, že adresár hovorí pravdu o jednotlivých členoch. Každá položka je v ZIP súbore opísaná dvakrát, raz centrálne a raz vo svojej lokálnej hlavičke, a nič vo formáte nenúti tieto dva popisy, aby sa zhodovali, takže čítačka, ktorá dôveruje centrálnemu adresáru, a čítačka, ktorá dôveruje lokálnym hlavičkám, môžu z jedného archívu extrahovať odlišný obsah. TZipEntry.ParseLocalHeader túto medzeru uzatvára tým, že spracuje lokálnu hlavičku na FCdFile.LocalFileHeaderOffset a porovná obe kópie pole po poli, vracajúc odlišný záporný kód pre každý druh nezhody: kanonizovaný názov položky, kompresnú metódu, bity všeobecných príznakov, a keď je príznak deskriptora dát vypnutý, CRC32 a obe veľkosti. S týmto príznakom zapnutým môžu byť lokálne kópie nulové, keďže skutočné hodnoty žijú v koncovom deskriptore, ale akákoľvek nenulová lokálna hodnota sa stále musí zhodovať. Posledná kontrola odmietne položky, ktorých dáta by presiahli koniec súboru, porovnaním Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) voči inputstream.Size. Akékoľvek zlyhanie sa šíri von z TCentralDirectory.Parse ako výsledok rôzny od 1, a TZipArchive.OpenArchive ho premení na Can't open zip archive, namiesto toho, aby vám odovzdal napoly dôveryhodný objekt archívu. Keď potrebujete iba vedieť, ktoré hárky súbor obsahuje, spustenie tejto validácie pred plným parsovaním je lacné, a ľahká cesta inšpekcie hárkov vám to poskytne presne, bez materializácie dát buniek
Čo sa stane, keď samotné bajty klamú?
Štrukturálna zhoda stále nič nehovorí o payloade, takže HotXLS obaľuje každý stream položky do TZipVerifiedStream, ktorý vynucuje deklarovanú veľkosť a CRC32 počas toho, ako volajúci číta. Toto zámerne nie je dodatočná kontrola: dekompresná bomba, ktorej deklarovaná nekomprimovaná veľkosť je 4 KB, ale ktorá sa rozbaľuje na gigabajty, sa zastaví na hranici 4 KB, nie po škode. Wrapper oreže každé čítanie na zostávajúce deklarované bajty, vyvolá ZIP entry ended before its declared size, ak zdroj vyschne skôr, vyskúša jeden extra bajt po dokončení a vyvolá ZIP entry exceeds its declared size, ak niečo zostáva, a nakoniec porovná bežiace CRC32 v VerifyComplete, vyvolávajúc ZIP entry uncompressed size mismatch alebo 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;
Jeden dôsledok stojí za naplánovanie. Stream je zámerne iba dopredný; Seek kamkoľvek inam než na aktuálnu pozíciu vyvolá ZIP entry stream is forward-only, s jedinou výnimkou pre soEnd s offsetom nula, aby stále fungovali dotazy na veľkosť. To je správny kompromis pre nedôveryhodný vstup, pretože stream, ktorý viete pretočiť späť, je stream, ktorého CRC účtovníctvo viete poraziť, ale znamená to, že spotrebiteľský kód, ktorý očakáva stream s podporou seek, potrebuje vlastný buffer. Rovnaká iba dopredná disciplína je základom streamovacieho priameho čítača, čo je API, po ktorom siahnete, keď je uploadovaný zošit dosť veľký na to, že ho vôbec nechcete mať rezidentný v pamäti
Limity zdrojov pred alokáciou, nie po nej
Tri konštanty v lxZipArchive ohraničujú, čo môže jeden archív požiadať proces urobiť, a TZipEntries.Add ich aplikuje, kým sa centrálny adresár ešte číta, skôr, než sa dotkne bajtu dát položky. ZipMaxEntryUncompressedSize zastropuje jedného člena na 1 GiB, ZipMaxTotalUncompressedSize zastropuje archív na 4 GiB, a ZipMaxCompressionRatio hodnoty 10000 odmieta akúkoľvek deflate položku, ktorej deklarovaná expanzia presahuje desaťtisícnásobok, spolu s degenerovaným prípadom nenulovej nekomprimovanej veľkosti spárovanej s nulovou komprimovanou veľkosťou. Názvy položiek prechádzajú cez CanonicalZipEntryName v tom istom volaní, ktorá odmieta vložené znaky NUL, dvojbodky, a akýkoľvek segment cesty .. s Invalid ZIP entry name, a ktorá prevádza segmenty na malé písmená a normalizuje ich, takže dvaja členovia líšiaci sa iba veľkosťou písmen alebo nadbytočnými oddeľovačmi kolidujú ako Duplicate ZIP entry name namiesto toho, aby sa navzájom ticho zatienili
Obrana do hĺbky nad ZIP vrstvou
ZIP vrstva je jedna z niekoľkých úrovní, a vzorec sa opakuje všade, kde HotXLS parsuje štruktúru kontrolovanú útočníkom. Najjasnejší príklad sedí v BIFF parseri vzorcov: TXLSFormula.GetTranslated rekurzuje cez tokeny tMemFunc, takže upravený prúd tokenov rgce v legacy .xls sa môže vnárať ľubovoľne hlboko a vyčerpať zásobník. Poistkou je konštanta, MaxTranslateDepth = 256, zvolená proti známemu upstream faktu namiesto uhádnutá. Excel zastropuje vnáranie vzorcov na 64, takže 256 necháva štvornásobnú rezervu a nikdy nemôže odmietnuť vzorec, ktorý vyprodukovala skutočná tabuľka, pričom stále dostatočne skoro ukončí zákerný prúd skôr, než dôjde zásobník
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Všimnite si, že poistka vráti nil namiesto toho, aby vyvolala výnimku. Vzorec príliš hlboký na to, aby bol skutočný, nevyprodukuje žiadny syntaktický strom, okolité parsovanie pokračuje, a zošit sa napriek tomu načíta. Táto asymetria je zámerná a stojí za to ju kopírovať vo vlastných limitoch: hranica, ktorá existuje na zastavenie vyčerpania zdrojov, by mala degradovať najmenšiu jednotku, ktorú môže, nie prerušiť dokument. Rovnaké uvažovanie sa uplatňuje, keď rozširujete výpočtovú vrstvu, takže ak registrujete vlastné handlery cez API vlastných funkcií formulárového enginu, dajte im vlastné hranice argumentov a rekurzie namiesto predpokladu, že volajúci to už skontroloval
Čo vám tieto kontroly nezaručia
Buďte presní ohľadom hranice. Štyri krížové kontroly EOCD robia index archívu jednoznačným, takže HotXLS a akýkoľvek iný konformný čítač vyriešia ten istý súbor na tú istú množinu položiek; nič nehovoria o tom, či je táto množina položiek neškodná. Zhoda lokálnych hlavičiek zastaví trik s dvoma pohľadmi, nie škodlivý payload, ktorý je konzistentne opísaný. Overený stream zastaví skrátenie, pretečenie a poškodenie, nie dokonale správne formovanú XML časť, ktorá kóduje niečo, čo ste nečakali. A nič z toho sa nedotýka makier: VBA projekt vnútri štrukturálne bezchybného zošita je stále VBA projekt, a rozhodnutie ho ponechať, odstrániť alebo odmietnuť patrí vašej politickej vrstve, nie ZIP čítaču
Čo za to dostanete, je čistá hranica zlyhania. Nedôveryhodný xlsx sa buď otvorí ako jeden jednoznačný archív, ktorého členovia zodpovedajú svojim deklarovaným veľkostiam a kontrolným súčtom, alebo vyvolá výnimku so správou, ktorá pomenuje konkrétny invariant, ktorý porušil, a vaša služba môže na základe výnimky vykonať karanténu namiesto hádania. ZIP čítač a vrstvy parsera nad ním sú súčasťou HotXLS Excel komponenty pre Delphi a C++Builder, ktorá na stroji vykonávajúcom parsovanie nepotrebuje ani Excel, ani OLE automatizáciu, a táto neprítomnosť je sama osebe významným znížením toho, čo uploadovaný súbor môže dosiahnuť