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 перелічує набір інструментів для завантажених документів, до яких застосовуються ці обмеження