PDF от 20 КБ, който притиска процес на услуга, докато OOM killer-ът не го отнесе, не е бъг във вашия код — това е декомпресионна бомба. HotPDF, нативният VCL PDF компонент за Delphi и C++Builder, ограничава такава с DecodeBudgetBytes, таван за отделна верига от филтри, по подразбиране 268435456 байта, който начислява всеки етап на декодиране срещу един общ бюджет
Файлът от 20 КБ, който изяде работен процес
Формата на инцидента е винаги един и същ. Опашков работен процес, който рендира миниатюри, поема качен файл, резидентната памет се покачва над 12 ГБ за под две секунди, и процесът изчезва без trace на стека. Файлът е 20 КБ. Има една страница, един поток със съдържание и масив /Filter с пет записа. Всяко име в този масив е филтър, дефиниран от спецификацията, всеки етап декодира без грешка, и нищо във файла не е деформирано. Точно това прави този клас входни данни неудобен: няма повреден байт за отхвърляне
Това не е същият проблем като правилното декодиране на един филтър. Правилното изпълнение на LZWDecode и предиктора в /DecodeParms е собствена тема, разгледана в разбора на LZW, предиктори и DecodeParms в заредени документи. Тук всеки декодер вече е правилен. Провалът е в това какво правят правилни декодери, когато пуснете пет от тях последователно и никой не брои общата сума. ISO 32000-1 §7.4 е изричен, че /Filter може да е единично име или масив от имена, и че масив се прилага последователно, първият запис пръв. Не казва нищо за това колко може даден етап да разшири входа си, и нищо за общата сума по веригата. Етап ASCIIHexDecode приблизително наполовинява входа си, което звучи безобидно. Етап FlateDecode над последователност от нулеви байтове достига съотношения от хиляди. Веригирайте ги и аритметиката е мултипликативна: 20 КБ стават 20 МБ стават 20 ГБ, а всяка отделна стъпка е съответстващо декодиране на законен поток
Защо лимит за отделен филтър не спира decode бомба?
Защото лимит за отделен филтър се презарежда при всеки елемент на масива /Filter. Верига от пет етапа под таван от 256 MiB на етап оторизира 1,25 GiB, а последният етап все пак започва с напълно свеж лимит, независимо какво са произвели четирите преди него. Лимитът се прилага честно и не ограничава нищо съществено. 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 MiB. 4000-страничен документ следователно има 4000 независими шанса да изразходва целия таван, а обектните потоци умножават бройката още повече, защото всеки от тях сам по себе си е компресиран контейнер, съдържащ много обекти, както е описано в бележките за обектни потоци и инкрементални обновявания. Ако реалното ви изискване е граница на общата памет на процеса, това свойство е един принос към нея, не цялата, и трябва да седи зад таван на ниво job или контейнер
Нула означава неограничено, и това е легитимна настройка, а не изход за бягство. Задайте я, когато притежавате входа: pipeline за преобработка на архив над документи, произведени от собствената ви система, или стъпка на растеризация, при която единична верига за цветно сканиране 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 компонент изброява инструментариума за заредени документи, към който тези лимити се прилагат