У збірках 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 ланцюжок ішов так:
- Signature widget — це
/Subtype /Widgetз/FT /Sig, тож він отримує роль annotation /Pвід widget наштовхує роль annotation на словник сторінки, який уже має роль page- Сторінка наштовхує обидві ролі в
/Contents,/Resourcesі, через/Parent, нагору в дерево Pages і поперек до кожної sister-сторінки - Словник content stream на кшталт
<< /Length 812 >>не має/Type, тож classifier відкатувався до role bits і перевіряв роль annotation перед роллю page
Модифікований 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, /Annots | ISO 32000-1 §7.7.3 |
| Annotation чи widget | /P | ISO 32000-1 §12.5.2 |
| Словник widget чи field | /Parent | ISO 32000-1 §12.7.3 |
Фільтрування за ім’ям ключа глобально створило б нову діру. Ресурс 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, і зміна лишається дозволеною
Одна деталь звітування важить для 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