Plik xlsx to archiwum ZIP, a ZIP nie ma jednego autorytatywnego spisu treści. HotXLS Excel Library dla Delphi i C++Builder traktuje tę niejednoznaczność jako powierzchnię ataku: jego parser end of central directory akceptuje kandydata na rekord dopiero, gdy zgodzą się cztery niezależne krzyżowe sprawdzenia, więc sfałszowany katalog ukryty w komentarzu ZIP nigdy nie wygrywa
Scenariusz, który to konkretyzuje, jest przyziemny. Serwer przyjmuje przesyłane przez klientów arkusze. Plik przechodzi skan antywirusowy, jest zapisywany do katalogu spool, a Twoja usługa Delphi otwiera go, by wyciągnąć trzy kolumny. Wszystko wygląda w porządku, tylko że skaner i Twój parser nie zgodzili się co do tego, co archiwum zawierało. Skaner wyliczył jeden zestaw elementów; Twój loader wyliczył inny zestaw z tych samych bajtów. Żaden z nich nie ma błędu w zwykłym sensie. Po prostu rozwiązali niejednoznaczność formatu ZIP w dwóch różnych kierunkach, a atakujący dobrał bajty tak, by tak się stało
Gdzie faktycznie mieszka prawda o archiwum ZIP?
Mieszka na samym końcu, w 22-bajtowej strukturze zwanej rekordem end of central directory. Plik ZIP nie jest czytany od początku do końca: każdy element niesie lokalny nagłówek pliku tuż przed swoimi skompresowanymi danymi, ale autorytatywnym indeksem jest central directory, ciąg rekordów blisko końca, który nazywa każdy wpis i podaje przesunięcie jego lokalnego nagłówka. Aby znaleźć central directory, trzeba najpierw znaleźć EOCD, ponieważ to EOCD mówi, gdzie zaczyna się katalog i ile rekordów zawiera. HotXLS modeluje go jako TEndOfCentralDirectoryRecord, którego pola mapują się jeden do jednego na układ na dysku: FDiskNumber pod przesunięciem 4, FStartDisk pod 6, FThisDiskEntries pod 8, FTotalEntries pod 10, FSizeOfCD pod 12, FOffsetOfStartCD pod 16 i FCommentLen pod 20. Ta suma to FMinSize, obliczana w konstruktorze jako 4*3 + 5*2. Po niej następuje komentarz archiwum, do 65535 bajtów dowolnej treści, co czyni FMaxSize równym 65557 i oznacza, że rekord nie znajduje się na stałej pozycji. Trzeba go szukać
Dlaczego skanowanie wstecz w poszukiwaniu sygnatury EOCD nie wystarcza?
Ponieważ cztery bajty, których szukasz, PK\005\006, mogą legalnie pojawić się wewnątrz komentarza archiwum, wewnątrz skompresowanych danych albo wewnątrz drugiego EOCD, który atakujący dołączył celowo. Parser, który zatrzymuje się na pierwszej napotkanej sygnaturze, idąc wstecz, jest trywialnie sterowalny: umieść atrapę EOCD blisko końca, a naiwny parser podąży za nią, podczas gdy parser skanujący w innej kolejności, albo taki, który traktuje ostatnią sygnaturę w pliku jako kanoniczną, podąży za prawdziwą. To rodzina ataków opartych na niejednoznaczności ZIP, a jej wynikiem jest dokładnie ten podział opisany powyżej, gdzie silnik skanujący i aplikacja konsumująca widzą różne zestawy wpisów z jednego pliku
TEndOfCentralDirectoryRecord.Parse faktycznie skanuje wstecz. Ustawia startscan na ostatni bajt, przycina endscan do lsize - FMaxSize albo zera, i przechodzi po oknie w 256-bajtowych buforach, które zachodzą na siebie o trzy bajty, tak by sygnatura rozciągnięta na granicy bufora nigdy nie została pominięta. Różnica polega na tym, co dzieje się przy trafieniu. Znalezienie sygnatury produkuje wyłącznie przesunięcie Candidate. HotXLS czyta wtedy 22 bajty pod tym przesunięciem, parsuje je za pomocą ReadEOCD, i wymaga, by wynikowe pola były wewnętrznie zgodne z plikiem, który rzekomo opisują, zanim FOffsetEOCD w ogóle zostanie przypisane
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;
Czytaj ten predykat jako cztery osobne twierdzenia, które fałszerstwo musi spełnić jednocześnie. Candidate + FMinSize + FCommentLen = lsize wymaga, by zadeklarowana długość komentarza sięgała dokładnie do końca pliku, co zabija sztuczkę z atrapą w komentarzu: fałszywy EOCD zagrzebany wewnątrz prawdziwego komentarza nie może jednocześnie rozliczyć każdego bajtu po sobie. FDiskNumber = 0 i FStartDisk = 0 odrzucają pola rozpięcia wielodyskowego, których żaden xlsx nigdy legalnie nie użył i które istnieją w spreparowanych archiwach wyłącznie po to, by wprowadzić zamęt. FThisDiskEntries = FTotalEntries odrzuca sztuczkę z rozdzielonymi licznikami, gdzie jeden parser wymiarowałby swoją pętlę z jednego pola, a drugi parser z drugiego. A Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate wymaga, by central directory kończył się dokładnie tam, gdzie zaczyna się EOCD, więc katalog nie może wskazywać na jakiś niepowiązany blob gdzie indziej w pliku. Rzutowanie Int64 na tym ostatnim ma znaczenie: oba operandy są 32-bitowe, a bez rozszerzenia spreparowana para mogłaby się przewinąć i spełnić test arytmetycznie, wskazując donikąd rozsądnego
Lokalne nagłówki muszą zgadzać się z central directory
Sprawdzenia EOCD ustalają, który katalog jest autorytatywny; nie gwarantują jeszcze, że katalog mówi prawdę o poszczególnych elementach. Każdy wpis jest opisany dwukrotnie w pliku ZIP, raz centralnie i raz w swoim lokalnym nagłówku, i nic w formacie nie wymusza, by te dwa opisy się zgadzały, więc czytnik ufający central directory i czytnik ufający lokalnym nagłówkom mogą wyekstrahować różną treść z jednego archiwum. TZipEntry.ParseLocalHeader zamyka tę lukę, parsując lokalny nagłówek pod FCdFile.LocalFileHeaderOffset i porównując obie kopie pole po polu, zwracając odrębny kod ujemny dla każdego rodzaju niezgodności: skanonizowaną nazwę wpisu, metodę kompresji, flagi bitowe ogólnego przeznaczenia, oraz — gdy flaga deskryptora danych jest zerowa — CRC32 i obie wielkości. Przy tej fladze ustawionej lokalne kopie mogą być zerowe, ponieważ prawdziwe wartości żyją w końcowym deskryptorze, ale jakakolwiek niezerowa lokalna wartość i tak musi się zgadzać. Ostateczne sprawdzenie odrzuca wpisy, których dane wybiegałyby poza koniec pliku, porównując Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) z inputstream.Size. Każda porażka propaguje się z TCentralDirectory.Parse jako wynik różny od 1, a TZipArchive.OpenArchive zamienia to na Can't open zip archive, zamiast oddać Ci obiekt archiwum obdarzony połowicznym zaufaniem. Gdy potrzebujesz tylko wiedzieć, jakie arkusze zawiera plik, uruchomienie tej walidacji przed pełnym parsowaniem jest tanie, a lekka ścieżka inspekcji arkuszy daje Ci dokładnie to bez materializowania danych komórek
Co się dzieje, gdy same bajty kłamią?
Zgodność strukturalna wciąż nic nie mówi o ładunku, więc HotXLS opakowuje każdy strumień wpisu w TZipVerifiedStream, który egzekwuje zadeklarowany rozmiar i CRC32, gdy wywołujący czyta. To celowo nie jest sprawdzenie post factum: bomba dekompresyjna, której zadeklarowany rozmiar nieskompresowany to 4 KB, ale która napełnia się do gigabajtów, jest zatrzymywana na granicy 4 KB, nie po szkodzie. Wrapper przycina każdy odczyt do pozostałych zadeklarowanych bajtów, zgłasza ZIP entry ended before its declared size, jeśli źródło wyschnie za wcześnie, sonduje o jeden dodatkowy bajt po zakończeniu i zgłasza ZIP entry exceeds its declared size, jeśli coś zostało, i na koniec porównuje bieżące CRC32 w VerifyComplete, zgłaszając ZIP entry uncompressed size mismatch albo 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;
Jedna konsekwencja warta zaplanowania. Strumień jest z projektu tylko-w-przód; Seek w dowolne miejsce inne niż bieżąca pozycja zgłasza ZIP entry stream is forward-only, z jednym ustępstwem dla soEnd z przesunięciem zero, tak by zapytania o rozmiar wciąż działały. To właściwy kompromis dla niezaufanych danych wejściowych, ponieważ strumień, który można przewinąć, jest strumieniem, którego rozliczanie CRC można pokonać, ale oznacza to, że kod konsumujący oczekujący strumienia przeszukiwalnego potrzebuje własnego bufora. Ta sama dyscyplina tylko-w-przód leży u podstaw strumieniowego czytnika bezpośredniego, czyli API, po które warto sięgnąć, gdy przesłany skoroszyt jest na tyle duży, że w ogóle nie chcesz mieć go rezydentnego w pamięci
Limity zasobów przed alokacją, nie po
Trzy stałe w lxZipArchive ograniczają, o co pojedyncze archiwum może poprosić proces, a TZipEntries.Add stosuje je, gdy central directory jest wciąż czytany, zanim dotknięty zostanie choćby bajt danych wpisu. ZipMaxEntryUncompressedSize ogranicza jeden element do 1 GiB, ZipMaxTotalUncompressedSize ogranicza archiwum do 4 GiB, a ZipMaxCompressionRatio o wartości 10000 odrzuca każdy wpis deflate, którego zadeklarowana ekspansja przekracza dziesięciotysięczny współczynnik, wraz ze zdegenerowanym przypadkiem niezerowego rozmiaru nieskompresowanego sparowanego z zerowym rozmiarem skompresowanym. Nazwy wpisów przechodzą przez CanonicalZipEntryName w tym samym wywołaniu, które odrzuca osadzone znaki NUL, dwukropki i jakikolwiek segment ścieżki .. błędem Invalid ZIP entry name, i które normalizuje segmenty do małych liter, tak by dwa elementy różniące się tylko wielkością liter albo nadmiarowymi separatorami zderzały się jako Duplicate ZIP entry name zamiast po cichu się wzajemnie przesłaniać
Obrona w głąb ponad warstwą ZIP
Warstwa ZIP jest jedną z kilku, a wzorzec powtarza się wszędzie tam, gdzie HotXLS parsuje strukturę kontrolowaną przez atakującego. Najwyraźniejszy przykład siedzi w parserze formuł BIFF: TXLSFormula.GetTranslated rekurencyjnie przechodzi przez tokeny tMemFunc, więc spreparowany strumień tokenów rgce w starszym .xls może zagnieżdżać się dowolnie głęboko i wyczerpać stos. Bramką jest stała, MaxTranslateDepth = 256, wybrana wobec znanego faktu upstreamowego, a nie zgadnięta. Excel ogranicza zagnieżdżanie formuł do 64, więc 256 zostawia czterokrotny zapas i nigdy nie może odrzucić formuły, którą wyprodukował prawdziwy arkusz, wciąż kończąc złośliwy strumień na tyle wcześnie, zanim stos się wyczerpie
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Zauważ, że bramka zwraca nil zamiast zgłaszać wyjątek. Formuła zbyt głęboka, by być prawdziwa, nie daje drzewa składni, otaczające parsowanie kontynuuje, a skoroszyt wciąż się wczytuje. Ta asymetria jest zamierzona i warta skopiowania we własnych limitach: ograniczenie istniejące po to, by zatrzymać wyczerpanie zasobów, powinno degradować najmniejszą jednostkę, jaką może, a nie przerywać dokument. Ta sama logika stosuje się, gdy rozszerzasz warstwę obliczeniową, więc jeśli rejestrujesz własne handlery przez API niestandardowych funkcji silnika formuł, nadaj im własne ograniczenia argumentów i rekursji zamiast zakładać, że wywołujący już to sprawdził
Czego te sprawdzenia Ci nie kupują
Bądź precyzyjny co do granicy. Cztery krzyżowe sprawdzenia EOCD czynią indeks archiwum jednoznacznym, więc HotXLS i każdy inny zgodny czytnik rozwiązują ten sam plik do tego samego zestawu wpisów; nie mówią nic o tym, czy ten zestaw wpisów jest niegroźny. Zgodność lokalnych nagłówków zatrzymuje sztuczkę dwóch widoków, nie złośliwy ładunek opisany konsekwentnie. Zweryfikowany strumień zatrzymuje obcięcie, przepełnienie i uszkodzenie, nie doskonale poprawną część XML, która koduje coś, czego się nie spodziewałeś. I nic z tego nie dotyka makr: projekt VBA wewnątrz strukturalnie nienagannego skoroszytu wciąż jest projektem VBA, a decyzja o zachowaniu, usunięciu albo odrzuceniu go należy do Twojej warstwy polityki, nie do czytnika ZIP
Tym, co dostajesz w zamian, jest czysta granica awarii. Niezaufany xlsx albo otwiera się jako jedno jednoznaczne archiwum, którego elementy zgadzają się ze swoimi zadeklarowanymi rozmiarami i sumami kontrolnymi, albo zgłasza wyjątek z komunikatem nazywającym konkretny naruszony niezmiennik, a Twoja usługa może poddać kwarantannie na podstawie wyjątku zamiast zgadywać. Czytnik ZIP i warstwy parsera ponad nim są dostarczane jako część komponentu HotXLS Excel dla Delphi i C++Builder, który nie potrzebuje na maszynie wykonującej parsowanie ani Excela, ani automatyzacji OLE, a ten brak sam w sobie jest znaczącym ograniczeniem tego, do czego przesłany plik może dotrzeć