PDF на 20 КБ, который держит рабочий процесс до тех пор, пока его не заберёт OOM killer, — это не баг в вашем коде, это бомба распаковки. HotPDF, нативный VCL-компонент PDF для Delphi и C++Builder, ограничивает такую угрозу через DecodeBudgetBytes, потолок на всю цепочку фильтров, по умолчанию равный 268435456 байт, списывающий каждую стадию декодирования с единого общего бюджета
Файл на 20 КБ, съевший рабочий процесс
Форма инцидента всегда одна и та же. Воркер очереди, рендерящий миниатюры, забирает загрузку, резидентная память взлетает выше 12 ГБ менее чем за две секунды, и процесс исчезает без трассировки стека. Файл — 20 КБ. У него одна страница, один поток содержимого и массив /Filter с пятью записями. Каждое имя в этом массиве — фильтр, определённый спецификацией, каждая стадия декодируется без ошибки, и ничто в файле не повреждено. Именно это делает данный класс входных данных неудобным: отклонять здесь нечего, ни один байт не испорчен
Это не та же задача, что корректно декодировать один фильтр. Правильная обработка LZWDecode и предиктора /DecodeParms — отдельная тема, разобранная в статье о LZW, предикторах и DecodeParms в загруженных документах. Здесь каждый декодер уже корректен. Сбой в том, что делают корректные декодеры, когда вы запускаете пять из них подряд, и никто не считает итог. ISO 32000-1 §7.4 прямо говорит, что /Filter может быть одним именем или массивом имён, и что массив применяется последовательно, первая запись первой. Ничего не сказано о том, насколько стадия может расширить свой вход, и ничего об агрегате по всей цепочке. Стадия ASCIIHexDecode примерно вдвое сокращает свой вход, что звучит безобидно. Стадия FlateDecode над последовательностью нулевых байт достигает коэффициентов в тысячи. Соедините их — и арифметика становится мультипликативной: 20 КБ становятся 20 МБ, становятся 20 ГБ, и каждый отдельный шаг — соответствующее спецификации декодирование легального потока
Почему лимит на отдельный фильтр не останавливает бомбу декодирования?
Потому что лимит на отдельный фильтр перевзводится на каждом элементе массива /Filter. Цепочка из пяти стадий с потолком в 256 МиБ на стадию разрешает 1,25 ГиБ, и последняя стадия всё равно начинает с совершенно свежего допуска независимо от того, что произвели четыре предыдущие. Лимит соблюдается честно и не ограничивает ничего значимого. Именно такую форму HotPDF имел до v2.447.0, и рядом с ней был второй пробел. Декомпрессор LZW нёс потолок MaxOutputBytes, а путь предиктора изображений учитывал собственные строки, так что эти два были ограничены локально. У FlateDecode, ASCIIHexDecode, ASCII85Decode и RunLengthDecode вообще не было потолка: каждый писал в TMemoryStream, пока не заканчивался вход или не сдавался аллокатор. Так что у враждебной цепочки было два пути. Она могла использовать вовсе не защищённый фильтр или использовать защищённые и просто добавить их побольше
Есть третья деталь, которую наивное исправление упускает. Число, которое важно, — не размер конечного декодированного вывода. Это пик, и пик обычно живёт в промежуточном буфере. Цепочка, заканчивающаяся скромным потоком содержимого в 4 МБ, может выделить 8 ГБ на третьей стадии и вернуть нечто, выглядящее вполне разумным. Проверка длины результата постфактум ничего не говорит о выделении, убившем процесс
Один трекер бюджета на цепочку фильтров
Исправление в HotPDF v2.447.0 состоит в том, чтобы учёт охватывал цепочку, а не стадию. Каждая цепочка фильтров создаёт один THPDFDecodeBudgetTracker, и каждый декодер пишет через THPDFBudgetWriteStream, оборачивающий реальную цель. Обёртка вызывает Budget.Consume(Count), прежде чем передать дальше хотя бы один байт, так что отказ происходит, пока целевой поток ещё прежнего размера. Именно этот порядок — вся суть: проверка, выполненная после того, как буфер уже вырос, — диагностика, а не защита
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
Локальные потолки не исчезли, они стали проекциями общего бюджета. Стадия LZW теперь задаёт Decoder.MaxOutputBytes := Budget.RemainingBytes, так что её собственный потолок — это то, что осталось у цепочки, а не независимый допуск. Стадия предиктора изображений открывается через BeginFilter и списывает свою потребность в строках через Consume перед выделением памяти, а это значит, что вывод предиктора списывается с того же бюджета, что и питавшие его обычные фильтры. Это особенно важно на пути изображений, где цепочка фильтров и предиктор — две половины одной операции, как описано в статье об извлечении изображений из загруженных документов через их фильтры декодирования
Что видит вызывающий код, когда бюджет отказывает?
В самом низу стека отказ вызывает EHPDFDecodeBudgetError. Выше ответ зависит от контракта, который уже имел вызывающий API. Высокоуровневые методы чтения, которые сообщали об ошибке через False или nil, продолжают делать ровно это, потому что превращение задокументированного булева результата в исключение сломало бы код, уже корректно обрабатывающий повреждённый вход. Путь содержимого загруженной страницы — намеренное исключение из этого правила: он заново выбрасывает EHPDFDecodeBudgetError вместо того, чтобы позволить усечённому потоку содержимого отрендериться как страница, просто оказавшаяся пустой. Такой замысел означает, что голый False сам по себе неоднозначен, поэтому бюджет публикует рядом диагностическую запись: THotPDF.GetLastDecodeBudgetInfo возвращает состояние последней цепочки, декодированной этим экземпляром
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
Читайте эти поля вместе, и они разделяют две формы атаки. Когда PeakStageBytes близок к DecodedBytes, весь урон нанесла одна стадия, и вы имеете дело с единственным фильтром высокого коэффициента. Когда PeakStageBytes — малая доля от DecodedBytes, а FilterCount велик, ни одна отдельная стадия не была чрезмерной, и цепочка накопила своё за счёт количества, пройдя через потолок, — именно этот случай лимит на отдельный фильтр видеть не способен. Одна оговорка, которую стоит записать в свой обработчик: GetLastDecodeBudgetInfo возвращает False, пока экземпляр не декодировал хотя бы один фильтр, так что False от неё — не доказательство того, что документ был чист
Где бюджет сбрасывается и когда ноль — честный ответ
DecodeBudgetBytes ограничивает одну цепочку потоков, а не один документ, и эта граница намеренная, но легко читается неверно. Каждый поток содержимого, каждый встроенный файл, каждый поток перекрёстных ссылок и каждый поток объектов начинают со свежих 256 МиБ. У документа на 4000 страниц поэтому 4000 независимых шансов истратить весь потолок, а потоки объектов ещё умножают счёт, потому что каждый сам по себе — сжатый контейнер, содержащий много объектов, как описано в заметках о потоках объектов и инкрементных обновлениях. Если ваше настоящее требование — предел на общую память процесса, это свойство — один из вкладов в него, а не всё решение целиком, и оно должно стоять за потолком уровня задания или контейнера
Ноль означает без ограничений, и это легитимная настройка, а не лазейка. Задавайте его, когда вы владеете входными данными: конвейер повторной обработки архива над документами, произведёнными вашей же системой, или этап растеризации, где одной цветной сканирующей цепочке на 600 dpi по-настоящему нужно больше, чем любой потолок, который вы решились бы зашить в код. Отрицательные значения отклоняются сразу же с ERangeError, потому что у отрицательного бюджета нет связного смысла, а молчаливое ограничение снизу скрыло бы ошибку конфигурации
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
Выбор числа заслуживает большего внимания, чем ему обычно уделяют, потому что слишком низкий бюджет — самопричинённый сбой в обслуживании. Прогоните существующий корпус со значением по умолчанию, зафиксируйте PeakStageBytes и DecodedBytes для каждой цепочки и задайте потолок выше наблюдённого максимума с реальным запасом. Круглое число, выбранное потому, что оно звучало безопасно, отклонит легитимный крупный скан в самый неподходящий момент, и сбой в ваших логах будет выглядеть в точности как атака
Копирование, которого больше не происходит
Пропуск каждой стадии через обёртку бюджета оказался способом сделать цепочку дешевле, а не дороже. Когда у потока есть фильтры, первая стадия теперь читает исходный поток напрямую вместо предварительного копирования закодированных байт в черновой буфер, и с этого момента живы только два буфера сразу: текущий вход и записываемый вывод стадии. Сырое копирование сохраняется в двух случаях, где оно нужно, а именно поток вовсе без фильтров и изображение, для которого вызывающий код хочет сохранить последнее кодирование, потому что оба случая возвращают поток, которым владеет и может независимо перемещаться вызывающий код. Незащищённая версия этого кода выделяла больше и ограничивала меньше, что и есть обычное соотношение между этими двумя. Впрочем, стоит сказать прямо: ничто из этого не делает произвольный PDF безопасным для загрузки. Это закрывает один конкретный и очень дешёвый вектор отказа в обслуживании, тот, где маленький файл покупает большое выделение памяти через вложенные фильтры. Переполнение целых чисел в учёте байт защищается отдельно, а более широкий вопрос разбора враждебных документов без доверия к их внутренним смещениям — отдельная дисциплина. Бюджет декодирования — один предел среди нескольких, и его ценность в том, что это тот предел, который можно задать одним свойством ещё до того, как вы коснётесь файла
Бюджет на цепочку, его диагностическая запись и пути декодирования загруженного документа, которые он защищает, поставляются как часть самого компонента, без внешней зависимости от распаковки, которую нужно настраивать или патчить. Если вы оцениваете, как ограничить недоверенный вход PDF внутри сервиса на Delphi или C++Builder, страница HotPDF Delphi PDF component перечисляет набор инструментов для загруженных документов, к которым применяются эти ограничения