Технічна стаття

DocMDP PDFium Component: як widget /P сховав правки сторінки

У збірках PDFium Component до v3.126.2 TPdf.AnalyzeSignatureRevisions міг оцінити справжню правку вмісту сторінки як дозволену зміну annotation під DocMDP P=3, бо його граф ролей ревізій трактував back-reference /P від signature widget до його сторінки як ownership. Від v3.126.2 PDFium Component розділяє navigation edges від owned-payload edges, тож вміст сторінки лишається вмістом сторінки. Bug-звіт за цим fix-ом на папері виглядає нешкідливо. Сертифікований контракт дозволяє annotations, контрагент робить один incremental save, і аналізатор каже, що кожна пізніша зміна дозволена. А потім хтось робить diff відрендерених сторінок — і сума платежу на сторінці 2 інша

Ця стаття — продовження з очей атакуючого до огляду аналізу змін ревізій після підпису, тож вона пропускає основи перебудови ревізій і DocMDP grading і йде прямо до object graph: як моделювався ownership, чому напрямок edge вирішує security вердикт, що змінилося у v3.126.2 і як аудитувати власну логіку приймання

Чому правка сторінки пройшла як зміна annotation під DocMDP P=3?

Правка сторінки пройшла, бо старий граф ролей ішов за кожним indirect reference у словнику так, ніби об’єкт, на який посилаються, належить тому, хто посилається, а signature widget вказує назад на свою сторінку. Словник annotation несе /P — indirect reference на об’єкт сторінки, на якій він сидить (ISO 32000-1 §12.5.2). Той запис — navigation hint. Widget не володіє сторінкою; сторінка володіє widget через свій масив /Annots

Аналізатор призначає кожному об’єкту набір role bits, перш ніж оцінювати пізніші зміни: page, annotation, form і validation material. Кореневі об’єкти беруть роль із власного словника, і роль потім розповзається на все, на що вони посилаються. У старій propagation ланцюжок ішов так:

  1. Signature widget — це /Subtype /Widget з /FT /Sig, тож він отримує роль annotation
  2. /P від widget наштовхує роль annotation на словник сторінки, який уже має роль page
  3. Сторінка наштовхує обидві ролі в /Contents, /Resources і, через /Parent, нагору в дерево Pages і поперек до кожної sister-сторінки
  4. Словник content stream на кшталт << /Length 812 >> не має /Type, тож classifier відкатувався до role bits і перевіряв роль annotation перед роллю page
Діаграма PDFium Component графа ролей DocMDP до v3.126.2, де back-reference /P від signature widget наштовхує роль annotation на словник сторінки, сторінка розносить її через /Contents на content stream без запису /Type, classifier виводить prckAnnotation, і P=3 grading повертає prdAllowed
До v3.126.2 граф ролей трактував кожен indirect reference як ownership, тож запис /P від widget наштовхував роль annotation на сторінку, і справжня правка сторінки виходила з аналізатора як дозволена зміна annotation

Модифікований content stream виходив тому як prckAnnotation. За ISO 32000-1 §12.8.2.2 DocMDP P=3 дозволяє зміни annotation, тож рішенням було prdAllowed, а звіт згортався в prasAllowed. Той самий файл під P=2 відхилявся, але лише випадково: P=2 забороняє зміни annotation, тож неправильно мічений stream відмовляли з неправильної причини. Фіксований чотирипрохідний propagation-цикл додавав другу слабкість. Payload, що доходить через indirect array чи через довгий ланцюжок, чиї object numbers йдуть назад, міг узагалі не отримати жодної ролі

Чому signature validator мусить питати, хто володіє об’єктом?

Signature validator мусить питати, хто володіє об’єктом, бо PDF incremental updates (ISO 32000-1 §7.5.6) дозволяють будь-кому додати ревізію, що перевизначає наявний object number, а перевизначене тіло не анонсує, чим воно є. Підпис усе ще верифікується, адже покриває лише байти власної ревізії. Кожен захист від after-signing маніпуляцій тому залежить від маплення кожного зміненого об’єкта на структуру, яка його уживає, і від питання, чи дозволяв підписант тій структурі змінюватися

Кілька опублікованих класів атак працюють рівно в тій дірі. Incremental saving attacks додають ревізію, що підмінює вміст сторінки, і розраховують на verifier, який перевіряє лише signed byte range. Shadow attacks саджають прихований вміст до підпису і активують його після малим, нешкідливим на вигляд зміненням. Атаки на certified documents зловживають тим, що P=2 і P=3 явно дозволяють деякі пізніші правки, а потім вдягають заборонену правку в дозволений одяг. Verifier, що класифікує об’єкти за мітками на кшталт /Type /Annot чи за будь-яким reference-шляхом, який трапиться до них дійти, відкритий до третього класу: атакуючому потрібна лише одна дозволена структура, яка може дістати заборонену

Тому питання не в тому, які об’єкти змінилися, а в тому, хто ними володіє. Content stream, досяжний зі сторінки через /Contents, — це вміст сторінки, хто б ще на нього не вказував. Annotation, що вказує назад на сторінку через /P, каже, де annotation живе, а не чим він володіє

Як PDFium Component v3.126.2 моделює ownership?

PDFium Component v3.126.2 трактує back-references як navigation, тримає їх поза propagation ролей і вирішує, які ключі рахувати navigation, за структурною роллю словника, що їх тримає, а не за самим ім’ям ключа. Таблиця підсумовує navigation-ключі, які більше не несуть ownership

Словник-власникКлючі, що трактуються як navigationПосилання на spec
Вузол Page чи Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation чи widget/PISO 32000-1 §12.5.2
Словник widget чи field/ParentISO 32000-1 §12.7.3
Граф об’єктів PDFium Component v3.126.2, що розділяє owned-payload edges на кшталт /Contents і /Annots, які розносять ролі page та annotation, від navigation edges на кшталт widget /P, які не несуть ролей, з navigation-ключами за словником-власником і prckPageContent, що лишається на content stream навіть під підробленим /Type
v3.126.2 тримає back-references поза propagation ролей: ролі подорожують лише через справжній ownership, тож content stream лишається вмістом сторінки, а hint /P не вирішує нічого

Фільтрування за ім’ям ключа глобально створило б нову діру. Ресурс font чи XObject може легітимно називатися /P, /Parent чи /Annots, і словник /Resources, що викинув свій запис /P з propagation, дозволив би атакуючому сховати XObject, яким володіє сторінка, за нешкідливою назвою ресурсу. У v3.126.2 navigation-фільтр застосовується лише коли словник-власник справді є page, вузлом Pages, annotation, widget чи field. Якщо один із тих словників несе дубльований navigation-ключ, як-от два записи /P у widget, аналізатор не вгадує, яку копію ужив би viewer: build ролей падає, і підпис стає Indeterminate

Кілька подальших правил закривають решту шляхів перемаркування:

  • Вузли Pages — корені ролі page самі по собі, тож ресурси, успадковані з дерева Pages (ISO 32000-1 §7.7.3.4), входять у page-контекст через справжній ownership, а не через прогулянку /Parent від дитячої сторінки
  • Роль annotation чи form, що приходить у catalog, вузол Pages, сторінку, annotation чи field словник, зупиняється там, бо ті структурні об’єкти встановлюють власні ролі, і role payload, що приходить, не мусить їх перекривати
  • Роль page — авторитетна під час класифікації: об’єкт, яким володіє сторінка, — це prckPageContent, навіть якщо пізніша ревізія перепише його з підробленим /FT чи міткою /Type /Annot, або розділить його з appearance stream
  • Form XObject, ужитий лише як appearance field чи annotation, тримає свою категорію form чи annotation, тож звичайна регенерація appearance після заповнення form усе ще оцінюється за звичайними правилами дозволів
  • Widget без власного /FT резолвить успадкований field type через ланцюжок /Parent, а нерозв’язний ланцюжок валить build ролей замість усталення до annotation
  • Role bits з кожної пізнішої ревізії зливаються в ролі покритої ревізії, тож пізніше оновлення не може стерти раніший page-ownership зв’язок, спершу відчепивши stream, а потім відредагувавши його

Нерухома точка замість фіксованого числа проходів

Досяжність ролей у v3.126.2 працює як work queue, що ітерує, доки жоден об’єкт не отримує нового role bit, — це справжня нерухома точка незалежно від глибини ланцюжка чи нумерації об’єктів. Indirect arrays, як-от масив /Contents, збережений власним об’єктом, теж обходяться. Кожен об’єкт може отримати щонайбільше чотири різні role bits, тож черга обмежена чотирма записами на object number; перевищення того бюджету піднімає prrResourceLimitExceeded. Reference на вільний об’єкт, розбіжність generation чи зламаний object header піднімають prrMalformedRevisionChain, а payload усередині компресованого object stream — prrCompressedObjectUnresolved. Кожен із цих провалів завершується prasIndeterminate, ніколи дозволеним вердиктом, і коли провал трапляється під час build ролей покритої ревізії, підпис узагалі не звітує жодних Changes

Наступна рутина перелічує правки page content, які виживають цей аналіз. Зміну 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;   // record, звільняти нічого
    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 і annotation ділять один об’єкт?

Коли /V field і /Contents annotation вказують на той самий indirect object, v3.126.2 тримає замок FieldMDP в силі, хоч зміна й класифікується як правка annotation. Сценарій легко зібрати руками: підписант замикає field Total через FieldMDP (ISO 32000-1 §12.8.2.4), а атакуючий робить так, що /Contents текстової annotation посилається на той самий string object, що тримає значення field. Під P=3 правка annotation дозволена, тож до fix переписування того спільного рядка змінювало замкнене значення field із дозволеним вердиктом

Об’єкт тепер несе і роль annotation, і роль form, а рішення annotation повторно перевіряє form-бік щоразу, коли підпис має FieldMDP transform:

  • Під P=2 зміну annotation відхилено прямо, рівно як і раніше
  • З FieldMDP All замкнено кожен field, тож спільна зміна — prdDisallowed
  • З FieldMDP Include чи Exclude аналізатор не може простежити спільний scalar до одного field name, тож рішення — prdIndeterminate, а не вгадування
  • Без FieldMDP застосовується правило annotation P=3, і зміна лишається дозволеною
Діаграма рішень FieldMDP у PDFium Component, де замкнений /V field Total і /Contents annotation посилаються на той самий indirect object, з розгалуженням по DocMDP P=2, FieldMDP All, FieldMDP Include чи Exclude і відсутності FieldMDP до вердиктів prdDisallowed, prdIndeterminate чи prdAllowed для тієї самої спільної правки
Коли один indirect object несе і роль annotation, і роль form, рішення annotation повторно перевіряє замок FieldMDP, тож та сама правка розкидається від дозволеної до забороненої до indeterminate

Одна деталь звітування важить для gate-коду. Спільний випадок звітується як Kind = prckAnnotation з Decision = prdIndeterminate, а prrFieldMdpUnresolved додається до risk set лише для змін, класифікованих як form fields. Gate, що шукає prrFieldMdpUnresolved і ігнорує Status, пропускає цей випадок повністю

Як Delphi-коду fail closed на аналізі ревізій?

Delphi-код мусить приймати підписаний документ лише коли статус аналізу — prasNoLaterChanges чи prasAllowed і жодного структурного ризику немає, і мусить трактувати prasIndeterminate та prasSuspicious як недовірений, а не як попередження, щоб залогувати і пропустити. Indeterminate означає, що аналізатор не зміг довести дозволеність пізніших ревізій; для атакуючого вхід, який надійно продукує Indeterminate, так само корисний, як той, що продукує Allowed, якщо ваш код його пропускає. Глобальний AnalyzePadesSignatureRevisions приймає будь-який TStream і читає його з позиції 0, що пасує upload-обробникам, яким ніколи не треба рендерити документ

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 integrity чи certificate trust, тож цей gate сидить поруч із криптографічною та trust-валідацією, а не замість них. І правильний граф ownership не робить P=3 безпечним для кожного workflow. P=3 щиро дозволяє annotations, і annotation з непрозорим appearance може покрити підписаний текст, не торкаючись жодного content stream. Якщо ваші сертифіковані документи — контракти, а не ревізорські копії, або сертифікуйте з P=2, або спрямовуйте дозволені зміни annotation до людини, як у цьому хелпері:

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;

Чек-лист аудиту ревізій підписів

Уживайте цей список, щоб перевірити, чи була ваша pipeline верифікації відкрита і чи вона тепер fail closed:

  • Збірки PDFium Component до v3.126.2 могли звітувати prasAllowed для правок page content у документах DocMDP P=3; перегоніть повторно TPdf.AnalyzeSignatureRevisions на сертифікованих P=3 файлах, прийнятих старішими збірками
  • Переперевіряйте P=3 документи з FieldMDP замками, де значення field і annotation можуть ділити indirect object
  • Приймайте лише prasNoLaterChanges і prasAllowed; трактуйте prasIndeterminate і prasSuspicious як недовірені
  • Тестуйте і Report.Risks, і Report.Status, бо prrDuplicateObjectDefinition сам по собі не змінює статус
  • Не читайте порожній масив Changes як чистий результат, коли статус підписа — Indeterminate; провалений build ролей не звітує змін
  • Не покладайтеся на сам prrFieldMdpUnresolved, щоб зловити проблеми FieldMDP, бо спільний annotation-випадок виходить на поверхню лише через рішення та статус
  • Вирішіть, чи дозволені зміни annotation під P=3 потребують людської перевірки у вашому workflow
  • Аналізуйте оригінальні байти файлу; документ, переписаний через SaveAs, більше не містить ланцюжка ревізій

Аналіз ревізій — один шар перевірки підпису. Поєднуйте його з інспекцією цифрових підписів PDF і рівнів PAdES для словника та baseline рівня і з ширшим аудитом security-ризиків PDF для JavaScript, launch actions та embedded files. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions і ownership-aware граф ролей, описаний тут, постачаються в PDFium Component для Delphi, C++Builder і Lazarus