Техническа статия

Безопасно анализиране на PDF речници (Dictionaries) в Delphi: Имена токени

Търсенето на поднизове като Pos('/Length', Dict) е неподходящ метод за четене на PDF речник (dictionary), тъй като имената на ключовете в PDF споделят общи префикси: /Length е префикс на /Length1, а /Encrypt е префикс на /EncryptMetadata. Стандартът ISO 32000-1 §7.3.5 дефинира името (name) като токен, който завършва само при разделител или празно пространство (whitespace), така че даден ключ се счита за намерен само когато следващият байт след него е някой от тези символи. Четец на речници, който пропуска тази проверка, в крайна сметка ще прочете грешна стойност от напълно валиден файл

Грешката, която ни научи на това, изобщо не приличаше на проблем с лексикалния анализатор (lexer). Проверка за съвместимост започна да докладва поток FontFile като повреден: декомпресираният шрифт беше отрязан наполовина. Файлът се отваряше без проблеми във всички програми за преглед. Данните от потока на диска бяха непокътнати. Причината се криеше в един-единствен ред от нашия общ четец на речници: Pos('/Length', ...) беше съвпаднал с /Length1 – стандартен ключ, който речниците на FontFile потоци съдържат съгласно ISO 32000-1 таблица 127, и четецът бе взел числото след /Length1 като дължина на потока. Програмата, създала този конкретен файл, беше записала /Length1 преди /Length, което е напълно допустимо, тъй като записите в речника не са подредени по ред съгласно §7.3.7. Потокът беше отрязан до некоректен брой байтове и всяка следваща проверка, която го консумираше, приключваше с неуспех, без да бъде сигнализирана грешка

Защо търсенето на поднизове поврежда анализа на PDF речници?

Търсенето на поднизове се проваля, тъй като пространството от имена в PDF съдържа множество умишлени префиксни групи, а подредбата на записите в речника е произволна. Таблица 127 на ISO 32000-1 дефинира /Length1, /Length2 и /Length3 за вградени шрифтови потоци, намиращи се непосредствено до /Length в същия речник. Речникът за шифриране свързва /Encrypt с /EncryptMetadata в него. Кратките ключове са още по-проблемни: търсене, изградено като Pos('/' + Key, ...) с Key = 'N', лесно съвпада с /Name или /Nums. Никое от тези съвпадения не изисква повреден файл. Програма, която подрежда /Length1 преди /Length, е напълно съвместима със стандартите, което означава, че грешката с поднизове не е проблем с устойчивостта при некоректни входни данни – тя е грешка в логиката на обработка на напълно валидни данни

Режимът на отказ също е от трудните за засичане. Неправилна стойност за /Length не предизвиква изключение, а просто ви подава по-къс или по-дълъг сегмент от байтове, отколкото потокът реално заема. Ако този сегмент се подаде на проверка за шрифтове, CMap анализ или сканиране на метаданни, модулът получава некоректни данни и обикновено не докладва нищо, тъй като незавършен zlib поток просто не може да се декомпресира и кодът продължава нататък. Ние се сблъскахме точно с този тип дефект и го коригирахме във версия v2.14.3 на нашия общ четец, след подробен одит на раздели §7.2–§7.3 от ISO 32000-1, който маркира всяко търсене в стил Pos като потенциално опасно

Какво реално дефинира ISO 32000-1 §7.3.5 за имената (Name)

Раздел 7.3.5 е кратък и точен: името е наклонена черта (solidus), последвана от поредица от стандартни символи, като токенът завършва при първия разделител или символ за празно пространство. Разделителите са осемте скоби плюс наклонената черта и знака за процент – ( ) < > [ ] { } / % – а празните пространства са нулев байт, табулация, нов ред, прекъсване на страница, връщане на каретката и интервал (§7.2.2–§7.2.3). Това правило за завършване описва всичко. Токенът /Length1 не е „/Length, последвано от 1“; той е единен, неделим токен, точно както LengthOne и Length са различни идентификатори в Pascal. Всеки четец, който търси ключове чрез просто сканиране на байтове, имплементира лексикалния анализатор с премахнато правило за завършване

Напасване на цели токени: проверка на байта след ключа

Правилният подход произтича директно от §7.3.5: потенциално съвпадение е реален ключ само ако символът непосредствено след него е разделител, празно пространство или край на буфера. Всичко останало е по-дълго име, което просто споделя същия префикс, така че търсенето трябва да продължи напред. Корекцията в нашия четец замени всяко просто търсене с Pos с една обща функция, базирана на това правило

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

Два детайла в този цикъл са важни. Първо, той продължава търсенето, вместо да върне грешка при първото префиксно съвпадение, тъй като подредбата /Length1 120 /Length 4076 е напълно валидна и реалният ключ се намира по-напред. Второ, случаят с край на буфера се брои за разделител, тъй като сегмент от речник може легитимно да завърши веднага след име. Един по-фин момент, който си струва да проверите във вашия код: същото правило важи и за лявата страна на съвпадението, ако търсеният низ не включва наклонена черта, тъй като в противен случай Pos('Length', ...) може да съвпадне с вътрешността на /PieceLength. Ограничаването на търсения низ с водеща наклонена черта / се справя с левия край, тъй като самата наклонена черта е разделител, завършващ предходния токен

Как зловреден PDF файл може да превърне грешка в анализатора в заделяне на гигабайти памет?

Повреден или злонамерен файл може да ескалира тези лексикални грешки в изчерпване на системните ресурси, тъй като целите числа в речниците често се използват за определяне на размери за заделяне на памет. Нашият одит откри точно такава уязвимост при обработката на потоци от обекти (ObjStm). Записът /N в речника на ObjStm указва колко компресирани обекта съдържа потокът, и разширяващият код извикваше метода SetLength за масив с този размер. Анализаторът на цели числа обаче оставяше своя изходен параметър непроменен при грешка, докато все пак го връщаше – така че некоректна стойност за /N подаваше на SetLength неинициализирана стойност от стека. Случайно положително число в този случай води до заявка за заделяне на гигабайти памет, предизвикана от няколко байта некоректни входни данни, при просто сканиране на документ, който все още не сте анализирали напълно

Корекцията имаше две независими части, които са приложими и на други места. Анализаторът вече връща изрично 0 при грешка, а не неинициализирана памет. Също така консуматорът вече не се доверява сляпо на стойността /N: заглавната част на ObjStm преди /First съхранява двойка цели числа – номер на обект и отместване – за всеки компресиран обект, като всяка двойка заема поне четири байта, включително разделителите. Всяка стойност на /N, по-голяма от FirstVal div 4 + 1, е физически невъзможна за декларирания размер на заглавната част и се отхвърля, преди да се задели каквато и да е памет. Това ограничение изисква едно просто сравнение и се извежда от наличните данни – модел, който си струва да се следва: ограничение, доказуемо от самия файл, а не произволна константа

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Още две ограничения допълват защитата в нашия четец, въведени във версия v2.12.0. Четецът на потоци отхвърля всяка стойност за /Length, по-голяма от целия файл, преди да задели буфер – потокът не може да бъде по-голям от контейнера, в който се намира, така че проверката не дава фалшиви резултати. Също така процесът на декомпресиране ограничава изходните данни до 256 MiB, което предотвратява атаки тип „zlib бомба“, при които малък обем входни данни се разширява неограничено; този лимит е напълно достатъчен за всеки реален PDF поток, като същевременно защитава системата в най-лошия сценарий. Основният принцип е един и същ: всеки размер, който файлът декларира, е само твърдение, и анализаторът проверява това твърдение спрямо параметри, които може да измери, преди да задели памет. Същият подход се прилага и при интеграцията на библиотеката, описан в статията за защита на PDFium ABI и сигурност на паметта в Delphi

Къде правилото за цели токени не е достатъчно

Реални ограничения, за да не се предоверявате на описаната функция. Търсенето на цели токени коригира идентификацията на ключовете, но плоското сканиране на байтове в речника все още не може да различи дали съвпадението се намира във вложен речник, текстов литерал или коментар – извикването на FindDictKey за обект на страница може да съвпадне с ключ във вложения речник /Resources, ако му подадете твърде широк обхват. Нашият четец първо ограничава обхвата до тялото на един обект и третира низовете и коментарите като отделни елементи. Сигурността при търсене е само една част от стълбата: съвместимостта на препратките е отделна задача, описана в валидиране на обекти и xref потоци, а по-широкият каталог от заплахи при външни документи е разгледан в одит на рисковете за сигурността в PDF

Ако поддържате ръчно написан четец на речници в Delphi или Lazarus, списъкът с действия от този случай е кратък. Намерете всяко извикване на Pos('/ в кода си и го пренасочете през помощна функция за цели токени. Направете списък с префиксните групи на ключовете си – /Length, /Encrypt, /N, /Type спрямо /Type1 се срещат в реални файлове. След това проверете всяко цяло число, което се подава на SetLength, GetMem или цикъл за копиране, и се уверете, че е ограничено: от размера на файла, от заглавната част или от друг параметър. Този слой за анализ е основата на нашия PDFium Component, при който четецът на байтово ниво и рендиращият модул се проверяват взаимно при всеки зареден документ