PDF o rozmiarze 20 KB, który przypina proces usługi, aż zabije go OOM killer, to nie błąd w Twoim kodzie, to bomba dekompresyjna. HotPDF, natywny komponent VCL PDF dla Delphi i C++Builder, ogranicza taką bombę za pomocą DecodeBudgetBytes, pułapu na cały łańcuch filtrów, który domyślnie wynosi 268435456 bajtów i nalicza każdy etap dekodowania na jeden współdzielony budżet
Plik 20 KB, który pożarł proces roboczy
Przebieg incydentu jest zawsze taki sam. Worker kolejki renderujący miniatury odbiera przesłany plik, pamięć rezydentna wspina się powyżej 12 GB w mniej niż dwie sekundy, a proces znika bez śladu w stacku. Plik ma 20 KB. Ma jedną stronę, jeden strumień treści i tablicę /Filter z pięcioma wpisami. Każda nazwa w tej tablicy to filtr zdefiniowany przez specyfikację, każdy etap dekoduje bez błędu i nic w pliku nie jest zniekształcone. To właśnie czyni tę klasę danych wejściowych kłopotliwą: nie ma zepsutego bajtu do odrzucenia
To nie jest ten sam problem co poprawne dekodowanie pojedynczego filtra. Właściwe obsłużenie LZWDecode i predyktora /DecodeParms to osobny temat, omówiony w przewodniku po LZW, predyktorach i DecodeParms na wczytanych dokumentach. Tutaj każdy dekoder jest już poprawny. Awarią jest to, co poprawne dekodery robią, gdy uruchomisz pięć z nich jeden po drugim, a nikt nie liczy sumy. ISO 32000-1 §7.4 jasno mówi, że /Filter może być pojedynczą nazwą albo tablicą nazw, i że tablica jest stosowana sekwencyjnie, pierwszy wpis pierwszy. Nie mówi nic o tym, o ile etap może rozszerzyć swoje dane wejściowe, ani nic o sumie dla całego łańcucha. Etap ASCIIHexDecode mniej więcej zmniejsza swoje dane o połowę, co brzmi nieszkodliwie. Etap FlateDecode na ciągu zerowych bajtów osiąga współczynniki rzędu tysięcy. Połącz je w łańcuch, a arytmetyka staje się multiplikatywna: 20 KB staje się 20 MB, staje się 20 GB, a każdy pojedynczy krok jest zgodnym dekodowaniem legalnego strumienia
Dlaczego limit per-filtr nie zatrzymuje bomby dekompresyjnej?
Ponieważ limit per-filtr jest ponownie uzbrajany przy każdym elemencie tablicy /Filter. Łańcuch pięciu etapów pod pułapem 256 MiB na etap upoważnia do 1,25 GiB, a ostatni etap wciąż zaczyna z zupełnie świeżym przydziałem, bez względu na to, co wyprodukowały cztery poprzednie. Limit jest egzekwowany uczciwie i nie ogranicza niczego, co ma znaczenie. HotPDF miał dokładnie taki kształt przed v2.447.0 i miał obok tego drugą lukę. Dekompresor LZW niósł pułap MaxOutputBytes, a ścieżka predyktora obrazu rozliczała własne wiersze, więc te dwa były ograniczone lokalnie. FlateDecode, ASCIIHexDecode, ASCII85Decode i RunLengthDecode nie miały żadnego pułapu: każdy pisał do TMemoryStream, dopóki nie skończyły się dane wejściowe albo alokator się nie poddał. Wrogi łańcuch miał więc dwie drogi. Mógł użyć całkowicie niestrzeżonego filtra albo mógł użyć strzeżonych i po prostu dodać ich więcej
Jest trzeci szczegół, który naiwna naprawa pomija. Liczbą, na której Ci zależy, nie jest rozmiar końcowego zdekodowanego wyniku. To szczyt, a szczyt zwykle żyje w buforze pośrednim. Łańcuch kończący się skromnym 4 MB strumieniem treści może zaalokować 8 GB na etapie trzecim i oddać coś, co wygląda całkowicie rozsądnie. Sprawdzenie długości wyniku po fakcie nic Ci nie powie o alokacji, która zabiła proces
Jeden tracker budżetu na łańcuch filtrów
Naprawą w HotPDF v2.447.0 jest sprawienie, by rozliczanie obejmowało łańcuch, a nie etap. Każdy łańcuch filtrów konstruuje jeden THPDFDecodeBudgetTracker, a każdy dekoder pisze przez THPDFBudgetWriteStream, który opakowuje rzeczywisty cel. Wrapper wywołuje Budget.Consume(Count), zanim przekaże choćby jeden bajt, więc odmowa następuje, gdy strumień docelowy wciąż ma swój stary rozmiar. Ta kolejność to cały sens: sprawdzenie wykonane po tym, jak bufor już urósł, jest diagnostyką, nie obroną
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
Lokalne pułapy nie zniknęły, stały się projekcjami współdzielonego budżetu. Etap LZW ustawia teraz Decoder.MaxOutputBytes := Budget.RemainingBytes, więc jego prywatny pułap jest tym, co zostało z łańcucha, a nie niezależnym przydziałem. Etap predyktora obrazu otwiera się BeginFilter i nalicza swoje zapotrzebowanie na wiersze przez Consume, zanim zaalokuje, co oznacza, że wyjście predyktora jest rozliczane na ten sam budżet co filtry generyczne, które je zasiliły. Ma to szczególne znaczenie na ścieżce obrazów, gdzie łańcuch filtrów i predyktor to dwie połówki jednej operacji, co omawia ekstrakcja obrazów z wczytanych dokumentów przez ich filtry dekodowania
Co widzi wywołujący, gdy budżet odmawia?
Na dole stosu odmowa zgłasza EHPDFDecodeBudgetError. Powyżej tego, odpowiedź zależy od kontraktu, jaki miało już dane API wywołujące. Metody odczytu wysokiego poziomu, które raportowały niepowodzenie przez False albo nil, robią dokładnie to samo dalej, ponieważ zamiana udokumentowanego wyniku boolowskiego na wyjątek zepsułaby wywołujących, którzy już poprawnie obsługiwali zniekształcone dane wejściowe. Ścieżka treści wczytanej strony jest celowym wyjątkiem: ponownie zgłasza EHPDFDecodeBudgetError zamiast pozwolić, by obcięty strumień treści wyrenderował się jako strona, która po prostu wyszła pusta. Ten projekt oznacza, że sam gołe False jest niejednoznaczne, więc budżet publikuje razem z nim rekord diagnostyczny: THotPDF.GetLastDecodeBudgetInfo zwraca stan ostatniego łańcucha, jaki instancja zdekodowała
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
Odczytane razem, te pola rozdzielają dwa kształty ataku. Gdy PeakStageBytes jest bliskie DecodedBytes, to jeden etap wyrządził całą szkodę i patrzysz na pojedynczy filtr o wysokim współczynniku. Gdy PeakStageBytes jest małym ułamkiem DecodedBytes, a FilterCount jest wysokie, żaden pojedynczy etap nie był ekstremalny i to łańcuch skumulował się ponad pułap, co jest właśnie przypadkiem, którego limit per-filtr nie potrafi zobaczyć. Jedna uwaga warta zapisania w Twoim handlerze: GetLastDecodeBudgetInfo zwraca False, dopóki instancja nie zdekodowała przynajmniej jednego filtra, więc False z niego nie jest dowodem, że dokument był czysty
Gdzie budżet się resetuje i kiedy zero jest uczciwą odpowiedzią
DecodeBudgetBytes ogranicza jeden łańcuch strumieni, nie jeden dokument, i ta granica jest celowa, ale łatwa do błędnego odczytania. Każdy strumień treści, każdy osadzony plik, każdy strumień cross-reference i każdy strumień obiektów zaczyna ze świeżym 256 MiB. Dokument liczący 4000 stron ma więc 4000 niezależnych szans na wydanie pełnego pułapu, a strumienie obiektów mnożą tę liczbę dalej, bo każdy z nich sam jest skompresowanym kontenerem trzymającym wiele obiektów, jak opisano w notatkach o strumieniach obiektów i aktualizacjach przyrostowych. Jeśli Twoim rzeczywistym wymogiem jest ograniczenie całkowitej pamięci procesu, ta właściwość jest jednym z wejść do tego, nie całością, i powinna siedzieć za pułapem na poziomie zadania albo kontenera
Zero oznacza brak limitu i jest to ustawienie legalne, nie furtka. Ustaw je, gdy jesteś właścicielem danych wejściowych: pipeline ponownego przetwarzania archiwum nad dokumentami wyprodukowanymi przez Twój własny system, albo krok rasteryzacji, gdzie pojedynczy łańcuch kolorowego skanu 600 dpi naprawdę potrzebuje więcej niż jakikolwiek pułap, który wygodnie byłoby zahardkodować. Wartości ujemne są odrzucane od razu przez ERangeError, ponieważ ujemny budżet nie ma spójnego znaczenia, a ciche przycięcie go ukryłoby błąd konfiguracji
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
Wybór liczby zasługuje na więcej uwagi, niż zwykle dostaje, ponieważ zbyt nisko ustawiony budżet to samozadana awaria. Uruchom swoje istniejące korpus danych z wartością domyślną, zapisz PeakStageBytes i DecodedBytes dla każdego łańcucha i ustaw pułap powyżej zaobserwowanego maksimum z realnym zapasem. Okrągła liczba wybrana, bo brzmiała bezpiecznie, odrzuci legalny duży skan w najgorszym możliwym momencie, a awaria w Twoich logach będzie wyglądać dokładnie jak atak
Kopia, która już nie zachodzi
Przepuszczenie każdego etapu przez wrapper budżetu okazało się uczynić łańcuch tańszym, a nie droższym. Gdy strumień ma filtry, pierwszy etap czyta teraz strumień źródłowy bezpośrednio zamiast najpierw kopiować zakodowane bajty do bufora roboczego, a od tego momentu żyją naraz tylko dwa bufory: bieżące wejście i wyjście aktualnego etapu, które jest zapisywane. Surowa kopia przetrwała w dwóch przypadkach, które jej potrzebują, mianowicie strumień bez żadnych filtrów i obraz, gdzie wywołujący chce zachować ostatnie kodowanie, ponieważ oba oddają strumień, którego wywołujący jest właścicielem i może przeszukiwać niezależnie. Niestrzeżona wersja tego kodu alokowała więcej i ograniczała mniej, co jest zwykłą zależnością między tymi dwoma. Warto jednak jasno powiedzieć: nic z tego nie czyni dowolnego PDF-a bezpiecznym do wczytania. Zamyka jeden konkretny i bardzo tani wektor odmowy usługi, ten, w którym mały plik kupuje dużą alokację przez zagnieżdżone filtry. Przepełnienie liczbowe w rozliczaniu bajtów jest strzeżone osobno, a szersze pytanie o parsowanie wrogich dokumentów bez ufania ich wewnętrznym przesunięciom to inna dyscyplina. Budżet dekodowania to jedno ograniczenie spośród kilku, a jego wartość polega na tym, że jest to jedyne, które możesz ustawić z pojedynczej właściwości, zanim dotkniesz pliku
Budżet per łańcuch, jego rekord diagnostyczny i ścieżki dekodowania wczytanego dokumentu, które chroni, są dostarczane jako część samego komponentu, bez żadnej zewnętrznej zależności dekompresyjnej do konfigurowania czy łatania. Jeśli oceniasz, jak ograniczyć niezaufane dane wejściowe PDF wewnątrz usługi Delphi lub C++Builder, strona komponentu HotPDF Delphi PDF wymienia zestaw narzędzi dla wczytanych dokumentów, do których stosują się te limity