Конвейерът за прием на документи (intake pipeline) приема файлове, написани от непознати. Фактури, сканирания, прикачени файлове от уеб формуляр: всеки твърди, че е PDF и носи стотици числа, въз основа на които се очаква вашият парсер (parser) да действа. Дължини на потоци, размери на изображения, байтови отмествания (byte offsets), препратки към обекти — всяко едно е избрано от този, който е произвел файла, и едно прекъснато качване или умишлено деформиран документ в крайна сметка ще постави едно от тези числа там, където ще нанесе щети. Разликата между парсер, който преживява този файл, и такъв, който се срива (crashes), или продължава да работи с повредена памет, е малък набор от навици, които не зависят от никоя конкретна PDF библиотека
Навиците споделят една предпоставка: стойност, прочетена от файла, е твърдение (claim), а не измерване. Тя става използваема едва след като бъде проверена спрямо нещо, което самият парсер е измерил — реалния размер на файла, реалния брой байтове, които един декодер е произвел, реалната дълбочина на една рекурсия. Това, което следва, е тази предпоставка, приложена към местата, където парсерите на документи действително се чупят
Декларираната дължина е твърдение, а не измерване
Най-простото несъответствие е дължината на потока (stream length). Един PDF обект поток декларира броя на байтовете си в ключа /Length, а действителните данни стоят между ключовите думи stream и endstream. Нищо не принуждава двете да съвпадат. Един прекъснат (truncated) файл съдържа по-малко реални байтове от декларирания брой; файл от счупен генератор може да декларира дължина, която достига отвъд края на файла или в съседен обект. Заделете (allocate) от декларираната стойност и копирайте до endstream и ще препълните буфера (overrun the buffer); прочетете точно декларирания брой, без да проверите наличността, и ще излезете извън края на файла. Оставете декларираната стойност да управлява заделянето само след като я ограничите (clamping) спрямо измереното разстояние до края на данните, и третирайте несъответствието като точка за решение — поправете чрез сканиране за endstream или отхвърлете потока — никога като нещо, в което мълчаливо да вярвате
Параметри на изображението, които описват по-голям растер, отколкото сте заделили
Потоците от изображения повишават залозите, защото два независими набора от числа описват едни и същи пиксели. Речникът на изображението носи /Width и /Height, и растерните буфери обикновено се оразмеряват от тях. Филтърът за декодиране носи своя собствена геометрия: CCITTFaxDecode взема /Columns, /Rows и /K от своите DecodeParms, където /K избира схемата Group 3 или Group 4, а декодерът излъчва (Columns + 7) div 8 байта на сканираща линия (scanline). Файл, който декларира /Width 100, но подава на филтъра /Columns 1728 — по подразбиране — кара декодера да произвежда над шестнадесет пъти повече байтове на ред, отколкото буферът очаква, и препълването (overflow) се приземява една сканираща линия по една в каквото и да седи след заделянето (allocation). Когато /Rows отсъства, декодерът работи, докато данните не кажат стоп, така че ограничете (bound) и броя на редовете. DCTDecode има същия шев (seam): JPEG данните носят свои собствени ширина и височина в своя SOF маркер и нищо не ги задължава да съвпадат с речника
Защитното правило е механично: изчислете очаквания размер на растера от валидираните параметри за декодиране — собствените /Columns и /Rows на филтъра за CCITT, SOF размерите за DCT — проверете го спрямо вашите граници, заделете (allocate) от него и проверявайте по време на декодирането, че изходът никога не преминава отвъд заделянето. Когато речникът и филтърът не са съгласни относно геометрията, примирете ги (reconcile) или отхвърлете изображението. Това, което парсерът никога не трябва да прави, е да оразмерява буфера от един набор от числа и да оставя декодера да работи с другия
Капани (pitfalls) при аритметиката и заделянето в Delphi
Три поведения на Delphi подкопават дори парсер, който възнамерява да валидира. Първото е 32-битовото умножение: Delphi оценява произведението на два Integer операнда на 32 бита, независимо от ширината на дестинацията, така че Width * Height * BytesPerPixel може да се превърти (wrap), дори когато всеки фактор минава собствената си проверка за здрав разум (sanity check). Сканиране 30000 на 30000 при три байта на пиксел е 2.7 милиарда байта, което се превърта отрицателно в знакова (signed) 32-битова аритметика; малко по-различни фактори се превъртат до малка положителна дължина, която заделя и недооразмерява (undersizes) буфера. Принудете целия израз да бъде широк (wide), като преобразувате (casting) първия операнд — Size := Int64(Width) * Height * BytesPerPixel — след това сравнете с изричен таван (cap), преди каквото и да било да достигне SetLength
Второто е проверката на диапазона (range checking). Конфигурацията за release (издание) по подразбиране на Delphi се доставя с изключена проверка, така че индекс извън обхвата (out-of-range), изчислен от данните на файла, не повдига (raise) изключение — той чете или записва памет, съседна на масива. Включете я обратно с {$R+} (и {$Q+} за аритметично препълване) в горната част на всеки модул (unit), който индексира със стойности, извлечени от файла. Цената е неизмерима в сравнение с I/O операциите, които един парсер прави така или иначе, и превръща тихата повреда в уловима (catchable) ERangeError
Третото е TMemoryStream.SetSize с подаден от файла Int64. При съвременен RTL той заделя каквото е поискал файлът, така че един-единствен поток, претендиращ за четири гигабайта, се превръща в провал поради липса на памет (out-of-memory) по средата на приема. При по-стари RTL, където SetSize взема Longint, стойността тихомълком се стеснява (narrowed) първо: декларирано $100000010 става 16, заделянето успява, а записът на реалните данни минава далеч отвъд него. Валидирайте всеки размер спрямо измерения размер на източника и твърд таван, преди което и да е извикване за заделяне да го види
Отмествания (Offsets), които сочат извън файла
Таблицата с кръстосани препратки (cross-reference table) картографира номерата на обектите към абсолютни байтови отмествания, а парсерът търси (seeks) накъдето и да сочи. В повреден или враждебен файл тези отмествания се приземяват след края на файла или вътре в несвързани структури. TStream прави провала тих: задаването на Position отвъд Size не е грешка, а обикновено Read отвъд края просто връща по-малко байтове от поисканите, така че код, който пропуска проверката на броя, продължава да парсва остарели байтове (stale bytes) от предишния обект. Защитата е точка на прищипване (chokepoint) — един помощник, през който преминава всяко търсене и четене, управлявано от файла, валидирайки отместването и броя спрямо измерения размер на файла, преди потокът да се премести
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // нито един обект не може да надвишава 64 MB
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;
Прекарайте през него отместванията на кръстосаните препратки, обхватите (extents) на потоците и четенията на вградени файлове, и лошото отместване се превръща в чисто отхвърляне, което назовава числата, вместо нарушение на достъпа (access violation) три извиквания по-късно
Цикли и дълбочина в графа на обектите
PDF е граф, а не дърво. Всяка стойност може да бъде непряка препратка (indirect reference), една препратка може да се разреши (resolve) до друга препратка — /Length 12 0 R, където обект 12 съдържа 13 0 R — и нищо не пречи на една верига да се затвори сама в себе си. Резолвър (resolver), който следва препратките наивно, рекурсира (recurses), докато нативният стек (native stack) не се изчерпи, а изчерпването на стека не е нещо, което хващате; то прекратява процеса. Дълбоко вложени масиви и речници достигат до същия край без никакъв цикъл въобще
Използвайте два пазача (guards) заедно: изричен брояч на дълбочината ограничава честния, но дълбок случай на граница, до която не се доближава нито един легитимен файл, а множество от посетени (visited set) хваща истински цикъл при второто му посещение, превръщайки го в точна, докладваема грешка, вместо в спъване (trip) на лимита
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
// ... полета с полезен товар (payload) за останалите видове
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); // братя и сестри (siblings) могат законно да споделят този обект
end;
end;
Декомпресията е усилвател (amplifier)
Няколко килобайта FlateDecode вход могат да се надуят до гигабайтове; компресията с общо предназначение възнаграждава повтарящия се обикновен текст, а един нападател може да го направи максимално повтарящ се. Сложете таван на надутия размер (inflated size) на всеки поток до това, от което неговият потребител би могъл правдоподобно да се нуждае, и поддържайте втори бюджет на ниво документ: петстотин потока, всеки точно под тавана на поток, изчерпват паметта също толкова сигурно, колкото един гигантски поток. Проверката принадлежи вътре в цикъла на надуване (inflation loop), като брои изходните байтове, докато се произвеждат, и прекратява (aborting) при нарушение, а не след цикъла, когато паметта вече е изразходвана. Бюджет на документа, изразен като кратно на размера на компресирания файл, работи добре, тъй като легитимните документи се групират далеч под съотношенията (ratios), които един изработен (crafted) поток достига
Защита в дълбочина (Defense in depth) отвъд вашите собствени модули
Същите класове дефекти живеят в библиотеките. Две казусни проучвания (case studies) в този блог преминават през реални случаи: превъртанията на цели числа (integer wraps), неограничената рекурсия (unbounded recursion) и неинициализираните буфери (uninitialized buffers), затворени в нативен Pascal енджин в Втвърдяване на Pascal PDF парсер срещу злонамерени файлове, и опасностите при конвенцията за извикване (calling-convention), ширината на цялото число (integer-width) и собствеността (ownership) при свързването (binding) на C енджин в Втвърдяване на PDFium Component свързване (Binding). За наистина ненадежден прием (untrusted intake) — публичен формуляр за качване, неудостоверена пощенска кутия — изпълнявайте работата по парсване и декодиране в отделен процес с ниски привилегии, така че файлът, който побеждава всеки вътрешен (in-process) пазач, да струва провалена задача, вместо свалена (downed) услуга
Контролен списък преди полет (Preflight checklist)
Преди следващият билд да бъде изпратен (ships), прекарайте парсера през този списък: всеки буфер на поток, оразмерен от ограничена (clamped) дължина, а не от декларираната; всеки растер, оразмерен от валидирани параметри на декодера и проверен спрямо изхода на декодера; всяко произведение на размери (dimension product), оценено в Int64 и сравнено с изричен таван; {$R+} активен във всеки модул, който индексира със стойности, извлечени от файла; всяко търсене (seek) проверено за граници спрямо измерения размер на файла; всяко разрешаване на препратка ограничено по дълбочина и проверено за цикли; всеки цикъл на надуване отчитащ изхода спрямо бюджетите за поток и за документ. Нито една от тези проверки не струва измеримо време при легитимен документ, и всяка превръща повредата на паметта в чисто, записваемо в дневник (loggable) отхвърляне
Забележка: компонентът HotPDF, Delphi PDF библиотеката PDFlibPas и компонентът PDFium на losLab прилагат тези проверки на границите, лимити на дълбочината и тавани на разширяване вътрешно, така че конвейер за прием (intake pipeline), изграден върху тях, започва от втвърдена базова линия (hardened baseline)