Техническая статья

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

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

PDF Library for Delphi трактует весь путь декодирования как враждебный: программы шрифтов (TrueType, Type1, CFF и таблицы CMap), декодеры изображений (PNG, GIF, TIFF, JBIG2 и CCITT Group 3 и Group 4) и фильтры потоков (LZW, ASCII85 и предикторы Flate). Ниже — пять ловушек безопасности памяти, которые этот путь обязан закрывать, каждая привязана к конкретному поведению Delphi, делающему её возможной, и те же формы повторяются в любом коде на Pascal, разбирающем недоверенный ввод

PDF Library for Delphi: карта поверхности атаки парсера PDF на Pascal, питающего интерпретаторы шрифтов, декодеры изображений и потоковые фильтры, — к пяти защищаемым классам ловушек
Ширины, глубины, смещения, длины и флаги фильтров приходят от производителя, что делает декодеры шрифтов, изображений и фильтров той поверхностью атаки, которую парсер обязан считать враждебной

Переполнение целого числа, приводящее к выделению буфера недостаточного размера

Классической ошибкой безопасности памяти в декодере изображений является переполнение при вычислении площади. Декодер считывает ширину, высоту, количество цветовых компонентов и разрядность цвета, перемножает их для определения размера буфера вывода, выделяет память и записывает изображение. Если умножение выполняется в 32-битной арифметике, произведение может переполниться и превратиться в небольшое число, даже если все исходные сомножители находятся в допустимых пределах. Выделение памяти завершается успешно, но размер буфера оказывается слишком мал, и процедура декодирования записывает данные за его пределами. Это уязвимость переполнения целого числа (CWE-190), приводящая на следующем шаге к выходу за границы буфера в куче (CWE-787)

Ограничения каждого измерения числом 65535 на общем пути обработки изображений недостаточно, когда отдельный декодер вычисляет собственные размеры. Выражение «байты строки на высоту» вида ByteCount * FHeight или попиксельное выражение вроде FWidth * Components * BitDepth — это 32-битное произведение в Delphi, когда оба операнда — 32-битные целые, независимо от того, насколько широка переменная, которой вы присваиваете результат. Ширина и высота по 60000 по отдельности правдоподобны для большого скана, но их произведение в байтах выходит за пределы знакового 32-битного диапазона, и длина получается маленькой. Та же ловушка сидит в шаге предиктора вроде BitsPerComponent * Colors * Columns

Защита в том, чтобы сделать хотя бы один операнд Int64, тогда всё выражение вычисляется в 64 битах, затем сравнить с MaxInt и отказать файлу, прежде чем сужать тип обратно для вызова SetLength

PDF Library for Delphi: сравнение рядом — переполненное 32-битное произведение размеров, вызывающее заниженное выделение и запись за границами, против проверки с расширением до Int64, отвергающей файл
Вычисление произведения счётчика байтов в Int64 позволяет парсеру сравнивать с MaxInt и отвергать враждебные размеры до того, как SetLength выкроит лгущий буфер
// Отклонять до выделения памяти, а не после записи.
// Вычислять произведение в Int64, чтобы оно не переполнилось на 32 битах.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // враждебные или неподдерживаемые размеры; отклонить изображение
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 достижимо и цикл завершается. Всякий раз, когда проверка границы имеет форму Value > MaxValueOfType(Value) и операнд уже типизирован ровно этим максимумом, условие — константная ложь: расширяйте тип или проверяйте равенство максимуму, чтобы условие могло сработать

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

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

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

PDF Library for Delphi держит проверку диапазонов выключенной на этом горячем пути и вместо этого делает индексы доказуемо валидными: прежде чем снять вершину стека операндов, интерпретатор проверяет, что стек непуст, а каждый гард индекса — строгое «меньше» против количества, а не «меньше или равно», допускающее ошибку на единицу. Директива перекладывает ответственность за границы с компилятора на вас, и снятую ею валидацию приходится возвращать вручную в каждой точке входа

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

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

Интерпретатор Type1 защищён от этого счётчиком глубины вызовов и потолком PLType1MaxCallDepth — он отказывается спускаться дальше, что отражает предел глубины, который называет сама спецификация Type1. Ловушка в том, что структурно похожий интерпретатор, написанный позже, — такой как Type2 — сам по себе этот гард не наследует, и собранный вручную шрифт с подпрограммой, вызывающей собственный номер, проходит сквозь отсутствующую проверку прямиком в переполнение стека

// Форма гарда глубины, которая нужна обоим интерпретаторам charstring.
// Отслеживать глубину через вложенные вызовы и отказываться рекурсировать за её пределы.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // враждебная самоссылающаяся подпрограмма; прекратить спуск
// ... интерпретировать подпрограмму, затем Dec(CallDepth) на выходе

PDF Library for Delphi даёт пути Type2 ту же ограниченную глубину, что и его собрату Type1. Любой рекурсивный спуск по структуре, управляемой атакующим, — подпрограммы шрифта, вложенный массив или цепочка cross-reference — нуждается в потолке глубины, который вход не может поднять

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

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

Путь дешифрования AES-CBC — ровно то место, где это кусается. Разметьте выходной буфер через SetLength, обрабатывайте шифротекст блоками по 16 байт, и когда длина шифротекста — а её выбирает атакующий — не кратна 16, хвостовой неполный блок никогда не записывается, так что последние байты сохраняют содержимое кучи, оставленное SetLength, и буфер уходит обратно как расшифрованный открытый текст объекта документа. Средство — два гарда, и ни один по отдельности не достаточен: точка входа дешифрования PDF Library for Delphi отвергает любой шифротекст, чья длина не кратна размеру блока, а в качестве подстраховки выход очищается через FillChar перед использованием, так что любой путь, не записавший область, возвращает нули, а не остатки кучи

Диаграмма PDF Library for Delphi: расшифрование AES-CBC, где незавершённый хвостовой блок оставляет неинициализированные байты кучи, если путь не защищают проверки длины записи и очистка буфера FillChar
Две независимые защиты закрывают утечку: отклонять шифротекст, чья длина не попадает на 16-байтовую границу, и заранее обнулять выход, чтобы даже пропущенная запись дала нули вместо остатков кучи

Что у пяти ловушек общего

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

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