Technical Article

Защита привязки PDFium VCL: ABI и безопасность памяти

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

Директива cdecl является частью типа функции, а не декорацией

Библиотека PDFium скомпилирована на C. На платформе Win32 ее экспортируемые функции и вызываемые обратные вызовы используют соглашение cdecl. При этом вызывающая сторона очищает стек после возврата управления. Стандартным соглашением в Delphi является register, а в некоторых библиотеках C для Win32 используется stdcall, при котором стек очищает вызываемая функция. Когда структура передает указатель на функцию в PDFium, а вы забываете указать cdecl в типе этого указателя, стороны расходятся во мнении о том, кто должен корректировать указатель стека. В итоге это делают либо оба модуля, либо никто из них, и при каждом вызове указатель стека смещается на размер аргументов

Этот дефект трудно обнаружить, так как сбой проявляется не сразу. Поврежденный вызов завершается успешно и выглядит корректно. Рассогласование обнаруживается позже, в другой функции, фрейм которой оказывается на смещенном указателе стека. Это выражается в неверном чтении данных, неправильном адресе возврата или аварии с трассировкой стека, которая не указывает на проблемный обратный вызов. Заполнение форм является классическим местом проявления этой ошибки, поскольку этот интерфейс представляет собой запись с обратными вызовами. Один из них, 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, передаваемый в библиотеку или записываемый ею. При передаче 64-битного смещения в 32-битный параметр теряется его старшая часть. Для небольшого файла все смещения умещаются в 32 бита, и проблемы не возникает. Для большого файла, как только смещение пересекает границу в четыре гигабайта, усеченное значение указывает на неверный адрес, из-за чего PDFium запрашивает не тот диапазон байтов, и прогрессивная загрузка зависает или считывает некорректные данные. Дефект незаметно усекает данные до тех пор, пока файл не станет достаточно большим, а целевой платформой не окажется та, где тип size_t расширился до 64 бит

Исключение 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 вместо вызова исключения через границу ABI. Пустой блок except без повторного вызова выглядит некорректным для программиста на 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;

Управляемые записи и динамический импорт библиотеки требуют явного освобождения ресурсов

Последний класс дефектов связан с памятью, которой компилятор управляет от вашего имени, и которую привычные методы C могут незаметно повредить. Многие вспомогательные функции этой привязки возвращают запись, содержащую 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);

Схожая проблема владения ресурсами возникает на этапе загрузки библиотеки. Эта привязка разрешает несколько сотен указателей на функции из библиотеки PDFium DLL с помощью 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 для рендеринга, извлечения текста и работы с формами