Artykuł techniczny

Zabezpieczanie parsera PDF w Pascalu przed złośliwymi

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

PDF Library for Delphi traktuje całą ścieżkę dekodowania jako wrogą — od programów czcionek (TrueType, Type1, CFF i tabele CMap), przez dekodery obrazu (PNG, GIF, TIFF, JBIG2 oraz CCITT Group 3 i Group 4), po filtry strumieni (LZW, ASCII85 i predyktory Flate). Poniżej pięć pułapek bezpieczeństwa pamięci, które ta ścieżka musi zamknąć; każda osadzona w konkretnym zachowaniu Delphi, które je umożliwia, a te same kształty wracają w każdym kodzie w Pascalu analizującym niezaufane dane wejściowe

PDF Library for Delphi: Mapa powierzchni ataku parsera PDF w Pascalu zasilającego interpretery czcionek, dekodery obrazów i filtry strumieni w kierunku pięciu strzeżonych klas pułapek
Szerokości, głębokości, offsety, długości i flagi filtrów wszystkie pochodzą od producenta, co czyni dekodery czcionek, obrazów i filtrów powierzchnią ataku, którą parser musi traktować jako wrogą

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

Sam limit 65535 na każdym wymiarze na wspólnej ścieżce obrazu nie wystarcza, gdy samodzielny dekoder liczy własne rozmiary. Wyrażenie bajty-wiersza-razy-wysokość, takie jak ByteCount * FHeight, czy wyrażenie na piksel, takie jak FWidth * Components * BitDepth, jest w Delphi iloczynem 32-bitowym, gdy oba operandy to 32-bitowe liczby całkowite — niezależnie od tego, jak szeroka jest zmienna, do której przypisujesz wynik. Szerokość i wysokość po 60000 są wiarygodne dla dużego skanu, ale ich iloczyn w bajtach przekracza zakres 32-bitowej liczby ze znakiem i długość wychodzi mała. Ta sama pułapka siedzi w kroku predyktora, takim jak BitsPerComponent * Colors * Columns

Obroną jest uczynienie przynajmniej jednego operandu typem Int64, żeby całe wyrażenie liczyło się jako 64-bitowe, potem porównanie z MaxInt i odrzucenie pliku, zanim zawęzimy typ z powrotem, by wywołać SetLength

PDF Library for Delphi: Porównanie obok siebie zawiniętego iloczynu wymiarów 32-bitowych powodującego za małą alokację i zapis poza granicami ze sprawdzeniem rozszerzającym do Int64 odmawiającym pliku
Liczenie iloczynu liczby bajtów w Int64 pozwala parserowi porównać z MaxInt i odrzucić wrogie wymiary, zanim SetLength w ogóle wykroje kłamiący bufor
// Odrzuć przed alokacją, nie po zapisie.
// Oblicz iloczyn w Int64, aby nie mógł zawinąć się przy 32 bitach.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // wrogie lub nieobsługiwane wymiary; odrzuć obraz
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

Zadeklaruj teraz licznik stron jako Word, który w Delphi przechowuje wartości od 0 do 65535, i daj pętli warunek stopu w stylu „zatrzymaj się, gdy liczba stron przekroczy 65535". 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, strażnik nigdy nie widzi wartości powyżej sufitu, a zapętlony łańcuch IFD utrzymuje czytnik w nieskończonym biegu

Lekiem jest rozszerzenie typu pola, żeby strażnik mógł wyrazić wartość, którą licznik faktycznie potrafi przyjąć. TPDFTIFF.FPageCount jest zadeklarowany jako Integer, więc porównanie FPageCount > 65535 jest osiągalne i pętla się kończy. Ilekroć sprawdzenie granicy ma kształt Value > MaxValueOfType(Value), a operand jest już otypowany dokładnie na tę maksymalną wartość, warunek jest stałą fałszem — rozszerz typ albo testuj równość z maksimum, żeby strażnik mógł 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 kompiluje się ją z wyłączonym sprawdzaniem zakresu i indeksuje backing store bezpośrednio, więc indeks poza zakresem byłby surowym dostępem do pamięci, a nie błędem. To w porządku, dopóki indeks jest zawsze poprawny, ale wymaga ostrożności wewnątrz interpretera charstring CFF lub Type2, gdzie indeks może przyjść z pliku. Instrukcja charstring pobierająca operand z pustego stosu produkuje indeks minus jeden; identyfikator glifu przesunięty o jeden względem liczby glifów indeksuje gniazdo tuż za końcem. Bez kontroli oba przypadki byłyby rzeczywistym dostępem poza granice pamięci zamiast przechwytywalnym wyjątkiem, a ponieważ gniazda przechowują zliczane referencją wartości AnsiString, zbłąkany odczyt może też uszkodzić licznik referencji ciągu

PDF Library for Delphi utrzymuje wyłączone sprawdzanie zakresu na tej gorącej ścieżce i zamiast tego czyni indeksy dowodliwie poprawnymi: przed pobraniem szczytu stosu operandów interpreter sprawdza, że stos nie jest pusty, a każde zabezpieczenie indeksu to ostre mniejsze-niż względem liczby elementów, a nie mniejsze-bądź-równe dopuszczające off-by-one. Dyrektywa przenosi odpowiedzialność za granice z kompilatora na ciebie, a walidacja, którą usunęła, 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 strzeże się tego licznikiem głębokości wywołań i sufitem PLType1MaxCallDepth, odmawiając schodzenia głębiej — co odzwierciedla limit głębokości, który sama specyfikacja Type1 wymienia. Pułapka polega na tym, że strukturalnie podobny interpreter napisany później, jak ten Type2, nie dziedziczy tego strażnika sam z siebie, a ręcznie zbudowana czcionka z podprogramem wywołującym własny numer przechodzi prosto przez brakujące sprawdzenie w przepełnienie stosu

// Kształt strażnika głębokości, którego potrzebują oba interpretery charstring.
// Śledź głębokość w zagnieżdżonych wywołaniach i odmów rekursji poza nią.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // wrogi podprogram samoodwołujący się; przerwij schodzenie
// ... zinterpretuj podprogram, potem Dec(CallDepth) przy wyjściu

PDF Library for Delphi daje ścieżce Type2 tę samą ograniczoną głębokość co jej brat 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 — potrzebuje sufitu głębokości, którego wejście nie może podnieść

Niezainicjalizowana pamięć wyciekająca do pliku wyjściowego

Najbardziej subtelna pułapka wycieka zawartością sterty do odszyfrowanego wyjścia, a jej przyczyną jest łatwa do przeoczenia właściwość SetLength. Gdy powiększasz AnsiString przez SetLength, Delphi przydziela bajty, ale ich nie zeruje, więc nowy obszar przechowuje to, co wcześniej było w tej pamięci sterty. Jeśli każdy bajt zostanie potem zapisany, nigdy to nie ma znaczenia; jeśli ścieżka zostawi część bufora niezapisaną i zwróci ją jako dane, te stare bajty wywędrują z wynikiem. To CWE-457, użycie niezainicjalizowanej pamięci, a gdy wynik przekracza granicę zaufania, staje się wyciekiem informacji

Ścieżka deszyfrowania AES-CBC to dokładnie miejsce, gdzie to gryzie. Wyznacz bufor wyjściowy przez SetLength, przetwarzaj szyfrogram po jednym 16-bajtowym bloku, i gdy długość szyfrogramu nie jest wielokrotnością 16 — długość, którą atakujący może wybrać — końcowy częściowy blok nie zostaje nigdy zapisany, więc te ostatnie bajty zachowują zawartość sterty pozostawioną przez SetLength, a bufor wraca jako odszyfrowany tekst jawny obiektu dokumentu. Remedium to dwa strażniki i żadne w pojedynkę nie wystarcza: punkt wejściowy deszyfrowania PDF Library for Delphi odrzuca każdy szyfrogram, którego długość nie jest wielokrotnością rozmiaru bloku, a jako zabezpieczenie awaryjne wyjście jest czyszczone przez FillChar przed użyciem, więc każda ścieżka, która nie zapisze obszaru, zwraca zera zamiast pozostałości sterty

Diagram PDF Library for Delphi deszyfrowania AES-CBC, gdzie niezakończony końcowy blok zostawia nieinicjalizowane bajty sterty, chyba że sprawdzenia długości wejścia i czyszczenie bufora FillChar strzegą ścieżki
Dwie niezależne strażnice zamykają wyciek: odrzucaj szyfrogram, którego długość mija granicę 16 bajtów, i wstępnie czyść wyjście, żeby nawet pominięty zapis dawał zera zamiast pozostałości po stercie

Co łączy te pięć pułapek

Te pięć pułapek to różne błędy, ale się rymują. Szerokość liczby całkowitej zawijająca iloczyn, typ pola przypinający strażnika do stałej fałszu, wyłączone sprawdzanie zakresu tam, gdzie indeksy przestały być bezpieczne, rekurencja bez podłogi i bufor, którego język odmówił wyzerowania. W każdym przypadku Delphi robi dokładnie to, co ma zdefiniowane, bo język daje ci arytmetykę, która się zawija, ciche zawężanie, sprawdzanie zakresu do wyłączenia, rekurencję bez wbudowanego limitu i alokację, która nie inicjalizuje. Taki jest kontrakt, a parser w Pascalu dotrzymuje go, trzymając w swoich rękach cztery rzeczy na każdej granicy kontrolowanej przez plik: szerokość liczb całkowitych, sprawdzanie zakresu, głębokość rekurencji i inicjalizację bufora

PDF Library for Delphi, silnik dla Delphi i C++Builder, strzeże swojej ścieżki dekodowania przed wszystkimi pięcioma. Jeśli twoja praca sięga też do tego, jak 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 PDF Library for Delphi Delphi PDF Library obok interfejsów API do ładowania, renderowania i podpisów cyfrowych omówionych w innych miejscach tego bloga