Конвейер приёмки документов принимает файлы, написанные незнакомцами. Счета, сканы, вложения из веб-формы: каждый называет себя PDF и несёт сотни чисел, с которыми ваш парсер обязан что-то делать. Длины потоков, размеры изображений, байтовые смещения, ссылки на объекты — каждое выбрал тот, кто произвёл файл, и рано или поздно усечённая загрузка или намеренно испорченный документ поставит одно из этих чисел туда, где оно навредит. Разницу между парсером, который переживёт такой файл, и тем, что падает или продолжает работать с испорченной памятью, составляет небольшой набор привычек, не зависящих ни от какой конкретной библиотеки PDF
У этих привычек общая посылка: значение, прочитанное из файла, — это утверждение, а не измерение. Пригодным оно становится лишь после сверки с чем-то, что парсер измерил сам: с настоящим размером файла, настоящим числом байтов, выданных декодером, настоящей глубиной рекурсии. Дальше эта посылка применяется к тем местам, где парсеры документов ломаются на практике
Объявленная длина — это утверждение, а не измерение
Простейшее расхождение — длина потока. Потоковый объект PDF объявляет число своих байтов в ключе /Length, а сами данные лежат между ключевыми словами stream и endstream. Ничто не обязывает эти двое совпадать. В усечённом файле реальных байтов меньше объявленного количества; файл от сломанного генератора может объявить длину, уходящую за конец файла или в соседний объект. Выделите память по объявленному значению и копируйте до endstream — и вы переполните буфер; прочтите ровно объявленное количество без проверки доступного — и уйдёте за конец файла. Позвольте объявленному значению управлять выделением только после зажатия его измеренным расстоянием до конца данных, а расхождение считайте точкой принятия решения — починить сканированием до endstream или отвергнуть поток, — но никогда чем-то, во что можно молча поверить
Параметры изображения, описывающие растр больше выделенного
Потоки изображений повышают ставки, потому что одни и те же пиксели описывают два независимых набора чисел. Словарь изображения несёт /Width и /Height, и растровые буферы обычно размеряют по ним. У фильтра декодирования своя геометрия: CCITTFaxDecode берёт /Columns, /Rows и /K из своего DecodeParms, где /K выбирает схему Group 3 или Group 4, а декодер выдаёт (Columns + 7) div 8 байт на строку развёртки. Файл, объявляющий /Width 100, но подающий фильтру /Columns 1728 — значение по умолчанию, — заставляет декодер производить более чем в шестнадцать раз больше байтов на строку, чем ждёт буфер, и переполнение ложится по одной строке развёртки за раз в то, что лежит после выделения. Когда /Rows отсутствует, декодер работает, пока данные не скажут «стоп», так что ограничивайте и число строк. У DCTDecode тот же шов: данные JPEG несут собственные ширину и высоту в своём маркере SOF, и ничто не обязывает их совпадать со словарём
Защитное правило механично: вычислите ожидаемый размер растра по проверенным параметрам декодирования — по собственным /Columns и /Rows фильтра для CCITT, по размерам SOF для DCT, — сверьте его со своими пределами, выделяйте память по нему и проверяйте по ходу декодирования, что вывод не выходит за выделение. Когда словарь и фильтр расходятся в геометрии, примирите их или отвергните изображение. Чего парсер не должен делать никогда — так это размерять буфер по одному набору чисел, а декодер пускать по другому
Подводные камни арифметики и выделения памяти в Delphi
Три особенности Delphi подрывают даже тот парсер, который намерен всё проверять. Первая — 32-битное умножение: Delphi вычисляет произведение двух операндов Integer в 32 битах независимо от разрядности приёмника, поэтому Width * Height * BytesPerPixel может переполниться даже тогда, когда каждый сомножитель проходит собственную проверку на разумность. Скан 30000 на 30000 при трёх байтах на пиксель — это 2,7 миллиарда байтов, что в знаковой 32-битной арифметике заворачивается в отрицательное; чуть иные сомножители завернутся в небольшую положительную длину, которая выделится и недоразмерит буфер. Заставьте всё выражение считаться широко, приведя первый операнд, — Size := Int64(Width) * Height * BytesPerPixel, — а затем сравните с явным пределом, прежде чем что-либо дойдёт до SetLength
Вторая — проверка диапазонов. Конфигурация Delphi для выпуска по умолчанию идёт с выключенной проверкой, поэтому индекс вне диапазона, вычисленный из данных файла, не возбуждает исключения — он читает или пишет память рядом с массивом. Включите её обратно через {$R+} (и {$Q+} для арифметического переполнения) в начале каждого модуля, который индексируется значениями из файла. Цена этого неизмерима на фоне того ввода-вывода, который парсер и так делает, а тихое повреждение памяти превращается в перехватываемое ERangeError
Третья — TMemoryStream.SetSize с Int64, взятым из файла. На современном RTL он выделяет ровно столько, сколько попросил файл, поэтому единственный поток, заявивший четыре гигабайта, превращается в отказ по нехватке памяти посреди приёмки. На старых RTL, где SetSize принимает Longint, значение сначала молча урезается: объявленное $100000010 становится 16, выделение проходит успешно, а запись настоящих данных уходит далеко за него. Проверяйте каждый размер по измеренному размеру источника и жёсткому пределу до того, как его увидит любой вызов выделения памяти
Смещения, указывающие за пределы файла
Таблица перекрёстных ссылок отображает номера объектов в абсолютные байтовые смещения, и парсер прыгает туда, куда она укажет. В повреждённом или враждебном файле такие смещения оказываются за концом файла или внутри посторонних структур. TStream делает отказ беззвучным: установка Position за пределы Size ошибкой не считается, а обычное Read за концом просто возвращает меньше байтов, чем запрошено, поэтому код, пропускающий проверку количества, продолжает разбирать устаревшие байты предыдущего объекта. Защита — узкое место: одна вспомогательная процедура, через которую проходят все управляемые файлом прыжки и чтения, проверяя смещение и количество по измеренному размеру файла до того, как поток сдвинется
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // ни один объект не должен превышать 64 МБ
type
EPdfBoundsError = class(Exception);
// Все управляемые файлом прыжки и чтения идут здесь. Offset и Count -
// утверждения из файла; Source.Size - измерение, в которое они обязаны влезть.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Пропустите через неё смещения перекрёстных ссылок, границы потоков и чтения встроенных файлов — и плохое смещение станет чистым отказом, называющим числа, а не нарушением доступа тремя вызовами позже
Циклы и глубина в графе объектов
PDF — это граф, а не дерево. Любое значение может быть косвенной ссылкой, ссылка может разрешаться в другую ссылку — /Length 12 0 R, где объект 12 содержит 13 0 R, — и ничто не мешает цепочке замкнуться на себя. Разрешающий код, наивно идущий по ссылкам, рекурсирует, пока не исчерпает системный стек, а исчерпание стека вы не перехватите; оно завершает процесс. Глубоко вложенные массивы и словари приводят туда же вообще без всякого цикла
Используйте два стража вместе: явный счётчик глубины ограничивает честный, но глубокий случай пределом, к которому не приближается ни один законный файл, а множество посещённых ловит настоящий цикл при втором визите, превращая его в точную сообщаемую ошибку вместо срабатывания предела
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // намного глубже любой законной цепочки ссылок
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // осмысленно, когда Kind = pvReference
// ... поля полезной нагрузки для остальных видов
end;
// LoadObject - ваша собственная процедура: она ищет смещение xref для
// ObjNumber, читает объект через ReadBounded и разбирает его.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // напр. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // соседи вправе законно делить этот объект
end;
end;
Распаковка работает усилителем
Несколько килобайт входа FlateDecode способны раздуться до гигабайтов; сжатие общего назначения вознаграждает повторяющийся открытый текст, а злоумышленник может сделать его предельно повторяющимся. Ограничьте раздутый размер каждого потока тем, что реально может понадобиться его потребителю, и держите второй бюджет на документ: пятьсот потоков, каждый чуть ниже предела на поток, исчерпают память так же надёжно, как один гигантский поток. Проверке место внутри цикла раздувания, где выходные байты считаются по мере производства и работа прерывается при нарушении, а не после цикла, когда память уже потрачена. Бюджет документа, выраженный кратностью к сжатому размеру файла, работает хорошо, поскольку законные документы кучкуются далеко ниже тех коэффициентов, которых достигает специально составленный поток
Эшелонированная защита за пределами ваших модулей
Те же классы дефектов живут и внутри библиотек. Два разбора случаев в этом блоге проходят через реальные примеры: целочисленные переполнения, неограниченная рекурсия и неинициализированные буферы, закрытые в нативном движке на Pascal, — в статье Укрепление парсера PDF на Pascal против вредоносных файлов, а опасности соглашений о вызовах, разрядности целых и владения при привязке движка на C — в статье Укрепление привязки PDFium Component. Для по-настоящему недоверенной приёмки — публичной формы загрузки, неаутентифицированного почтового ящика — выполняйте разбор и декодирование ещё и в отдельном процессе с низкими привилегиями, чтобы файл, победивший все внутрипроцессные заслоны, стоил вам провалившегося задания, а не упавшего сервиса
Предполётный чек-лист
Прежде чем уйдёт следующая сборка, пройдите по парсеру с этим списком: каждый буфер потока размерён по зажатой длине, а не по объявленной; каждый растр размерён по проверенным параметрам декодера и сверен с его выводом; каждое произведение размеров вычислено в Int64 и сравнено с явным пределом; {$R+} включён в каждом модуле, который индексируется значениями из файла; каждый прыжок проверен по измеренному размеру файла; каждое разрешение ссылки ограничено по глубине и проверено на циклы; каждый цикл раздувания считает вывод против бюджетов на поток и на документ. Ни одна из этих проверок не стоит измеримого времени на законном документе, и каждая превращает повреждение памяти в чистый, пригодный для журнала отказ
Примечание: HotPDF Delphi Component от losLab, PDF Library for Delphi Delphi PDF Library и PDFium Component применяют эти проверки границ, ограничения глубины и пределы расширения внутри себя, поэтому конвейер приёмки, построенный на них, стартует с укреплённой базы