Artykuł techniczny

Utwardzanie podpisu PDF w Delphi przeciw złośliwemu PKCS#12

Kiedy podpisujesz plik PDF, zazwyczaj myślisz o kluczu podpisującym jako o czymś, co kontrolujesz. Znajduje się on w wygenerowanym przez Ciebie pliku .pfx, chronionym wybranym przez Ciebie hasłem. Kod, który odczytuje ten plik, wydaje się zwykłym kodem łączącym, a nie granicą bezpieczeństwa. Ta intuicja staje się błędna w momencie, gdy certyfikat przestaje być Twój. Narzędzie desktopowe pozwalające użytkownikowi wybrać dowolny plik .pfx, serwer akceptujący przesłane dane uwierzytelniające, moduł podpisywania wsadowego zasilany certyfikatami z sieci – wszystkie te elementy przekazują bajty zależne od atakującego do parsera, zanim zostanie wyprodukowany choćby jeden bajt podpisu. Czytnik PKCS#12 to powierzchnia ataku, w tym samym sensie co dekoder obrazu czy moduł ładujący czcionki

Ten artykuł przechodzi przez dwie pułapki, przed którymi musi bronić się każdy czytnik PKCS#12, obie na ścieżce importu danych uwierzytelniających podpisu, oraz przez to, jak zamyka je HotPDF. Żadna nie jest egzotyczna. Obie wychodzą z tej samej przyczyny źródłowej, która trafia niemal każdy parser binarny napisany w języku z liczbami całkowitymi o stałej szerokości: długość albo liczba z pliku jest obdarzana zaufaniem o jeden krok za daleko. Jedna prowadzi do odczytu poza zakresem, druga do procesu wiszącego, dopóki go nie zabijesz

Diagram HotPDF pułapki liczby iteracji PBKDF2: nieograniczona podrobiona liczba rund czterech miliardów przygwoździłaby procesor, podczas gdy dolna i górna granica w HPDFCrypt ją odrzuca
sto milionów leży ponad jakimkolwiek legalnym plikiem, jednocześnie ograniczając najgorszy przypadek — zdekoduj szeroko, potem ogranicz, potem użyj
HotPDF: dwa tory pułapki zawijania długości ASN.1: podrobiona długość dodana w 32 bitach przeciska się przez naiwne sprawdzenie granic, podczas gdy HPDFASN1ParseNode czyta w Int64 i ją odrzuca
wąski strażnik licuje ze skróconym widmem prawdziwej wartości; najpierw czytanie szeroko robi podrobioną długość widoczną przed jakimkolwiek SetLength

Gdzie wędrują bajty

Importowanie pliku .pfx w celu podpisania dokumentu to nie jedna operacja, lecz krótki potok, a każdy jego etap analizuje coś, co mógł zapisać atakujący. Kontenerem jest struktura PKCS#12 zdefiniowana w RFC 7292, czyli gniazdo bezpiecznych worków (AuthenticatedSafe) owinięte wokół zaszyfrowanej osłony przechowującej klucz prywatny. Odczytanie go oznacza przejście przez ASN.1, wygenerowanie klucza z hasła, odszyfrowanie, a następnie przekazanie odzyskanego klucza RSA do kodu budującego podpis

W HotPDF etapy te są mapowane na osobne moduły. Logika kontenera PKCS#12 znajduje się w HPDFPFX. Każdy znacznik, długość i wartość, których dotyka, jest dekodowana przez czytnik ASN.1 w HPDFASN1. Generowanie klucza i deszyfrowanie PBES2 znajdują się w HPDFCrypt obok PBKDF2HMACSHA256. Po odzyskaniu klucza, moduł HPDFRSA i kreator CMS SignedData w HPDFCMS zamieniają go w odłączony podpis osadzony w pliku PDF. Publiczny punkt wejściowy, który napędza cały łańcuch, to jedno wywołanie

Potok importu .pfx w HotPDF: bajty podatne na wpływ atakującego przechodzą przez HPDFPFX i HPDFASN1, zanim nastąpi wyprowadzenie klucza HPDFCrypt i podpisywanie HPDFRSA/HPDFCMS
czytnik PKCS#12 to powierzchnia ataku — kryptografia dalej nigdy nie ma znaczenia, jeśli dwa parsery są nieostrożne
// Napędza cały potok: wczytaj PDF będący miejscem, sparsuj PFX,
// wyprowadź klucz, zbuduj CMS SignedData, zapisz podpisane wyjście.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // podpis osadzony
else
  // podpisywanie nie zostało ukończone
;

Każdy bajt pliku signer.pfx przepływa przez HPDFASN1 i HPDFPFX, zanim nastąpi jakakolwiek kryptografia. Jeśli te dwa moduły nie będą ostrożne z danymi z pliku, kryptografia na dalszych etapach nigdy nie będzie miała znaczenia

Pułapka pierwsza: długość ASN.1, która zawija się poza strażnikiem

ASN.1 w DER i BER koduje każdy element jako znacznik, długość i tyleż bajtów zawartości. Długość to pole, któremu musisz ufać, ale które musisz zweryfikować, bo mówi parserowi, jak daleko czytać, a zapisał go ten, kto wyprodukował plik. X.690 §8.1.3 definiuje dwa kodowania. Forma krótka pakuje długość od 0 do 127 w pojedynczym bajcie. Forma długa, używana dla wszystkiego większego, wydaje jeden bajt wiodący, którego siedem najmłodszych bitów podaje liczbę następujących bajtów długości, po czym tyleż bajtów big-endian niesie właściwą wartość. Cztery bajty długości mogą zadeklarować rozmiar zawartości zbliżony do czterech gigabajtów

Po zdekodowaniu takiej wartości parser musi sprawdzić, czy zawartość faktycznie mieści się w buforze, zanim jej zaufa. Naturalnym testem jest potwierdzenie, że bieżąca pozycja plus długość zawartości nie wykracza poza koniec danych. Zapisane w oczywisty sposób, gdzie pozycja, długość zawartości i suma są przechowywane w 32-bitowych liczbach całkowitych ze znakiem, to zabezpieczenie jest uszkodzone:

// Pułapka: arytmetyka 32-bitowej liczby ze znakiem. Przy ContentLen bliskim MaxInt,
// Pos + ContentLen przepełnia się do WARTOŚCI UJEMNEJ, więc porównanie
// jest fałszywe i sfałszowana długość ~2 GB przechodzi wprost dalej.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

Problem polega na dodawaniu, a nie na porównywaniu. Gdy ContentLen jest bliskie MaxInt (2147483647), Pos + ContentLen powoduje przepełnienie zakresu 32-bitowej liczby ze znakiem i zawija się do liczby ujemnej. Suma ujemna nigdy nie jest większa niż Total, więc zabezpieczenie zgłasza, że wszystko jest w porządku i pozwala parserowi kontynuować pracę z długością zawartości wynoszącą około dwóch gigabajtów, której bufor nie zawiera. To, co dzieje się dalej, to uszkodzenie: czytnik przydziela bufor dla tej zadeklarowanej długości i kopiuje do niego dane, wykonując SetLength, a następnie Move z odczytem ze źródła. Źródło ma tylko kilkaset bajtów, więc kopiowanie czyta daleko poza końcem wejścia – jest to odczyt poza zakresem, który w najlepszym przypadku powoduje awarię, a w najgorszym ujawnia sąsiednią pamięć procesu do parsera

Jedyne poprawne zabezpieczenie poszerza sumę pośrednią przed porównaniem, więc dodawanie nie może przepełnić typu, w którym jest liczone. HotPDF promuje oba operandy do Int64:

// Poprawnie: oba operandy poszerzone do Int64 przed dodaniem, więc suma
// nie może się zawinąć. Sfałszowana długość 2 GB teraz zawodzi przy sprawdzeniu zakresu.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64 mieści sumę dwóch wartości 32-bitowych bez straty, więc porównanie widzi prawdziwą liczbę i odrzuca podrobioną długość. Osobne sprawdzenie nieujemności ContentLen zamyka pasujący przypadek, w którym zdekodowana wartość sama z siebie wyląduje ujemna. W HotPDF ten strażnik mieszka w HPDFASN1ParseNode, funkcji produkującej węzeł, na którym buduje się każdy inny pomocnik. Ponieważ HPDFASN1Content wymiaruje swoje SetLength i Move wprost z długości zawartości węzła, węzeł, który przeszedłby przez słaby strażnik, zatrułby każdy odczyt pobrany z niego. Wyegzekwowanie granicy w punkcie dekodowania to to, co czyni pomocników powyżej bezpiecznymi

Pułapka druga: liczba iteracji PBKDF2 użyta jako broń

Druga wada to nie błąd pamięci, lecz plik mówiący procesorowi, jak ciężko ma pracować. PKCS#12 chroni materiał klucza za pomocą PBES2, schematu opartego na haśle z PKCS#5, opisanego w RFC 8018. PBES2 uruchamia funkcję wyprowadzania klucza, tutaj PBKDF2 z HMAC-SHA-256, a następnie szyfr, tutaj AES-256-CBC. PBKDF2 przyjmuje liczbę iteracji, a ta liczba to parametr przenoszony w pliku. Jej jedynym celem jest spowolnienie procesu: większa liczba iteracji oznacza, że każda próba odgadnięcia hasła kosztuje więcej, co jest dobre przeciwko atakującemu offline. RFC 8018 §4.2 wyraźnie wskazuje, że większa liczba jest lepsza dla bezpieczeństwa i celowo nie ustawia górnego limitu

Ta otwartość jest w porządku, gdy to Ty wygenerowałeś plik. Staje się ona bronią, gdy plik został utworzony przez atakującego. Liczba iteracji to współczynnik pracy kontrolowany przez atakującego, a to z kolei stanowi odmowę usługi o złożoności algorytmicznej (DoS). Sfałszowany plik .pfx może zakodować liczbę iteracji w miliardach; parser posłusznie ją odczytuje i wywołuje PBKDF2 dla tak wielu rund HMAC-SHA-256, a proces znika w pętli, która nie powróci przez minuty lub godziny po podaniu jednego pliku. Na serwerze podpisującym, który obsługuje jedno uwierzytelnienie na żądanie, pojedyncze spreparowane przesłanie blokuje wątek roboczy

Liczba pogarsza zawinięcie, zanim sprawi, że procesor zacznie się kręcić. Wartość iteracji żyje w pliku jako ASN.1 INTEGER, który nie ma stałej szerokości, podczas gdy pole, które PBKDF2 ostatecznie konsumuje, to 32-bitowy Integer. Zdekoduj INTEGER wprost do tego pola, a duża wartość się obetnie, a wartość skonstruowana, by wylądować na bicie znaku, wróci ujemna albo jako jakaś niepowiązana mała liczba, więc nawet rozmiar pracy nie jest już tym, o co plik wyglądał na proszący. HotPDF czyta wartość w pełnej szerokości i ogranicza ją przed zawężeniem:

// Przeczytaj liczbę iteracji najpierw jako Int64, potem ogranicz do rozsądnego pasma
// ZANIM zostanie zwężona do 32-bitowego pola Iterations używanego przez PBKDF2.
LIter := HPDFASN1ToInteger(Data, Node);          // zwraca Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // bezpieczne: już ograniczone

Odczyt do Int64 oznacza, że zdekodowana wartość jest tą rzeczywistą, a nie jej obciętym duchem. Dolna granica odrzuca liczby zerowe i ujemne, które są bezsensowne przy generowaniu klucza. Górna granica, sto milionów, plasuje się znacznie powyżej jakiegokolwiek legalnego pliku PKCS#12, który obecnie używa od kilkudziesięciu do kilkuset tysięcy iteracji, jednocześnie ograniczając najgorszy przypadek do skończonej, możliwej do przetrwania ilości pracy. Dopiero po przejściu tego zakresu wartość jest zawężana do pola 32-bitowego, więc obcięcie nie może już nikogo zaskoczyć. W HotPDF to ograniczenie znajduje się w ParsePBES2Params, gdzie parametry PBKDF2 są dekodowane w drodze do PBKDF2HMACSHA256

Dlaczego oba strażniki to ten sam strażnik

Dwie pułapki wyglądają różnie — jedna to przepełnienie bufora, druga wiszący proces — ale to ta sama pomyłka. W każdym przypadku liczba z niezaufanego pliku trafia do typu o stałej szerokości o jeden krok za wcześnie, zanim zostanie skonfrontowana z rzeczywistością. Długość jest dodawana w 32 bitach przed testem granicznym; liczba iteracji jest zawężana do 32 bitów przed testem zakresu. Obie ulegają tej samej dyscyplinie: dekoduj w pełnej szerokości, sprawdź względem prawdziwego limitu i dopiero wtedy zawężaj. Pośredni Int64 to nie wybór stylistyczny, to jedyna szerokość, w której strażnik widzi wartość, którą atakujący faktycznie zapisał. Granica, która się przepełnia, nie jest granicą, a liczba bez sufitu nie jest parametrem — to zdalny przepustnik na twoim własnym procesorze

Praktyczne wskazówki dla potoku podpisującego

Wąska lekcja polega na tym, by walidować niezaufane certyfikaty wejściowe tak, jak każdą niezaufaną zawartość przesyłaną na serwer. Ogranicz rozmiar pliku .pfx, który akceptujesz, ponieważ legalny plik ma rozmiar kilkuba jtowy, a nie megabajtowy. Traktuj niepowodzenie analizy jako rutynowe odrzucenie danych wejściowych, a nie błąd warty wyświetlania śladu stosu użytkownikowi. Jeśli podpisujesz na serwerze, uruchamiaj import tam, gdzie zablokowany proces roboczy nie może wyłączyć usługi, i ustaw limit czasu operacji, aby nieoczekiwanie kosztowny plik był ograniczony czasem zegarowym, a także limitem iteracji

Szersza lekcja wykracza poza certyfikaty. Zabezpieczanie parsera to nie jednorazowy audyt jednego modułu, lecz cecha każdego miejsca, w którym Twoja biblioteka odczytuje bajty, których sama nie zapisała. Biblioteka PDF analizuje wiele rzeczy z niezaufanych źródeł: czcionki osadzone w dokumencie, obrazy w pół tuzinie kodeków, filtry strumieni oraz, na ścieżce podpisów, certyfikaty. Każde z nich to powierzchnia ataku i każde zasługuje na taką samą podejrzliwość wobec każdej długości i każdej liczby. HotPDF buduje ścieżkę importu i podpisów na bazie zabezpieczonych modułów HPDFASN1, HPDFPFX, HPDFCrypt i HPDFCMS opisanych tutaj, dzięki czemu przekazywane dane uwierzytelniające, niezależnie od ich pochodzenia, są analizowane defensywnie, zanim zostaną obdarzone zaufaniem

Przebieg podpisów chroniony przez te testy został omówiony od początku do końca w naszym przewodniku po podpisach cyfrowych PAdES w Delphi, a ta sama obronna postawa zastosowana do szyfrowania dokumentów, w tym ścieżka klucza AES-256 współdzieląca tę bazę kodu, została opisana w artykule na temat szyfrowania AES-256 i bezpieczeństwa. Wszystko to jest dostarczane jako część komponentu HotPDF Delphi Component dla Delphi i C++Builder wraz z interfejsami API do ładowania, edycji, szyfrowania i podpisywania omówionymi w innych miejscach tego bloga