Artykuł techniczny

Parser PDF wisi w Delphi: kursor się nie rusza

Parser zawiesza się na uszkodzonym PDF-ie, gdy jakaś gałąź odczyta bajt, odmówi jego obsługi i wróci bez przesunięcia kursora. Pętla wywołująca nie widzi postępu ani powodu do zatrzymania, więc kręci się w nieskończoność. HotPDF dostarczył dokładnie ten defekt w trzech niezależnych parserach, wszystkie osiągalne z wejść poniżej pięćdziesięciu bajtów

To klasa ataków denial-of-service, którą warto nazwać osobno, bo nie wygląda jak ta, przed którą wszyscy już się zabezpieczają. Nie ma dekompresji, nie ma wzrostu alokacji, nie ma presji na pamięć, która uruchomiłaby watchdog. Proces po prostu siedzi na stu procentach jednego rdzenia z garścią bajtów w buforze. Jeśli dodałeś budżet dekodowania po lekturze o amplifikacji zagnieżdżonych filtrów i bombach PDF, tu on nie zadziała: nic nie jest dekodowane i nic nie jest alokowane. Inna awaria, inna poprawka

Dlaczego czterdziestobajtowy plik kosztuje więcej niż bomba dekompresyjna?

Bo koszt w ogóle nie zależy od rozmiaru wejścia. Atak amplifikacyjny ma współczynnik, który da się ograniczyć: cap na bajty wyjścia, cap na głębokość rekurencji, a najgorszy przypadek staje się skończony. Pętla zerowego postępu nie ma żadnego współczynnika do ograniczenia. Warunek pętli jest funkcją kursora, który się nigdy nie zmienia, więc to nie jest wolne działanie, tylko brak terminacji, i żaden limit rozmiaru po stronie wywołującej tego nie złapie. To czyni go też wyjątkowo paskudnym operacyjnie. Crash daje ci stack trace, brak pamięci daje wpis w logu, a zablokowany parser daje wątek roboczy, który nigdy nie zwraca wyniku, żądanie, które nigdy nie dostaje timeoutu, bo nikt niżej nie wie, że się w ogóle zaczęło, i ticket, że we wtorek serwer nagle zwolnił. Przyczyna strukturalna ma zawsze ten sam kształt. Tokenizator lub parser wartości ma łańcuch gałęzi kluczowany bieżącym bajtem. Każda gałąź, która coś rozpoznaje, konsumuje to, co rozpoznała. Potem jest gałąź końcowa albo strażnik przed łańcuchem, który zwraca wartość dla bajtów, których nikt nie zaadoptował, a autor traktuje go jako ścieżkę błędu, nie jako krok skanowania. Ścieżki błędów zwracają. Kroki skanowania przesuwają kursor. Gdy jedna funkcja musi być jednym i drugim, to właśnie przesunięcie zostaje zapomniane

Diagram pętli zerowego postępu w parserze HotPDF: wywołujący prosi ParseValue o następną wartość, parser odrzuca bieżący bajt i zwraca nil bez dotknięcia kursora, a wywołujący pyta w nieskończoność; w przeciwieństwie do bomby dekompresyjnej nie ma tu współczynnika do ograniczenia ani budżetu dekodowania, który mógłby zadziałać
Zablokowany parser siedzi na stu procentach CPU z garścią bajtów, więc każda funkcja czytająca kursor musi skonsumować co najmniej jeden bajt albo zwrócić jednoznaczną porażkę

Parser obiektów: dwa ograniczniki, których nikt nie skonsumował

W HPDFParser metoda THPDFSpanObjectParser.ParseValue na starcie odrzuca dwie sekwencje bajtów. Zgodnie z sekcją 7.3 normy ISO 32000-1 wartość nie może zaczynać się od ] ani >>, bo to zamyka kontener zamiast otwierać wartość, więc ParseValue zwraca nil dla obu bez ich konsumowania. To poprawny kontrakt i cały ciężar przesuwania spada na wywołującego. Gałąź tablicy wypełniła połowę zobowiązania: przy wyniku nil sprawdzała ], zostawiała go na miejscu, żeby następny przebieg zamknął tablicę, i przeskakiwała wszystko inne — z tym że sprawdzenie było napisane tak, że zbłąkane >> też zostawało na miejscu. Więc [ >> ] wisi, bo ParseArray prosi o wartość, dostaje nil, nie przesuwa niczego i pyta dalej. Gałąź słownika miała lustrzaną dziurę i << /A ] >> wisi tak samo. Oba wejścia są na tyle poprawne, żeby dotrzeć do parsera, i na tyle krótkie, żeby zmieścić się w jednym tweecie

Diagram defektu parsera obiektów HotPDF, gdzie ParseValue odrzuca oba ograniczniki zamykające bez ich konsumowania: gałąź tablicy toleruje zamykający nawias, ale zostawia na miejscu zbłąkane podwójne zamknięcie, przez co próbka tablicy kręci się w nieskończoność; gałąź słownika ma lustrzaną dziurę, a poprawka wprost przeskakuje sekwencję, na której pętla nie może zadziałać
Zwrócenie nil dla niezaadoptowanego bajtu to poprawny kontrakt i cały ciężar przesuwania spada na pętlę wywołującą
uses
  HPDFObjs, HPDFParser;

procedure ProbeParserStalls;
var
  Stats: THPDFParserViewStatistics;
  Arr: THPDFArrayObject;
  Dict: THPDFDictionaryObject;
begin
  // Przed poprawką żadne z poniższych wywołań nie zwracało sterowania
  Arr := HPDFParserParseSimpleArrayWithStatistics('[ >> ]', Stats);
  Arr.Free;
  WriteLn('array errors=', Stats.ErrorCount, ' valid=', Stats.Valid);

  Dict := HPDFParserParseSimpleDictionaryWithStatistics('<< /A ] >>', Stats);
  Dict.Free;
  WriteLn('dict errors=', Stats.ErrorCount, ' valid=', Stats.Valid);
end;

Poprawka nie jest sprytna i o to chodzi: ścieżka nil musi wprost zdecydować, na którą z dwóch nieskonsumowanych sekwencji bieżąca pętla potrafi zareagować, i przeskoczyć tę drugą

Value := ParseValue(Depth + 1);
if Value = nil then
begin
  RecordError;
  // ParseValue zwraca nil zarówno dla ']', jak i '>>', nie konsumując
  // ich. ']' kończy tablicę przy następnym przebiegu, ale samotny '>>'
  // wewnątrz tablicy trzeba pominąć, bo inaczej parsowanie nigdy nie ruszy dalej
  if (FPosition < FLength) and StartsWithPair('>', '>') then
    Inc(FPosition, 2)
  else if (FPosition < FLength) and (CharAt(FPosition) <> ']') then
    Inc(FPosition);
  Continue;
end;

Jeszcze jedna rzecz zasługuje tu na uwagę. THPDFSpanObjectParser.RecordError tylko zwiększa ErrorCount w THPDFParserViewStatistics; niczego nie ogranicza. Parser ustawia HPDFParserMaximumDepth na 64, żeby obciąć zagnieżdżanie, ale nie ma odpowiednika sufitu na liczbę błędów, a licznik, którego nikt nigdy nie czyta, to przepuszczony wyłącznik awaryjny. Nawet po przywróceniu przesuwania wejście produkujące jeden błąd na bajt przegryzie duży obiekt w tempie, które zasługuje na wcześniejsze wyjście zamiast pełnego przejścia. Sprawdzaj Valid na rekordzie statystyk po każdym parsowaniu i traktuj duży ErrorCount jako powód, żeby przestać ufać obiektowi, a nie tylko jako diagnostykę

Ten sam kształt w tokenizatorze CMap i czytniku XFDF

HPDFCMapReader.NextToken niósł identyczny defekt w gałęzi awaryjnej. Funkcja zwraca TCMapTok i obsługuje <, ( i / w dedykowanych gałęziach; wszystko inne spada do „czytaj do białego znaku lub ogranicznika". Gdy bajt startowy sam jest ogranicznikiem ze zbioru z sekcji 7.2.2 normy ISO 32000-1, ta pętla wykonuje się zero razy, token wraca pusty, a wywołujący prosi o ten sam token ponownie. Z sześciu znaków, które mogą tam trafić, tylko [ był wcześniej przechwytywany przez obsługę bfrange, więc zbłąkane ), >, ], { lub } gdziekolwiek w CMap ToUnicode wystarczało, żeby zablokować ekstrakcję tekstu

// keyword lub liczba: czytaj aż do białego znaku lub delimitera
StartP := P;
while (P <= Len) and (not IsCMapWhiteSpace(Text[P]))
  and (not IsCMapDelimiter(Text[P])) do
  Inc(P);
// Delimiter, którego nie obsługuje żadna z powyższych gałęzi (samotny ')', '>',
// '[', ']', '{' lub '}'), zostawiłby P w miejscu i zwrócił
// pusty keyword, więc wywołujący prosiłby o ten sam token w nieskończoność
if P = StartP then
  Inc(P);
AKeyword := Copy(Text, StartP, P - StartP);
Result := ctKeyword;

Strona XFDF miała wariant o innym pochodzeniu i tym samym efekcie. HPDFXFDFXml.ParseElement na początku ustawia Result := P, gdy P wciąż wskazuje otwierający <, i dopiero potem zwiększa lokalny kursor, więc każde wcześniejsze wyjście zwraca pozycję wejścia, chyba że najpierw nadpisze Result. Deklaracja doctype według sekcji 2.8 XML 1.0 albo cokolwiek innego bez parsowalnej nazwy tagu, jak <1a>, szło wyjściem, które nigdy nie aktualizowało Result, i pętla elementów potomnych dostawała tę samą pozycję, którą sama przekazała. Pętla atrybutów miała trzy kolejne takie miejsca, bo ReadName, pomijanie białych znaków i obsługa = odrzucają bajt, który nie jest znakiem nazwy, białym znakiem ani jednym z =, >, /, i żadna z nich go nie konsumowała. Sąsiednia naprawa CDATA przenosiła odpowiedzialność zamiast dodawać strażnika: sekcja CDATA według sekcji 2.7 XML 1.0 zaczyna się od <, więc pętla potomna przekazywała ją do ParseElement, który potem czytał nazwę tagu z zawartości sekcji. Rozpoznanie jej w pętli potomnej przez IsCDATAStart i kopiowanie bajtów do bufora tekstu aż do IsCDATAEnd to poprawny podział, a stary skip był też o jeden bajt za krótki, bo marker liczy osiem bajtów od !

Zadeklarowane liczniki, które przeżywają dane

Późniejsza wersja ujawniła ten sam wzorzec z drugiej strony, i to jest wariant najprawdopodobniej obecny w twoim własnym kodzie. Nagłówek podsekcji cross-reference według sekcji 7.5.4 normy ISO 32000-1 to para first count, a oba parsery klasycznego xref w HotPDF kręciły po nim pętlą for I := 0 to Count - 1, używając Continue zamiast Break, gdy wpis się nie parsował. To wygląda jak tolerancja na jedną złą linię. Nie jest: HPDFReadLine zwraca pusty string bez przesuwania, gdy dane się skończą, a HPDFParseTraditionalXrefEntry natychmiast odrzuca pusty wpis, więc każda pozostała iteracja zadeklarowanego licznika to czysty spin. Arytmetyka jest bezlitosna — nagłówek xref\n0 2000000000\ntrailer plus minimalna otoczka to około czterdziestu bajtów, a kupuje dziesiątki sekund obciążonego rdzenia bez żadnego sprawdzania anulowania w pętli. Trop siedział w tym samym unicie, bo HPDFRevisionParseTraditionalXRef zawsze używał w tym miejscu Exit. Gdy dwie funkcje parsują tę samą gramatykę i nie zgadzają się co do tego, czy nieudany wpis kończy podsekcję, jedna z nich jest forkiem, który przestał dostawać poprawki — ten sam sygnał, który widać w obsłudze hybrydowego cross-reference, gdzie klasyczna tabela i strumień opisują te same obiekty

Diagram klasycznej podsekcji xref w HotPDF, której nagłówek deklaruje 2000000000 wpisów dla około czterdziestu bajtów pliku: HPDFReadLine zwraca pusty string bez przesuwania, gdy dane się skończą, Continue trzyma każdą pozostałą iterację w spinie, a poprawiona pętla przerywa na pierwszym złym wpisie z sufitem Stream.Size div 8
Zadeklarowany licznik to twierdzenie o pliku, nie o danych, więc nieudany wpis musi kończyć podsekcję zamiast pomijać iterację
if not HPDFParseIntegerPair(Line, First, Count) then
  Exit;
for I := 0 to Count - 1 do
begin
  Entry := HPDFReadLine(XrefData, P);
  // W przeciwnym razie zadeklarowana liczba większa niż rozmiar tabeli zapętliłaby się w nieskończoność
  // tu, bez danych do odczytania
  if not HPDFParseTraditionalXrefEntry(Entry, EntryOffset, GenNum, InUse) then
    Break;
  // Pierwsza pochodzi z nagłówka podsekcji, więc suma zawija się dla
  // zadeklarowanego początku blisko limitu Integer, a funkcja powiększania
  // silently refuses a negative index
  if (First < 0) or (Int64(First) + I > MaxInt) then
    Break;
  ObjNumber := First + I;
  if (ObjNumber < 0) or (Int64(ObjNumber) > Stream.Size div 8) then
    Break;
  HPDFEnsureDirectXrefLength(State, ObjNumber);
  // ... zapisz wpis ...
end;

Oba ograniczenia za Break należą do tej samej naprawy. HPDFEnsureDirectXrefLength odmawia rozszerzenia tablicy dla ujemnego numeru obiektu i nic o tym nie mówi, a First + I zawija się przy zadeklarowanym starcie blisko limitu Integer, więc ujemny indeks dociera do zapisów poniżej. Sufit na numer obiektu Stream.Size div 8 to tania, ogólna odpowiedź, bo żadna klasyczna tabela cross-reference nie może opisać więcej obiektów, niż plik ma miejsca, a każdy nagłówek twierdzący inaczej opisuje plik, który nie istnieje. Jeśli ręcznie naprawiasz uszkodzoną tabelę, ta sama logika działa z drugiej strony, a to teren opisany w notatkach o diagnozowaniu i naprawianiu ręcznie edytowanych plików PDF

Jak udowodnić, że poprawka na zawieszenie faktycznie działa?

Uruchamiając stary kod, nie czytając go ponownie. Zakleszczenie to jedna z niewielu klas defektów, w których poprawkę można zweryfikować sygnałem binarnym bez instrumentacji, bo proces albo wraca, albo nie, a rozumowanie o przepływie sterowania to dokładnie ta umiejętność, która wyprodukowała błąd w pierwszej kolejności. Mechanikę warto skopiować. Wyciągnij rewizję sprzed poprawki zmienianych unitów do katalogu scratch, a potem skompiluj jedną małą sondę ze ścieżką poszukiwań unitów ustawiającą ten katalog przed prawdziwą biblioteką: dcc32 -U<scratchdir>;<Lib> probe.dpr. Kompilator bierze stare źródła dla wszystkiego, co znajdzie pierwsze, i bieżące źródła dla reszty, więc jedna binarka sondy ćwiczy stary parser przeciw reszcie biblioteki. Uruchom ją pod timeoutem — czterdzieści sekund to hojnie — i traktuj kod wyjścia 124 jako asercję. Potem wycofuj unity z katalogu scratch pojedynczo i przebudowuj. Wszystkie trzy zakleszczenia odtworzyły się w ten sposób, każde przypisane konkretnemu unitowi, i wszystkie trzy zniknęły, gdy poprawione źródła wróciły na ścieżkę, kosztem jednej kompilacji na unit

Dwie pułapki warto znać, zanim spróbujesz. Ścieżka poszukiwań unitów jest po cichu pobłażliwa: podaj -U ścieżkę w stylu Unix z shella, jak $PWD, a kompilator zignoruje ten katalog bez słowa, co znaczy, że twoja sonda skompilowała się przeciw bieżącym źródłom i przeszła z niewłaściwego powodu. Najpierw przekonwertuj ścieżkę do natywnej formy Windows. Drugie: katalog scratch nie niesie plików include, więc potrzebujesz też -I<Lib> dla HotPDF.inc, inaczej build padnie w sposób wyglądający jak problem ze źródłami, nie ze ścieżką. I bądź szczery co do tego, czego metoda ci nie daje: potwierdza, że konkretne wejście przestało wisieć na konkretnym buildzie, i nie mówi nic o wejściach, których nie wymyśliłeś. Defekty zerowego postępu znajduje się fuzzingiem albo czytaniem każdej gałęzi każdego tokenizatora, nie testami regresyjnymi pisanymi po fakcie

Co audytować we własnych parserach

Wielokrotnego użytku reguła to kontrakt, nie pozycja z checklisty. Każda funkcja czytająca współdzielony kursor musi na każdej ścieżce, wliczając ścieżki błędów, albo skonsumować co najmniej jeden bajt, albo zwrócić wartość, której wywołujący nie pomyli z „spróbuj jeszcze raz". Zmieszanie tych dwóch znaczeń w jednym nilu albo jednym pustym tokenie to właśnie to, co zamienia odmowę w zakleszczenie. Audytuj kształt, nie symptom. Znajdź każdą pętlę, której terminacja zależy od kursora należącego do innej funkcji, i zapytaj, co się dzieje, gdy ta funkcja odrzuca bieżący bajt. Znajdź każdą gałąź awaryjną napisaną jako „czytaj do X" i zapytaj, co robi, gdy kursor już siedzi na X. Znajdź każdą pętlę ograniczoną licznikiem pochodzącym z pliku, nie z danych, i spraw, żeby nieudany element kończył pętlę zamiast pomijać iterację. Tam, gdzie ta sama gramatyka jest parsowana w dwóch miejscach, zrób diff, bo jedno z nich dostanie poprawkę, której drugie nie ma. I wstaw sprawdzanie anulowania do każdej pętli, która może działać przez liczbę iteracji podaną przez wywołującego, żeby operator miał co pociągnąć, gdy następny taki przypadek wyląduje w produkcji

Opisane tu parsery są dostępne w bieżącym HotPDF Delphi Component dla Delphi i C++Builder, z wystawionymi rekordami statystyk i limitami głębokości, żeby twoje własne utwardzanie mogło na nich budować