Plik PDF to nie jest dokument, który po prostu otwierasz. To mały program, który uruchamiasz. Każda osadzona czcionka to oparty na stosie interpreter czekający na instrukcje charstrings, każdy obraz to dekoder zasilany polami szerokości, wysokości i głębi bitowej wybranymi przez plik, a każdy strumień przybywa owinięty w filtry, których parametry ustawiono w pliku. Żadna z tych liczb nie należy do Ciebie. Pochodzą od tego, kto wygenerował plik, co w rzeczywistych warunkach pracy oznacza fakturę klienta lub załącznik od nieznanego nadawcy. Dekodery zamieniające te bajty w piksele i glify to powierzchnia ataku, a parser, który ufa swoim danym wejściowym w tych miejscach, dzieli od awarii lub czegoś gorszego tylko jeden zniekształcony plik.
Biblioteka PDFlibPas przeszła proces zabezpieczania, w którym całą ścieżkę dekodowania potraktowano jako wrogą – w poprzek programów czcionek (TrueType, Type1, CFF i tabel CMap), dekoderów obrazu (PNG, GIF, TIFF, JBIG2 oraz CCITT Group 3 i Group 4) oraz filtrów strumieni (LZW, ASCII85 i predyktorów Flate). Poniżej opisano pięć klas defektów, które zostały zamknięte, z których każdy osadzony jest w specyficznym zachowaniu Delphi, które je umożliwiało. Zostały one naprawione w obecnych wydaniach, a te same kształty błędów powracają w każdym kodzie w Pascalu analizującym niezaufane dane wejściowe.
Przepełnienie liczby całkowitej dające zbyt mały bufor
Klasyczny błąd bezpieczeństwa pamięci w dekoderze obrazu to iloczyn wymiarów, który ulega zawinięciu. Dekoder odczytuje szerokość, wysokość, liczbę komponentów i głębię bitową, mnoży je w celu określenia rozmiaru wyjścia, przydziela odpowiednią liczbę bajtów, a następnie zapisuje obraz w jego rzeczywistych wymiarach. Jeśli mnożenie jest wykonywane w arytmetyce 32-bitowej, iloczyn może zawinąć się do małej wartości, nawet gdy każdy pojedynczy czynnik mieści się w rozsądnym zakresie. W rezultacie alokacja kończy się sukcesem, bufor okazuje się o wiele za mały, a dekodowanie wychodzi poza jego zakres. Jest to błąd typu CWE-190 (przepełnienie liczby całkowitej) prowadzący do CWE-787 (zapis poza zakresem sterty) krok później.
Wspólna ścieżka obrazu ograniczała już każdy wymiar do wartości 65535; samodzielne dekodery nie wszystkie dziedziczyły to ograniczenie. Wyrażenie typu bajty-wiersza-razy-wysokość, takie jak ByteCount * FHeight, lub wyrażenie na piksel, takie jak FWidth * Components * BitDepth, jest w Delphi iloczynem 32-bitowym, gdy oba operandy są 32-bitowymi liczbami całkowitymi, niezależnie od tego, jak szeroka jest zmienna, do której przypisujesz wynik. Szerokość i wysokość równe 60000 są wiarygodne dla dużego skanu, ale ich iloczyn w bajtach przekracza zakres 32-bitowej liczby ze znakiem i wynikowa długość okazuje się mała. Ta sama pułapka tkwiła w kroku predyktora ZLib, BitsPerComponent * Colors * Columns.
Rozwiązaniem jest uczynienie przynajmniej jednego operandu typem Int64, aby całe wyrażenie było obliczane jako 64-bitowe, a następnie porównanie z MaxInt i odrzucenie pliku przed zawężeniem typu z powrotem w celu wywołania SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
Tym, co czyni ten problem specyficznym dla Delphi, a nie ogólnym, jest ciche zawężanie typów. Przypisanie zbyt szerokiego wyrażenia do 32-bitowego miejsca docelowego jest legalną konwersją, o której kompilator domyślnie nie ostrzega, a sprawdzanie zakresu (range checking) nie wychwytuje zawinięcia, które następuje przed użyciem wartości jako indeksu. Pozostawienie iloczynu na poziomie 32 bitów powoduje, że język po cichu daje Ci długość, która kłamie na temat tego, jak dużą ilość pamięci dekoder zaraz zmodyfikuje.
Typ pola uniemożliwiający zadziałanie zabezpieczenia
Plik TIFF to łańcuch katalogów obrazów (IFD), z których każdy niesie przesunięcie bajtowe następnego. Złośliwy plik może skierować ten łańcuch z powrotem na siebie, a czytnik przechodzący przez niego bez warunku stopu będzie działał w nieskończoność. Jest to błąd CWE-835 (nieskończona pętla sterowana danymi wejściowymi atakującego), a obroną przed nim jest licznik zatrzymujący operację po przekroczeniu limitu, którego nie osiągnąłby żaden legalny plik.
Licznik stron został zadeklarowany jako typ Word, który w Delphi przechowuje wartości od 0 do 65535. Pętla niosła zabezpieczenie o postaci "zatrzymaj się, gdy liczba stron przekroczy 65535", co brzmi poprawnie, dopóki nie zauważysz, że operand i próg dzielą górną granicę. Typ Word nigdy nie może być większy niż 65535, więc porównanie strukturalnie zawsze jest fałszywe: gdy licznik osiąga 65535, kolejny inkrement zawija go z powrotem do zera, zabezpieczenie nigdy nie widzi wartości powyżej sufitu, a zapętlony łańcuch IFD utrzymuje czytnik w nieskończonym biegu.
Poprawka polegała na rozszerzeniu typu pola, aby zabezpieczenie mogło wyrazić wartość, którą licznik jest w stanie faktycznie przyjąć. Po zadeklarowaniu TPDFTIFF.FPageCount jako Integer to samo porównanie FPageCount > 65535 staje się osiągalne, pętla się kończy, a publiczna właściwość PageCount zmieniła typ na zgodny bez psucia kodu wywołującego. Ilekroć sprawdzenie granicy ma postać Value > MaxValueOfType(Value), a operand jest już otypowany na dokładnie tę maksymalną wartość, warunek jest stałą fałszem – rozszerz typ lub przetestuj równość z wartością maksymalną, aby zabezpieczenie mogło zadziałać.
Wyłączone sprawdzanie zakresu na gorącej ścieżce
Przy włączonym sprawdzaniu zakresu kompilator Delphi wstawia weryfikację granic przy każdym indeksie tablicy i ciągu znaków. Stanowi to różnicę między indeksem poza zakresem zgłaszającym przechwytywany błąd ERangeError a tym samym indeksem czytającym lub piszącym w pamięci, która nie należy do struktury. Gorące ścieżki czasami wyłączają tę weryfikację lokalną dyrektywą {$R-}, co jest uzasadnione aż do momentu, gdy indeksy przestają być godne zaufania.
Metoda dostępu do listy, na której opierają się interpretery czcionek, TPDFlibStringList.Get, jest dokładnie taką ścieżką. W systemie Windows jest kompilowana z wyłączonym sprawdzaniem zakresu i indeksuje swój backing store bezpośrednio, więc indeks poza zakresem nie jest błędem, lecz surowym dostępem do pamięci. To nie problem, gdy indeks jest zawsze prawidłowy, ale przestaje być w porządku wewnątrz interpretera instrukcji charstrings CFF lub Type2, gdzie indeks może pochodzić z pliku. Instrukcja charstring pobierająca operand z pustego stosu daje indeks minus jeden; identyfikator glifu przesunięty o jeden w stosunku do liczby glifów indeksuje jedno gniazdo za końcem. Przy wyłączonym sprawdzaniu zakresu oba te przypadki stają się rzeczywistym dostępem poza granice pamięci, a ponieważ gniazda przechowują zliczane referencją wartości AnsiString, zbłąkany odczyt może również uszkodzić licznik referencji ciągu.
Proces zabezpieczania nie polegał na ponownym włączeniu sprawdzania zakresu dla gorącej ścieżki. Zamiast tego sprawił, że indeksy są najpierw sprawdzane pod kątem poprawności: przed pobraniem szczytu stosu operandów interpreter sprawdza, czy stos nie jest pusty, a każde zabezpieczenie indeksu zostało zapisane jako ostre mniejsze-niż w stosunku do liczby elementów, a nie mniejsze-bądź-równe dopuszczające błąd off-by-one. Dyrektywa przenosi odpowiedzialność za granice z kompilatora na Ciebie, a usunięta przez nią walidacja musi zostać wprowadzona ręcznie w każdym punkcie wejściowym.
Nieograniczona rekurencja w interpreterze charstring
Instrukcja charstring Type2 może wywołać podprogram, a podprogram sam jest instrukcją charstring, która może wywołać kolejną. Operatory wywołania podprogramu lokalnego i globalnego pozwalają plikowi zdecydować, jak głęboko to pójdzie. Podprogram wywołujący sam siebie (bezpośrednio lub w cyklu) wykonuje rekurencję bez końca, aż natywny stos ulegnie wyczerpaniu i proces umrze. Jest to błąd typu CWE-674 (niekontrolowana rekurencja).
Interpreter Type1 zabezpieczał przed tym, przenosząc licznik głębokości wywołań oraz sufit PLType1MaxCallDepth i odmawiając schodzenia głębiej, co odzwierciedla limit głębokości wymieniony w samej specyfikacji Type1. Interpreter Type2, dodany później i strukturalnie podobny, nie posiadał tego sprawdzenia, a ręcznie przygotowana czcionka z podprogramem wywołującym własny numer przechodziła prosto przez brakujące zabezpieczenie do przepełnienia stosu.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
Rozwiązaniem było wyposażenie ścieżki Type2 w ten sam ograniczony limit głębokości, który posiadał już Type1. Każde rekurencyjne schodzenie po strukturze kontrolowanej przez atakującego (czy to podprogramy czcionek, zagnieżdżona tablica, czy łańcuch odsyłaczy skrośnych) wymaga sufitu głębokości, którego wejście nie może podnieść.
Niezainicjalizowana pamięć wyciekająca do pliku wyjściowego
Najbardziej subtelny defekt ujawniał zawartość sterty w odszyfrowanych danych wyjściowych, a przyczyną jest łatwa do przeoczenia właściwość SetLength. Gdy zwiększasz rozmiar AnsiString za pomocą SetLength, Delphi przydziela bajty, ale ich nie zeruje, więc nowy obszar przechowuje to, co wcześniej znajdowało się w pamięci sterty. Jeśli każdy bajt zostanie następnie zapisany, nie ma to znaczenia; jeśli jednak ścieżka pozostawi część bufora niezapisaną, a następnie zwróci ją jako dane, te przestarzałe bajty wywędrują na zewnątrz wraz z wynikiem. Jest to błąd CWE-457 (użycie niezainicjalizowanej pamięci), a gdy wynik przekracza granicę zaufania, staje się wyciekiem informacji.
Ścieżka deszyfrowania AES-CBC napotkała dokładnie ten problem. Bufor wyjściowy był określany za pomocą SetLength, a deszyfrator przetwarzał szyfrogram po jednym 16-bajtowym bloku na raz. Gdy długość szyfrogramu nie była wielokrotnością 16 (długość, którą atakujący może wybrać), końcowy częściowy blok nigdy nie był zapisywany, więc te ostatnie bajty zachowywały zawartość sterty pozostawioną przez SetLength, a bufor był przekazywany z powrotem jako odszyfrowany tekst jawny obiektu dokumentu. Rozwiązaniem są dwa zabezpieczenia i żadne z nich w pojedynkę nie wystarcza: punkt wejściowy deszyfrowania odrzuca teraz każdy szyfrogram, którego długość nie jest wielokrotnością rozmiaru bloku, a jako zabezpieczenie awaryjne (backstop) bufor wyjściowy jest czyszczony za pomocą FillChar przed użyciem, dzięki czemu każda ścieżka, która nie zapisze danego obszaru, zwraca zera zamiast pozostałości sterty.
Z czym pozostawia Cię ten proces
Te pięć defektów to różne błędy, ale mają wspólny mianownik: szerokość liczby całkowitej zawijająca iloczyn, typ pola przypinający zabezpieczenie do stałej fałszu, wyłączone sprawdzanie zakresu tam, gdzie indeksy przestały być bezpieczne, rekurencja bez podłogi oraz bufor, którego język odmówił wyzerowania. W każdym z nich środowisko Delphi zrobiło dokładnie to, co ma zdefiniowane w swoich regułach, ponieważ język daje Ci arytmetykę, która się zawija, ciche zawężanie typów, sprawdzanie zakresu, które możesz wyłączyć, rekurencję bez wbudowanego limitu oraz alokację, która nie inicjalizuje pamięci. Taki jest kontrakt, a parser w Pascalu spełnia go poprzez ręczne kontrolowanie czterech rzeczy na każdej granicy opisywanej przez plik: szerokości liczb całkowitych, sprawdzania zakresu, głębokości rekurencji i inicjalizacji bufora.
Te defekty są zamknięte w obecnych wydaniach PDFlibPas – silnika dla Delphi i C++Builder. Jeśli Twoja praca sięga również do sposobu, w jaki plik twierdzi, że jest chroniony, towarzyszące notatki o audycie szyfrowania i uprawnień oraz o preflight PDF/A i PDF/UA omawiają stronę analityczną tego samego parsera, a wszystko to dostarczane jest wewnątrz biblioteki PDFlibPas Delphi PDF Library obok interfejsów API do ładowania, renderowania i podpisów cyfrowych omówionych w innych miejscach tego bloga.