Technical Article

Захист прив'язків PDFium VCL: ABI та безпека пам'яті

Прив'язка Pascal для бібліотеки C сприймається як звичайний Pascal. Ви викликаєте метод, отримуєте запис і звільняєте виділені ресурси. Проблема полягає в тому, що PDFium - це бібліотека C та C++ зі власною угодою про виклики, власними ширинами цілих чисел та власними правилами щодо того, хто володіє пам'яттю і хто її звільняє. Ніщо з цього само по собі не перетинає межу між мовами. Кожен із цих контрактів має бути вручну переоголошений в деклараціях Pascal, і одне неправильне слово перетворює охайний виклик на пошкодження стеку, усічене зміщення або подвійне звільнення пам'яті. Аудит версії v1.61.0 прив'язки PDFium VCL виявив по одному дефекту кожного типу. Їх варто розглянути, оскільки вони не є унікальними для цієї прив'язки. Це постійні небезпеки при обгортанні будь-якого API C у Delphi або Lazarus.

cdecl є частиною типу функції, а не просто прикрасою

PDFium - це комільований код C. На Win32 його експорт і, що важливіше, функції зворотного виклику (коллбеки), які він запускає, використовують угоду про виклики cdecl. При cdecl той, хто викликає, очищає стек після повернення керування. Стандартним для Delphi є register, а стандартом C для зворотних викликів на Win32 у деяких бібліотеках є stdcall, де стек очищає сама викликана функція. Коли структура передає PDFium вказівник на функцію і ви забуваєте вказати cdecl для типу цього вказівника, дві сторони не можуть узгодити, хто саме коригує вказівник стеку. Або обидві сторони роблять це, або жодна, і вказівник стеку зсувається на розмір аргументів при кожному виклику.

Причина, чому цей дефект важко знайти, полягає в тому, що пошкодження має нелокальний характер. Пошкоджений виклик повертає результат і виглядає нормально. Невідповідність виявляється пізніше, у якійсь зовсім іншій функції, фрейм якої тепер спирається на вказівник стеку, зсунутий на кілька байтів. Це виявляється у формі хаотичного читання пам'яті, неправильної адреси повернення або збою з трасуванням стеку, що не має нічого спільного зі зворотним викликом, у якому ви помилилися. Заповнення форм - це класичне місце, де виникає ця проблема, оскільки інтерфейс заповнення форм - це запис, повний зворотних викликів, до яких звертається PDFium. Один із них, FFI_OpenFile, передає PDFium функцію, яку он викликатиме для відкриття зовнішнього файлу, оголошену як function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Завершальне слово cdecl - це саме те, що варто скопіювати. Видаліть його, і код все одно скомпілюється, зв'яжеться та навіть працюватиме, доки PDFium не звернеться до цієї функції. Угода виклику належить до самого типу функції. Це не просто прикраса, і компилятор не попередить вас про її відсутність, оскільки звичайний тип функції є абсолютно дозволеним типом Pascal. Єдиний захист - вважати угоду про виклики обов'язковим полем кожної імпортованої сигнатури та кожного зворотного виклику, який ви передаєте назовні.

size_t відповідає ширині вказівника, і на FPC Win64 це означає 64 біти

Другий дефект - це невідповідність ширини цілого числа, яка з'являється лише на одній цільовій платформі. У мові C тип size_t визначений так, щоб бути достатньо широким для збереження розміру будь-якого об'єкта, що на 64-розрядній платформі означає 64-розрядне ціле число без знаку. Інтерфейси прогресивного завантаження PDFium оперують зміщеннями байтів у форматі size_t. Запис провайдера доступності даних FX_FILEAVAIL містить зворотний виклик IsDataAvail, який PDFium викликає із вказанням зміщення та розміру, а зворотний виклик AddSegment у записі FX_DOWNLOADHINTS отримує такі ж параметри. Обидва параметри мають тип size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Якщо ви оголосите ці зміщення як 32-розрядний тип, прив'язка працюватиме на Win32 та на Delphi Win64, але непомітно зламається на FPC та Lazarus Win64. Причина тонка. На FPC Win64 тип NativeUInt є справжнім 64-розрядним типом ширини вказівника, і size_t є його аліасом. Прив'язка містить коментар у розділі типів, який застерігає від переозначення NativeUInt на FPC, оскільки зміна його на 32-розрядний аліас змусить size_t стати 32-розрядним і зіпсує кожен параметр size_t, що передається бібліотеці або записується нею. 64-розрядне зміщення, що потрапляє в 32-розрядний параметр, втрачає свою старшу половину. Для малого файлу кожне зміщення вміщується у 32 біти, і все працює нормально. Для великого файлу, щойно зміщення перетинає межу чотирьох гігабайтів, усічене значення вказує на зовсім інше місце, PDFium запитує інформацію про неправильний діапазон байтів, і прогресивне завантаження зупиняється або зчитує сміття. Дефект є непомітним, поки файл не стане достатньо великим, а цільовою платформою не виявиться та, де size_t дійсно розширився.

Виняток Pascal ніколи не повинен розгортати стек через фрейм C

Третій клас проблем стосується моделі винятків, якої в мові C немає. Коли PDFium звертається до одного з ваших зворотних викликів, ваш код на Pascal виконується всередині стеку фреймів C та C++, які нічого не знають про механізм винятків Delphi. Якщо ваш зворотний виклик генерує виняток і дозволяє йому поширюватися далі, він розгортає стек через фрейми, які ніколи не створювалися для цього. Власне очищення ресурсів у PDFium не запускається, його внутрішні інваріанти залишаються частково оновленими, а процес переходить у стан, який бібліотека ніколи не передбачала. Контракт для цих зворотних викликів вимагає повернення коду помилки, а не генерації винятку.

Два зворотні виклики роблять це очевидним. FPDF_FILEWRITE - це приймач, куди PDFium записує збережений документ, а FPDF_FILEACCESS - джерело, з якого він читає вхідний документ. Обидва вони реалізовані тут поверх Delphi TStream, і обидва можуть завершитися помилкою так само, як і будь-який потік: диск переповнюється, потік закривається під вами, читання виходить за межі файлу. Зворотний виклик запису обгортає свій запис у потік і перетворює будь-який збій на код помилки PDFium, замість того щоб дозволити винятку вийти назовні.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Сторона читання діє так само: невдале читання повертає нуль відповідно до вимог контракту FPDF_FILEACCESS, замість того щоб генерувати виняток через межу інтерфейсу. Звичайний блок except без повторної генерації виглядає помилковим для програміста на Pascal, якого вчили ніколи не приховувати винятки, і у звичайному коді Pascal це дійсно так. Але на межі ABI це є правильною формою, оскільки єдине безпечне значення для повернення викликачеві на C - це код статусу, який он вміє інтерпретувати. Збій усе одно поширюється, але через повернуте значення, а викликаючий код над бібліотекою представляє його як EPdfError, щойно керування повертається на сторону Pascal.

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

Четвертий дефект стосується власності на ресурси. Дескриптор документа PDFium відкривається бібліотекою і має бути закритий рівно один раз за допомогою FPDF_CloseDocument. Небезпека полягає в шляху обробки помилок, який звільняє дескриптор, яким також володіє інший процес очищення. Уявіть процедуру, яка створює об'єкт-обгортку, призначає йому щойно відкритий дескриптор документа, а потім виконує додаткове налаштування, яке може завершитися збоєм. Якщо налаштування викликає виняток, обробник раннього повернення, який викликає FPDF_CloseDocument для необробленого дескриптора, закриє його, а потім власний деструктор об'єкта-обгортки закриє його знову при звільненні об'єкта. Дескриптор звільняється двічі, що є невизначеною поведінкою і, швидше за все, призведе до збою програми.

Аудит виявив це на шляху імпорту у стилі спуску смуг, який створює об'єкт TPdf навколо вже відкритого дескриптора. Виправлення полягає в тому, щоб зробити передачу власності єдиним джерелом істини. Щойно дескриптор призначається полю обгортки, обгортка володіє ним, і єдиним очищенням на шляху помилки є звільнення обгортки. Деструктор обгортки викликає FPDF_CloseDocument за вас, тому другий явний виклик закриття призведе до подвійного звільнення того самого документа. Виправлений обробник помилок звільняє об'єкт і повторно генерує виняток, забезпечуючи єдиний шлях до закриття ресурсу.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Керовані записи та бібліотека, повна експортів, потребують явного очищення

Багато допоміжних функцій цієї прив'язки повертають запис, який містить WideString або динамічний масив. Ці поля використовують підрахунок посилань, і компилятор створює приховані операції для підтримки їх лічильників. Звичка, перенесена з мови C, полягає в очищенні нового запису за допомогою FillChar(Result, SizeOf(Result), 0). Це записує нулі поверх керованого посилання всередині запису, не зменшуючи спочатку лічильник посилань. Компилятор повторно використовує одну приховану тимчасову змінну для результату функції між ітераціями циклу, тому на другій ітерації FillChar перезаписує активний вказівник на рядок, який ніколи не був вивільнений, і цей рядок втрачається в пам'яті. Викличте функцію в циклі для тисячі анотацій, і ви отримаєте витік тисячі рядків.

Виправлення полягає в тому, щоб дозволити мові очистити запис так, як вона вміє, за допомогою Default(T), що вивільняє будь-яке кероване поле перед його обнуленням.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Пов'язана проблема власності на ресурси існує на межі завантаження бібліотеки. Ця прив'язка розпізнає кілька сотень вказівників на функції з DLL PDFium за допомогою GetProcAddress після LoadLibrary. Якщо один обов'язковий експорт відсутній, частково зв'язаний стан є небезпечним: десятки вказівників є дійсними, інші є nil або застарілими, і будь-який наступний виклик через один із них веде в модуль, який уже може бути вивантажений з пам'яті. Прив'язка вирішує це шляхом вивантаження бібліотеки та завантаження повної процедури ClearAllBindings, яка скидає кожен імпортований вказівник на nil у разі невдалого розпізнавання обов'язкового експорту. Після цього жоден вказівник на функцію не вказує на вивантажений модуль, а наступний виклик завершується чистою перевіркою на nil-вказівник, замість переходу до звільненого коду.

Обгортка - це місце, де чотири контракти повторюються вручну

Жоден із цих п'яти дефектів не є екзотичним. Це передбачувані режими збоїв тонкого шару Pascal над C API, і вони згруповані разом, оскільки цей шар є саме тим місцем, де чотири окремі контракти мають бути оголошені заново. Угода про виклики має бути вказана як cdecl для кожного зворотного виклику. Ширина цілого числа має відповідати size_t на тій єдиній платформі, де цей тип розширюється. Модель винятків має бути перетворена на коди повернення в кожному зворотному виклику, який виходить за межі Pascal. Власність на кожен дескриптор і кожне кероване поле має бути визначена один раз і дотримуватися на кожному шляху виконання, включаючи шляхи обробки помилок, які ніхто не тестує до початку промислової експлуатації. Пропустіть будь-який з цих пунктів, і ви отримаєте дефект, симптом якого з'явиться далеко від його причини, що робить цю категорію помилок дорогою. Цінність аудиту полягала не стільки в якомусь одному виправленні, скільки в тому, щоб перевірити кожен із цих пунктів як окрему дисципліну в межах усієї прив'язки.

Якщо ви хочете побачити прив'язку в дії, а не лише захист її меж, методи кешування рендерингу та масштабування в нашій примітці про кешування рендерингу та продуктивність масштабування демонструють шлях відтворення, а посібник із крос-компіляції для створення переглядача на Lazarus та FPC показує місце, де поведінка size_t на Win64 дійсно має значення. Обидва рішення базуються на тій самій роботі з безпеки пам'яті та ABI, яка постачається в складі компонента PDFium для Delphi, Lazarus та C++Builder разом з API рендерингу, вилучення тексту та форм, описаними в інших статтях цього блогу.