Пошук підрядків на кшталт Pos('/Length', Dict) є неправильним інструментом для читання словника PDF, оскільки ключі імен PDF мають однакові префікси: наприклад, /Length є префіксом для /Length1, а /Encrypt — префіксом для /EncryptMetadata. Стандарт ISO 32000-1 §7.3.5 визначає ім'я як токен, що закінчується лише роздільником або пробільним символом, тому ключ вважається знайденим тільки тоді, коли байт після нього є одним із цих символів. Зчитувач словників, який пропускає цю єдину перевірку, рано чи пізно прочитає неправильне значення з абсолютно коректного файлу
Помилка, яка навчила нас цього, зовсім не була схожою на проблему лексичного аналізатора. Сканування відповідності почало повідомляти, що потік FontFile пошкоджено: розпакована програма шрифту виявилася фрагментом, обрізаним посередині таблиці. Файл без проблем відкривався в будь-якому переглядачі, а дані потоку на диску були цілими. Першопричина ховалася в одному рядку нашого спільного зчитувача словників: Pos('/Length', ...) збігся з /Length1 — стандартним ключем, який словники потоків FontFile містять відповідно до таблиці 127 стандарту ISO 32000-1, і зчитувач сприйняв ціле число після /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
Розділ 7.3.5 є коротким і точним: об'єкт імені — це коса риска (solidus), за якою йде послідовність звичайних символів, і токен завершується першим символом-роздільником або пробілом. Роздільниками є вісім символів дужок, коса риска та знак відсотка — ( ) < > [ ] { } / %, а пробілами є символи null, табуляція, переведення рядка, переведення сторінки, повернення каретки та пробіл (§7.2.2–§7.2.3). Це правило завершення визначає все. Запис /Length1 — це не «/Length з доданою одиницею»; це один неподільний токен, точно так само, як LengthOne та Length є різними ідентифікаторами в Pascal. Будь-який зчитувач, який шукає ключі простим пошуком байтів, повторно реалізує лексичний аналізатор без урахування правил завершення токенів
Ось спрощена суть цього дефекту. Цей варіант компілюється, проходить тести для файлів, де /Length записано першим, але псує потоки для файлів, де порядок інший
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Зіставлення всього токена: перевірка байта після ключа
Правильна логіка випливає безпосередньо з §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 може перетворити помилку парсера на виділення гігабайтів пам'яті?
Пошкоджений або шкідливий файл перетворює ці лексичні помилки на вичерпання ресурсів, оскільки цілі числа словника часто визначають розміри виділення пам'яті. Наш аудит виявив ланцюжок саме такого типу при розпакуванні потоків об'єктів (object streams). Запис /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 МіБ, що нейтралізує класичну «бомбу стиснення» zlib, коли кілька кілобайтів вхідних даних розширюються безмежно; цей ліміт є цілком достатнім для будь-якого реального потоку PDF, але захищає від критичних збоїв. Суть усіх цих перевірок однакова: будь-який розмір, оголошений у файлі, є лише заявою, і парсер перевіряє її на основі вимірюваних параметрів перед виділенням пам'яті. Така ж логіка аудиту застосовується і на рівень нижче — на межі зв'язування бібліотеки, про що йдеться у посиленні ABI PDFium та безпеці пам'яті в Delphi
Чесні обмеження, щоб ви не покладалися безмежно на описану вище процедуру. Зіставлення всього токена виправляє ідентифікацію ключів, але простий пошук байтів у словнику все одно не може визначити, чи знаходиться знайдений збіг усередині вкладеного словника, текстового літералу чи коментаря — метод FindDictKey для об'єкта сторінки може знайти ключ всередині його підсловника /Resources, якщо ви передасте йому занадто велику область пошуку. Наш зчитувач спочатку обмежує область пошуку тілом одного об'єкта, а контексти рядків і коментарів розглядає як окремі питання. Безпека підрядків — це лише одна сходинка: цілісність перехресних посилань є власною дисципліною, описаною у валідації об'єктів та потоків перехресних посилань (xref), а ширший перелік ризик-факторів для чужих документів описаний в аудиті ризиків безпеки PDF
Якщо ви підтримуєте написаний вручну зчитувач словників у Delphi або Lazarus, список завдань після цього інциденту дуже короткий. Знайдіть через grep усі виклики Pos('/ у коді та переведіть їх на використання помічника всього токена. Складіть список сімейств префіксів ваших ключів — /Length, /Encrypt, /N, /Type проти /Type1 зустрічаються в багатьох файлах. Потім перевірте кожне ціле число, яке передається в SetLength, GetMem чи цикл копіювання, і визначте, чим воно обмежене: розміром файлу, лімітом із заголовка чи нічим. Рівень парсингу, описаний тут, є основою нашого компонента PDFium Component, де зчитувач байтового рівня та движок рендерингу перевіряють один одного для кожного відкритого документа