Egy xlsx fájl egy ZIP archívum, a ZIP-nek pedig nincs egyetlen hiteles tartalomjegyzéke. A HotXLS Excel Library Delphihez és C++Builderhez ezt a kétértelműséget támadási felületként kezeli: a központi könyvtár végét jelző rekord elemzője csak akkor fogad el egy jelölt rekordot, ha négy független kereszthivatkozás-ellenőrzés egyetért, így egy ZIP megjegyzésbe rejtett hamisított könyvtár soha nem nyer
A forgatókönyv, amely ezt konkréttá teszi, hétköznapi. Egy szerver táblázatfeltöltéseket fogad ügyfelektől. A fájl átmegy egy vírusellenőrzésen, egy sorkezelő könyvtárba kerül írásra, a Delphi szolgáltatásunk pedig megnyitja, hogy kihúzzon három oszlopot. Minden rendben látszik, kivéve, hogy a szkenner és az elemzőnk nem értett egyet abban, mit tartalmaz az archívum. A szkenner az egyik tagszettet sorolta fel; a betöltőnk egy másikat ugyanazokból a bájtokból. Egyikük sem hibás a szokásos értelemben. Egyszerűen két különböző irányba oldották fel a ZIP formátum kétértelműségét, és egy támadó úgy választotta a bájtokat, hogy ez így legyen
Hol lakik valójában az igazság egy ZIP archívumról?
A legvégén lakik, egy 22 bájtos struktúrában, amelyet a központi könyvtár végét jelző rekordnak (end of central directory record) hívnak. Egy ZIP fájlt nem elölről hátrafelé olvasunk: minden tag közvetlenül a tömörített adata előtt hordoz egy lokális fájlfejlécet, de a hiteles index a központi könyvtár, egy rekordsorozat a vége közelében, amely megnevez minden bejegyzést, és megadja a lokális fejlécének eltolását. Ahhoz, hogy megtaláljuk a központi könyvtárat, előbb meg kell találnunk az EOCD-t, mert az EOCD mondja meg, hol kezdődik a könyvtár, és hány rekordot tartalmaz. A HotXLS TEndOfCentralDirectoryRecord-ként modellezi, amelynek mezői egy az egyben leképeződnek a lemezen lévő elrendezésre: FDiskNumber a 4-es eltolásnál, FStartDisk a 6-nál, FThisDiskEntries a 8-nál, FTotalEntries a 10-nél, FSizeOfCD a 12-nél, FOffsetOfStartCD a 16-nál, és FCommentLen a 20-nál. Ez az összeg az FMinSize, amelyet a konstruktor 4*3 + 5*2-ként számít ki. Ezt követi az archívummegjegyzés, akár 65535 bájt tetszőleges tartalom, ami az FMaxSize-ot 65557-re teszi, és azt jelenti, hogy a rekord nincs fix pozíción. Meg kell keresnünk
Miért nem elég visszafelé pásztázni az EOCD aláírásért?
Mert a négy bájt, amelyet keresünk, a PK\005\006, legálisan megjelenhet az archívummegjegyzésben, a tömörített adatokban, vagy egy második EOCD-ben, amelyet egy támadó szándékosan fűzött hozzá. Egy elemző, amely megáll az első aláírásnál, amellyel visszafelé haladva találkozik, triviálisan irányítható: helyezzünk el egy csalidat EOCD-t a vég közelében, és a naiv elemző azt fogja követni, míg egy más sorrendben pásztázó elemző, vagy egy olyan, amely a fájlban lévő utolsó aláírást tekinti hitelesnek, a valódit követi. Ez a ZIP kétértelműségi támadások családja, és a hozadéka pontosan a fent leírt szétválás, ahol a pásztázó motor és a fogyasztó alkalmazás különböző bejegyzéskészleteket lát egyetlen fájlból
A TEndOfCentralDirectoryRecord.Parse valóban visszafelé pásztáz. A startscan-t az utolsó bájtra állítja, az endscan-t az lsize - FMaxSize-ra vagy nullára korlátozza, és 256 bájtos, három bájttal átfedő pufferekben járja be az ablakot, így egy pufferhatáron átnyúló aláírás soha nem marad ki. A különbség abban van, mi történik találat esetén. Az aláírás megtalálása csak egy Candidate eltolást eredményez. A HotXLS ezután beolvassa az adott eltolásnál lévő 22 bájtot, elemzi a ReadEOCD-vel, és megköveteli, hogy az eredményül kapott mezők belsőleg konzisztensek legyenek azzal a fájllal, amelyet állítólag leírnak, mielőtt az FOffsetEOCD egyáltalán hozzárendelésre kerülne
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;
Olvassuk a predikátumot négy különálló állításként, amelyeket egy hamisításnak egyidejűleg kell teljesítenie. A Candidate + FMinSize + FCommentLen = lsize megköveteli, hogy a deklarált megjegyzéshossz pontosan a fájl végéig érjen, ami megöli a megjegyzésbe rejtett csali trükköt: egy valódi megjegyzésbe temetett hamis EOCD nem tudja elszámolni a magát követő minden bájtot is. Az FDiskNumber = 0 és FStartDisk = 0 elutasítja a többlemezes tagolási mezőket, amelyeket egyetlen xlsx sem használt soha legitim módon, és amelyek csak azért léteznek kifundált archívumokban, hogy összezavarjanak. A FThisDiskEntries = FTotalEntries elutasítja azt a felosztott-számláló trükköt, ahol az egyik elemző a ciklusát az egyik mezőből méretezi, egy másik elemző pedig a másikból. Az Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate pedig megköveteli, hogy a központi könyvtár pontosan ott érjen véget, ahol az EOCD kezdődik, így a könyvtár nem mutathat valami nem kapcsolódó blobra a fájl máshol lévő részén. Az utolsóra alkalmazott Int64 típuskonverzió számít: mindkét operandus 32 bites, és szélesítés nélkül egy kifundált pár aritmetikailag körbefordulhatna, és teljesíthetné a tesztet, miközben sehova sem mutat értelmesen
A lokális fejléceknek egyet kell érteniük a központi könyvtárral
Az EOCD-ellenőrzések rögzítik, melyik könyvtár hiteles; még nem garantálják, hogy a könyvtár igazat mond az egyes tagokról. Minden bejegyzés kétszer van leírva egy ZIP fájlban, egyszer központilag, egyszer pedig a saját lokális fejlécében, és semmi a formátumban nem kényszeríti, hogy a két leírás egyezzen, így egy olvasó, amely a központi könyvtárban bízik, és egy olvasó, amely a lokális fejlécekben bízik, különböző tartalmat nyerhet ki egyetlen archívumból. A TZipEntry.ParseLocalHeader ezt a rést úgy zárja be, hogy elemzi a lokális fejlécet az FCdFile.LocalFileHeaderOffset-nél, és mezőnként összehasonlítja a két másolatot, minden nézeteltérés-fajtára külön negatív kódot adva vissza: a kanonikus bejegyzésnév, a tömörítési módszer, az általános célú bitjelzők, és amikor az adatleíró jelző nincs beállítva, a CRC32 és mindkét méret. Ha ez a jelző be van állítva, a lokális másolatok lehetnek nullák, mivel a valódi értékek egy záró leíróban élnek, de bármely nem nulla lokális érték továbbra is egyeznie kell. Egy utolsó ellenőrzés elutasítja azokat a bejegyzéseket, amelyek adatai túllépnék a fájl végét, összehasonlítva az Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize)-t az inputstream.Size-szal. Bármilyen hiba a TCentralDirectory.Parse-ból nem 1-es eredményként terjed tovább, és a TZipArchive.OpenArchive ezt Can't open zip archive-vé alakítja, ahelyett hogy egy félig megbízhatónak minősített archívumobjektumot adna nekünk. Amikor csak azt kell tudnunk, mely lapokat tartalmazza egy fájl, ennek az ellenőrzésnek a teljes elemzés előtti lefuttatása olcsó, és a könnyűsúlyú laplista-vizsgálati útvonal pontosan ezt adja meg cellaadatok materializálása nélkül
Mi történik, ha maguk a bájtok hazudnak?
A strukturális egyetértés még mindig semmit nem mond a hasznos adatról, így a HotXLS minden bejegyzésfolyamot becsomagol a TZipVerifiedStream-be, amely a deklarált méretet és a CRC32-t érvényesíti, amint a hívó olvas. Ez szándékosan nem utólagos ellenőrzés: egy dekompressziós bomba, amelynek deklarált kitömörítetlen mérete 4 KB, de amely gigabájtokra fúvódik fel, a 4 KB-os határnál áll meg, nem azután, hogy a kár megtörtént. A burkoló minden olvasást a fennmaradó deklarált bájtokra korlátoz, ZIP entry ended before its declared size-t dob, ha a forrás korán kiszárad, egy extra bájtot vizsgál befejezéskor, és ZIP entry exceeds its declared size-t dob, ha bármi maradt, végül összehasonlítja a futó CRC32-t a VerifyComplete-ben, ZIP entry uncompressed size mismatch-et vagy ZIP entry CRC32 mismatch-et dobva
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;
Egy következményt érdemes megtervezni. A folyam tervezetten csak előrefelé olvasható; egy Seek bárhova az aktuális pozíción kívül ZIP entry stream is forward-only-t dob, egyetlen engedménnyel a soEnd-re nulla eltolással, hogy a méretlekérdezések így is működjenek. Ez a helyes kompromisszum nem megbízható bemenetnél, mert egy visszatekerhető folyam olyan folyam, amelynek CRC-elszámolását le lehet győzni, de ez azt jelenti, hogy egy visszakereshető folyamot váró fogyasztói kódnak saját pufferre van szüksége. Ugyanez a csak-előrefelé fegyelem alapozza meg a streamelő direct readert, amely az API-t, amelyhez nyúlni kell, amikor a feltöltött munkafüzet elég nagy ahhoz, hogy ne akarjuk egyáltalán memóriában tartani
Erőforráskorlátok allokáció előtt, nem utána
Három konstans az lxZipArchive-ban korlátozza, mit kérhet egy egyetlen archívum a folyamattól, és a TZipEntries.Add alkalmazza ezeket, miközben a központi könyvtárat még olvassa, mielőtt egyetlen bejegyzésadat-bájthoz is hozzáérne. A ZipMaxEntryUncompressedSize egy tagot 1 GiB-ra korlátoz, a ZipMaxTotalUncompressedSize az archívumot 4 GiB-ra korlátozza, a ZipMaxCompressionRatio pedig, amely 10000, elutasít minden deflate-elt bejegyzést, amelynek deklarált bővülése meghaladja a tízezerszerest, valamint a degenerált esetet, amikor egy nem nulla kitömörítetlen méret nulla tömörített mérettel párosul. A bejegyzésnevek ugyanabban a hívásban mennek át a CanonicalZipEntryName-en, amely elutasítja a beágyazott NUL karaktereket, a kettőspontokat, és bármilyen .. útvonalszegmenst Invalid ZIP entry name-mel, és amely kisbetűssé teszi és normalizálja a szegmenseket, így két, csak kis- és nagybetűben vagy redundáns elválasztókban különböző tag Duplicate ZIP entry name-ként ütközik, ahelyett hogy csendben árnyékolnák egymást
Mélységi védelem a ZIP réteg fölött
A ZIP réteg csupán egy a több szint közül, és a minta megismétlődik mindenhol, ahol a HotXLS támadó által irányított struktúrát elemez. A legvilágosabb példa a BIFF formulaelemzőben található: a TXLSFormula.GetTranslated tMemFunc tokeneken keresztül rekurzál, így egy kifundált rgce tokenfolyam egy régi .xls fájlban tetszőleges mélységben ágyazódhat, és kimerítheti a vermet. A kapu egy konstans, MaxTranslateDepth = 256, amely egy ismert felsőágazati ténnyel szemben lett kiválasztva, nem találgatva. Az Excel a formulaágyazást 64-nél korlátozza, így a 256 négyszeres tartalékot hagy, és soha nem utasíthat el egy formulát, amelyet egy valódi táblázat állított elő, miközben még mindig elég korán befejez egy rosszindulatú folyamot, mielőtt a verem kifogyna
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Vegyük észre, hogy a kapu nil-lel tér vissza, nem dob kivételt. Egy valódinak lenni túl mély formula nem eredményez szintaxisfát, a körülötte lévő elemzés folytatódik, és a munkafüzet még mindig betöltődik. Ez az aszimmetria szándékos, és érdemes átvenni saját korlátainkban is: egy korlátnak, amely az erőforrás-kimerülés megállítására létezik, a lehető legkisebb egységet kell lefokoznia, nem a dokumentumot megszakítania. Ugyanez a gondolkodás alkalmazandó, amikor a számítási réteget bővítjük, így ha saját kezelőket regisztrálunk a formulamotor egyedi függvény API-ján keresztül, adjunk nekik saját argumentum- és rekurziókorlátokat, ahelyett hogy feltételeznénk, hogy a hívó már ellenőrizte
Amit ezek az ellenőrzések nem vásárolnak meg számunkra
Legyünk pontosak a határt illetően. A négy EOCD kereszthivatkozás-ellenőrzés egyértelművé teszi az archívum indexét, így a HotXLS és bármely más megfelelő olvasó ugyanazt a fájlt ugyanarra a bejegyzéskészletre oldja fel; semmit nem mondanak arról, hogy ez a bejegyzéskészlet ártalmatlan-e. A lokális fejléc-egyetértés megállítja a két-nézet trükköt, nem egy konzisztensen leírt rosszindulatú hasznos adatot. Az érvényesített folyam megállítja a csonkolást, a túlcsordulást és a korrupciót, nem egy tökéletesen jólformázott XML részt, amely valami olyat kódol, amire nem számítottunk. És ezek közül semmi nem érinti a makrókat: egy VBA projekt egy strukturálisan kifogástalan munkafüzeten belül még mindig VBA projekt, és a döntés, hogy megtartjuk, eltávolítjuk vagy elutasítjuk, a szabályzati rétegünkhöz tartozik, nem a ZIP olvasóhoz
Amit cserébe kapunk, az egy tiszta hibahatár. Egy nem megbízható xlsx vagy megnyílik egyetlen egyértelmű archívumként, amelynek tagjai megfelelnek a deklarált méreteiknek és ellenőrzőösszegeiknek, vagy dob egy üzenetet, amely megnevezi a konkrét invariánst, amelyet megsértett, és a szolgáltatásunk karanténba tehet a kivétel alapján, ahelyett hogy találgatna. A ZIP olvasó és az efölötti elemzőrétegek a HotXLS Excel Component részeként érkeznek Delphihez és C++Builderhez, amelynek sem Excelre, sem OLE automatizálásra nincs szüksége azon a gépen, amely az elemzést végzi, és ez a hiány önmagában is jelentős csökkentése annak, amit egy feltöltött fájl elérhet