Конвейер приема документов принимает файлы, написанные незнакомцами. Счета-фактуры, сканы, вложения из веб-формы: каждый из них утверждает, что является 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(
'границы объекта %d+%d превышают размер файла %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('цепочка ссылок превышает предел глубины');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'циклическая ссылка через объект %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 в Защита Pascal PDF Parser от вредоносных файлов, а также соглашения о вызовах, ширина целого числа и опасности владения привязки движка C в Защита привязки PDFium Component. Для действительно ненадежного приема — общедоступной формы загрузки, неаутентифицированного почтового ящика — также запускайте работу по синтаксическому анализу и декодированию в отдельном процессе с низкими привилегиями, чтобы файл, который обходит каждую защиту в процессе, стоил неудачного задания, а не неработающей службы
Предварительный чек-лист
Перед поставкой следующей сборки пройдитесь парсером по этому списку: каждый буфер потока имеет размер из ограниченной длины, а не из заявленной; каждый растр имеет размер из проверенных параметров декодера и сверяется с выводом декодера; каждый продукт измерения оценивается в Int64 и сравнивается с явным ограничением; {$R+} активен в каждой единице, которая индексирует значения, полученные из файла; каждый поиск проверяется на границу по сравнению с измеренным размером файла; каждое разрешение ссылки ограничено по глубине и проверено на цикл; каждый цикл расширения подсчитывает вывод относительно бюджетов для потока и документа. Ни одна из этих проверок не стоит измеримого времени на легитимном документе, и каждая превращает повреждение памяти в чистый, регистрируемый отказ
Примечание: HotPDF Component, PDFlibPas Delphi PDF Library и PDFium Component компании losLab применяют эти проверки границ, ограничения глубины и ограничения расширения внутри, поэтому конвейер приема, построенный на них, начинается с защищенной базовой линии