Technical Article

Zabezpieczanie modułu podpisywania PDF w Delphi przed złośliwymi plikami 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.

W tym artykule omówiono dwa rzeczywiste defekty, które istniały w tym czytniku, oba na ścieżce importu danych uwierzytelniających podpisu. Żaden z nich nie jest egzotyczny. Oba wynikają z tej samej przyczyny źródłowej, która dotyka niemal każdego parsera binarnego napisanego w języku z liczbami całkowitymi o stałej szerokości: długość lub liczba z pliku jest obdarzana zbyt dużym zaufaniem. Jeden z nich prowadzi do odczytu poza zakresem bufora, a drugi do zawieszenia procesu, dopóki go nie zabijesz.

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.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

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.

Defekt pierwszy: długość ASN.1, która zawija się poza zabezpieczenie

ASN.1 w kodowaniu DER i BER koduje każdy element jako znacznik, długość i odpowiednią liczbę bajtów zawartości. Długość to pole, które należy sprawdzić, ponieważ mówi ono parserowi, jak daleko ma czytać, a zostało zapisane przez tego, kto wygenerował plik. Norma X.690 §8.1.3 definiuje dwa rodzaje kodowania. Krótka forma pakuje długość od 0 do 127 w pojedynczym bajcie. Długa forma, używana do wszystkiego, co większe, przeznacza jeden bajt wiodący, którego siedem najmniej znaczących bitów podaje liczbę bajtów długości, które następują po nim, a następnie odpowiednia liczba bajtów w formacie big-endian niesie rzeczywistą wartość. Cztery bajty długości mogą zatem zadeklarować rozmiar zawartości zbliżający się 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:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
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 rozszerza sumę pośrednią przed porównaniem, dzięki czemu dodawanie nie może przepełnić typu, w którym jest obliczane. Poprawka promuje oba operandy do Int64:

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
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');

Typ Int64 mieści sumę dwóch wartości 32-bitowych bez straty, więc porównanie widzi rzeczywistą liczbę i odrzuca sfałszowaną długość. Osobne sprawdzenie, czy wartość ContentLen nie jest ujemna, zamyka przypadek, w którym zdekodowana wartość sama w sobie staje się ujemna. W HotPDF zabezpieczenie to znajduje się w HPDFASN1ParseNode, czyli funkcji produkującej węzeł, na którym bazuje każdy inny pomocnik. Ponieważ HPDFASN1Content określa rozmiar swojego SetLength i Move bezpośrednio z długości zawartości węzła, węzeł, który przeszedłby błędne zabezpieczenie, zatrułby każdy odczyt z niego pobierany. Naprawienie granicy w punkcie dekodowania jest tym, co czyni powyższe metody pomocnicze bezpiecznymi.

Defekt drugi: 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 ta pogarsza sprawę zawijania, zanim jeszcze procesor zacznie się kręcić. Wartość iteracji istnieje w pliku jako ASN.1 INTEGER, który nie ma stałej szerokości, podczas gdy pole ostatecznie konsumowane przez PBKDF2 to 32-bitowy Integer. Zdekoduj INTEGER bezpośrednio do tego pola, a duża wartość ulegnie obcięciu, a wartość spreparowana tak, aby wylądować na bicie znaku, powróci jako ujemna lub jako jakaś niepowiązana mała liczba, przez co nawet rozmiar pracy nie jest już tym, o co plik wydawał się prosić. Poprawka odczytuje wartość w pełnej szerokości i ogranicza ją przed zawężeniem:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns 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);                    // safe: already bounded

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 obie poprawki to ta sama poprawka

Te dwa defekty wyglądają inaczej – jeden to przepełnienie bufora, a drugi to zawieszony proces – ale to ten sam błąd. W każdym przypadku liczba z niezaufanego pliku została przeniesiona do typu o stałej szerokości o jeden krok za wcześnie, zanim została sprawdzona pod kątem rzeczywistości. Długość została dodana w 32 bitach przed testem granicznym; liczba iteracji została zawężona do 32 bitów przed testem zakresu. Oba te problemy poddają się tej samej dyscyplinie: dekoduj w pełnej szerokości, sprawdź z rzeczywistym limitem i dopiero wtedy zawęź. Pośredni typ Int64 to nie wybór stylu kodowania, lecz jedyna szerokość, w której zabezpieczenie widzi wartość faktycznie zapisaną przez atakującego. Przepełniona granica nie jest granicą, a liczba bez sufitu nie jest parametrem, lecz zdalną przepustnicą dla Twojego własnego procesora.

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 Component dla Delphi i C++Builder wraz z interfejsami API do ładowania, edycji, szyfrowania i podpisywania omówionymi w innych miejscach tego bloga.