Soubor xlsx je archiv ZIP a ZIP nemá jediný autoritativní obsah. Excel Library HotXLS pro Delphi a C++Builder tuto nejednoznačnost bere jako útočnou plochu: jeho parser end of central directory přijme kandidátní záznam teprve poté, co se shodnou čtyři nezávislé křížové kontroly, takže padělaný adresář skrytý v komentáři ZIPu nikdy nezvítězí
Scénář, který to zpřehledňuje, je všední. Server přijímá nahrání tabulek od zákazníků. Soubor projde antivirovou kontrolou, zapíše se do fronty a vaše služba v Delphi jej otevře, aby vytáhla tři sloupce. Vše vypadá v pořádku, jenže skener a váš parser se neshodli na tom, co archiv obsahoval. Skener vyjmenoval jednu množinu vstupů; váš loader ze stejných bajtů vyjmenoval jinou množinu. Ani jeden z nich není chybný v obvyklém smyslu. Prostě vyřešili nejednoznačnost formátu ZIP dvěma různými směry, a útočník bajty zvolil právě proto, aby k tomu došlo
Kde skutečně žije pravda o archivu ZIP?
Žije úplně na konci, ve 22bajtové struktuře zvané end of central directory record. Soubor ZIP se nečte odpředu dozadu: každý vstup nese lokální hlavičku souboru bezprostředně před svými komprimovanými daty, ale autoritativní index je centrální adresář, řada záznamů poblíž konce, která pojmenovává každou položku a udává offset její lokální hlavičky. Chcete-li najít centrální adresář, musíte nejprve najít EOCD, protože EOCD je to, co říká, kde adresář začíná a kolik záznamů obsahuje. HotXLS jej modeluje jako TEndOfCentralDirectoryRecord, jehož pole se jedna ku jedné mapují na fyzický rozvrh na disku: FDiskNumber na offsetu 4, FStartDisk na 6, FThisDiskEntries na 8, FTotalEntries na 10, FSizeOfCD na 12, FOffsetOfStartCD na 16 a FCommentLen na 20. Tento součet je FMinSize, spočítaný v konstruktoru jako 4*3 + 5*2. Po něm následuje komentář archivu, až 65535 bajtů libovolného obsahu, což dělá FMaxSize 65557 a znamená to, že záznam není na pevné pozici. Musíte jej jít hledat
Proč nestačí skenovat od konce po podpis EOCD?
Protože čtyři bajty, které hledáte, PK\005\006, se mohou legálně objevit uvnitř komentáře archivu, uvnitř komprimovaných dat, nebo uvnitř druhého EOCD, který útočník připojil záměrně. Parser, který se zastaví u prvního podpisu, na nějž při procházení od konce narazí, je triviálně ovladatelný: umístěte návnadový EOCD blízko konce a naivní parser jej následuje, zatímco parser, který skenuje v jiném pořadí, nebo který za autoritativní považuje poslední podpis v souboru, následuje ten skutečný. Toto je rodina útoků typu nejednoznačnosti ZIP a jejím výsledkem je přesně to rozdělení popsané výše, kde skenovací engine a spotřebitelská aplikace vidí z jednoho souboru různé sady položek
TEndOfCentralDirectoryRecord.Parse skutečně skenuje pozpátku. Nastaví startscan na poslední bajt, oříže endscan na lsize - FMaxSize nebo nulu a prochází okno v 256bajtových bufferech, které se překrývají o tři bajty, takže podpis rozkročený přes hranici bufferu nikdy neujde. Rozdíl je v tom, co se stane při nalezení. Nalezení podpisu vytvoří pouze kandidátní offset Candidate. HotXLS pak přečte 22 bajtů na tomto offsetu, zpracuje je pomocí ReadEOCD a požaduje, aby výsledná pole byla vnitřně konzistentní se souborem, který mají popisovat, ještě předtím, než je FOffsetEOCD vůbec přiřazeno
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;
Čtěte tento predikát jako čtyři samostatná tvrzení, která musí padělek splnit současně. Candidate + FMinSize + FCommentLen = lsize vyžaduje, aby deklarovaná délka komentáře dosáhla přesně konce souboru, což je to, co zabíjí trik s návnadou v komentáři: falešný EOCD zakopaný uvnitř skutečného komentáře nemůže zároveň zúčtovat každý bajt po sobě. FDiskNumber = 0 a FStartDisk = 0 odmítají pole pro rozdělení na více disků, která žádný xlsx nikdy legitimně nepoužil a která v upravených archivech existují jen proto, aby matla. FThisDiskEntries = FTotalEntries odmítá trik s rozdílným počtem, kdy jeden parser velikost své smyčky odvozuje z jednoho pole a jiný parser z druhého. A Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate vyžaduje, aby centrální adresář končil přesně tam, kde EOCD začíná, takže adresář nemůže ukazovat na nesouvisející blob jinde v souboru. Přetypování na Int64 u posledního výrazu má význam: oba operandy jsou 32bitové, a bez rozšíření by upravená dvojice mohla aritmeticky přetéct a splnit test, přestože ukazuje nikam smysluplně
Lokální hlavičky se musí shodovat s centrálním adresářem
Kontroly EOCD určují, který adresář je autoritativní; ještě nezaručují, že adresář říká pravdu o jednotlivých položkách. Každá položka je v souboru ZIP popsána dvakrát, jednou centrálně a jednou ve své lokální hlavičce, a nic ve formátu nenutí obě popisy, aby se shodovaly, takže čtečka, která důvěřuje centrálnímu adresáři, a čtečka, která důvěřuje lokálním hlavičkám, mohou z jednoho archivu extrahovat různý obsah. TZipEntry.ParseLocalHeader tuto mezeru uzavírá tím, že zpracuje lokální hlavičku na FCdFile.LocalFileHeaderOffset a porovná obě kopie pole po poli, přičemž vrací odlišný záporný kód pro každý druh neshody: kanonizovaný název položky, metodu komprese, obecné bitové příznaky a při vypnutém příznaku data descriptoru CRC32 i obě velikosti. Při zapnutém příznaku smí být lokální kopie nulové, protože skutečné hodnoty žijí v koncovém deskriptoru, ale jakákoli nenulová lokální hodnota se stále musí shodovat. Poslední kontrola odmítá položky, jejichž data by přesahovala za konec souboru, porovnáním Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) proti inputstream.Size. Jakékoli selhání se propaguje z TCentralDirectory.Parse jako výsledek různý od 1 a TZipArchive.OpenArchive jej promění na Can't open zip archive, místo aby vám předal napůl důvěryhodný objekt archivu. Potřebujete-li jen vědět, které listy soubor obsahuje, spuštění této validace před plným zpracováním je levné, a lehká cesta pro inspekci listů vám to poskytne přesně, bez materializace dat buněk
Co se stane, když lžou samotné bajty?
Strukturální shoda stále neříká nic o obsahu, takže HotXLS obaluje každý stream položky do TZipVerifiedStream, který vynucuje deklarovanou velikost a CRC32 v okamžiku, kdy volající čte. Toto záměrně není dodatečná kontrola: dekompresní bomba s deklarovanou nekomprimovanou velikostí 4 KB, která se ale rozbalí na gigabajty, je zastavena na hranici 4 KB, ne až po škodě. Wrapper ořezává každé čtení na zbývající deklarované bajty, vyvolá ZIP entry ended before its declared size, pokud zdroj dojde předčasně, po dokončení sonduje jeden extra bajt a vyvolá ZIP entry exceeds its declared size, zbyl-li ještě nějaký, a nakonec porovná průběžné CRC32 v VerifyComplete, přičemž vyvolá ZIP entry uncompressed size mismatch nebo 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ůsledek stojí za plánování dopředu. Stream je záměrně jen dopředný; Seek na jakoukoli pozici jinou než aktuální vyvolá ZIP entry stream is forward-only, s jedinou výjimkou pro soEnd s offsetem nula, aby fungovaly i dotazy na velikost. To je správný kompromis pro nedůvěryhodný vstup, protože stream, který lze převinout zpět, je stream, jehož účtování CRC lze porazit, ale znamená to, že spotřebitelský kód, který očekává stream s možností vyhledávání, potřebuje vlastní buffer. Stejná disciplína pouze-dopředu podpírá streamovací přímý čtec, což je API, po kterém sáhnout, když je nahraný sešit natolik velký, že jej vůbec nechcete mít rezidentní v paměti
Limity zdrojů před alokací, ne po ní
Tři konstanty v lxZipArchive omezují, co jeden archiv smí od procesu vyžádat, a TZipEntries.Add je aplikuje ještě během čtení centrálního adresáře, ještě než se dotkne jediného bajtu dat položky. ZipMaxEntryUncompressedSize omezuje jednu položku na 1 GiB, ZipMaxTotalUncompressedSize omezuje archiv na 4 GiB a ZipMaxCompressionRatio s hodnotou 10000 odmítá jakoukoli deflatovanou položku, jejíž deklarovaná expanze přesahuje desettisícinásobek, spolu s degenerovaným případem nenulové nekomprimované velikosti spárované s nulovou komprimovanou velikostí. Názvy položek procházejí ve stejném volání funkcí CanonicalZipEntryName, která odmítá vložené znaky NUL, dvojtečky a jakýkoli segment cesty .. pomocí Invalid ZIP entry name, a která segmenty převádí na malá písmena a normalizuje, takže dvě položky lišící se pouze velikostí písmen nebo nadbytečnými oddělovači se srazí jako Duplicate ZIP entry name místo toho, aby se tiše navzájem zastínily
Obrana do hloubky nad vrstvou ZIP
Vrstva ZIP je jedna z několika a stejný vzor se opakuje všude, kde HotXLS zpracovává strukturu ovládanou útočníkem. Nejjasnější příklad sedí v parseru vzorců BIFF: TXLSFormula.GetTranslated rekurzivně prochází tokeny tMemFunc, takže upravený proud tokenů rgce v legacy .xls se může vnořit libovolně hluboko a vyčerpat zásobník. Bránou je konstanta MaxTranslateDepth = 256, zvolená podle známého faktu z Excelu, ne odhadem. Excel omezuje vnoření vzorců na 64, takže 256 ponechává čtyřnásobnou rezervu a nikdy nemůže odmítnout vzorec, který vyprodukoval skutečný sešit, a přitom stále ukončí škodlivý proud dostatečně dřív, než dojde 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šimněte si, že brána vrací nil místo vyvolání výjimky. Vzorec příliš hluboký na to, aby byl skutečný, nedá žádný syntaktický strom, okolní zpracování pokračuje a sešit se přesto načte. Tato asymetrie je záměrná a stojí za to ji ve vlastních limitech napodobit: limit, který existuje k zastavení vyčerpání zdrojů, by měl degradovat co nejmenší jednotku, kterou umí, ne přerušit celý dokument. Stejné uvažování platí, když rozšiřujete výpočetní vrstvu, takže pokud registrujete vlastní handlery přes API vlastních funkcí formulového enginu, dejte jim vlastní limity argumentů a rekurze místo předpokladu, že volající už kontrolu provedl
Co vám tyto kontroly nezajistí
Buďte přesní ohledně hranice. Čtyři křížové kontroly EOCD dělají index archivu jednoznačným, takže HotXLS i jakákoli jiná konformní čtečka rozhodnou o stejném souboru stejnou sadu položek; neříkají nic o tom, zda je tato sada položek neškodná. Shoda lokálních hlaviček zastaví trik se dvěma pohledy, ne škodlivý payload, který je popsán konzistentně. Ověřený stream zastaví oříznutí, přetečení a poškození, ne dokonale dobře formovanou část XML, která kóduje něco, co jste nečekali. A nic z toho se netýká maker: projekt VBA uvnitř strukturálně bezvadného sešitu je pořád projekt VBA, a rozhodnutí, zda jej ponechat, odstranit nebo odmítnout, patří do vaší politické vrstvy, ne do čtečky ZIP
Co za to dostanete výměnou, je čistá hranice selhání. Nedůvěryhodný xlsx se buď otevře jako jeden jednoznačný archiv, jehož položky odpovídají deklarovaným velikostem a kontrolním součtům, nebo vyvolá výjimku se zprávou, která pojmenovává konkrétní invariant, jenž porušil, a vaše služba může do karantény poslat na základě výjimky místo hádání. Čtečka ZIP i vrstvy parseru nad ní jsou součástí komponenty HotXLS Excel pro Delphi a C++Builder, která na stroji, jenž provádí zpracování, nepotřebuje ani Excel, ani OLE automatizaci, a už tato nepřítomnost je sama o sobě smysluplným snížením toho, čeho může nahraný soubor dosáhnout