PDF - це не документ, який ви відкриваєте. Це невелика програма, яку ви запускаєте. Кожен вбудований шрифт є інтерпретатором на основі стеку, що очікує символьних рядків (charstrings), кожне зображення передається декодеру з параметрами ширини, висоти та глибини кольору, вибраними файлом, а кожен потік надходить загорнутим у фільтри, параметри яких встановив сам файл. Жодне з цих чисел не належить вам. Вони надійшли від того, хто створив файл, що в реальній роботі може бути рахунком-фактурою клієнта або вкладенням від невідомого відправника. Декодери, які перетворюють ці байти на пікселі та гліфи, є зоною атаки, і парсер, який довіряє цим вхідним даним, відділяє від збою чи чогось гіршого лише один спеціально сформований файл.
Бібліотека PDFlibPas пройшла етап захисту, під час якого весь шлях декодування розглядався як ворожий, включаючи програми шрифтів (TrueType, Type1, CFF та таблиці CMap), декодери зображень (PNG, GIF, TIFF, JBIG2 та CCITT Group 3 і Group 4), а також фільтри потоків (LZW, ASCII85 та Flate-предиктори). Нижче описано п'ять класів дефектів, які було закрито, кожен з яких базується на специфічній поведінці Delphi, що зробила його можливим. Вони виправлені в поточних версіях, і ті самі шаблони повторюються в будь-якому коді на Pascal, який аналізує ненадійні вхідні дані.
Переповнення цілого числа, яке дає буфер недостатнього розміру
Класична помилка безпеки пам'яті в декодері зображень - це переповнення при множенні розмірів. Декодер зчитує ширину, висоту, кількість компонентів і глибину кольору, перемножує їх для визначення розміру виходу, виділяє цю кількість байтів, а потім записує зображення з його реальними розмірами. Якщо множення виконується в 32-розрядній арифметиці, результат може переповнитися до невеликого значення навіть тоді, коли кожен окремий фактор знаходиться в розумних межах. У результаті виділення пам'яті проходить успішно, але буфер виходить занадто малим, і процес декодування виходить за його межі. Це вразливість CWE-190 (переповнення цілого числа), яка наступним кроком призводить до запису за межами купи (CWE-787).
Спільний шлях зображень уже обмежував кожен вимір значенням 65535; окремі декодери не всі успадкували це обмеження. Вираз множення байтів рядка на висоту, такий як ByteCount * FHeight, або вираз на піксель, такий як FWidth * Components * BitDepth, є 32-розрядним множенням у Delphi, якщо обидва операнди є 32-розрядними цілими числами, незалежно від ширини змінної, якій ви призначаєте результат. Ширина та висота 60000 є цілком можливими для великого сканованого зображення, але їхній добуток у байтах виходить за межі 32-розрядного діапазону зі знаком, і довжина виходить невеликою. Така ж пастка існувала в кроці предиктора ZLib: BitsPerComponent * Colors * Columns.
Виправлення полягає в тому, щоб зробити принаймні один операнд типу Int64, щоб увесь вираз обчислювався в 64-розрядному форматі, потім порівняти його з MaxInt і відхилити файл перед звуженням типу для виклику 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);
Що робить це проблемою саме Delphi, а не загальною проблемою, так це тихе звуження типу. Призначення занадто широкого виразу 32-розрядному одержувачу є дозволеним перетворенням, про яке компилятор не попередить за замовчуванням, а перевірка діапазону не перехоплює переповнення, яке відбувається до того, як значення буде використано як індекс. Залиште добуток 32-розрядним, і мова тихо надасть вам довжину, яка не відповідає обсягу пам'яті, яку збирається торкнутися декодер.
Тип поля, який унеможливлює спрацьовування захисту
Файл TIFF - це ланцюжок каталогів файлів зображень (IFD), кожен з яких містить зміщення в байтах до наступного. Шкідливий файл може спрямувати цей ланцюжок на самого себе, і зчитувач, який обходить його без умови зупинки, працюватиме нескінченно. Це вразливість CWE-835 (нескінченний цикл під впливом вхідних даних зловмисника), і захистом є лічильник, який зупиняється, щойно перевищує ліміт, якого легітимний файл ніколи б не досяг.
Лічильник сторінок був оголошений як Word, який у Delphi зберігає значення від 0 до 65535. Цикл містив захист від завершення у формі "зупинити, коли кількість сторінок перевищить 65535", що виглядає правильно, поки ви не помітите, що операнд і поріг мають спільну верхню межу. Тип Word ніколи не може бути більшим за 65535, тож порівняння структурно завжди є хибним: коли лічильник досягає 65535, наступне збільшення повертає його до 0, захист ніколи не бачить значення вище межі, а зациклений ланцюжок IFD змушує зчитувач працювати нескінченно.
Виправлення полягало в розширенні типу поля, щоб захист міг виразити значення, яке лічильник дійсно здатний прийняти. Після оголошення TPDFTIFF.FPageCount як Integer, порівняння FPageCount > 65535 стає досяжним, цикл завершується, а публічна властивість PageCount змінила свій тип для відповідності без порушення роботи інших модулів. Щоразу, коли перевірка меж має вигляд Value > MaxValueOfType(Value), а операнд уже належить саме до цього максимального типу, умова є постійно хибною: розширте тип або перевіряйте рівність максимуму для спрацьовування захисту.
Вимкнення перевірки діапазону на критичному шляху виконання
При ввімкненій перевірці діапазону Delphi вставляє перевірку меж для кожного індексу масиву та рядка. Це забезпечує різницю між виходом індексу за межі діапазону з генерацією винятку ERangeError та тим самим індексом, що читає або записує пам'ять, яка не належить цій структурі. На критичних шляхах виконання її іноді вимикають за допомогою локальної директиви {$R-}, що є допустимим рівно доти, доки індекси залишаються надійними.
Метод доступу до списку, на який спираються інтерпретатори шрифтів (TPDFlibStringList.Get), є саме таким шляхом. На Windows він компілюється з вимкненою перевіркою діапазону та індексує сховище даних безпосередньо, тому вихід індексу за межі є не помилкою, а прямим доступом до пам'яті. Це прийнятно, коли індекс завжди валідний, і перестає бути таким всередині інтерпретатора символьних рядків CFF або Type2, де індекс може надходити з файлу. Символьний рядок, який виштовхує операнд із порожнього стеку, дає індекс мінус один; ідентифікатор гліфа, зсунутий на одиницю відносно кількості гліфів, індексує на один слот далі за кінець. При вимкненій перевірці діапазону обидва випадки перетворюються на прямий доступ поза межами буфера замість винятку, а оскільки слоти містять рядові значення AnsiString із підрахунком посилань, помилкове читання може також пошкодити лічильник посилань рядка.
Захист не вмикав перевірку діапазону назад на критичному шляху. Він зробив індекси перевірено валідними заздалегідь: перед взяттям верхнього елемента стеку операндів інтерпретатор перевіряє, що стек не є порожнім, і кожен захист індексу був записаний як суворе "менше ніж" відносно кількості, а не "менше або дорівнює", що допускає помилку на одиницю. Директива переносить відповідальність за межі з компилятора на вас, і видалену валідацію доводиться повертати вручную в кожній точці входу.
Необмежена рекурсія в інтерпретаторі символьних рядків
Символьний рядок Type2 може викликати підпрограму, а підпрограма сама є символьним самим рядком, який може викликати інший. Тому оператори виклику локальних та глобальних підпрограм дозволяють файлу визначати глибину вкладеності. Підпрограма, яка викликає саму себе безпосередньо або через цикл, рекурсує нескінченно, поки рідний стек не буде вичерпано і процес не завершиться аварійно. Це вразливість CWE-674 (неконтрольована рекурсія).
Інтерпретатор Type1 уже мав захист від цього. Він містив лічильник глибини викликів та верхню межу PLType1MaxCallDepth і відхиляв подальший спуск, що відповідає ліміту глибини, визначеному самою специфікацією Type1. Інтерпретатор Type2, доданий пізніше та структурно подібний, не мав такого захисту, і створений вручну шрифт із підпрограмою, яка викликає саму себе, проходив повз відсутню перевірку безпосередньо до переповнення стеку.
// 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
Виправлення полягало в наданні шляху Type2 тієї самої обмеженої глибини, яку вже мав його брат Type1. Будь-який рекурсивний спуск по структурах під контролем зловмисника - підпрограмах шрифтів, вкладених масивах чи ланцюжках перехресних посилань - вимагає стелі глибини, яку вхідні дані не можуть перевищити.
Неініціалізована пам'ять, яка потрапляє у вихідні дані
Найбільш тонкий дефект виявлявся у витоку вмісту купи в розшифровані вихідні дані, а причиною є властивість SetLength, про яку легко забути. Кови ви збільшуєте AnsiString за допомогою SetLength, Delphi виділяє байти, але не обнуляє їх, тому нова область містить усе, що раніше знаходилося в цій пам'яті купи. Якщо згодом записується кожен байт, це не має значення; але якщо шлях залишає частину буфера незаписаною, а потім повертає її як дані, ці застарілі байти передаються разом із результатами. Це вразливість CWE-457 (використання неініціалізованої пам'яті), і коли результат перетинає межу довіри, це призводить до витоку інформації.
Шлях дешифрування AES-CBC зіткнувся саме з цим. Розмір вихідного буфера задавався через SetLength, і дешифратор обробляв шифротекст блоками по 16 байтів за раз. Коли довжина шифротексту не була кратною 16 (довжину може вибирати зловмисник), кінцевий частковий блок ніколи не записувався. Тому ці останні байти зберігали вміст купи, залишений SetLength, і буфер повертався як розшифрований відкритий текст об'єкта документа. Засобом виправлення є дві перевірки, і жодної з них окремо недостатньо: точка входу дешифрування тепер відхиляє будь-який шифротекст, довжина якого не є кратною розміру блоку, а для додаткового захисту вихідний буфер очищується за допомогою FillChar перед використанням, щоб будь-який шлях, який не зміг записати область, повертав нулі замість залишків купи.
З чим вас залишає цей етап захисту
Ці п'ять дефектів є різними помилками, але вони схожі. Ширина цілого числа, яка переповнює добуток, тип поля, який фіксує перевірку на постійне хибне значення, вимкнена перевірка діапазону, де індекси стали небезпечними, рекурсія без обмежень та буфер, який мова відмовилася обнулити. У кожному випадку Delphi діяв точно відповідно до своїх визначень, оскільки мова надає вам арифметику з переповненням, тихе звуження типів, перевірки діапазонів, які можна вимкнути, рекурсію без вбудованого ліміту та виділення пам'яті без ініціалізації. Такий контракт, і парсер на Pascal відповідає йому, вручну керуючи чотирма речами на кожній межі, контрольованій файлом: шириною цілих чисел, перевіркою діапазонів, глибиною рекурсії та ініціалізацією буферів.
Ці дефекти закриті в поточних випусках PDFlibPas - рушія для Delphi та C++Builder. Якщо ваша робота також стосується аналізу захисту файлів, супутні примітки про аудит шифрування та дозволів та про попередню перевірку PDF/A та PDF/UA описують аналітичну сторону того самого парсера. Усе це постачається в складі Delphi PDF Library від PDFlibPas разом з API завантаження, рендерингу та підписання, описаними в інших статтях цього блогу.