En xlsx-fil är ett ZIP-arkiv, och ZIP har ingen enda auktoritativ innehållsförteckning. HotXLS Excel Library för Delphi och C++Builder behandlar den tvetydigheten som en attackyta: dess slut-av-centralkatalog-tolk accepterar en kandidatpost bara efter att fyra oberoende korskontroller är överens, så en förfalskad katalog gömd i en ZIP-kommentar vinner aldrig
Scenariot som gör det här konkret är vardagligt. En server tar emot kalkylbladsuppladdningar från kunder. Filen klarar en antivirus-skanning, skrivs till en spool-katalog, och din Delphi-tjänst öppnar den för att dra ut tre kolumner. Allt ser bra ut, förutom att skannern och din tolk inte var överens om vad arkivet innehöll. Skannern räknade upp en uppsättning medlemmar; din laddare räknade upp en annan uppsättning från samma byte. Ingen av dem är buggig i vanlig mening. De löste helt enkelt en tvetydighet i ZIP-formatet i två olika riktningar, och en angripare valde byten så att de skulle göra det
Var bor egentligen sanningen om ett ZIP-arkiv?
Den bor allra sist, i en 22-byte-struktur kallad slut-av-centralkatalog-posten. En ZIP-fil läses inte från början till slut: varje medlem bär ett lokalt filhuvud omedelbart före sin komprimerade data, men det auktoritativa indexet är centralkatalogen, en körning av poster nära slutet som namnger varje post och ger offseten till dess lokala huvud. För att hitta centralkatalogen måste du först hitta EOCD:n, eftersom EOCD:n är det som säger var katalogen börjar och hur många poster den håller. HotXLS modellerar den som TEndOfCentralDirectoryRecord, vars fält mappar en-till-en på disklayouten: FDiskNumber vid offset 4, FStartDisk vid 6, FThisDiskEntries vid 8, FTotalEntries vid 10, FSizeOfCD vid 12, FOffsetOfStartCD vid 16, och FCommentLen vid 20. Den totalen är FMinSize, beräknad i konstruktorn som 4*3 + 5*2. Efter den kommer arkivkommentaren, upp till 65535 byte godtyckligt innehåll, vilket gör FMaxSize 65557 och betyder att posten inte är på en fast position. Du måste leta efter den
Varför räcker det inte att skanna bakåt efter EOCD-signaturen?
Eftersom de fyra byten du skannar efter, PK\005\006, lagligt kan dyka upp inuti arkivkommentaren, inuti komprimerad data, eller inuti en andra EOCD en angripare fäst på med avsikt. En tolk som stannar vid den första signaturen den möter medan den vandrar bakåt är trivialt styrbar: placera en lockfågel-EOCD nära svansen och den naiva tolken följer den, medan en tolk som skannar i en annan ordning, eller som behandlar den sista signaturen i filen som kanonisk, följer den riktiga. Det här är familjen av ZIP-tvetydighetsattacker, och dess utdelning är exakt uppdelningen beskriven ovan, där skanningsmotorn och den konsumerande applikationen ser olika postuppsättningar från en fil
TEndOfCentralDirectoryRecord.Parse skannar faktiskt bakåt. Den sätter startscan till sista byten, klämmer endscan till lsize - FMaxSize eller noll, och vandrar fönstret i 256-byte-buffertar som överlappar med tre byte så en signatur som spänner över en buffertgräns aldrig missas. Skillnaden är vad som sker vid en träff. Att hitta signaturen producerar bara en Candidate-offset. HotXLS läser sedan de 22 byten vid den offseten, tolkar dem med ReadEOCD, och kräver att de resulterande fälten är internt konsistenta med filen de påstår sig beskriva innan FOffsetEOCD tilldelas alls
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;
Läs predikatet som fyra separata påståenden en förfalskning måste uppfylla samtidigt. Candidate + FMinSize + FCommentLen = lsize kräver att den deklarerade kommentarlängden når exakt slutet av filen, vilket är det som dödar lockfågel-i-kommentaren-tricket: en falsk EOCD begravd inuti en riktig kommentar kan inte också stå för varje byte efter sig själv. FDiskNumber = 0 och FStartDisk = 0 avvisar de flerskiva-spänningsfälten som ingen xlsx någonsin legitimt använt och som existerar i konstruerade arkiv bara för att förvirra. FThisDiskEntries = FTotalEntries avvisar delnings-räkne-tricket där en tolk storleksätter sin loop från ett fält och en annan tolk från det andra. Och Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate kräver att centralkatalogen slutar precis där EOCD:n börjar, så katalogen inte kan pekas mot en orelaterad blob någon annanstans i filen. Int64-castningen på den sista spelar roll: båda operanderna är 32-bitars, och utan utvidgning skulle ett konstruerat par kunna wrappa runt och tillfredsställa testet aritmetiskt medan det pekar ingenstans förnuftigt
Lokala huvuden måste överensstämma med centralkatalogen
EOCD-kontrollerna fastställer vilken katalog som är auktoritativ; de garanterar ännu inte att katalogen talar sanning om enskilda medlemmar. Varje post beskrivs två gånger i en ZIP-fil, en gång centralt och en gång i sitt lokala huvud, och inget i formatet tvingar de två beskrivningarna att matcha, så en läsare som litar på centralkatalogen och en läsare som litar på lokala huvuden kan extrahera olika innehåll från ett arkiv. TZipEntry.ParseLocalHeader stänger det gapet genom att tolka det lokala huvudet vid FCdFile.LocalFileHeaderOffset och jämföra de två kopiorna fält för fält, och returnerar en distinkt negativ kod för varje sorts oenighet: det kanoniserade postnamnet, komprimeringsmetoden, de allmänna ändamåls-bitflaggorna, och, när databeskrivningsflaggan är avstängd, CRC32 och båda storlekarna. Med den flaggan satt får de lokala kopiorna vara noll, eftersom de verkliga värdena bor i en efterföljande deskriptor, men varje icke-noll lokalt värde måste ändå matcha. En slutlig kontroll avvisar poster vars data skulle sträcka sig förbi slutet av filen, och jämför Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) mot inputstream.Size. Varje fel sprider sig ut från TCentralDirectory.Parse som ett icke-1-resultat och TZipArchive.OpenArchive förvandlar det till Can't open zip archive, i stället för att ge dig ett halvt betrott arkivobjekt. När du bara behöver veta vilka blad en fil innehåller är att köra den valideringen innan en fullständig tolkning billigt, och den lättviktiga bladinspektionsvägen ger dig exakt det utan att materialisera celldata
Vad händer när byten själva ljuger?
Strukturell överensstämmelse säger fortfarande ingenting om nyttolasten, så HotXLS omsluter varje poströmm i TZipVerifiedStream, som upprätthåller den deklarerade storleken och CRC32 medan anroparen läser. Det här är avsiktligt inte en kontroll i efterhand: en dekomprimeringsbomb vars deklarerade okomprimerade storlek är 4 KB men som blåses upp till gigabyte stoppas vid 4 KB-märket, inte efter skadan. Omslaget klämmer varje läsning till de återstående deklarerade byten, kastar ZIP entry ended before its declared size om källan sinar tidigt, sonderar efter en extra byte vid slutförande och kastar ZIP entry exceeds its declared size om något är kvar, och jämför slutligen den löpande CRC32 i VerifyComplete, och kastar ZIP entry uncompressed size mismatch eller 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;
En konsekvens är värd att planera för. Strömmen är framåt-endast av design; ett Seek till någon annanstans än den aktuella positionen kastar ZIP entry stream is forward-only, med en enda eftergift för soEnd med offset noll så storleksfrågor fortfarande fungerar. Det är rätt avvägning för opålitlig indata, eftersom en ström du kan spola tillbaka är en ström vars CRC-bokföring du kan besegra, men det betyder att konsumentkod som förväntar sig en sökbar ström behöver en egen buffert. Samma framåt-endast-disciplin ligger bakom den strömmande direktläsaren, vilket är API:et att sträcka sig efter när den uppladdade arbetsboken är stor nog att du inte vill ha den resident i minnet alls
Resursgränser före allokering, inte efter
Tre konstanter i lxZipArchive begränsar vad ett enda arkiv kan be processen göra, och TZipEntries.Add tillämpar dem medan centralkatalogen fortfarande läses, innan en byte postdata rörs. ZipMaxEntryUncompressedSize begränsar en medlem till 1 GiB, ZipMaxTotalUncompressedSize begränsar arkivet till 4 GiB, och ZipMaxCompressionRatio på 10000 avvisar varje deflaterad post vars deklarerade expansion överstiger tiotusenfaldigt, tillsammans med det degenererade fallet av en icke-noll okomprimerad storlek parad med en noll komprimerad storlek. Postnamn går genom CanonicalZipEntryName i samma anrop, som avvisar inbäddade NUL-tecken, kolon, och varje ..-sökvägssegment med Invalid ZIP entry name, och som gör segment gemener och normaliserar dem så att två medlemmar som bara skiljer sig i skiftläge eller i överflödiga separatorer kolliderar som Duplicate ZIP entry name i stället för att tyst skugga varandra
Försvar på djupet ovanför ZIP-lagret
ZIP-lagret är en nivå av flera, och mönstret upprepas varhelst HotXLS tolkar angripar-kontrollerad struktur. Det tydligaste exemplet sitter i BIFF-formeltolkaren: TXLSFormula.GetTranslated rekurserar genom tMemFunc-token, så en konstruerad rgce-tokenström i en äldre .xls kan nästla godtyckligt djupt och tömma stacken. Grinden är en konstant, MaxTranslateDepth = 256, vald mot ett känt uppströmsfaktum snarare än gissad. Excel begränsar formelnästling till 64, så 256 lämnar fyrfaldig marginal och kan aldrig avvisa en formel ett riktigt kalkylblad producerade, medan den ändå avslutar en illvillig ström tillräckligt tidigt innan stacken tar slut
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Notera att grinden returnerar nil snarare än att kasta. En formel för djup för att vara genuin ger inget syntaxträd, den omgivande tolkningen fortsätter, och arbetsboken laddas ändå. Den asymmetrin är avsiktlig och värd att kopiera i dina egna gränser: en gräns som existerar för att stoppa resursutmattning bör degradera den minsta enhet den kan, inte avbryta dokumentet. Samma resonemang gäller när du utökar beräkningslagret, så om du registrerar dina egna hanterare genom API:et för anpassade formelmotorfunktioner, ge dem sina egna argument- och rekursionsgränser i stället för att anta att anroparen redan kontrollerade
Vad de här kontrollerna inte köper dig
Var precis om gränsen. De fyra EOCD-korskontrollerna gör arkivindexet otvetydigt, så HotXLS och varje annan konform läsare löser upp samma fil till samma postuppsättning; de säger ingenting om huruvida den postuppsättningen är ofarlig. Lokal-huvud-överensstämmelse stoppar de-två-vyerna-tricket, inte en illvillig nyttolast som är konsekvent beskriven. Den verifierade strömmen stoppar trunkering, överflöde, och korruption, inte en perfekt välformad XML-del som kodar något du inte förväntade dig. Och inget av detta rör makron: ett VBA-projekt inuti en strukturellt oklanderlig arbetsbok är fortfarande ett VBA-projekt, och beslutet att behålla, ta bort, eller avvisa det hör hemma i din policynivå, inte ZIP-läsaren
Vad du får i utbyte är en ren felgräns. En opålitlig xlsx antingen öppnas som ett otvetydigt arkiv vars medlemmar matchar sina deklarerade storlekar och kontrollsummor, eller så kastar den med ett meddelande som namnger den specifika invarianten den bröt, och din tjänst kan karantänsätta på undantaget i stället för att gissa. ZIP-läsaren och tolknivåerna ovanför den levereras som del av HotXLS Excel-komponenten för Delphi och C++Builder, som varken behöver Excel eller OLE-automation på maskinen som gör tolkningen, och den frånvaron är i sig en meningsfull minskning av vad en uppladdad fil kan nå