Технічна стаття

Парсинг PDF з безпекою пам'яті: захист від зловмисних документів

Конвеєр прийому документів приймає файли, написані незнайомцями. Рахунки-фактури, скани, вкладення з веб-форми: кожен з них стверджує, що є PDF, і містить сотні чисел, на які ваш парсер має реагувати. Довжина потоків, розміри зображень, байтові зміщення, посилання на об'єкти — кожне з них було вибрано тим, хто створив файл, і усічене завантаження або навмисно деформований документ зрештою розмістить одне з цих чисел там, де воно завдасть шкоди. Різниця між парсером, який переживає цей файл, і тим, що аварійно завершує роботу або продовжує працювати з пошкодженою пам'яттю, полягає в невеликому наборі звичок, які не залежать від жодної конкретної бібліотеки PDF

Ці звички мають одну спільну передумову: значення, зчитане з файлу, є заявою, а не вимірюванням. Воно стає придатним для використання лише після того, як буде перевірено за чимось, що парсер виміряв сам — реальним розміром файлу, реальною кількістю байтів, яку створив декодер, реальною глибиною рекурсії. Те, що описано нижче, — це застосування цієї передумови до місць, де парсери документів дійсно ламаються

Заявлена довжина — це заява, а не вимірювання

Найпростіша невідповідність — це довжина потоку. Об'єкт потоку PDF оголошує свою кількість байтів у ключі /Length, а фактичні дані знаходяться між ключовими словами stream та endstream. Ніщо не змушує їх збігатися. Усічений файл містить менше реальних байтів, ніж заявлена кількість; файл від зламаного генератора може оголосити довжину, яка виходить за межі кінця файлу або зачіпає сусідній об'єкт. Виділіть пам'ять на основі заявленого значення і копіюйте до endstream, і ви переповните буфер; прочитайте рівно заявлену кількість без перевірки доступності, і ви вийдете за межі файлу. Дозвольте заявленому значенню керувати виділенням лише після того, як воно буде обмежене виміряною відстанню до кінця даних, і розглядайте невідповідність як точку прийняття рішення — виправити шляхом сканування на наявність endstream або відхилити потік — але ніколи як те, чому можна сліпо вірити

Параметри зображення, які описують більший растр, ніж ви виділили

Потоки зображень підвищують ставки, оскільки два незалежних набори чисел описують одні й ті самі пікселі. Словник зображення містить /Width та /Height, і растрові буфери зазвичай розміщуються на їх основі. Фільтр декодування містить власну геометрію: CCITTFaxDecode приймає /Columns, /Rows та /K зі своїх DecodeParms, де /K вибирає схему Group 3 або Group 4, а декодер видає (Columns + 7) div 8 байтів на кожен рядок розгортки. Файл, який оголошує /Width 100, але передає фільтру /Columns 1728 (значення за замовчуванням), змушує декодер генерувати в шістнадцять разів більше байтів на рядок, ніж очікує буфер, і переповнення лягає по одному рядку за раз у те, що знаходиться після виділеної пам'яті. Якщо /Rows відсутній, декодер працює доти, доки дані не скажуть зупинитися, тому обмежте і кількість рядків. DCTDecode має такий самий шов: дані JPEG містять власні ширину та висоту в маркері SOF, і ніщо не зобов'язує їх збігатися зі словником

Захисне правило є механічним: обчисліть очікуваний розмір растру з перевірених параметрів декодування — власних /Columns та /Rows фільтра для CCITT, розмірів SOF для DCT — перевірте його щодо ваших лімітів, виділіть із нього пам'ять і під час декодування переконайтеся, що вихід ніколи не виходить за межі виділеного. Коли словник і фільтр не згодні щодо геометрії, узгодьте їх або відхиліть зображення. Чого парсер ніколи не повинен робити, так це встановлювати розмір буфера з одного набору чисел і дозволяти декодеру працювати з іншим

Підводні камені арифметики та виділення пам'яті в Delphi

Три особливості поведінки Delphi підривають навіть той парсер, який має намір здійснювати валідацію. Перша — це 32-бітне множення: Delphi обчислює добуток двох операндів Integer у 32 бітах незалежно від ширини призначення, тому Width * Height * BytesPerPixel може переповнитися (wrap), навіть якщо кожен множник проходить власну перевірку на адекватність. Скан 30000 на 30000 з трьома байтами на піксель становить 2,7 мільярда байтів, що в 32-бітній арифметиці зі знаком обертається на від'ємне значення; трохи інші множники обертаються на невелику додатну довжину, яка виділяє і занижує розмір буфера. Зробіть увесь вираз широким, виконавши приведення першого операнда — Size := Int64(Width) * Height * BytesPerPixel — а потім порівняйте з явним лімітом, перш ніж щось досягне SetLength

Друга — перевірка діапазону. За замовчуванням конфігурація випуску Delphi постачається з вимкненою перевіркою, тому індекс поза межами, обчислений на основі даних файлу, не викликає помилки — він читає або пише в пам'ять, що прилягає до масиву. Увімкніть її знову за допомогою {$R+} (та {$Q+} для арифметичного переповнення) на початку кожного модуля, який індексує значення, отримані з файлу. Вартість цього незміренна порівняно з вводом-виводом, який парсер робить у будь-якому разі, і це перетворює тихе пошкодження на ERangeError, яку можна відловити

Третя — TMemoryStream.SetSize з наданим файлом Int64. У поточній RTL вона виділяє стільки, скільки попросив файл, тому єдиний потік, що заявляє про чотири гігабайти, перетворюється на збій нестачі пам'яті посеред прийому. На старих RTL, де SetSize приймає Longint, значення спочатку тихо звужується: заявлені $100000010 стають 16, виділення проходить успішно, а запис реальних даних проходить далеко за його межі. Перевіряйте кожен розмір щодо виміряного розміру джерела та жорсткого обмеження, перш ніж його побачить будь-який виклик виділення пам'яті

Зміщення, які вказують за межі файлу

Таблиця перехресних посилань відображає номери об'єктів на абсолютні байтові зміщення, і парсер здійснює пошук туди, куди вона вказує. У пошкодженому або ворожому файлі ці зміщення потрапляють за межі файлу або всередину непов'язаних структур. TStream робить збій тихим: встановлення Position за межами Size не є помилкою, а звичайне Read за кінцем просто повертає менше байтів, ніж вимагалося, тому код, який пропускає перевірку кількості, продовжує розбирати застарілі байти з попереднього об'єкта. Захистом є «вузьке горлечко» (chokepoint) — один помічник, через якого проходить кожен пошук і читання на основі файлу, що перевіряє зміщення та кількість щодо виміряного розміру файлу до того, як потік переміститься

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
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;

Пропускайте через нього зміщення перехресних посилань, екстенти потоків і читання вбудованих файлів, і погане зміщення стане чистим відхиленням, яке називає числа, замість порушення доступу через три виклики

Цикли та глибина в графі об'єктів

PDF — це граф, а не дерево. Будь-яке значення може бути непрямим посиланням, посилання може розв'язуватися в інше посилання — /Length 12 0 R, де об'єкт 12 містить 13 0 R — і ніщо не заважає ланцюжку замкнутися на собі. Резолвер, який наївно слідує за посиланнями, виконує рекурсію доти, доки не вичерпається власний стек, а вичерпання стеку — це не те, що можна відловити; це завершує процес. Глибоко вкладені масиви та словники досягають того самого кінця без будь-якого циклу взагалі

Використовуйте два захисти разом: явний лічильник глибини обмежує чесний, але глибокий випадок на межі, до якої не наближається жоден легітимний файл, а набір відвіданих вузлів відловлює справжній цикл під час другого візиту, перетворюючи його на точну помилку, про яку можна повідомити, замість спрацьовування ліміту

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // meaningful when Kind = pvReference
    // ... payload fields for the remaining kinds
  end;

// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
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 // e.g. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // siblings may legally share this object
  end;
end;

Розпакування — це підсилювач

Кілька кілобайтів вхідних даних FlateDecode можуть роздутися до гігабайтів; стиснення загального призначення винагороджує повторюваний відкритий текст, і зловмисник може зробити його максимально повторюваним. Обмежте роздутий розмір кожного потоку тим, що правдоподібно може знадобитися його споживачеві, і зберігайте другий бюджет для всього документа: п'ятсот потоків, кожен з яких трохи менший за ліміт на потік, вичерпують пам'ять так само надійно, як і один гігантський потік. Ця перевірка належить до циклу роздуття, підраховуючи вихідні байти в міру їх створення та перериваючи роботу в разі порушення, а не після циклу, коли пам'ять уже витрачено. Бюджет документа, виражений як кратне розміру стисненого файлу, працює добре, оскільки легітимні документи скупчуються набагато нижче коефіцієнтів, яких досягає спеціально створений потік

Глибокий захист за межами ваших власних модулів

Ті самі класи дефектів живуть і всередині бібліотек. Два тематичні дослідження в цьому блозі розглядають реальні випадки: переповнення цілих чисел, необмежену рекурсію та неініціалізовані буфери, закриті у власному рушії Pascal, у Посиленні парсера PDF на Pascal проти зловмисних файлів, а також небезпеки, пов'язані з угодою про виклик, шириною цілих чисел та володінням під час прив'язки рушія C у Посиленні прив'язки PDFium Component. Для дійсно ненадійного прийому — публічної форми завантаження, неавтентифікованої поштової скриньки — також запускайте розбір і декодування в окремому процесі з низькими привілеями, щоб файл, який долає кожен внутрішньопроцесний захист, обійшовся лише невдалим завданням, а не відмовою служби

Контрольний список переддрукарської підготовки

Перед випуском наступної збірки перевірте парсер за цим списком: кожен буфер потоку має розмір на основі обмеженої довжини, а не заявленої; кожен растр має розмір на основі перевірених параметрів декодера і перевіряється на виході декодера; кожен добуток вимірів обчислюється в Int64 і порівнюється з явним лімітом; {$R+} активний у кожному модулі, який індексує значеннями з файлу; кожен пошук перевіряється на межі відносно виміряного розміру файлу; кожне розв'язання посилання обмежене за глибиною та перевірене на цикли; кожен цикл роздуття підраховує вихідні дані відповідно до бюджетів на потік і на документ. Жодна з цих перевірок не коштує вимірюваного часу на легітимному документі, і кожна з них перетворює пошкодження пам'яті на чисте відхилення, яке можна записати в журнал

Примітка: HotPDF Component, PDFlibPas Delphi PDF Library та PDFium Component від losLab застосовують ці перевірки меж, обмеження глибини та обмеження розширення внутрішньо, тому конвеєр прийому, побудований на них, починається з посиленої базової лінії