В сборках PDFium Component до v3.126.2 TPdf.AnalyzeSignatureRevisions мог засчитать настоящую правку содержимого страницы как дозволенное изменение аннотации при DocMDP P=3, потому что граф ролей ревизий трактовал обратную ссылку /P у signature widget на его страницу как владение. С v3.126.2 PDFium Component отделяет навигационные рёбра от рёбер владения payload-ом, так что содержимое страницы остаётся содержимым страницы. Баг-репорт за этой правкой на бумаге выглядит безобидно. Сертифицированный договор дозволяет аннотации, контрагент делает один инкрементальный save, и анализатор говорит, что всякая последующая правка дозволена. А потом кто-то диффает отрендеренные страницы — и сумма платежа на странице 2 другая
Эта статья — продолжение обзора анализа изменений ревизий после подписи глазами атакующего, поэтому она пропускает основы пересборки ревизий и градации DocMDP и идёт прямо к графу объектов: как моделировалось владение, почему направление ребра решает вердикт безопасности, что поменялось в v3.126.2 и как аудитить собственную логику приёма
Почему правка страницы проходила как изменение аннотации при DocMDP P=3?
Правка страницы проходила, потому что старый граф ролей шёл за каждой косвенной ссылкой в словаре, как будто объект по ссылке принадлежит ссылающемуся, а signature widget указывает назад на свою страницу. Словарь аннотации несёт /P — косвенную ссылку на объект страницы, на которой он сидит (ISO 32000-1 §12.5.2). Та запись — навигационная подсказка. Widget страницей не владеет; страница владеет widget-ом через свой массив /Annots
Анализатор назначает каждому объекту набор ролевых бит, прежде чем градировать поздние правки: страница, аннотация, форма и материал валидации. Корневые объекты получают роль из собственного словаря, и роль затем растекается по всему, на что они ссылаются. В старой пропагации цепочка шла так:
- Signature widget — это
/Subtype /Widgetс/FT /Sig, так что он получает роль аннотации /Pwidget-а вталкивает роль аннотации в словарь страницы, у которого уже есть роль страницы- Страница вталкивает обе роли в
/Contents,/Resourcesи через/Parentвверх по дереву Pages и поперёк на каждую соседнюю страницу - Словарь потока содержимого вроде
<< /Length 812 >>не имеет/Type, и классификатор откатывался к ролевым битам, проверяя роль аннотации раньше роли страницы
Правленный поток содержимого потому выходил как prckAnnotation. По ISO 32000-1 §12.8.2.2 DocMDP P=3 дозволяет изменения аннотаций, так что решением было prdAllowed, а отчёт скатывался в prasAllowed. Тот же файл под P=2 отвергался, но лишь по стечению обстоятельств: P=2 запрещает изменения аннотаций, так что неверно помеченный поток отворачивался по неверной причине. Фиксированный цикл пропагации в четыре прохода добавлял вторую слабость. Payload, достигнутый через косвенный массив или через длинную цепочку, чьи номера объектов идут назад, мог вовсе не получить никакой роли
Почему валидатор подписи обязан спрашивать, кто владеет объектом?
Валидатор подписи обязан спрашивать, кто владеет объектом, потому что инкрементальные обновления PDF (ISO 32000-1 §7.5.6) позволяют кому угодно дописать ревизию, переопределяющую существующий номер объекта, а переопределённое тело не объявляет, чем оно является. Подпись по-прежнему верифицируется, ведь она покрывает лишь байты собственной ревизии. Всякая защита от манипуляций после подписания потому зависит от отображения каждого изменённого объекта на структуру, которая им пользуется, и от последующего вопроса: дозволял ли подписант менять эту структуру
Несколько опубликованных классов атак работают ровно в этой щели. Incremental saving attacks дописывают ревизию, подменяющую содержимое страницы, и рассчитывают, что верификатор проверяет лишь подписанный диапазон байтов. Shadow attacks сажают скрытое содержимое до подписания и активируют его потом маленькой, безобидной на вид правкой. Атаки на сертифицированные документы злоупотребляют тем, что P=2 и P=3 явно дозволяют некоторые поздние правки, и рядят запрещённую правку в дозволенную. Верификатор, классифицирующий объекты по ярлыкам вроде /Type /Annot или по любому ссылочному пути, который случается до них дотянуться, открыт третьему классу: атакующему нужна лишь одна дозволенная структура, способная достать запрещённую
Потому вопрос не в том, какие объекты изменились, а в том, кто ими владеет. Поток содержимого, достигнутый от страницы через /Contents, — содержимое страницы, что бы ещё на него ни указывало. Аннотация, указывающая назад на страницу через /P, сообщает, где аннотация живёт, а не чем она владеет
Как PDFium Component v3.126.2 моделирует владение?
PDFium Component v3.126.2 трактует обратные ссылки как навигацию, держит их вне пропагации ролей и решает, какие ключи считать навигацией, по структурной роли словаря, который их держит, а не по имени ключа. Таблица суммирует навигационные ключи, которые больше не несут владения
| Словарь-владелец | Ключи, трактуемые как навигация | Ссылка на спецификацию |
|---|---|---|
| Страница или узел Pages | /Parent, /Kids, /Annots | ISO 32000-1 §7.7.3 |
| Аннотация или widget | /P | ISO 32000-1 §12.5.2 |
| Словарь widget или поля | /Parent | ISO 32000-1 §12.7.3 |
Фильтрация по имени ключа глобально проделала бы новую дыру. Ресурс-шрифт или XObject может легально носить имя /P, /Parent или /Annots, и словарь /Resources, выбросивший свою запись /P из пропагации, позволил бы атакующему спрятать принадлежащий странице XObject за невинным именем ресурса. В v3.126.2 навигационный фильтр применяется, только когда словарь-владелец — действительно страница, узел Pages, аннотация, widget или поле. Если такой словарь несёт дублированный навигационный ключ, скажем две записи /P в widget-е, анализатор не угадывает, какую копию взял бы вьюер; сборка ролей падает, и подпись становится Indeterminate
Ещё несколько правил закрывают оставшиеся маршруты переименования:
- Узлы Pages — корни роли страницы сами по себе, так что ресурсы, унаследованные от дерева Pages (ISO 32000-1 §7.7.3.4), входят в контекст страницы через настоящее владение, а не через прогулку по
/Parentот дочерней страницы - Роль аннотации или формы, пришедшая в каталог, узел Pages, страницу, словарь аннотации или поля, на том останавливается, потому что эти структурные объекты устанавливают собственные роли, и входящая payload-роль не должна их перекрывать
- Роль страницы авторитетна при классификации: объект во владении страницы — это
prckPageContent, даже если поздняя ревизия переписывает его с подделанным/FT, ярлыком/Type /Annotили делит его с потоком-внешностью - Form XObject, используемый лишь как внешность поля или аннотации, хранит свою категорию формы или аннотации, так что обычная перегенерация внешности после заполнения формы по-прежнему градируется по нормальным правилам дозволений
- Widget без собственного
/FTразрешает унаследованный тип поля по цепочке/Parent, а неразрешимая цепочка валит сборку ролей вместо дефолта в аннотацию - Ролевые биты из каждой поздней ревизии сливаются в роли покрытой ревизии, так что позднее обновление не может стереть более раннее отношение владения страницей, сперва отцепив поток, а потом правя его
Неподвижная точка вместо фиксированного числа проходов
Достижимость ролей в v3.126.2 идёт как рабочая очередь, итерирующая, пока ни один объект не получает нового ролевого бита, — настоящая неподвижная точка независимо от глубины цепочки и нумерации объектов. Косвенные массивы, скажем массив /Contents, сохранённый как собственный объект, тоже обходятся. Каждый объект может получить не более четырёх различных ролевых бит, так что очередь ограничена четырьмя записями на номер объекта; превышение бюджета поднимает prrResourceLimitExceeded. Ссылка на свободный объект, несовпадение generation или битый заголовок объекта поднимают prrMalformedRevisionChain, а payload внутри сжатого объектного потока — prrCompressedObjectUnresolved. Всякий из этих отказов кончается prasIndeterminate и никогда — дозволяющим вердиктом, а когда отказ случается при сборке ролей покрытой ревизии, подпись вовсе не сообщает Changes
Следующая подпрограмма перечисляет правки содержимого страниц, выживающие при этом анализе. Изменение prckPageContent никогда не градируется как prdAllowed: DocMDP P=1, 2 или 3 делает его prdDisallowed, а подпись без DocMDP градирует его prdSuspicious
uses
SysUtils, TypInfo, PDFium, FPdfPades;
procedure ListPageContentEdits(const FileName: string);
const
ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
Sig: TPadesSignatureRevisionAnalysis;
Change: TPadesRevisionObjectChange;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions; // запись, освобождать нечего
for i := 0 to High(Report.Signatures) do
begin
Sig := Report.Signatures[i];
if not (prrPageContentChanged in Sig.Risks) then
Continue;
Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
[Sig.SignatureIndex, Sig.DocMdpPermission]));
for j := 0 to High(Sig.Changes) do
begin
Change := Sig.Changes[j];
if Change.Kind <> prckPageContent then
Continue;
Writeln(Format(' revision %d object %d %d R %s%s',
[Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
ShadowTag[Change.IsAuthoritative]]));
end;
end;
finally
Pdf.Free;
end;
end;
Что происходит, когда FieldMDP и аннотация делят один объект?
Когда /V поля и /Contents аннотации указывают на один и тот же косвенный объект, v3.126.2 держит замок FieldMDP в силе, даже если изменение классифицировано как правка аннотации. Сценарий легко собирается руками: подписант закрывает поле Total замком FieldMDP (ISO 32000-1 §12.8.2.4), а атакующий заставляет /Contents текстовой аннотации ссылаться на тот же строковый объект, что держит значение поля. Под P=3 правка аннотации дозволена, так что до фикса переписывание той общей строки меняло закрытое значение поля с вердиктом «дозволено»
Объект теперь несёт и роль аннотации, и роль формы, а решение по аннотации перепроверяет форменную сторону всякий раз, когда у подписи есть transform FieldMDP:
- Под P=2 изменение аннотации отклоняется целиком — ровно как прежде
- При FieldMDP
Allзакрыто всякое поле, так что общее изменение —prdDisallowed - При FieldMDP
IncludeилиExcludeанализатор не может проследить общий скаляр назад до одного имени поля, так что решение —prdIndeterminate, а не догадка - Без FieldMDP действует правило аннотаций P=3, и изменение остаётся дозволенным
Одна деталь отчётности важна для кода ворот. Общий случай сообщается как Kind = prckAnnotation с Decision = prdIndeterminate, а prrFieldMdpUnresolved добавляется в набор рисков лишь для изменений, классифицированных как поля формы. Ворота, ищущие prrFieldMdpUnresolved и игнорирующие Status, промахиваются мимо этого случая полностью
Как Delphi-коду падать наглухо на анализе ревизий?
Delphi-код должен принимать подписанный документ, только когда статус анализа — prasNoLaterChanges или prasAllowed и нет структурных рисков, а prasIndeterminate и prasSuspicious должен считать недоверенными, а не предупреждениями, которые логируются и пропускаются. Indeterminate значит, что анализатор не смог доказать дозволенность поздних ревизий; для атакующего вход, стабильно дающий Indeterminate, так же полезен, как дающий Allowed, — если ваш код его пропускает. Глобальная AnalyzePadesSignatureRevisions берёт любой TStream и читает его с позиции 0 — как раз для обработчиков загрузок, которым некогда рендерить документ
uses
Classes, SysUtils, TypInfo, FPdfPades;
const
// Дублированные определения записываются без понижения Status
BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
prrResourceLimitExceeded];
function SignedRevisionsAcceptable(const FileName: string;
out Reason: string): Boolean;
var
Source: TFileStream;
Report: TPadesRevisionAnalysisReport;
begin
Result := False;
Reason := '';
Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Report := AnalyzePadesSignatureRevisions(Source);
finally
Source.Free;
end;
if Report.SignatureCount = 0 then
begin
Reason := 'no signature anchors the analysis';
Exit;
end;
if Report.Risks * BlockingRisks <> [] then
begin
Reason := 'structural risk in the revision chain';
Exit;
end;
case Report.Status of
prasNoLaterChanges, prasAllowed:
Result := True;
else
// prasIndeterminate и prasSuspicious — отказы, а не предупреждения
Reason := 'revision status ' +
GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
end;
end;
Две границы стоит проговорить прямо. TPadesRevisionAnalysisReport не говорит ничего о целостности CMS и доверии сертификату, так что эти ворота стоят рядом с криптографической и доверительной валидацией, а не вместо них. И корректный граф владения не делает P=3 безопасным для всякого рабочего процесса. P=3 честно дозволяет аннотации, и аннотация с непрозрачной внешностью способна накрыть подписанный текст, не тронув ни одного потока содержимого. Если ваши сертифицированные документы — договоры, а не копии для рецензии, сертифицируйте с P=2 или направляйте дозволенные изменения аннотаций человеку, как в этом хелпере:
function AllowedAnnotationEditsUnderP3(
const Report: TPadesRevisionAnalysisReport): Integer;
var
i, j: Integer;
begin
Result := 0;
for i := 0 to High(Report.Signatures) do
if Report.Signatures[i].DocMdpPermission = 3 then
for j := 0 to High(Report.Signatures[i].Changes) do
if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
(Report.Signatures[i].Changes[j].Decision = prdAllowed) then
Inc(Result);
end;
Чек-лист аудита ревизий подписей
Возьмите этот список, чтобы проверить, была ли открыта ваша верификационная цепочка и падает ли она теперь наглухо:
- Сборки PDFium Component до v3.126.2 могли сообщать
prasAllowedдля правок содержимого страниц в документах DocMDP P=3; прогонитеTPdf.AnalyzeSignatureRevisionsзаново на сертифицированных P=3 файлах, принятых старыми сборками - Перепроверьте P=3 документы с замками FieldMDP там, где значение поля и аннотация могут делить косвенный объект
- Принимайте только
prasNoLaterChangesиprasAllowed;prasIndeterminateиprasSuspiciousсчитайте недоверенными - Проверяйте
Report.Risksнаравне сReport.Status, потому чтоprrDuplicateObjectDefinitionсам по себе статус не меняет - Не читайте пустой массив
Changesкак чистый результат, когда статус подписи Indeterminate: сорванная сборка ролей не сообщает изменений - Не полагайтесь на один
prrFieldMdpUnresolvedдля отлова проблем FieldMDP: общий случай с аннотацией всплывает только через решение и статус - Решите, нужна ли дозволенным изменениям аннотаций под P=3 человеческая проверка в вашем рабочем процессе
- Анализируйте исходные байты файла; документ, переписанный
SaveAs, цепочки ревизий больше не содержит
Анализ ревизий — один слой проверки подписи. Соедините его с осмотром цифровых подписей PDF и уровней PAdES для словарей и базового уровня и с более широким аудитом рисков безопасности PDF для JavaScript, launch actions и встроенных файлов. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions и граф ролей с учётом владения, описанный здесь, поставляются в PDFium Component для Delphi, C++Builder и Lazarus