Articolo tecnico

PDFium Component DocMDP: un /P che nascondeva le modifiche

Nelle build di PDFium Component precedenti alla v3.126.2, TPdf.AnalyzeSignatureRevisions poteva classificare una vera modifica del contenuto di pagina come cambiamento di annotazione permesso sotto DocMDP P=3, perché il suo grafo dei ruoli delle revisioni trattava il back-reference /P del widget firma alla sua pagina come proprietà. Dalla v3.126.2, PDFium Component separa gli archi di navigazione dagli archi di payload posseduto, così il contenuto di pagina resta contenuto di pagina. La segnalazione dietro a questa correzione sembra innocua sulla carta. Un contratto certificato consente le annotazioni, la controparte aggiunge un salvataggio incrementale, e l'analizzatore dice che ogni cambiamento successivo è permesso. Poi qualcuno fa il diff delle pagine renderizzate e l'importo del pagamento a pagina 2 è diverso

Questo articolo è il seguito dal punto di vista dell'attaccante della panoramica sull'analisi dei cambiamenti delle revisioni post-firma, quindi salta le basi della ricostruzione delle revisioni e della classificazione DocMDP e va dritto al grafo degli oggetti: come era modellata la proprietà, perché la direzione di un arco decide un verdetto di sicurezza, che cosa è cambiato nella v3.126.2, e come auditare la tua logica di accettazione

Perché una modifica di pagina passava come modifica di annotazione sotto DocMDP P=3?

La modifica di pagina passava perché il vecchio grafo dei ruoli seguiva ogni riferimento indiretto in un dizionario come se l'oggetto referenziato appartenesse a quello referenziante, e il widget firma punta alla sua pagina. Un dizionario di annotazione porta /P, un riferimento indiretto all'oggetto pagina su cui sta (ISO 32000-1 §12.5.2). Quell'entry è un indizio di navigazione. Il widget non possiede la pagina; la pagina possiede il widget attraverso il suo array /Annots

L'analizzatore assegna a ogni oggetto un insieme di bit di ruolo prima di classificare i cambiamenti successivi: pagina, annotazione, form e materiale di validazione. Gli oggetti radice prendono il ruolo dal proprio dizionario, e il ruolo poi si sparga su tutto ciò che referenziano. Nella vecchia propagazione, la catena andava così:

  1. Il widget firma è un /Subtype /Widget con /FT /Sig, quindi prende il ruolo di annotazione
  2. La /P del widget spinge il ruolo di annotazione sul dizionario di pagina, che ha già il ruolo di pagina
  3. La pagina spinge entrambi i ruoli in /Contents, /Resources e, tramite /Parent, su nell'albero Pages e attraverso verso ogni pagina gemella
  4. Un dizionario di content stream come << /Length 812 >> non ha /Type, quindi il classificatore ricadeva sui bit di ruolo e controllava il ruolo di annotazione prima di quello di pagina
Diagramma PDFium Component del grafo dei ruoli DocMDP precedente alla v3.126.2 in cui un back-reference /P di widget firma spinge il ruolo di annotazione sul dizionario di pagina, la pagina lo sparge tramite /Contents su un content stream senza entry /Type, il classificatore emette prckAnnotation e la classificazione P=3 restituisce prdAllowed
Prima della v3.126.2 il grafo dei ruoli trattava ogni riferimento indiretto come proprietà, quindi l'entry /P del widget spingeva il ruolo di annotazione sulla pagina e una genuina modifica di pagina usciva dall'analizzatore come cambiamento di annotazione permesso

Il content stream modificato quindi usciva come prckAnnotation. Sotto ISO 32000-1 §12.8.2.2, DocMDP P=3 consente i cambiamenti di annotazione, quindi la decisione era prdAllowed e il report si arrotolava su prasAllowed. Lo stesso file sotto P=2 veniva rifiutato, ma solo per coincidenza: P=2 vieta i cambiamenti di annotazione, così lo stream etichettato male veniva respinto per la ragione sbagliata. Un loop di propagazione a quattro passate fisse aggiungeva una seconda debolezza. Il payload arrivato attraverso un array indiretto, o attraverso una lunga catena i cui numeri di oggetto girano al contrario, poteva non ricevere alcun ruolo

Perché un validatore di firme deve chiedersi chi possiede un oggetto?

Un validatore di firme deve chiedersi chi possiede un oggetto perché gli aggiornamenti incrementali PDF (ISO 32000-1 §7.5.6) lasciano che chiunque appenda una revisione che ridefinisce un numero di oggetto esistente, e il corpo ridefinito non annuncia che cosa è. La firma verifica comunque, dato che copre solo i byte della propria revisione. Ogni difesa contro la manipolazione post-firma dipende quindi dal mappare ogni oggetto cambiato sulla struttura che lo usa, e poi dal chiedersi se il firmatario abbia permesso a quella struttura di cambiare

Parecchie classi di attacco pubblicate lavorano esattamente in quel varco. Gli incremental saving attack appendono una revisione che sostituisce il contenuto di pagina e contano sul verificatore che controlla solo il byte range firmato. Gli shadow attack piantano contenuto nascosto prima della firma e lo attivano dopo con un piccolo cambiamento dall'aria innocente. Gli attacchi ai documenti certificati abusano del fatto che P=2 e P=3 consentono esplicitamente alcune modifiche successive, poi travestono una modifica vietata da permessa. Un verificatore che classifica gli oggetti per etichette come /Type /Annot, o per qualsiasi percorso di riferimento che capita di raggiungerli, è esposto alla terza classe: all'attaccante basta una struttura permessa che possa raggiungere quella vietata

Ecco perché la domanda non è quali oggetti sono cambiati ma chi li possiede. Un content stream raggiunto da una pagina tramite /Contents è contenuto di pagina non importa cos'altro punti a lui. Un'annotazione che punta alla pagina tramite /P dice dove vive l'annotazione, non che cosa possiede

Come modella la proprietà PDFium Component v3.126.2?

PDFium Component v3.126.2 tratta i back-reference come navigazione, li tiene fuori dalla propagazione dei ruoli, e decide quali chiavi contano come navigazione dal ruolo strutturale del dizionario che le contiene, non dal solo nome della chiave. La tabella riassume le chiavi di navigazione che non trasportano più proprietà

Dizionario proprietarioChiavi trattate come navigazioneRiferimento alla specifica
Nodo Page o Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotazione o widget/PISO 32000-1 §12.5.2
Dizionario widget o campo/ParentISO 32000-1 §12.7.3
Grafo degli oggetti PDFium Component v3.126.2 che separa gli archi di payload posseduto come /Contents e /Annots, che spargono ruoli di pagina e annotazione, dagli archi di navigazione come la /P del widget, che non portano ruoli, con le chiavi di navigazione per dizionario proprietario e prckPageContent conservato sul content stream persino sotto una /Type contraffatta
La v3.126.2 tiene i back-reference fuori dalla propagazione dei ruoli: i ruoli viaggiano solo attraverso vera proprietà, così il content stream resta contenuto di pagina e l'indizio /P non decide niente

Filtrare per nome chiave a livello globale avrebbe creato un buco nuovo. Una risorsa font o XObject può legittimamente chiamarsi /P, /Parent o /Annots, e un dizionario /Resources che togliesse la sua entry /P dalla propagazione lascerebbe un attaccante nascondere un XObject di proprietà della pagina dietro un nome di risorsa innocente. Nella v3.126.2 il filtro di navigazione si applica solo quando il dizionario proprietario è davvero una pagina, un nodo Pages, un'annotazione, un widget o un campo. Se uno di quei dizionari porta una chiave di navigazione duplicata, come due entry /P in un widget, l'analizzatore non indovina quale copia userebbe un viewer; la costruzione dei ruoli fallisce e la firma diventa Indeterminate

Altre regole chiudono le restanti vie di rietichettatura:

  • I nodi Pages sono radici di ruolo pagina a pieno titolo, quindi le risorse ereditate dall'albero Pages (ISO 32000-1 §7.7.3.4) entrano nel contesto pagina attraverso vera proprietà, non attraverso una camminata /Parent da una pagina figlia
  • Un ruolo di annotazione o form che arriva su un catalogo, nodo Pages, pagina, annotazione o dizionario di campo si ferma lì, perché quegli oggetti strutturali stabiliscono i propri ruoli e un ruolo payload in arrivo non deve sovrascriverli
  • Il ruolo pagina è autorevole durante la classificazione: un oggetto di proprietà della pagina è prckPageContent anche se una revisione successiva lo riscrive con una /FT contraffatta, un'etichetta /Type /Annot, o lo condivide con un appearance stream
  • Un Form XObject usato solo come appearance di campo o annotazione conserva la sua categoria form o annotazione, così la rigenerazione ordinaria degli appearance dopo una compilazione di form viene classificata sotto le normali regole di permesso
  • Un widget senza una /FT propria risolve il tipo di campo ereditato attraverso la catena /Parent, e una catena irrisolvibile fa fallire la costruzione dei ruoli invece di ripiegare su annotazione
  • I bit di ruolo di ogni revisione successiva vengono fusi nei ruoli della revisione coperta, così un aggiornamento posteriore non può cancellare una precedente relazione di proprietà della pagina staccando prima uno stream e modificandolo dopo

Punto fisso invece di un numero fisso di passate

La raggiungibilità dei ruoli nella v3.126.2 gira come una coda di lavoro che itera finché nessun oggetto guadagna un nuovo bit di ruolo, il che è un vero punto fisso a prescindere dalla profondità delle catene e dalla numerazione degli oggetti. Anche gli array indiretti come un array /Contents memorizzato come oggetto proprio vengono percorsi. Ogni oggetto può guadagnare al massimo quattro bit di ruolo distinti, quindi la coda è limitata a quattro entry per numero di oggetto; superare quel budget solleva prrResourceLimitExceeded. Un riferimento a un oggetto libero, una mancata combacianza di generazione o un header di oggetto rotto solleva prrMalformedRevisionChain, e payload dentro un object stream compresso solleva prrCompressedObjectUnresolved. Ognuno di questi fallimenti finisce con prasIndeterminate, mai con un verdetto di permesso, e quando il fallimento avviene durante la costruzione dei ruoli della revisione coperta la firma non riporta proprio nessun Changes

La routine seguente elenca le modifiche di contenuto di pagina che sopravvivono a questa analisi. Una modifica prckPageContent non viene mai classificata prdAllowed: DocMDP P=1, 2 o 3 la rende prdDisallowed, e una firma senza DocMDP la classifica 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, niente da liberare
    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;

Che cosa succede quando FieldMDP e un'annotazione condividono un oggetto?

Quando la /V di un campo e la /Contents di un'annotazione puntano allo stesso oggetto indiretto, la v3.126.2 mantiene il lucchetto FieldMDP in forza anche se il cambiamento viene classificato come modifica di annotazione. Lo scenario è facile da costruire a mano: un firmatario blocca il campo Total con FieldMDP (ISO 32000-1 §12.8.2.4), e l'attaccante fa sì che la /Contents di un'annotazione testuale referenzi lo stesso oggetto stringa che contiene il valore del campo. Sotto P=3 la modifica di annotazione è permessa, quindi prima della correzione riscrivere quella stringa condivisa cambiava un valore di campo bloccato con un verdetto di permesso

L'oggetto ora porta sia il ruolo di annotazione sia quello di form, e la decisione di annotazione ricontrolla il lato form ogni volta che la firma ha una trasformazione FieldMDP:

  • Sotto P=2 il cambiamento di annotazione è vietato a prescindere, esattamente come prima
  • Con FieldMDP All, ogni campo è bloccato, quindi il cambiamento condiviso è prdDisallowed
  • Con FieldMDP Include o Exclude, l'analizzatore non può risalire da uno scalare condiviso a un nome di campo, quindi la decisione è prdIndeterminate anziché un'ipotesi
  • Senza FieldMDP, vale la regola di annotazione P=3 e il cambiamento resta permesso
Diagramma decisionale FieldMDP di PDFium Component in cui la /V di un campo Total bloccato e la /Contents di un'annotazione referenziano lo stesso oggetto indiretto, con diramazioni su DocMDP P=2, FieldMDP All, FieldMDP Include o Exclude e nessun FieldMDP verso verdetti prdDisallowed, prdIndeterminate o prdAllowed per la stessa modifica condivisa
Quando un oggetto indiretto porta sia il ruolo di annotazione sia quello di form, la decisione di annotazione ricontrolla il lucchetto FieldMDP, così la stessa modifica va da permessa a vietata a indeterminata

Un dettaglio di reporting conta per il codice gate. Il caso condiviso viene riportato come Kind = prckAnnotation con Decision = prdIndeterminate, e prrFieldMdpUnresolved viene aggiunto all'insieme dei rischi solo per i cambiamenti classificati come campi form. Un gate che cerca prrFieldMdpUnresolved e ignora Status si perde questo caso completamente

Come deve fallire in modo chiuso il codice Delphi sull'analisi delle revisioni?

Il codice Delphi dovrebbe accettare un documento firmato solo quando lo stato dell'analisi è prasNoLaterChanges o prasAllowed e nessun rischio strutturale è presente, e dovrebbe trattare prasIndeterminate e prasSuspicious come non affidabili, non come avvisi da registrare e far passare. Indeterminate significa che l'analizzatore non è riuscito a dimostrare che le revisioni successive erano permesse; per un attaccante, un input che produce in modo affidabile Indeterminate è utile quanto uno che produce Allowed se il tuo codice lo lascia passare. La AnalyzePadesSignatureRevisions globale prende qualsiasi TStream e lo legge dalla posizione 0, il che la adatta agli handler di upload che non hanno mai bisogno di renderizzare il documento

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Le definizioni duplicate vengono registrate senza degradare 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 e prasSuspicious sono rifiuti, non avvisi
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Due confini valgono la pena di essere dichiarati senza giri di parole. TPadesRevisionAnalysisReport non dice nulla sull'integrità CMS o sulla fiducia nei certificati, quindi questo gate sta accanto alla validazione crittografica e di fiducia, non al loro posto. E un grafo di proprietà corretto non rende P=3 sicuro per ogni flusso di lavoro. P=3 consente genuinamente le annotazioni, e un'annotazione con un appearance opaco può coprire testo firmato senza toccare nemmeno un content stream. Se i tuoi documenti certificati sono contratti anziché copie di revisione, o certifica con P=2 o instrada i cambiamenti di annotazione permessi a un essere umano, come in questo helper:

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;

Checklist di audit delle revisioni di firma

Usa questa lista per controllare se la tua pipeline di verifica era esposta e se ora fallisce in modo chiuso:

  • Le build di PDFium Component precedenti alla v3.126.2 potevano riportare prasAllowed per modifiche di contenuto di pagina in documenti DocMDP P=3; rifai girare TPdf.AnalyzeSignatureRevisions sui file P=3 certificati accettati da build più vecchie
  • Ricontrolla i documenti P=3 con lucchetti FieldMDP dove un valore di campo e un'annotazione potrebbero condividere un oggetto indiretto
  • Accetta solo prasNoLaterChanges e prasAllowed; tratta prasIndeterminate e prasSuspicious come non affidabili
  • Testa Report.Risks oltre a Report.Status, perché prrDuplicateObjectDefinition da solo non cambia lo status
  • Non leggere un array Changes vuoto come risultato pulito quando lo status della firma è Indeterminate; una costruzione dei ruoli fallita non riporta cambiamenti
  • Non contare solo su prrFieldMdpUnresolved per beccare i problemi FieldMDP, visto che il caso dell'annotazione condivisa emerge solo dalla decisione e dallo status
  • Decidi se i cambiamenti di annotazione permessi sotto P=3 richiedono revisione umana nel tuo flusso di lavoro
  • Analizza i byte originali del file; un documento riscritto da SaveAs non contiene più la catena delle revisioni

L'analisi delle revisioni è uno strato di un controllo di firma. Accoppiala con l'ispezione delle firme digitali PDF e dei livelli PAdES per il dizionario e il livello baseline, e con un più ampio audit dei rischi di sicurezza PDF per JavaScript, azioni launch e file incorporati. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions e il grafo dei ruoli consapevole della proprietà descritto qui viaggiano nel PDFium Component per Delphi, C++Builder e Lazarus