Artykuł techniczny

Bezpieczne analizowanie słowników PDF w Delphi: tokeny nazw

Wyszukiwanie podciągów za pomocą funkcji takich jak Pos('/Length', Dict) to niewłaściwe narzędzie do odczytywania słowników PDF, ponieważ klucze nazw PDF często współdzielą te same przedrostki (prefiks): /Length to prefiks dla /Length1, a /Encrypt to prefiks dla /EncryptMetadata. Standard ISO 32000-1 §7.3.5 definiuje nazwę jako token kończący się wyłącznie separatorem (delimiter) lub białym znakiem, stąd klucz uważa się za znaleziony tylko wtedy, gdy następujący po nim bajt jest jednym z tych znaków. Moduł odczytu słownika, który pomija to jedno sprawdzenie, ostatecznie odczyta błędną wartość z całkowicie poprawnego pliku

Błąd, który nas tego nauczył, w niczym nie przypominał problemu z analizatorem leksykalnym (lexer). Skaner zgodności zaczął zgłaszać uszkodzenie strumienia FontFile: rozpakowany program czcionki był ucięty w połowie tabeli. Plik otwierał się poprawnie w każdej przeglądarce. Dane strumienia na dysku były nienaruszone. Główna przyczyna tkwiła w jednej linijce naszego wspólnego czytnika słowników: funkcja Pos('/Length', ...) dopasowała klucz /Length1, będący standardowym kluczem, który słowniki strumieni FontFile przechowują zgodnie z tabelą 127 standardu ISO 32000-1. Czytnik przyjął liczbę po /Length1 as the stream length. Generator tego konkretnego pliku akurat zapisał klucz /Length1 przed /Length, co jest całkowicie dozwolone, ponieważ wpisy w słownikach są nieuporządkowane zgodnie z sekcją §7.3.7. Strumień został skrócony do błędnej liczby bajtów, a każda kolejna funkcja korzystająca z tych danych cicho pomijała błędy

Dlaczego dopasowywanie podciągów uszkadza analizowanie słowników PDF?

Dopasowywanie podciągów zawodzi, ponieważ przestrzeń nazw PDF jest pełna celowych rodzin prefiksów, a kolejność wpisów w słowniku nie jest określona. Tabela 127 standardu ISO 32000-1 definiuje klucze /Length1, /Length2 oraz /Length3 dla wbudowanych strumieni czcionek, z których wszystkie znajdują się obok klucza /Length w tym samym słowniku. Słownik szyfrowania (encryption dictionary) łączy klucz /Encrypt w sekcji trailer z kluczem /EncryptMetadata w jego wnętrzu. Krótkie klucze stanowią jeszcze większe zagrożenie: wyszukiwanie zapisane jako Pos('/' + Key, ...), gdzie Key = 'N', bez problemu dopasuje się do /Name lub /Nums. Żadna z tych kolizji nie wymaga uszkodzonego pliku. Zapisanie przez generator klucza /Length1 przed /Length jest w pełni zgodne ze specyfikacją, co oznacza, że błąd dopasowania podciągów nie jest kwestią odporności na uszkodzone dane — to błąd poprawności obsługi w pełni poprawnych plików

Ten rodzaj błędu należy również do tych „cichych”. Błędna wartość /Length nie wywołuje wyjątku; po prostu przekazuje krótszy lub dłuższy fragment danych niż ten, który w rzeczywistości zajmuje strumień. Jeśli ten wycinek trafia do analizy podzbiorów czcionek, mapowania CMap lub skanowania metadanych, odbiorca otrzymuje niepoprawne dane i zazwyczaj nic nie zgłasza, ponieważ niepełny strumień zlib po prostu nie daje się rozpakować, a kod przechodzi dalej. Wydaliśmy dokładnie taki rodzaj błędu i naprawiliśmy go w wersji v2.14.3 naszego wspólnego czytnika, po szczegółowym audycie sekcji ISO 32000-1 §7.2–§7.3, który zakwalifikował każde wyszukiwanie w stylu Pos jako podejrzane

Jak sekcja ISO 32000-1 §7.3.5 definiuje nazwę

Sekcja 7.3.5 jest krótka i precyzyjna: obiekt nazwy to ukośnik (solidus), po którym następuje ciąg zwykłych znaków, a token kończy się na pierwszym separatorze lub białym znaku. Separatory to osiem znaków nawiasów oraz ukośnik i znak procentu — ( ) < > [ ] { } / % — a białymi znakami są znaki null, tabulacja, znak nowej linii (LF), znak końca strony (FF), powrót karetki (CR) oraz spacja (§7.2.2–§7.2.3). Ta reguła zakończenia tłumaczy wszystko. Zapis /Length1 to nie „/Length, po którym następuje 1”; to pojedynczy, niepodzielny token, podobnie jak LengthOne i Length to dwa różne identyfikatory w Pascalu. Każdy czytnik szukający kluczy metodą czystego przeszukiwania bajtów próbuje zaimplementować analizator leksykalny z pominięciem reguły zakończenia tokenu

Oto postać tego defektu sprowadzona do minimum. Ta wersja kompiluje się, przechodzi testy dla plików, w których autorzy umieścili klucz /Length na początku, lecz uszkadza strumienie przy innej kolejności zapisu:

// BŁĄD: dopasuje również /Length1, /Length2, /Length3
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Dopasowywanie całych tokenów: sprawdzenie bajtu po kluczu

Poprawny predykat wynika bezpośrednio z sekcji §7.3.5: dopasowanie kandydackie jest rzeczywistym kluczem tylko wtedy, gdy znak bezpośrednio po nim jest separatorem, białym znakiem lub końcem bufora. Wszystko inne to dłuższa nazwa, która jedynie współdzieli przedrostek, więc wyszukiwanie musi być kontynuowane dalej. Rozwiązanie wprowadzone w naszym czytniku zastąpiło każde zwykłe wywołanie Pos jedną wspólną funkcją opartą na tej regule

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Poprawne: dopasowanie całego tokenu według ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token kończy się tutaj: właściwy klucz
    P := PosEx(Key, Dict, P + 1);    // przedrostek dłuższej nazwy: szukaj dalej
  end;
end;

Dwa szczegóły w tej pętli mają kluczowe znaczenie. Po pierwsze, wyszukiwanie jest kontynuowane pomimo kolizji pierwszego przedrostka, ponieważ zapis /Length1 120 /Length 4076 jest poprawny, a właściwy klucz znajduje się dalej. Po second, sytuacja końca bufora jest traktowana jako terminator, ponieważ fragment słownika może poprawnie kończyć się zaraz po nazwie. Subtelna uwaga warta analizy w Twoim kodzie: ta sama zasada dotyczy lewej strony dopasowania, jeśli szukany ciąg nie zawiera ukośnika — w przeciwnym razie wywołanie Pos('Length', ...) może dopasować się wewnątrz słowa /PieceLength. Zakotwiczenie ciągu wyszukiwania z początkowym ukośnikiem /, jak pokazano wyżej, zabezpiecza lewą krawędź, ponieważ ukośnik / sam w sobie jest separatorem kończącym poprzedni token

Jak złośliwy plik PDF może przekształcić błąd parsera w gigabajtową alokację pamięci?

Uszkodzony lub złośliwy plik może przekształcić te błędy leksykalne w wyczerpanie zasobów, ponieważ liczby całkowite w słownikach często określają rozmiary alokacji. Nasz audyt wykazał taką zależność w procesie rozwijania strumieni obiektów (object streams). Wpis /N w słowniku ObjStm określa, ile skompresowanych obiektów zawiera strumień, a kod odpowiedzialny za rozpakowanie wywoływał procedurę SetLength na tablicy o takim rozmiarze. Jednak parser liczb całkowitych w przypadku błędu pozostawiał parametr wyjściowy (out-parameter) niezmieniony, jednocześnie go zwracając — w efekcie nieliczbowa wartość /N przekazywała do SetLength niezainicjalizowaną wartość ze stosu. Błędna dodatnia liczba całkowita oznacza żądanie alokacji pamięci na poziomie gigabajtów, wywołane kilkoma bajtami uszkodzonych danych wejściowych, podczas zwykłego skanowania dokumentu, którego nawet jeszcze nie zdążyliśmy zweryfikować

Naprawa składała się z dwóch niezależnych części i ma charakter ogólny. Parser zwraca teraz w przypadku błędu jednoznaczną wartość 0, nigdy niezainicjalizowaną pamięć. Z kolei moduł odbiorczy nie ufa już bezpośrednio wartości /N: obszar nagłówka ObjStm przed kluczem /First przechowuje parę liczb — numer obiektu i offset — dla każdego skompresowanego obiektu, a każda para zajmuje co najmniej cztery bajty wraz z separatorami. Wartość /N wyższa niż FirstVal div 4 + 1 jest zatem fizycznie niemożliwa dla zadeklarowanego rozmiaru nagłówka i zostaje odrzucona przed wykonaniem jakiejkolwiek alokacji. Ten warunek kosztuje jedno porównanie i opiera się na danych, którymi już dysponujemy, co stanowi właściwy wzorzec projektowy: limit weryfikowany na podstawie struktury samego pliku, a nie dowolnej stałej

// Wartość /N jest kontrolowana przez plik; ogranicz ją możliwościami /First
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // jawne zero, nigdy śmieci ze stosu
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // nagłówek nie może pomieścić tylu par

// /Length nie może przekraczać rozmiaru pliku zawierającego strumień
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // odrzuć żądanie przed alokacją bufora

Dwa kolejne limity uzupełniają zabezpieczenia naszego czytnika i zostały wprowadzone w wersji v2.12.0. Moduł odczytu strumieni odrzuca każdą wartość /Length przekraczającą rozmiar całego pliku przed zaalokowaniem bufora wynikowego — strumień nie może być większy niż kontener, w którym się znajduje, stąd test ten nie generuje fałszywych alarmów. Ścieżka rozpakowywania (inflate) ogranicza dodatkowo dekompresowane dane do 256 MiB, co zabezpiecza przed bombami zlib, gdzie kilka kilobajtów danych wejściowych rozwija się bez ograniczeń. Taki limit jest w zupełności wystarczający dla poprawnych strumieni PDF, zabezpieczając jednocześnie system przed awarią. Temat przewodni tych zabezpieczeń jest wspólny: każdy rozmiar zadeklarowany w pliku to jedynie deklaracja, którą parser weryfikuje na podstawie mierzalnych parametrów przed zarezerwowaniem dla niej pamięci. Ta sama zasada dotyczy warstwy powiązań binarnych, co opisano w artykule o zabezpieczaniu ABI biblioteki PDFium i bezpieczeństwie pamięci w Delphi

Gdzie reguła całego tokenu nie wystarcza

Szczere ograniczenia, aby nie ufać bezgranicznie powyższej funkcji. Dopasowanie całego tokenu rozwiązuje kwestię identyfikacji kluczy, lecz zwykłe przeszukiwanie bajtów w obszarze słownika nadal nie potrafi określić, czy dopasowanie znajduje się w słowniku zagnieżdżonym, ciągu znaków czy komentarzu — funkcja FindDictKey wywołana na obiekcie strony może dopasować klucz w słowniku podrzędnym /Resources, jeśli przekażemy jej zbyt szeroki zakres. Nasz czytnik najpierw zawęża obszar do ciała jednego obiektu, a konteksty ciągów znaków i komentarzy traktuje jako osobne zagadnienie. Bezpieczeństwo podciągów to tylko jeden ze szczebli drabiny: spójność tabeli adresowej to osobne zagadnienie opisane w artykule o weryfikacji obiektów i strumieni xref, a szerszy katalog zagrożeń związanych z obcymi dokumentami opisano w artykule o audycie bezpieczeństwa plików PDF

Jeśli utrzymujesz ręcznie napisany moduł odczytu słowników w Delphi lub Lazarusie, lista kontrolna po tym zdarzeniu jest krótka. Przeszukaj kod pod kątem wywołań Pos('/ i skieruj je do jednej funkcji pomocniczej dopasowującej całe tokeny. Stwórz listę rodzin przedrostków, w których biorą udział Twoje klucze — w rzeczywistych plikach występują /Length, /Encrypt, /N oraz /Type obok /Type1. Następnie przeanalizuj każdą liczbę całkowitą przekazywaną do SetLength, GetMem czy pętli kopiującej i zdefiniuj jej limity: rozmiar pliku, limit wynikający z nagłówka, czy brak jakichkolwiek limitów. Opisana tu warstwa analizy leksykalnej stanowi fundament komponentu PDFium Component, gdzie czytnik bajtów i silnik renderowania wzajemnie weryfikują poprawność każdego otwieranego dokumentu