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

HotPDF: изоляция кодеков изображений PDF в процессах

HotPDF умеет декодировать три самых рискованных фильтра изображений PDF — DCTDecode, JPXDecode и JBIG2Decode — внутри отдельного недолговечного рабочего процесса вместо вашего приложения. Свойство, включающее это поведение, — CodecIsolationMode, а практический эффект в том, что повреждённый кодовый поток JPEG 2000, который раньше обрушил бы ваше VCL-приложение, теперь убивает одноразовый дочерний процесс, пока хост-процесс сообщает код статуса и продолжает работу

Эта разница важнее всего именно там, откуда PDF-файлы реально приходят: форма загрузки, почтовый шлюз, сканирующее устройство, FTP-приёмник партнёра. Вы не контролируете эти байты, а кодеки изображений — это как раз то место, где исторически и таился ущерб

Почему одно плохое изображение обрушивает всё приложение?

Потому что кодек изображения — единственная часть читателя PDF, которая прогоняет сложный автомат состояний над данными, контролируемыми злоумышленником, практически без структурных проверок, на которые можно опереться. К моменту, когда байты доходят до декодера JPEG 2000 или JBIG2, таблица перекрёстных ссылок уже разобрана, объект уже разрешён, цепочка фильтров уже развёрнута, и остаётся лишь сырой кодовый поток, сообщающий, сколько тайлов, сколько компонентов, сколько бит на отсчёт. Неверное число здесь — это не ошибка разбора. Это неправильный размер выделения памяти или выход индекса за границы внутри плотного цикла декодирования

Лимиты бюджета помогают, и у вас они уже должны быть настроены. HotPDF ограничивает разрастание данных через DecodeBudgetBytes и DocumentDecodeBudgetBytes, а цепочки фильтров — через DecodeFilterLimit и DecodePipelineDepthLimit; обоснование этих ограничений раскрыто в статье об ограниченном декодировании для вложенных фильтров и PDF-бомб. Но байтовый бюджет отвечает лишь на один вопрос — сколько вывода разрешено. Он не может ответить, что произойдёт, если декодер откажет ещё до того, как выдаст хоть какой-то вывод. Нарушение доступа к памяти внутри цикла декодирования — это не нарушение политики, которое можно отклонить, это событие уровня процесса, а единственное надёжное сдерживание для события уровня процесса — это другой процесс

Что HotPDF изолирует, а что нет

HotPDF изолирует ровно три вида кодеков, перечисленных как hckDCT, hckJPX и hckJBIG2 в модуле HPDFCodecIsolation. Всё остальное — Flate, LZW, RunLength, ASCII85, CCITT — остаётся внутри процесса, потому что эти декодеры достаточно просты, чтобы ограничить их бюджетами, и именно там интересных сбоев не встречается

Транспорт намеренно узкий. Хост выделяет одну ограниченную область разделяемой памяти, записывает в неё фиксированный заголовок THPDFCodecSharedHeader, сжатые входные данные и любые глобальные сегменты JBIG2, запускает рабочий процесс и ожидает. Рабочий процесс записывает декодированные пиксели обратно в ту же область и устанавливает слово статуса. Здесь нет протокола каналов, который может рассинхронизироваться, нет формата сериализации, который можно фаззить, а заголовок несёт магическое значение и версию, поэтому несовпадающий бинарник рабочего процесса отклоняется, а не читается неверно

uses
  HPDFDoc, HPDFCodecIsolation;

var
  Pdf: THotPDF;
  Info: THPDFCodecWorkerInfo;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Отказ в закрытую сторону: никогда не декодировать эти кодеки внутри процесса
    Pdf.CodecIsolationMode := cimRequired;
    Pdf.CodecWorkerExecutable := 'HotPDFCodecWorker.exe';
    Pdf.CodecWorkerTimeoutMilliseconds := 5000;       // 1..600000
    Pdf.CodecWorkerMemoryLimitBytes := 268435456;     // 0 или >= 64 MiB
    Pdf.DecodeBudgetBytes := 134217728;

    if Pdf.LoadFromFile('untrusted-upload.pdf') = 1 then
      if Pdf.GetLoadedImageCount > 0 then
      begin
        Bmp := Pdf.ExtractLoadedImage(0);
        try
          if Pdf.GetLastCodecWorkerInfo(Info) then
            LogCodecOutcome(Info);
        finally
          Bmp.Free;
        end;
      end;
  finally
    Pdf.Free;
  end;
end;

Оставьте CodecWorkerExecutable пустым, и HotPDF найдёт рабочий процесс рядом с вашим собственным исполняемым файлом — как HotPDFCodecWorker.exe в каталоге ParamStr(0). Задавайте значение явно, когда ваше развёртывание кладёт рабочий процесс в другое место; значение разворачивается через ExpandFileName, поэтому относительный путь разрешается относительно текущего каталога, а не каталога приложения, что редко бывает нужным для службы

Automatic или required: какой отказ вы предпочитаете?

Три значения THPDFCodecIsolationMode кодируют три разных ответа на один вопрос — что должно произойти, если рабочий процесс вообще не может запуститься. cimDisabled полностью пропускает изоляцию и декодирует внутри процесса — это поведение до версии 3.x. cimAutomatic, значение по умолчанию, пытается запустить рабочий процесс и молча откатывается к декодированию внутри процесса, если исполняемый файл рабочего процесса отсутствует или не запускается, что отражается статусом cwsUnavailable. cimRequired отказывается от такого отката: недоступный рабочий процесс помечает декодирование как обработанное и неудачное, поэтому ни один недоверенный кодовый поток никогда не попадёт в ваше адресное пространство

Выбирайте исходя из модели угроз, а не из удобства. Настольная программа просмотра, открывающая документы, которые у пользователя уже есть на диске, вполне может работать на cimAutomatic, где отсутствующий рабочий процесс деградирует до классического поведения, а не ломает продукт. Служба приёма файлов из интернета должна работать на cimRequired, потому что ошибка развёртывания, тихо отключающая слой изоляции, — это именно тот вид регрессии, который никто не замечает, пока не станет поздно. Обратите внимание на асимметрию: только cwsUnavailable запускает откат. Рабочий процесс, который запустился, а затем упал, завис по таймауту или упёрся в лимит, — это отказ декодирования в обоих режимах, и никогда не тихий повтор внутри процесса

Чтение вердикта из THPDFCodecWorkerStatus

GetLastCodecWorkerInfo возвращает результат последнего изолированного декодирования, а перечисление статусов достаточно конкретно, чтобы служить основой реальных операционных решений, а не строкой в логе вида «изображение не удалось». Значения: cwsNotRun, cwsSucceeded, cwsUnavailable, cwsLaunchFailed, cwsTimedOut, cwsCrashed, cwsDecodeFailed, cwsProtocolError и cwsOutputLimit

Рассматривайте их как три группы. Проблемы развёртывания — это cwsUnavailable и cwsLaunchFailed: кто-то выпустил сборку без рабочего процесса, либо антивирус блокирует создание процессов. Проблемы документа — это cwsDecodeFailed и cwsOutputLimit: файл повреждён или крупнее, чем допускает ваша политика, и отклонить его — правильный ответ. Интересная группа — cwsTimedOut и cwsCrashed, потому что раньше именно эти события вешали или убивали хост-процесс. Когда это происходит, сопутствующие поля ProcessId, ExitCode и ElapsedMilliseconds дают достаточно данных, чтобы сопоставить их с записью Windows Error Reporting и решить, патологичен ли один клиентский файл или кто-то вас прощупывает

procedure LogCodecOutcome(const Info: THPDFCodecWorkerInfo);
begin
  case Info.Status of
    cwsSucceeded:
      ; // нечего сообщать
    cwsUnavailable, cwsLaunchFailed:
      Alert('Codec worker not deployed: ' + Info.ErrorMessage);
    cwsTimedOut, cwsCrashed:
      Quarantine(Format('pid %d exit %d after %d ms',
        [Info.ProcessId, Info.ExitCode, Info.ElapsedMilliseconds]));
  else
    RejectDocument(Info.ErrorMessage);
  end;
end;

Лимиты, которые реально действуют

К каждому изолированному декодированию применяются три отдельных потолка, и знание того, какой именно сработал, экономит целый день догадок. CodecWorkerTimeoutMilliseconds по умолчанию равен 10 000 и проверяется в диапазоне от 1 до 600 000; значение за пределами диапазона вызывает исключение, а не молчаливое обрезание. CodecWorkerMemoryLimitBytes по умолчанию равен 536 870 912 байт и должен быть либо нулём, что означает отсутствие лимита, либо не менее 67 108 864 байт, потому что меньший потолок не способен вместить реалистичный рабочий набор декодера и провалит любой документ. Лимит памяти обеспечивается объектом задания Windows (Job Object) с семантикой kill-on-close, поэтому рабочий процесс умирает вместе с заданием, даже если хост-процесс завершается аварийно

Третий потолок — это лимит вывода, и он вычисляется, а не настраивается напрямую. HotPDF рассчитывает необходимое число байт из запрошенной области либо из ожидаемой геометрии изображения — как ширину, умноженную на высоту и на три для 24-битного вывода, — а затем обрезает это значение до DecodeBudgetBytes, если бюджет задан. Декодер, который сообщает правдоподобный заголовок, а затем пытается выдать намного больше пикселей, чем допускает геометрия, останавливается самой областью отображения, и хост видит cwsOutputLimit. Именно поэтому слой изоляции и бюджет декодирования дополняют друг друга: бюджет определяет, насколько большим может быть изображение, а граница изоляции гарантирует, что ложь об этом размере не превратится в запись за пределы буфера в вашем процессе

Место этого механизма в защищённом конвейере приёма

Изоляция процессов — это самый внешний слой цепочки защиты, которая начинается гораздо раньше. Структурные лимиты отклоняют неправдоподобные документы ещё на этапе разбора. Бюджеты фильтров ограничивают разрастание данных. Изоляция сдерживает то, что пережило оба этапа. Для документов, доходящих до слоя изображений, стоит знать, какой именно кодек вы задействуете, поскольку у обработки JPXDecode и словарей символов JBIG2 совершенно разные профили отказов, а JBIG2, в частности, несёт межстраничные глобальные сегменты, которые наивная песочница по одному изображению за раз просто сломала бы

Цена честна и стоит того, чтобы её назвать: запуск процесса на каждое изолируемое изображение добавляет миллисекунды, и документ с сотнями отсканированных страниц это почувствует. Соизмеряйте это с тем, что вы получаете взамен. На пакетном конвертере, работающем без присмотра ночью, потеря пропускной способности незаметна, а сдерживание сбоев — это как раз весь смысл. На интерактивной программе просмотра, открывающей документы, которым пользователь уже доверяет, разумным значением по умолчанию будет cimDisabled или cimAutomatic. Режим — это обычное свойство, поэтому ничто не мешает выбирать его для каждого класса документов во время выполнения

HotPDF поставляет слой изоляции, бюджеты декодирования и структурные лимиты разбора в составе одного нативного VCL-компонента для Delphi и C++Builder, без необходимости разворачивать какой-либо внешний runtime, кроме самого исполняемого файла рабочего процесса. Полная документация по API и пробная сборка доступны на странице HotPDF Delphi PDF component