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

PDFium Component DocMDP: как /P на widget скри промени

В версии на PDFium Component преди v3.126.2 TPdf.AnalyzeSignatureRevisions можеше да оцени истинска промяна по съдържанието на страница като разрешена annotation промяна под DocMDP P=3, защото графът на revision ролите третираше /P обратната референция на signature widget-а към страницата му като ownership. От v3.126.2 насам PDFium Component разделя navigation ребрата от ребрата на притежаван payload, така че съдържанието на страница си остава съдържание на страница. Бъг репортът зад тази поправка изглежда безобиден на хартия. Сертифициран договор позволява annotations, контрагентът добавя един incremental save, а анализаторът казва, че всяка по-късна промяна е позволена. После някой diff-ва визуализираните страници и сумата за плащане на страница 2 е друга

Тази статия е продължението от гледна точка на атакуващия на прегледа на анализа на промените в revision-и след подписа, затова прескача основите на възстановяването на revision-и и на DocMDP оценяването и отива направо в обектния граф: как е бил моделиран ownership, защо посоката на едно ребро решава security извода, какво се е променило в v3.126.2 и как да одитирате собствена си логика за приемане

Защо промяна по страница мина като annotation промяна под DocMDP P=3?

Промяната мина, защото старият ролеви граф следваше всяка indirect референция в речник така, сякаш соченият обект принадлежи на сочещия, а signature widget-ът сочи обратно към страницата си. Annotation речникът носи /P — indirect референция към обекта страница, върху който стои (ISO 32000-1 §12.5.2). Този запис е navigation намек. Widget-ът не притежава страницата; страницата притежава widget-а чрез масива си /Annots

Анализаторът раздава на всеки обект набор от ролеви битове, преди да оцени по-късните промени: страница, annotation, форма и validation материал. Root обектите получават ролята си от собствения си речник, а ролята след това се разпространява към всичко, което сочат. В старата пропагация веригата минаваше така:

  1. Signature widget-ът е /Subtype /Widget с /FT /Sig, така че получава annotation ролята
  2. /P на widget-а тласка annotation ролята върху речника страница, който вече има page ролята
  3. Страницата тласка и двете роли в /Contents, /Resources и през /Parent нагоре в Pages дървото и по диагонал към всяка сестра страница
  4. Речник на content stream като << /Length 812 >> няма /Type, така че класификаторът се връщаше на ролевите битове и проверяваше annotation ролята преди page ролята
Диаграма на DocMDP ролевия граф в PDFium Component преди v3.126.2, в която /P обратната референция на signature widget тласка annotation ролята върху речника страница, страницата я разпространява през /Contents към content stream без запис /Type, класификаторът извежда prckAnnotation и оценяването при P=3 връща prdAllowed
Преди v3.126.2 ролевият граф третираше всяка indirect референция като 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 беше отрязан по погрешна причина. Фиксираният цикъл от четири прохода на пропагация добави и втора слабост. Payload, стигнат през indirect масив или през дълга верига, чиито обектни номера текат назад, може изобщо да не получи роля

Защо валидатор на подписи трябва да пита кой притежава даден обект?

Валидатор на подписи трябва да пита кой притежава обекта, защото incremental обновяванията в PDF (ISO 32000-1 §7.5.6) позволяват на всеки да долепи revision, който предефинира съществуващ обектен номер, а предефинираното тяло не обявява какво е. Подписът пак верифицира, защото покрива само байтовете на собствения си revision. Всяка защита от манипулация след подписването затова зависи от нанасянето на всеки променен обект върху структурата, която го ползва, и после от въпроса дали подписалият е позволил тази структура да се променя

Няколко публикувани класа атаки работят точно в тази празнина. Incremental saving attack-ите долепят revision, който сменя съдържанието на страница, и разчитат верификаторът да проверява само подписания байтов диапазон. Shadow attack-ите засаждат скрито съдържание преди подписването и го активират после с малка, невинна на вид промяна. Атаките върху сертифицирани документи злоупотребяват с факта, че P=2 и P=3 изрично позволяват някои по-късни промени, а после обличат забранена промяна като позволена. Верификатор, който класифицира обектите по етикети като /Type /Annot или по произволен референтен път, който случайно ги достига, е изложен на третия клас: на атакуващия му трябва само една позволена структура, която да достигне забранената

Затова въпросът не е кои обекти са се променили, а кой ги притежава. Content stream, достигнат от страница през /Contents, е съдържание на страница, каквото и друго да сочи към него. Annotation, сочеща обратно към страницата през /P, казва къде живее annotation-ът, а не какво притежава

Как PDFium Component v3.126.2 моделира ownership?

PDFium Component v3.126.2 третира обратните референции като navigation, държи ги извън ролевата пропагация и решава кои ключове се броят за navigation от структурната роля на речника, който ги носи, а не от самото име на ключа. Таблицата обобщава navigation ключовете, които вече не носят ownership

Притежаващ речникКлючове, третирани като navigationПрепратка към стандарта
Страница или Pages възел/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation или widget/PISO 32000-1 §12.5.2
Widget или полеви речник/ParentISO 32000-1 §12.7.3
Обектен граф в PDFium Component v3.126.2, разделящ ребрата на притежаван payload като /Contents и /Annots, които разпространяват page и annotation роли, от navigation ребра като widget /P, които не носят роли, с navigation ключовете за всеки притежаващ речник и prckPageContent, запазено върху content stream-а дори под фалшифициран /Type
v3.126.2 държи обратните референции извън ролевата пропагация: ролите пътуват само през истински ownership, така че content stream-ът си остава съдържание на страница, а /P намекът не решава нищо

Глобално филтриране по име на ключ би създало нова дупка. Ресурс шрифт или XObject може легитимно да се казва /P, /Parent или /Annots, а /Resources речник, който махне записа си /P от пропагацията, би позволил на атакуващия да скрие page-притежаван XObject зад невинно име на ресурс. В v3.126.2 navigation филтърът се прилага само когато притежаващият речник реално е страница, Pages възел, annotation, widget или поле. Ако някой от тези речници носи дублиран navigation ключ, например два записа /P в widget, анализаторът не отгатва кое копие би ползвал viewer-ът; ролевата конструкция проваля и подписът става Indeterminate

Още няколко правила затварят останалите маршрути за преетикетиране:

  • Pages възлите са page-ролеви корени сами по себе си, така че ресурсите, наследени от Pages дървото (ISO 32000-1 §7.7.3.4), влизат в page контекст през истински ownership, а не през разходка по /Parent от дъщерна страница
  • Annotation или form роля, стигнала до catalog, Pages възел, страница, annotation или полеви речник, спира там, защото тези структурни обекти установяват собствените си роли и входяща payload роля не бива да ги презаписва
  • Page ролята е авторитетна при класификацията: обект, притежаван от страница, е prckPageContent, дори по-късен revision да го презапише с фалшив /FT, с етикет /Type /Annot или да го сподели с appearance stream
  • Form XObject, ползван само като appearance на поле или annotation, пази категорията си form или annotation, така че обичайната регенерация на appearances след попълване на форма пак се оценява по нормалните правила за разрешения
  • Widget без собствен /FT разрешава наследения field тип през веригата /Parent, а неразрешима верига проваля ролевата конструкция вместо да default-не към annotation
  • Ролевите битове от всеки по-късен revision се сливат в ролите на покрития revision, така че по-късна актуализация не може да изтрие по-ранна page-ownership връзка, като първо откачи stream и го редактира след това

Фиксирана точка вместо фиксиран брой проходи

Ролевата достижимост в v3.126.2 върви като work queue, която итерира, докато нито един обект не добие нов ролеви бит — истинска фиксирана точка, независимо от дълбочината на веригата и номерацията на обектите. Indirect масиви като /Contents масив, записан като самостоятелен обект, също се обхождат. Всеки обект може да добие най-много четири различни ролеви бита, така че queue-ът е ограничен до четири записа на обектен номер; превишаването вдига prrResourceLimitExceeded. Референция към свободен обект, несъответствие в generation или счупен обектен header вдига prrMalformedRevisionChain, а payload вътре в компресиран обектен stream вдига prrCompressedObjectUnresolved. Всяко от тези провали завършва с prasIndeterminate, никога с позволен извод, и когато провалът стане при строенето на ролите на покрития revision, подписът изобщо не докладва 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 и annotation споделят един обект?

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

Обектът вече носи и annotation, и form роля, а annotation решението преотчита form страната, когато подписът има FieldMDP transform:

  • При P=2 annotation промяната е отхвърляна направо, точно както преди
  • С FieldMDP All всяко поле е заключено, така че споделената промяна е prdDisallowed
  • С FieldMDP Include или Exclude анализаторът не може да проследи споделен скалар назад до едно име на поле, така че решението е prdIndeterminate, а не догадка
  • Без FieldMDP важи правилото за annotation при P=3 и промяната си остава позволена
Диаграма на FieldMDP решението в PDFium Component, в която заключеното поле Total с /V и annotation с /Contents сочат към един и същ indirect обект, с разклонения по DocMDP P=2, FieldMDP All, FieldMDP Include или Exclude и без FieldMDP към изводите prdDisallowed, prdIndeterminate или prdAllowed за една и съща споделена промяна
Когато един indirect обект носи и annotation, и form роля, annotation решението преотчита FieldMDP заключването, така че една и съща промяна се класира от позволена до отхвърляна до indeterminate

Един детайл в доклада е важен за gate кода. Споделеният случай се докладва като Kind = prckAnnotation с Decision = prdIndeterminate, а prrFieldMdpUnresolved се добавя към набора рискове само за промени, класифицирани като form полета. Gate, който търси prrFieldMdpUnresolved и игнорира Status, пропусва този случай напълно

Как Delphi код да fail closed при анализ на revision-и?

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

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 цялостност или доверие в сертификати, така че този gate стои до криптографската и trust валидацията, а не на тяхно място. И правилен ownership граф не прави P=3 безопасен за всеки работен поток. 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;

Чеклист за одит на revision анализ на подписи

Ползвайте този списък, за да проверите дали верификационната ви верига е била изложена и дали вече fail-ва затворено:

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

Revision анализът е един слой от проверката на подпис. Комбинирайте го с прегледа на PDF цифрови подписи и PAdES нива за речниците и baseline нивото, и с по-широк одит на PDF security рискове за JavaScript, launch действия и вградени файлове. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions и ownership-съзнателният ролеви граф, описан тук, се доставят в PDFium Component за Delphi, C++Builder и Lazarus