Technical Article

Защита парсера PDF на Pascal от вредоносных файлов

Документ PDF представляет собой не просто файл, который вы открываете. Это небольшая программа, которую вы запускаете. Каждый встроенный шрифт представляет собой стек интерпретатора, ожидающий символьные строки charstring, каждое изображение обрабатывается декодером с шириной, высотой и разрядностью цвета, заданными файлом, а каждый поток передается через фильтры с параметрами, настроенными создателем документа. Все эти числа принадлежат внешней стороне. Они получены из обрабатываемого файла, который в реальной работе может быть счетом клиента или вложением от неизвестного отправителя. Декодеры, превращающие эти байты в пиксели и глифы, представляют собой вектор атаки, и парсер, доверяющий этим значениям, может завершиться сбоем при первом некорректном файле

Библиотека 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 вычисляются в Delphi как 32-битное произведение, если оба операнда являются 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, вмещающим значения от 0 до 65535. Завершение цикла содержало условие вида «остановиться, когда счетчик страниц превысит 65535», которое выглядит правильным, пока вы не заметите, что операнд и предел имеют одну верхнюю границу. Значение типа Word никогда не может быть больше 65535, поэтому сравнение всегда ложно: при достижении значения 65535 следующий инкремент сбрасывает счетчик в 0, проверка никогда не фиксирует превышение предела, а зацикленная цепочка каталогов заставляет парсер работать бесконечно

Исправление заключалось в расширении типа поля, чтобы проверка могла сравнивать значения в реальных диапазонах. После объявления переменной TPDFTIFF.FPageCount с типом Integer сравнение FPageCount > 65535 стало достижимым, цикл стал корректно завершаться, а публичное свойство PageCount сменило тип для соответствия без вреда для вызывающего кода. Если проверка диапазона имеет вид Value > MaxValueOfType(Value), а операнд уже имеет тип этого максимума, условие всегда ложно: расширьте тип данных или проверяйте равенство максимуму для срабатывания защиты

Отключение проверки диапазонов на критическом пути выполнения

При включенной проверке диапазонов Delphi проверяет индексы каждого массива и строки, что гарантирует генерацию перехватываемого исключения ERangeError вместо произвольного чтения или записи памяти за пределами структуры. На критических путях разработчики иногда отключают эту проверку локальной директивой {$R-}, что допустимо лишь до тех пор, пока используемые индексы остаются полностью надежными

Функция доступа к списку TPDFlibStringList.Get, используемая интерпретаторами шрифтов, является именно таким критическим путем. На Windows она компилируется с выключенной проверкой диапазонов и обращается к хранилищу напрямую, поэтому выход индекса за границы приводит к прямому доступу к чужой памяти, а не к генерации ошибки. Это допустимо, когда индекс гарантированно валиден, но становится опасным в интерпретаторах CFF или Type2, где индекс считывается из файла. Попытка получить операнд из пустого стека возвращает индекс -1, а ошибка в идентификаторе глифа на единицу адресует память за концом массива. При отключенной проверке оба случая вызывают некорректный доступ к памяти вместо генерации исключения, а так как слоты хранят переменные AnsiString с подсчетом ссылок, ошибочное чтение может повредить счетчик ссылок строки

Бесконечная рекурсия в интерпретаторе символьных строк charstring

Символьная строка 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

Утечка неинициализированной памяти в выходные данные

Наиболее скрытый дефект приводил к утечке содержимого кучи в расшифрованные данные, а его причиной является свойство функции SetLength, о котором легко забыть. При увеличении размера AnsiString с помощью SetLength Delphi выделяет байты, но не обнуляет их, поэтому новая область содержит случайные остаточные данные из кучи. Если в дальнейшем заполняются все байты буфера, это не имеет значения, но если часть буфера остается незаписанной и возвращается в качестве результата, эти старые байты передаются наружу. Это уязвимость использования неинициализированной памяти (CWE-457), которая при передаче данных за границу безопасности превращается в утечку информации

Путь дешифрования AES-CBC содержал именно эту проблему. Выходной буфер размечался с помощью SetLength, а дешифратор обрабатывал зашифрованный текст блоками по 16 байт. Если длина зашифрованного текста не была кратна 16 (длину может подбирать атакующий), последний неполный блок не записывался, сохраняя старое содержимое памяти после вызова SetLength, и буфер возвращался как расшифрованный текст объекта документа. Решением стало внедрение двух проверок, так как одной было бы недостаточно: точка входа дешифрования теперь отклоняет зашифрованный текст некратной длины, а в качестве дополнительной защиты буфер принудительно очищается с помощью FillChar перед использованием, гарантируя возврат нулей вместо случайных остатков данных кучи при сбое записи

Итоги проделанной работы

Описанные пять дефектов различаются, но имеют общую природу: переполнение разрядности при умножении, сравнение типов с постоянным ложным результатом, отключение проверок диапазонов там, где индексы стали ненадежными, рекурсия без ограничения глубины и буфер, который язык не обнулил при создании. В каждом случае Delphi работал в строгом соответствии со своей спецификацией, так как язык допускает переполнение при вычислениях, неявное сужение типов, ручное отключение проверок, рекурсию без встроенных лимитов и выделение неинициализированной памяти. Это условия контракта языка, и парсер на Pascal должен самостоятельно контролировать четыре аспекта на любой границе внешних данных: разрядность целых чисел, проверку диапазонов, глубину рекурсии и инициализацию буферов

Эти дефекты устранены в текущих версиях PDFlibPas для Delphi и C++Builder. Если ваши задачи связаны с анализом механизмов защиты файлов, сопутствующие заметки об аудите шифрования и прав доступа, а также о проверке соответствия стандартам PDF/A и PDF/UA описывают аналитическую сторону работы парсера. Все эти функции поставляются в составе библиотеки PDFlibPas Delphi PDF Library вместе с API для загрузки, рендеринга и подписания файлов