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

Обёртка подписи PDF: зазоры ByteRange и вторая подпись

HotPDF, Delphi PDF компонент, теперь отвергает signature wrapping: с v2.759.0 и VerifyLoadedSignatureEx, и пакетный валидатор требуют, чтобы зазор между двумя сегментами /ByteRange был ровно hex-строкой /Contents, включая разделители, а v2.761.0 добавляет AddLoadedSignedSignatureField, чтобы вторую подпись можно было дописать к уже подписанному PDF чистой инкрементальной ревизией. Две правки — из одной истории, потому что корректная вторая подпись — это ровно та раскладка, которой ждёт более строгий верификатор

Ситуация, вскрывшая проблему, обыкновенная. Договор подписывает поставщик, затем он уходит к согласующему, который обязан контрподписать, не потревожив первую подпись. Вторая ревизия дописывается за первой, её собственный /ByteRange накрывает весь подросший файл, и обе подписи должны верифицироваться. Добраться туда вручную значило написать инкрементальную секцию самому, а тестовая фикстура, делавшая ровно это, оказалась хрестоматийной структурой signature wrapping, которую старый верификатор принимал с улыбкой. Если вы раньше не заглядывали в API верификации, руководство по проверке цифровых подписей PDF с HotPDF покрывает основы, на которых строится эта статья

Что именно лежит в зазоре ByteRange?

Зазор обязан содержать полное значение /Contents и ничего больше: ISO 32000-1 §12.8.3.3 говорит, что hex-строка с её разделителями < и > заполняет ровно пространство между двумя диапазонами байтов, и ISO 32000-2 §12.8.1 несёт то же правило дальше. Table 252 и документы PAdES говорят лишь, что дайджест исключает значение Contents, что легко прочесть как исключение одних hex-цифр. Более ранние релизы HotPDF прочли именно так: PreparePDFForSigning и потоковая подготовка CMS хешировали и угловые скобки, с комментарием в исходнике, настаивавшим, что скобки обязаны быть покрыты. Валидаторы, сверяющие зазор со значением подписи, помечают ту раскладку как неверный диапазон байтов, поэтому v2.759.0 выносит оба разделителя из подписанных диапазонов. Быстрая независимая проверка любого подписанного файла — два байта: байт по смещению ByteRange[1] обязан быть <, а байт по смещению ByteRange[2] - 1 — >

Анатомия корректно заполненного ByteRange подписи PDF в HotPDF: первый диапазон накрывает файл с нулевого байта, зазор держит полную hex-строку /Contents включая разделители «меньше» и «больше», второй диапазон накрывает trailer до конца, а две однобайтовые проверки на ByteRange[1] и ByteRange[2] - 1 подтверждают раскладку на любом подписанном файле
С v2.759.0 разделители сидят вне подписанных диапазонов, поэтому дайджест покрывает одни цифры, а зазор можно валидировать побайтово

Почему проверка непустого зазора пропускает signature wrapping?

Проверка непустого зазора доказывает лишь, что из дайджеста что-то выкинули, но не что именно, — а это и есть вся поверхность атаки. Заглушка /Contents резервируется тысячами нулевых цифр, тогда как настоящий CMS-контейнер редко её заполняет. Атакующий может закрыть hex-строку раньше времени внутри того нулевого заполнения знаком >, вписать новые объекты или подделанную ревизию в остаток зарезервированного места и не тронуть диапазоны байтов. CMS-подпись всё ещё верифицируется, потому что каждый подписанный байт не изменился, диапазоны по-прежнему стартуют с 0 и кончаются размером файла, а старый верификатор HotPDF отчитывал svValid с CoversWholeDocument в True. PDF-ридер тем временем парсит всё, что сидит в той неподписанной дыре

HotPDF теперь обращается с зазором как с данными, которые надо валидировать побайтово. Верификатор читает зазор, снимает разделители, принимает только hex-цифры плюс PDF-whitespace (табуляция, line feed, form feed, carriage return, пробел), декодирует цифры и требует точного равенства результата словарю подписи /Contents. Всё прочее понижает результат до svInvalidByteRange. Проверка работает и в одноподписанном пути, и в ValidateLoadedSignatureBatch, который вёл собственную логику покрытия и нуждался в той же починке. Файлы, произведённые HotPDF до v2.759.0, — с зазором из одних цифр и скобками сразу внутри диапазонов — по-прежнему верифицируются, так что архивные документы не покраснеют внезапно

Как signature wrapping эксплуатирует небрежно проверяемый ByteRange PDF в Delphi: атакующий закрывает hex-строку раньше времени внутри тысяч зарезервированных нулевых цифр, вписывает подделанную ревизию в неподписанный зазор, не тронув ни одного накрытого байта, а старая проверка HotPDF отчитывала svValid с CoversWholeDocument true, пока v2.759.0 не начала валидировать зазор побайтово
Непустой зазор доказывает лишь, что из дайджеста что-то выкинули, но не что именно — набитое заполнением отверстие и есть вся поверхность атаки
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Как добавить вторую подпись к уже подписанному PDF?

Откройте подписанный файл через BeginIncrementalUpdate, позовите AddLoadedSignedSignatureField, сохраните через SaveIncrementalUpdate, затем подпишите подготовленный файл классовой функцией THotPDF.SignPDFWithPFX. До v2.761.0 документированный рецепт позвать THPDFPage.AddSignedSignatureField после BeginIncrementalUpdate работать не мог: CurrentPage в инкрементальном режиме — nil, и ничто не могло прикрепить заглушку /V к полю на загруженном документе. Новый метод создаёт виджет на загруженной странице и подвешивает под /V тот же словарь-заглушку, каким пользуется путь нового документа, так что оба подписных маршрута делят одну сериализацию. Про самую первую подпись — в статье о создании цифровых подписей PAdES в Delphi разобран весь PFX-конвейер

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Страница 0, прямоугольник виджета в пунктах, 8192 байта зарезервированы под CMS
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField намеренно тише своих собратьев. Прочие создатели полей AddLoaded* выставляют на AcroForm /NeedAppearances true, что велит вьюверу перегенерировать appearance полей; на подписанном документе такая регенерация может переписать подписанное содержимое, поэтому новый метод снимает флаг обратно, если источник его не нёс. /SigFlags хранит исходное значение OR 3 (SignaturesExist плюс AppendOnly, ISO 32000-1 Table 219). Звать MarkDirty на странице тоже не нужно: добавление в /Annots и /Fields протаскивает dirty-флаг в владеющий косвенный объект, а явная пометка страницы лишь притащила бы в новую ревизию неизменённый словарь страницы, который анализ ревизий затем отчитал бы как модификацию страницы. Напоследок заглушка пишет /ByteRange до /Contents, потому что патчер сперва находит маяк /ByteRange и ищет вперёд парную hex-строку

Рабочий процесс HotPDF в Delphi для контрподписания уже подписанного PDF: BeginIncrementalUpdate открывает файл, AddLoadedSignedSignatureField создаёт виджет и резервирует заглушку /Contents, SaveIncrementalUpdate дописывает вторую ревизию, а SignPDFWithPFX её заполняет, оставляя первую подпись валидной с UnsignedTrailingBytes, пока новый ByteRange накрывает весь подросший файл
Одна заглушка на ревизию, подготовленная и запатченная одной сериализацией на обоих подписных маршрутах — та чистая раскладка, которой ждёт более строгий верификатор

Что меняется, когда CMS производит внешний подписант или HSM?

В рабочем процессе ничего, но смещения теперь значат то, что говорит спецификация. PreparePDFForSigning возвращает два 0-базных диапазона, чей зазор — вся строка /Contents, а ContentsHexStart — 1-базный индекс первой hex-цифры в AnsiString. Более короткий CMS добивается цифрами 0 в конец, перед закрывающей >. Поскольку PreparePDFForSigning патчит первый непропатченный маяк, готовьте ровно одну заглушку на ревизию и предпочитайте InsertSignatureHexAt с возвращёнными смещениями поисковому InsertSignatureHex, когда в файле уже есть прежние подписи

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // ваш хелпер
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // Зазор — вся hex-строка: '<' кончает диапазон 1, '>' предшествует диапазону 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // ваш CMS-подписант, hex DER
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // ваш хелпер
end;

Где пределы новых проверок?

Проверка зазора закрывает одну конкретную дыру, и продавать её за больше, чем она есть, не стоит. svValid по-прежнему значит целостность байтов плюс ключ, совпадающий со вшитым сертификатом; доверие к тому сертификату — отдельное решение. Зазор валидируется, только когда у верификатора есть исходные байты: VerifyLoadedSignatureEx читает их из загруженного файла, а перегрузки с TStream берут их у вас. Для первой подписи в контрподписанном файле CoversWholeDocument корректно False, а вопрос, добавила ли дописанная ревизия только подпись или тронула и страницы, — это вопрос к анализу DocMDP, FieldMDP и ревизий в HotPDF. Учтите также, что проверка attached PDF MAC сверяет смещения с позициями < и >, поэтому принимает и старую, и новую раскладку; любой ваш инструмент, жёстко вбитый под смещения до v2.759.0, споткнётся первым, встретив свежеподписанный файл

Если ваше приложение на Delphi или C++Builder подписывает, контрподписывает или аудирует PDF, самый безопасный путь — позволить одной библиотеке производить и верифицировать одну и ту же раскладку. HotPDF, нативный Delphi PDF компонент везёт более строгую валидацию зазора, инкрементальные вторые подписи и хуки для внешнего подписанта из примеров выше в одном компоненте