Technical Article

Захист інструменту підписання PDF у Delphi від шкідливих файлів PKCS#12

Коли ви підписуєте PDF, ви зазвичай думаєте про ключ підпису як про щось, що ви контролюєте. Він міститься у створеному вами файлі .pfx, захищеному обраним вами паролем. Код, який читає цей файл, сприймається як звичайний інструмент, а не як межа безпеки. Ця інтуїція є помилковою, щойно сертифікат перестає бути вашим. Настільний додаток, який дозволяє користувачеві вибрати будь-який файл .pfx, сервер, який приймає завантажені облікові дані, або пакетний підписувач, який отримує сертифікати через мережу, - усі вони передають байти під впливом зловмисника в парсер ще до того, як буде створено хоча б один байт підпису. Зчитувач PKCS#12 є зоною атаки в тому ж сенсі, що й декодер зображень або завантажувач шрифтів.

У цій статті розглядаються два реальних дефекти, які існували в цьому зчитувачі, причому обидва на шляху імпорту облікових даних підпису. Жоден з них не є екзотичним. Обидва виникають через ту саму першопричину, яка вражає майже кожен бінарний парсер, написаний мовою з цілими числами фіксованої ширини: довжині або кількості з файлу довіряють на один крок більше, ніж слід було б. Один призводить до читання за межами буфера, інший - до зависання процесу, доки ви його не завершите примусово.

Куди спрямовуються байти

Імпорт файлу .pfx для підписання документа - це не одна операція, а короткий конвеєр, і кожен етап аналізує дані, які міг створити зловмисник. Контейнер являє собою структуру PKCS#12, визначену в RFC 7292, тобто набір об'єктів AuthenticatedSafe, загорнутих навколо зашифрованої оболонки, що містить закритий ключ. Зчитування цього контейнера означає обхід ASN.1, отримання ключа з пароля, дешифрування, а потім передачу відновленого ключа RSA коду, який створює підпис.

У HotPDF ці етапи відповідають окремим модулям. Логіка контейнера PKCS#12 знаходиться в HPDFPFX. Кожний тег, довжина та значення, яких він торкається, декодуються зчитувачем ASN.1 в HPDFASN1. Отримання ключа та дешифрування PBES2 розташовані в HPDFCrypt разом із PBKDF2HMACSHA256. Коли ключ відновлено, HPDFRSA та конструктор CMS SignedData в HPDFCMS перетворюють його на відокремлений підпис, вбудований у PDF. Публічною точкою входу, яка запускає весь цей ланцюжок, є один виклик.

// 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
;

Кожен байт signer.pfx проходить через HPDFASN1 та HPDFPFX до початку будь-яких криптографічних операцій. Якщо ці два модулі не будуть уважно перевіряти дані файлу, криптографія далі по конвеєру вже не матиме жодного значення.

Дефект перший: довжина ASN.1, яка переповнюється в обхід перевірки

Стандарти ASN.1 у DER та BER кодують кожен елемент як тег, довжину та відповідну кількість байтів вмісту. Довжина - це поле, якому ви повинні довіряти, але обов'язково перевіряти, оскільки воно вказує парсеру, скільки даних потрібно прочитати, і воно було записано тим, хто створив файл. Стандарт X.690 §8.1.3 визначає два кодування. Коротка форма пакує довжину від 0 до 127 в один байт. Довга форма, що використовується для більших значень, містить один перший байт, молодші сім бітів якого вказують кількість наступних байтів довжини, а потім ця кількість байтів у форматі big-endian містить фактичне значення. Таким чином, чотири байти довжини можуть оголосити розмір вмісту, що наближається до чотирьох гігабайтів.

Після декодування такого значення парсер повинен перевірити, чи вміст справді вміщується в буфер, перш ніж довіряти йому. Природна перевірка полягає в тому, щоб переконатися, що поточна позиція плюс довжина вмісту не виходять за межі даних. Якщо написати цю перевірку очевидним способом, коли позиція, довжина вмісту та загальний розмір зберігаються у 32-розрядних цілих числах зі знаком, цей захист виявиться недієвим:

// 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');

Проблема полягає в додаванні, а не в порівнянні. Коли ContentLen наближається до MaxInt (2147483647), вираз Pos + ContentLen переповнює діапазон 32-розрядних чисел зі знаком і перетворюється на від'ємне число. Від'ємна сума ніколи не перевищує Total, тож захист повідомляє, що все гаразд, і дозволяє парсеру продовжити роботу з довжиною вмісту близько двох гігабайтів, якої в буфері насправді немає. Наслідком цього є пошкодження: зчитувач виділяє буфер для заявленої довжини та копіює в нього дані через SetLength, а потім Move читає з джерела. Джерело має лише кілька сотень байтів, тому копіювання зчитує дані далеко за межами вхідного буфера. Це читання за межами буфера в найкращому випадку призводить до збою програми, а в найгіршому - до витоку суміжної пам'яті процесу в результати аналізу.

Єдиний правильний захист розширює проміжну суму перед порівнянням, щоб операція додавання не могла переповнити тип, у якому вона обчислюється. Виправлення приводить обидва операнди до типу 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');

Тип Int64 зберігає суму двох 32-розрядних значень без втрати даних, тому порівняння бачить реальне число та відхиляє підроблену довжину. Окрема перевірка на невід'ємність ContentLen закриває випадок, коли декодоване значення саме по собі виявляється від'ємним. У HotPDF цей захист міститься в HPDFASN1ParseNode - функції, яка створює вузол, на якому базуються всі інші допоміжні засоби. Оскільки HPDFASN1Content визначає розміри своїх SetLength та Move безпосередньо з довжини вмісту вузла, вузол, який пройшов неправильну перевірку, зіпсував би кожне зчитування з нього. Виправлення межі в точці декодування - це те, що робить безпечними всі допоміжні функції вищого рівня.

Дефект другий: кількість ітерацій PBKDF2, використана як зброя

Друга вразливість - це не помилка роботи з пам'яттю, а вказівка файлу вашому процесору, наскільки важко він має працювати. PKCS#12 захищає свої ключові матеріали за допомогою PBES2 - схеми на основі пароля з PKCS#5, визначеної в RFC 8018. PBES2 запускає функцію отримання ключа, у даному випадку PBKDF2 з HMAC-SHA-256, а потім шифр, тут AES-256-CBC. PBKDF2 приймає кількість ітерацій, і ця кількість є параметром, що міститься у файлі. Її мета полягає в уповільненні процесу: більше ітерацій означає, що кожна спроба вгадати пароль коштує дорожче, що корисно проти офлайн-зловмисників. Стандарт RFC 8018 §4.2 чітко вказує, що більша кількість ітерацій є кращою для безпеки, і навмисно не встановлює верхньої межі.

Така відкритість є нормальною, коли ви самі створили файл. Але вона стає зброєю, коли файл створив зловмисник. Кількість ітерацій є фактором роботи під контролем зловминника, а це класична відмова в обслуговуванні через алгоритмічну складність (algorithmic-complexity DoS). Підроблений файл .pfx може кодувати кількість ітерацій у мільярди; парсер слухняно зчитує її та викликає PBKDF2 для такої кількості раундів HMAC-SHA-256, і процес зникає в циклі, який не поверне керування протягом хвилин або годин через один наданий файл. На сервері підписання, який обробляє одні облікові дані на запит, одне спеціально створене завантаження зупиняє робочий процес.

Ця кількість погіршує ситуацію з переповненням ще до того, як змусить процесор завантажитися на повну. Значення ітерації міститься у файлі як об'єкт INTEGER в ASN.1, який не має фіксованої ширини, тоді як поле, яке зрештою споживає PBKDF2, є 32-розрядним цілим числом Integer. Декодування INTEGER безпосередньо в це поле призводить до усічення великого значення, а значення, налаштоване для встановлення знакового біта, повертається як від'ємне або як випадкове невелике число. Тому навіть обсяг роботи більше не відповідає тому, що просив файл. Виправлення зчитує значення повної ширини та обмежує його перед звуженням типу:

// 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

Чому обидва виправлення є однаковими за своєю суттю

Ці два дефекти виглядають по-різному (один - переповнення буфера, інший - зависання процесу), але вони є результатом однієї помилки. В обох випадках число з ненадійного файлу було перенесено в тип фіксованої ширини на один крок занадто рано, до того, як його перевірили на відповідність реальності. Довжина додавалася у 32 бітах перед перевіркою меж; кількість ітерацій звужувалася до 32 бітів перед перевіркою діапазону. Обидва дефекти усуваються за допомогою однакової дисципліни: декодувати у повну ширину, перевірити на відповідність реальному ліміту і лише потім звужувати тип. Проміжний Int64 - це не питання стилю, це єдина ширина, у якій захист може бачити значення, що фактично було записано зловмисником. Обмеження, яке переповнюється, не є обмеженням, а кількість без верхньої межі - це не параметр, а пульт дистанційного керування вашим процесором.

Практичні рекомендації для конвеєра підписання

Безпосередній урок полягає в тому, що вхідні дані ненадійних сертифікатів потрібно перевіряти так само, як і будь-яке інше завантаження з ненадійного джерела. Обмежуйте розмір файлів .pfx, які ви приймаєте, оскільки легітимний файл має розмір у кілобайтах, а не в мегабайтах. Ставтеся до помилки аналізу як до звичайного відхилення вхідних даних, а не як до критичної помилки, що потребує показу трасування стеку користувачеві. Якщо ви виконуєте підписання на сервері, запускайте імпорт у середовищі, де зупинка робочого процесу не зможе вивести з ладу всю службу, і встановлюйте таймаут для операції, щоб неочікувано ресурсомісткий файл обмежувався як за часом виконання, так і за лімітом ітерацій.

Більш загальний урок виходить за рамки роботи з сертифікатами. Захист парсера - це не оразовий аудит одного модуля, це властивість кожного місця, де ваша бібліотека читає байти, які вона не записувала. Бібліотека PDF аналізує багато даних із ненадійних джерел: шрифти, вбудовані в документ, зображення в половині десятка кодеків, фільтри потоків і, на шляху підписання, сертифікати. Кожне з цих місць є зоною атаки, і кожне заслуговує на таку ж підозру до кожної довжини та кожної кількості. HotPDF будує шлях імпорту та підписання на основі захищених модулів HPDFASN1, HPDFPFX, HPDFCrypt та HPDFCMS, описаних тут, завдяки чому облікові дані, які ви передаєте йому, незалежно від їхнього походження, аналізуються захищено ще до того, як їм буде надано довіру.

Робочий процес підписання, який захищають ці перевірки, детально описаний у нашій інструкції з цифрових підписів PAdES у Delphi, а аналогічний захищений підхід, застосований до шифрування документів, включаючи шлях ключів AES-256, що використовує цю ж кодову базу, описано в статті про шифрування та безпеку AES-256. Усе це постачається як частина компонента HotPDF для Delphi та C++Builder разом з API для завантаження, редагування, шифрування та підписання, описаними в інших статтях цього блогу.