PDF не е просто документ, който отваряте. Той е малка програма, която изпълнявате. Всеки вграден шрифт е базиран на стек интерпретатор, чакащ charstrings, всяко изображение е декодер, захранван с полета за ширина, височина и дълбочина на бита, избрани от файла, и всеки поток пристига обвит във филтри, чиито параметри са зададени от файла. Нито едно от тези числа не е ваше. Те идват от този, който е създал файла, което при реална натовареност е фактура на клиент или прикачен файл от неизвестен подател. Декодерите, които превръщат тези байтове в пиксели и глифове, са повърхността за атака, а анализатор, който се доверява на входа си там, е на един деформиран файл разстояние от срив или нещо по-лошо.
PDFlibPas премина през етап на защита, който третира целия път на декодиране като враждебен – от програмите за шрифтове (TrueType, Type1, CFF и CMap таблиците), през декодерите за изображения (PNG, GIF, TIFF, JBIG2 и CCITT Group 3 и Group 4), до филтрите на потоци (LZW, ASCII85 и Flate предвижданията). Следват пет класа дефекти, които той затвори, всеки от които се основава на конкретното поведение на Delphi, което го е направило възможен. Те са коригирани в настоящите версии и същите формати се повтарят във всеки код на Pascal, който анализира ненадежден вход.
Препълване на цели числа, което ви предава недостатъчен буфер
Класическият бъг в безопасността на паметта в декодер на изображения е произведение на размерите, което се превърта. Декодерът чете ширина, височина, брой компоненти и дълбочина на бита, умножава ги, за да оразмери изхода си, заделя съответния брой байтове, след което запива изображението в неговите реални размери. Ако умножението се извършва в 32-битова аритметика, произведението може да се превърти до малка стойност, дори когато всеки отделен фактор е в разумен диапазон, така че разпределянето на памет е успешно, резултатът е твърде малък и декодирането излиза извън него. Това е CWE-190 (препълване на цели числа), водещо до запис на купчината извън границите (CWE-787) една стъпка по-късно.
Споделеният път за изображения вече ограничаваше всяко измерение до 65535; самостоятелните декодери не наследяваха изцяло това ограничение. Израз за байтове на ред по височина като ByteCount * FHeight или израз за пиксел като FWidth * Components * BitDepth е 32-битово произведение в Delphi, когато и двата операнда са 32-битови цели числа, независимо от това колко широка е променливата, на която присвоявате резултата. Ширина и височина от 60000 са напълно възможни за голямо сканиране, но тяхното произведение в байтове препълва подписания 32-битов диапазон и дължината се оказва малка. Същият капан съществуваше в ZLib предвиждането, BitsPerComponent * Colors * Columns.
Корекцията е да се направи поне един операнд Int64, така че целият израз да се изчисли в 64-битов формат, след което да се сравни с MaxInt и файлът да бъде отхвърлен, преди да се стесни отново за извикване на SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
Това, което прави това проблем на Delphi, а не общ, е тихото стесняване. Присвояването на твърде широк израз в 32-битова дестинация е легално преобразуване, за което компилаторът няма да предупреди по подразбиране, а проверката на диапазона не улавя превъртане, което се случва, преди стойността да бъде използвана като индекс. Оставете произведението на 32 бита и езикът тихо ви дава дължина, която лъже за това колко памет ще докосне декодирането.
Тип на поле, който прави предпазителя невъзможен за задействане
TIFF файлът е верига от директории с файлове с изображения, всяка от които носи отместването в байтове на следващата. Зловреден файл може да насочи тази верига обратно към себе си и четецът, който я обхожда без условие за спиране, работи вечно. Това е CWE-835 – безкраен цикъл, управляван от въведени от атакуващия данни, а защитата е брояч, който спира, след като премине лимит, който нито един легитимен файл не би достигнал.
Броячът на страници беше деклариран като Word, което в Delphi съдържа от 0 до 65535. Цикълът носеше предпазител за прекратяване от типа „спри, когато броят на страниците надхвърли 65535“, което изглежда вярно, докато не забележите, че операндът и прагът споделят една и съща горна граница. Стойност от тип Word никога не може да бъде по-голяма от 65535, така че сравнението е структурно винаги невярно: когато броячът достигне 65535, следващото увеличение го превърта обратно до 0, предпазителят никога не вижда стойност над тавана и зациклената IFD верига поддържа четеца да се върти безкрайно.
Корекцията беше полето да се разшири, така че предпазителят да може да изрази стойност, която броячът действително може да задържи. С TPDFTIFF.FPageCount, деклариран като Integer, същото сравнение FPageCount > 65535 става достижимо, цикълът завършва и публичното свойство PageCount промени типа си, за да съответства, без да пречи на никой извикващ. Всеки път, когато проверката на границата има формата Value > MaxValueOfType(Value) и операндът вече е от тип, съответстващ на този максимум, условието е постоянно невярно: разширете типа или тествайте равенство спрямо максимума, за да може да се задейства.
Проверката на диапазона е изключена при критичен път (hot path)
При включена проверка на диапазона Delphi вмъква проверка на границите за всеки индекс на масив и низ, което е разликата между индекс извън диапазона, предизвикващ прихващане на ERangeError, и същия този индекс, който чете или пише памет, която не принадлежи на структурата. Критичните пътища (hot paths) понякога я деактивират с локална директива {$R-}, което е защитимо точно до момента, в който индексите престанат да бъдат надеждни.
Достъпът до списъци, на който се основават интерпретаторите на шрифтове, TPDFlibStringList.Get, е точно такъв път. В Windows той се компилира с изключена проверка на диапазона и индексира своето резервно хранилище директно, така че индекс извън диапазона не е грешка, а директен достъп до паметта. Това е добре, когато индексът винаги е валиден, и престава да бъде добре в интерпретатор на CFF или Type2 charstring, където индексът може да дойде от файла. Нишка с charstring, която изважда операнд от празен стек, произвежда индекс минус едно; идентификатор на глиф, отместен с единица спрямо броя на глифовете, индексира един слот след края. При изключена проверка на диапазона и двете се превръщат в истински достъп извън границите, вместо в прихващаемо изключение, и тъй като слотовете съдържат референтно преброени стойности AnsiString, случайно четене може също така да повреди броя на референциите на низ.
Защитата не включи отново проверката на диапазона за критичния път. Тя първо направи индексите доказуемо валидни: преди да вземе горната част на стека на операндите, интерпретаторът проверява дали стекът не е празен и всеки предпазител на индекс беше написан като строго по-малко от броя, а не като по-малко или равно, което допуска грешка с единица. Директивата премества отговорността за границите от компилатора върху вас, а валидацията, която тя премахва, трябва да се върне на ръка при всяка входна точка.
Неограничена рекурсия в интерпретатор на charstring
Type2 charstring може да извика подпрограма, а самата подпрограма е charstring, който може да извика друга, така че операторите за извикване на локални и глобални подпрограми позволяват на файла да реши колко дълбоко да стигне. Подпрограма, която извиква себе си – директно или чрез цикъл – се рекурсира безкрайно, докато системният стек се изчерпи и процесът приключи. Това е CWE-674 (неконтролирана рекурсия).
Интерпретаторът на Type1 вече беше защитен срещу това. Той съдържаше брояч за дълбочина на извикване и таван PLType1MaxCallDepth и отказваше да слиза под него, което отразява лимита за дълбочина, който самата спецификация на Type1 определя. Интерпретаторът на Type2, добавен по-късно и подобен по структура, не съдържаше същия предпазител и ръчно създаден шрифт с подпрограма, която извиква собствения си номер, преминава директно през липсващата проверка към препълване на стека.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
Корекцията беше да се даде на пътя на Type2 същата ограничена дълбочина, каквато неговият събрат Type1 вече имаше. Всяко рекурсивно спускане по контролирана от атакуващия структура – независимо дали са подпрограми за шрифтове, вложен масив или верига от кръстосани препратки – се нуждае от таван за дълбочина, който входните данни не могат да превишат.
Неинициализирана памет, която изтича в изходния резултат
Най-тънкият дефект изпускаше съдържание от купчината (heap) в дешифрирания изход и причината е свойство на SetLength, което лесно се забравя. Когато увеличавате AnsiString с SetLength, Delphi разпределя байтовете, но не ги нулира, така че новият регион съдържа каквото е имало преди това в тази памет на купчината. Ако всеки байт е записан впоследствие, това никога няма значение; но ако даден път остави част от буфера незаписана и след това я върне като данни, тези остарели байтове излизат с резултата. Това е CWE-457 (използване на неинициализирана памет) и когато резултатът премине границата на доверие, се превръща в изтичане на информация.
Пътят на дешифриране с AES-CBC се сблъска точно с това. Изходният буфер беше оразмерен със SetLength и дешифраторът обработваше шифрования текст блок по блок от по 16 байта. Когато дължината на шифрования текст не беше кратна на 16 (дължина, която атакуващият може да избере), крайният частичен блок никога не се записваше, така че тези крайни байтове запазваха съдържанието от купчината, което SetLength е оставило, и буферът се връщаше като дешифриран чист текст на документен обект. Решението е два предпазителя и нито един от тях сам по себе си не е достатъчен: входната точка за дешифриране сега отхвърля всеки шифрован текст, чиято дължина не е кратна на размера на блока, и като подсигуряване изходът се изчиства с FillChar преди употреба, така че всеки път, който не успее да запише даден регион, да връща нули вместо остатъци от купчината.
С какво ви оставя този етап
Петте дефекта са различни бъгове, но се допълват. Ширина на цяло число, която превърта произведение, тип на поле, който закрепва предпазител към постоянно невярно състояние, изключена проверка на диапазона там, където индексите вече не са безопасни, рекурсия без под и буфер, който езикът отказва да нулира. Във всеки един от тях Delphi направи точно това, което дефинира, тъй като езикът ви дава аритметика, която се превърта, тихо стесняване, проверки на диапазона, които можете да изключите, рекурсия без вграден лимит и заделяне на памет, което не я инициализира. Това е договорът и анализатор на Pascal го изпълнява, като управлява на ръка четири неща при всяка граница, контролирана от файла: ширина на целите числа, проверка на диапазона, дълбочина на рекурсията и инициализация на буфера.
Тези дефекта са отстранени в настоящите версии на PDFlibPas, механизмът за Delphi и C++Builder. Ако вашата работа обхваща и това как файлът твърди, че е защитен, съпътстващите бележки за одит на шифрирането и правата и за PDF/A и PDF/UA проверка покриват анализаторската страна на същия този анализатор, и всичко това се доставя в рамките на PDFlibPas Delphi PDF Library, заедно с API за зареждане, рендиране и подписване, разгледани на други места в този блог.