Een xlsx-bestand is een ZIP-archief, en ZIP heeft geen enkele gezaghebbende inhoudsopgave. HotXLS Excel Library voor Delphi en C++Builder behandelt die dubbelzinnigheid als een aanvalsoppervlak: de end-of-central-directory-parser accepteert een kandidaatrecord pas nadat vier onafhankelijke kruiscontroles het eens zijn, zodat een vervalste directory verstopt in een ZIP-commentaar nooit wint
Het scenario dat dit concreet maakt, is alledaags. Een server accepteert spreadsheetuploads van klanten. Het bestand komt door een antivirusscan, wordt naar een spooldirectory geschreven, en je Delphi-service opent het om drie kolommen eruit te halen. Alles ziet er goed uit, behalve dat de scanner en je parser het niet eens waren over wat het archief bevatte. De scanner somde de ene set leden op; je loader somde een andere set op uit dezelfde bytes. Geen van beide is buggy in de gewone zin. Ze losten simpelweg een dubbelzinnigheid in het ZIP-formaat op in twee verschillende richtingen, en een aanvaller koos de bytes zodat dat zou gebeuren
Waar leeft de waarheid over een ZIP-archief eigenlijk?
Ze leeft helemaal aan het eind, in een structuur van 22 bytes genaamd het end-of-central-directory-record. Een ZIP-bestand wordt niet van voor naar achter gelezen: elk lid draagt een lokale bestandsheader direct vóór zijn gecomprimeerde data, maar de gezaghebbende index is de central directory, een reeks records nabij het eind die elk item benoemt en de offset van zijn lokale header geeft. Om de central directory te vinden moet je eerst de EOCD vinden, want de EOCD zegt waar de directory begint en hoeveel records ze bevat. HotXLS modelleert het als TEndOfCentralDirectoryRecord, waarvan de velden één-op-één corresponderen met de layout op schijf: FDiskNumber op offset 4, FStartDisk op 6, FThisDiskEntries op 8, FTotalEntries op 10, FSizeOfCD op 12, FOffsetOfStartCD op 16, en FCommentLen op 20. Dat totaal is FMinSize, berekend in de constructor als 4*3 + 5*2. Daarna komt het archiefcommentaar, tot 65535 bytes willekeurige inhoud, wat FMaxSize 65557 maakt en betekent dat het record niet op een vaste positie staat. Je moet ernaar op zoek
Waarom is achterwaarts scannen naar de EOCD-signature niet genoeg?
Omdat de vier bytes waarnaar je scant, PK\005\006, legaal kunnen voorkomen binnen het archiefcommentaar, binnen gecomprimeerde data, of binnen een tweede EOCD die een aanvaller er opzettelijk aan heeft toegevoegd. Een parser die stopt bij de eerste signature die hij tegenkomt tijdens het achterwaarts lopen, is triviaal te sturen: plaats een lokaas-EOCD nabij het eind en de naïeve parser volgt die, terwijl een parser die in een andere volgorde scant, of die de laatste signature in het bestand als gezaghebbend behandelt, de echte volgt. Dit is de familie van ZIP-dubbelzinnigheidsaanvallen, en de opbrengst ervan is precies de hierboven beschreven splitsing, waarbij de scanengine en de consumerende applicatie verschillende itemsets zien uit één bestand
TEndOfCentralDirectoryRecord.Parse scant inderdaad achterwaarts. Het zet startscan op de laatste byte, klemt endscan op lsize - FMaxSize of nul, en loopt door het venster in buffers van 256 bytes die drie bytes overlappen zodat een signature die een bufferrand overspant nooit gemist wordt. Het verschil zit in wat er gebeurt bij een treffer. Het vinden van de signature levert alleen een kandidaatoffset Candidate op. HotXLS leest dan de 22 bytes op die offset, parseert ze met ReadEOCD, en eist dat de resulterende velden intern consistent zijn met het bestand dat ze beweren te beschrijven voordat FOffsetEOCD überhaupt wordt toegewezen
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;
Lees het predicaat als vier afzonderlijke claims waar een vervalsing tegelijkertijd aan moet voldoen. Candidate + FMinSize + FCommentLen = lsize eist dat de verklaarde commentaarlengte precies het einde van het bestand bereikt, wat de decoy-in-het-commentaar-truc doodt: een vervalste EOCD begraven binnen een echt commentaar kan niet ook nog eens elke byte na zichzelf verklaren. FDiskNumber = 0 en FStartDisk = 0 verwerpen de multi-disk-spanningvelden die geen enkele xlsx ooit legitiem gebruikt heeft en die in geprepareerde archieven alleen bestaan om te verwarren. FThisDiskEntries = FTotalEntries verwerpt de gesplitste-telling-truc waarbij de ene parser zijn lus dimensioneert vanuit het ene veld en een andere parser vanuit het andere. En Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate eist dat de central directory precies eindigt waar de EOCD begint, zodat de directory niet naar een niet-gerelateerde blob elders in het bestand kan wijzen. De Int64-cast op die laatste doet ertoe: beide operanden zijn 32-bit, en zonder verbreding zou een geprepareerd paar rekenkundig kunnen omslaan en aan de test voldoen terwijl het nergens zinnigs wijst
Lokale headers moeten overeenkomen met de central directory
De EOCD-controles bepalen welke directory gezaghebbend is; ze garanderen nog niet dat de directory de waarheid vertelt over individuele leden. Elk item wordt tweemaal beschreven in een ZIP-bestand, eenmaal centraal en eenmaal in zijn lokale header, en niets in het formaat dwingt de twee beschrijvingen om overeen te komen, dus een reader die de central directory vertrouwt en een reader die lokale headers vertrouwt, kunnen verschillende inhoud uit één archief extraheren. TZipEntry.ParseLocalHeader dicht dat gat door de lokale header op FCdFile.LocalFileHeaderOffset te parseren en de twee kopieën veld voor veld te vergelijken, met een aparte negatieve code voor elk soort onenigheid: de gecanonicaliseerde itemnaam, de compressiemethode, de general-purpose-bitvlaggen, en, wanneer de data-descriptor-vlag niet gezet is, de CRC32 en beide groottes. Met die vlag gezet mogen de lokale kopieën nul zijn, aangezien de echte waarden in een volgende descriptor leven, maar elke niet-nul lokale waarde moet nog steeds overeenkomen. Een laatste controle verwerpt items waarvan de data voorbij het einde van het bestand zou lopen, door Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) te vergelijken met inputstream.Size. Elk falen propageert vanuit TCentralDirectory.Parse als een niet-1-resultaat en TZipArchive.OpenArchive zet dat om in Can't open zip archive, in plaats van je een half vertrouwd archiefobject te geven. Als je alleen wilt weten welke bladen een bestand bevat, is die validatie draaien vóór een volledige parse goedkoop, en het lichte bladinspectiepad geeft je precies dat zonder celdata te materialiseren
Wat gebeurt er wanneer de bytes zelf liegen?
Structurele overeenstemming zegt nog steeds niets over de payload, dus HotXLS wikkelt elke itemstream in TZipVerifiedStream, die de verklaarde grootte en CRC32 afdwingt terwijl de aanroeper leest. Dit is bewust geen achteraf-controle: een decompressiebom waarvan de verklaarde ongecomprimeerde grootte 4 KB is maar die uitzet tot gigabytes, wordt gestopt bij het 4 KB-punt, niet nadat de schade is toegebracht. De wrapper klemt elke lees op de resterende verklaarde bytes, werpt ZIP entry ended before its declared size als de bron voortijdig opdroogt, test bij voltooiing op één extra byte en werpt ZIP entry exceeds its declared size als er nog iets over is, en vergelijkt ten slotte de lopende CRC32 in VerifyComplete, met ZIP entry uncompressed size mismatch of 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;
Eén gevolg is het waard om erop voorbereid te zijn. De stream is per ontwerp alleen voorwaarts; een Seek naar ergens anders dan de huidige positie werpt ZIP entry stream is forward-only, met één toegeving voor soEnd met offset nul zodat groottevragen nog werken. Dat is de juiste afweging voor onvertrouwde invoer, want een stream die je kunt terugspoelen is een stream waarvan je de CRC-boekhouding kunt omzeilen, maar het betekent wel dat consumentcode die een seekbare stream verwacht een eigen buffer nodig heeft. Dezelfde alleen-voorwaarts-discipline ligt onder de streaming directe reader, de API om naar te grijpen wanneer de geüploade werkmap groot genoeg is dat je hem helemaal niet resident in het geheugen wilt hebben
Resourcelimieten vóór allocatie, niet erna
Drie constanten in lxZipArchive begrenzen wat een enkel archief het proces kan laten doen, en TZipEntries.Add past ze toe terwijl de central directory nog gelezen wordt, voordat ook maar één byte item-data aangeraakt is. ZipMaxEntryUncompressedSize begrenst één lid op 1 GiB, ZipMaxTotalUncompressedSize begrenst het archief op 4 GiB, en ZipMaxCompressionRatio van 10000 verwerpt elk deflated item waarvan de verklaarde expansie tienduizendvoudig overschrijdt, samen met het gedegenereerde geval van een niet-nul ongecomprimeerde grootte gekoppeld aan een nul gecomprimeerde grootte. Itemnamen gaan door CanonicalZipEntryName in dezelfde aanroep, die ingesloten NUL-tekens, dubbele punten, en elk ..-padsegment verwerpt met Invalid ZIP entry name, en die segmenten omzet naar kleine letters en normaliseert zodat twee leden die alleen verschillen in hoofdlettergebruik of in overbodige scheidingstekens botsen als Duplicate ZIP entry name in plaats van elkaar stilzwijgend te overschaduwen
Verdediging in de diepte boven de ZIP-laag
De ZIP-laag is één laag van meerdere, en het patroon herhaalt zich overal waar HotXLS door een aanvaller gecontroleerde structuur parseert. Het duidelijkste voorbeeld zit in de BIFF-formuleparser: TXLSFormula.GetTranslated recurseert door tMemFunc-tokens, dus een geprepareerde rgce-tokenstream in een legacy .xls kan willekeurig diep nesten en de stack uitputten. De grens is een constante, MaxTranslateDepth = 256, gekozen tegen een bekend upstream-feit in plaats van gegokt. Excel begrenst formulenesting op 64, dus 256 laat een viervoudige marge over en kan nooit een formule verwerpen die een echt spreadsheet produceerde, terwijl het toch een kwaadaardige stream ruim voordat de stack opraakt beëindigt
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Merk op dat de grens nil teruggeeft in plaats van een exception te werpen. Een formule te diep om echt te zijn levert geen syntaxboom op, de omringende parse gaat door, en de werkmap laadt nog steeds. Die asymmetrie is opzettelijk en het waard om over te nemen in je eigen limieten: een grens die bestaat om resource-uitputting te stoppen, moet de kleinste eenheid degraderen die hij kan, niet het document afbreken. Dezelfde redenering geldt wanneer je de berekeningslaag uitbreidt, dus als je je eigen handlers registreert via de custom-function-API van de formule-engine, geef die dan hun eigen argument- en recursiegrenzen in plaats van aan te nemen dat de aanroeper het al gecontroleerd heeft
Wat deze controles je niet opleveren
Wees precies over de grens. De vier EOCD-kruiscontroles maken de archiefindex ondubbelzinnig, zodat HotXLS en elke andere conforme reader hetzelfde bestand naar dezelfde itemset oplossen; ze zeggen niets over of die itemset onschadelijk is. Overeenstemming van lokale headers stopt de two-views-truc, niet een kwaadaardige payload die consistent beschreven is. De geverifieerde stream stopt afkapping, overflow en corruptie, niet een perfect welgevormd XML-deel dat iets codeert dat je niet verwachtte. En niets hiervan raakt macro's: een VBA-project binnen een structureel onberispelijke werkmap is nog steeds een VBA-project, en de beslissing om het te behouden, te strippen of te weigeren hoort bij je beleidslaag, niet bij de ZIP-reader
Wat je er wel voor terugkrijgt, is een schone faalgrens. Een onvertrouwde xlsx opent hetzij als één ondubbelzinnig archief waarvan de leden overeenkomen met hun verklaarde groottes en checksums, hetzij werpt een exception met een melding die de specifieke invariant benoemt die geschonden werd, en je service kan quarantaine toepassen op de exception in plaats van te gokken. De ZIP-reader en de parserlagen erboven worden geleverd als onderdeel van het HotXLS Excel-component voor Delphi en C++Builder, dat noch Excel noch OLE-automation nodig heeft op de machine die parseert, en die afwezigheid is zelf al een betekenisvolle vermindering van wat een geüpload bestand kan bereiken