Поиск по подстроке вроде Pos('/Length', Dict) — это неподходящий инструмент для чтения словаря PDF, так как ключи имен PDF часто имеют общие префиксы: например, /Length является префиксом /Length1, а /Encrypt — префиксом /EncryptMetadata. Стандарт ISO 32000-1 §7.3.5 определяет имя как токен, который заканчивается только разделителем или пробелом. Таким образом, ключ считается найденным только тогда, когда байт сразу после него является одним из этих символов. Модуль чтения словарей, игнорирующий эту проверку, рано или поздно прочитает неверное значение из абсолютно корректного файла
Баг, который указал нам на это, изначально не выглядел как ошибка лексического анализатора. Сканирование на соответствие стандартам начало сообщать о повреждении потока FontFile: разжамый файл шрифта оказывался фрагментированным и обрывался посреди таблицы. При этом файл без проблем открывался в любом просмотрщике, а данные потока на диске были целы. Первопричина крылась в одной строке нашего общего обработчика словарей: вызов Pos('/Length', ...) сопоставился с /Length1 — стандартным ключом, который словари потоков FontFile содержат согласно таблице 127 стандарта ISO 32000-1. Программа посчитала целое число после /Length1 длиной потока. Создатель этого файла записал /Length1 перед /Length, что полностью допустимо, так как записи в словаре не упорядочены согласно §7.3.7. Поток был усечен до неверного количества байтов, а все последующие проверки, использовавшие его, молча завершались с ошибкой
Почему поиск по подстроке ломает парсинг словарей PDF?
Поиск по подстроке не работает, потому что пространство имен PDF наполнено семействами префиксов, а порядок записей в словаре не фиксирован. Таблица 127 стандарта ISO 32000-1 определяет ключи /Length1, /Length2 и /Length3 для встроенных потоков шрифтов, которые находятся рядом с /Length в том же словаре. Словарь шифрования содержит пару из /Encrypt в трейлере и /EncryptMetadata внутри него. Короткие ключи еще опаснее: поиск вида Pos('/' + Key, ...) с Key = 'N' легко сопоставится с /Name или /Nums. Для таких конфликтов не нужен поврежденный файл. Запись, в которой /Length1 идет перед /Length, полностью соответствует спецификации, а значит, этот баг с подстрокой — не проблема устойчивости к некорректным файлам, а логическая ошибка при разборе валидных данных
Этот сбой происходит незаметно. Неверное значение /Length не вызывает исключения; оно просто возвращает более короткий или длинный срез байтов, чем поток занимает на самом деле. Если этот срез передается для проверки подмножества шрифта, анализа CMap или сканирования метаданных, принимающий компонент получает некорректные данные и обычно ничего не сообщает, поскольку половина zlib-потока просто не разжимается, и код продолжает выполнение. Мы столкнулись именно с этой проблемой и исправили ее в версии v2.14.3 нашего общего модуля чтения после того, как подробный аудит разделов 7.2–7.3 стандарта ISO 32000-1 выявил ненадежность всех поисков ключей на основе Pos
Чем на самом деле является имя согласно ISO 32000-1 §7.3.5
Раздел 7.3.5 краток и точен: объект имени — это косая черта (солидус), за которой следует последовательность обычных символов, и токен завершается первым символом-разделителем или пробелом. Разделителями являются восемь символов скобок, косая черта и знак процента: ( ) < > [ ] { } / %, а пробельными символами — null, табуляция, перевод строки, перевод страницы, возврат каретки и пробел (§7.2.2–§7.2.3). Это правило завершения токена определяет все. /Length1 — это не "/Length с добавлением единицы", а отдельный неделимый токен, точно так же, как LengthOne и Length являются разными идентификаторами в Pascal. Любой модуль чтения, ищущий ключи по необработанной строке байтов, фактически дублирует лексер, но без учета правил завершения токенов
Вот пример дефекта в упрощенном виде. Этот вариант компилируется, проходит тесты на файлах, где сначала записан /Length, но повреждает данные в остальных случаях
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Точное совпадение: проверка байта после ключа
Корректное правило прямо следует из §7.3.5: потенциальное совпадение является настоящим ключом только в том случае, если сразу за ним идет символ-разделитель, пробел или конец буфера. Все остальное — это более длинное имя, у которого совпадает лишь префикс, поэтому поиск должен продолжаться далее. Исправление в нашем модуле чтения заменило все прямые вызовы Pos одной общей подпрограммой, построенной на этом правиле
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
Две детали в этом цикле имеют значение. Во-первых, поиск продолжается, а не завершается ошибкой при первом же частичном совпадении префикса, так как последовательность /Length1 120 /Length 4076 допустима и нужный ключ находится дальше. Во-вторых, достижение конца буфера считается корректным завершением токена, так как фрагмент словаря может закончиться сразу после имени. Более тонкий момент, требующий проверки в вашем коде: то же правило применимо и к левой границе совпадения, если ваша поисковая строка не включает косую черту, иначе вызов Pos('Length', ...) может сработать внутри /PieceLength. Закрепление поисковой строки ведущим символом /, как показано выше, решает проблему левой границы, поскольку / сам является разделителем, завершающим предыдущий токен
Как вредоносный PDF может превратить ошибку парсера в гигабайтную аллокацию?
Поврежденный или вредоносный файл превращает подобные лексические ошибки в уязвимость исчерпания ресурсов, поскольку целые числа из словарей часто определяют размеры выделяемой памяти. Наш аудит обнаружил цепочку именно такого характера при развертывании потока объектов. Запись /N в словаре ObjStm указывает количество сжатых объектов в потоке, и код развертывания вызывал SetLength для массива соответствующего размера. Однако парсер чисел при ошибке оставлял свой out-параметр неизменным, при этом возвращая его — в итоге некорректное значение /N передавало в SetLength неинициализированное значение из стека. Произвольное положительное число в этой переменной приводило к запросу на выделение гигабайт памяти, вызываемому несколькими байтами некорректных данных при простом сканировании документа, который пользователь еще даже не открыл
Исправление состояло из двух независимых частей, имеющих общий характер. Парсер теперь возвращает явный 0 при сбое, а не мусор из памяти. Кроме того, принимающий компонент больше не доверяет значению /N напрямую: область заголовка ObjStm перед /First stores a pair of integers — object number and offset — для каждого сжатого объекта, и каждая пара занимает не менее четырех байт с учетом разделителей. Любое значение /N, превышающее FirstVal div 4 + 1, физически невозможно для объявленного размера заголовка и отклоняется до выделения памяти. Эта проверка стоит одну операцию сравнения и опирается на уже известные данные — пример надежного ограничения, подтверждаемого самим файлом, а не случайной константой
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Еще два барьера завершают защиту нашего модуля чтения (добавлены в версии v2.12.0). Модуль чтения потоков отклоняет любые значения /Length, превышающие размер самого файла, до выделения буфера под результат — поток не может быть больше содержащего его файла, поэтому проверка не дает ложных срабатываний. Путь распаковки (inflate) ограничивает размер неупакованных данных на уровне 256 МБ, предотвращая атаки типа "архивная бомба" (zlib bomb), когда несколько килобайт входных данных бесконечно расширяются. Этот лимит достаточен для любого реального PDF-потока и защищает систему от зависания. Идея всех трех проверок одинакова: любой размер, указанный в файле — это лишь предположение, и парсер проверяет его физическую выполнимость перед выделением памяти. Тот же подход применяется уровнем ниже на границе API, о чем рассказано в защите ABI PDFium и безопасности памяти в Delphi
Где правила точного совпадения токенов недостаточно
Честные ограничения, чтобы не переоценивать описанный метод. Точное совпадение токенов решает проблему поиска ключей, но линейный поиск по байтам словаря все еще не может определить, находится ли совпадение внутри вложенного словаря, текстовой строки или комментария — вызов FindDictKey для объекта страницы может обнаружить ключ внутри его подсловаря /Resources, если передать слишком широкий диапазон. Наш модуль чтения сначала ограничивает диапазон телом одного объекта, а обработка контекстов строк и комментариев выделена в отдельную задачу аудита. Безопасность на уровне подстрок — это лишь одна ступень: целостность таблицы перекрестных ссылок является отдельной темой, описанной в проверке потоков объектов и xref, а общий перечень угроз безопасности для сторонних документов приведен в аудите рисков безопасности PDF
Если вы поддерживаете написанный вручную парсер словарей на Delphi или Lazarus, список задач по итогам этой проблемы прост. Выполните поиск по всему проекту для Pos('/ и перенаправьте вызовы через общую функцию проверки токенов. Составьте список семейств префиксов ваших ключей: /Length, /Encrypt, /N, /Type против /Type1 встречаются в реальных файлах. Затем проверьте каждое целое число, которое попадает в функции SetLength, GetMem или циклы копирования, и определите, чем оно ограничено: размером файла, лимитом заголовка или ничем. Описанный уровень парсинга лежит в основе нашего компонента PDFium Component, где побайтовый анализатор и движок рендеринга перепроверяют данные при каждом обращении к документам