Artigo Técnico

DocMDP no PDFium Component: um widget /P escondeu edições

Em builds do PDFium Component anteriores à v3.126.2, o TPdf.AnalyzeSignatureRevisions podia classificar uma edição real de conteúdo de página como uma mudança de annotation permitida sob DocMDP P=3, porque o grafo de papéis de revisão dele tratava a referência inversa /P do widget de assinatura à página dele como ownership. Desde a v3.126.2, o PDFium Component separa arestas de navegação de arestas de payload possuído, então conteúdo de página continua conteúdo de página. O relato de bug por trás desta correção parece inofensivo no papel. Um contrato certificado permite annotations, a outra parte faz um save incremental, e o analisador diz que toda mudança posterior é permitida. Aí alguém faz diff das páginas renderizadas e o valor do pagamento na página 2 está diferente

Este artigo é a sequência pelo olho do atacante do visão geral da análise de mudanças de revisão pós-assinatura, então pula o básico de reconstrução de revisões e de graduação DocMDP e vai direto ao grafo de objetos: como o ownership era modelado, por que a direção de uma aresta decide um veredito de segurança, o que mudou na v3.126.2, e como auditar a sua própria lógica de aceitação

Por que uma edição de página passou como mudança de annotation sob DocMDP P=3?

A edição de página passou porque o grafo de papéis antigo seguia toda referência indireta num dicionário como se o objeto referenciado pertencesse ao referenciador, e o widget de assinatura aponta de volta para a página dele. Um dicionário de annotation carrega um /P, uma referência indireta ao objeto de página em que ele se situa (ISO 32000-1 §12.5.2). Essa entrada é uma dica de navegação. O widget não é dono da página; a página é dona do widget pelo array /Annots dela

O analisador atribui a cada objeto um conjunto de bits de papel antes de graduar mudanças posteriores: página, annotation, formulário e material de validação. Objetos raiz recebem o papel do próprio dicionário deles, e o papel então se espalha para tudo que eles referenciam. Na propagação antiga, a cadeia ia assim:

  1. O widget de assinatura é um /Subtype /Widget com /FT /Sig, então ele recebe o papel de annotation
  2. O /P do widget empurra o papel de annotation para o dicionário de página, que já tem o papel de página
  3. A página empurra os dois papéis para /Contents, /Resources e, pelo /Parent, para cima na árvore Pages e de lá para toda página irmã
  4. Um dicionário de content stream como << /Length 812 >> não tem /Type, então o classificador caía para os bits de papel e conferia o papel de annotation antes do de página
Diagrama do PDFium Component do grafo de papéis DocMDP anterior à v3.126.2 em que uma referência inversa /P de widget de assinatura empurra o papel de annotation para o dicionário de página, a página o espalha pelo /Contents para um content stream sem entrada /Type, o classificador devolve prckAnnotation e a graduação P=3 retorna prdAllowed
Antes da v3.126.2 o grafo de papéis tratava toda referência indireta como ownership, então a entrada /P do widget empurrava o papel de annotation para a página e uma edição genuína de página saía do analisador como uma mudança de annotation permitida

O content stream modificado portanto saía como prckAnnotation. Sob a ISO 32000-1 §12.8.2.2, o DocMDP P=3 permite mudanças de annotation, então a decisão era prdAllowed e o relatório se consolidava em prasAllowed. O mesmo arquivo sob P=2 era rejeitado, mas só por coincidência: o P=2 proíbe mudanças de annotation, então o stream rotulado errado era recusado pelo motivo errado. Um loop de propagação de quatro passos fixos adicionava uma segunda fraqueza. Payload alcançado por um array indireto, ou por uma cadeia longa cujos números de objeto correm de trás para frente, podia não receber papel nenhum

Por que um validador de assinaturas precisa perguntar quem é dono de um objeto?

Um validador de assinaturas precisa perguntar quem é dono de um objeto porque as atualizações incrementais do PDF (ISO 32000-1 §7.5.6) permitem que qualquer um anexe uma revisão que redefina um número de objeto existente, e o corpo redefinido não anuncia o que é. A assinatura continua verificando, já que ela cobre só os bytes da própria revisão dela. Toda defesa contra manipulação pós-assinatura depende portanto de mapear cada objeto alterado para a estrutura que o usa, e depois perguntar se o assinante permitiu que essa estrutura mudasse

Várias classes de ataque publicadas trabalham exatamente nessa brecha. Incremental saving attacks anexam uma revisão que troca o conteúdo da página e contam com o verificador conferindo só o byte range assinado. Shadow attacks plantam conteúdo escondido antes da assinatura e o ativam depois com uma mudança pequena e de aparência inocente. Ataques a documentos certificados abusam do fato de que P=2 e P=3 permitem explicitamente algumas edições posteriores, e então vestem uma edição proibida de edição permitida. Um verificador que classifica objetos por rótulos como /Type /Annot, ou por qualquer caminho de referência que por acaso os alcance, está exposto à terceira classe: o atacante só precisa de uma estrutura permitida que possa alcançar a proibida

É por isso que a pergunta não é quais objetos mudaram, mas quem são os donos deles. Um content stream alcançado de uma página pelo /Contents é conteúdo de página não importa o que mais aponte para ele. Uma annotation que aponta de volta para a página pelo /P diz onde a annotation vive, não o que ela possui

Como o PDFium Component v3.126.2 modela ownership?

O PDFium Component v3.126.2 trata referências inversas como navegação, as mantém fora da propagação de papéis, e decide quais chaves contam como navegação a partir do papel estrutural do dicionário que as contém, não do nome da chave sozinho. A tabela resume as chaves de navegação que não carregam mais ownership

Dicionário donoChaves tratadas como navegaçãoReferência da spec
Nó Page ou Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation ou widget/PISO 32000-1 §12.5.2
Dicionário widget ou field/ParentISO 32000-1 §12.7.3
Grafo de objetos do PDFium Component v3.126.2 separando arestas de payload possuído como /Contents e /Annots, que espalham papéis de página e annotation, de arestas de navegação como o /P de widget, que não carregam papéis, com as chaves de navegação por dicionário dono e prckPageContent mantido no content stream mesmo sob um /Type forjado
A v3.126.2 mantém referências inversas fora da propagação de papéis: papéis viajam só por ownership real, então o content stream continua conteúdo de página e a dica /P não decide nada

Filtrar por nome de chave globalmente teria criado um buraco novo. Um recurso de fonte ou XObject pode legitimamente se chamar /P, /Parent ou /Annots, e um dicionário /Resources que descartasse a entrada /P dele da propagação deixaria um atacante esconder um XObject de posse da página atrás de um nome de recurso inocente. Na v3.126.2 o filtro de navegação se aplica só quando o dicionário dono é de fato uma página, nó Pages, annotation, widget ou field. Se um desses dicionários carrega uma chave de navegação duplicada, como dois /P num widget, o analisador não adivinha qual cópia um viewer usaria; a construção de papéis falha e a assinatura vira Indeterminate

Várias regras adicionais fecham as rotas de re-rotulação restantes:

  • Nós Pages são raízes de papel de página por direito próprio, então recursos herdados da árvore Pages (ISO 32000-1 §7.7.3.4) entram no contexto de página por ownership real, e não por uma caminhada de /Parent a partir de uma página filha
  • Um papel de annotation ou formulário que chega a um catalog, nó Pages, página, annotation ou dicionário de field para aí, porque esses objetos estruturais estabelecem os próprios papéis deles e um papel de payload que chega não deve sobrepô-los
  • O papel de página é autoritativo durante a classificação: um objeto de posse da página é prckPageContent mesmo se uma revisão posterior o reescreve com um /FT forjado, um rótulo /Type /Annot, ou o compartilha com um appearance stream
  • Um Form XObject usado só como appearance de field ou annotation mantém a categoria de formulário ou annotation dele, então a regeneração comum de appearance depois de um preenchimento de formulário continua sendo graduada pelas regras de permissão normais
  • Um widget sem /FT próprio resolve o tipo de field herdado pela cadeia /Parent, e uma cadeia irresolúvel falha a construção de papéis em vez de assumir annotation como padrão
  • Bits de papel de toda revisão posterior são fundidos nos papéis da revisão coberta, então uma atualização posterior não pode apagar uma relação de posse de página anterior destacando um stream primeiro e editando-o depois

Ponto fixo em vez de uma contagem de passos fixa

A alcançabilidade de papéis na v3.126.2 roda como uma fila de trabalho que itera até nenhum objeto ganhar um bit de papel novo, o que é um verdadeiro ponto fixo independente da profundidade da cadeia ou da numeração de objetos. Arrays indiretos como um array /Contents armazenado como objeto próprio também são percorridos. Cada objeto pode ganhar no máximo quatro bits de papel distintos, então a fila é limitada a quatro entradas por número de objeto; exceder esse orçamento lança prrResourceLimitExceeded. Uma referência a um objeto livre, um mismatch de generation ou um cabeçalho de objeto quebrado lança prrMalformedRevisionChain, e payload dentro de um compressed object stream lança prrCompressedObjectUnresolved. Cada uma dessas falhas termina em prasIndeterminate, nunca num veredito de permitido, e quando a falha acontece enquanto os papéis da revisão coberta são construídos a assinatura não reporta Changes algum

A rotina seguinte lista as edições de conteúdo de página que sobrevivem a esta análise. Uma mudança prckPageContent nunca é graduada prdAllowed: DocMDP P=1, 2 ou 3 a torna prdDisallowed, e uma assinatura sem DocMDP a gradua 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, nada a liberar
    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;

O que acontece quando FieldMDP e uma annotation compartilham um objeto?

Quando o /V de um field e o /Contents de uma annotation apontam para o mesmo objeto indireto, a v3.126.2 mantém o lock FieldMDP em vigor mesmo que a mudança seja classificada como edição de annotation. O cenário é fácil de montar à mão: um assinante trava o field Total com FieldMDP (ISO 32000-1 §12.8.2.4), e o atacante faz o /Contents de uma annotation de texto referenciar o mesmo objeto string que guarda o valor do field. Sob P=3 a edição de annotation é permitida, então antes da correção reescrever essa string compartilhada mudava um valor de field travado com veredito de permitido

O objeto agora carrega tanto o papel de annotation quanto o de formulário, e a decisão de annotation reconfere o lado de formulário sempre que a assinatura tem um transform FieldMDP:

  • Sob P=2 a mudança de annotation é proibida de cara, exatamente como antes
  • Com FieldMDP All, todo field está travado, então a mudança compartilhada é prdDisallowed
  • Com FieldMDP Include ou Exclude, o analisador não consegue rastrear um escalar compartilhado de volta a um nome de field, então a decisão é prdIndeterminate em vez de um chute
  • Sem FieldMDP, a regra de annotation do P=3 se aplica e a mudança continua permitida
Diagrama de decisão FieldMDP do PDFium Component em que o /V de um field Total travado e o /Contents de uma annotation referenciam o mesmo objeto indireto, ramificando sobre DocMDP P=2, FieldMDP All, FieldMDP Include ou Exclude e sem FieldMDP para vereditos prdDisallowed, prdIndeterminate ou prdAllowed para a mesma edição compartilhada
Quando um objeto indireto carrega os papéis de annotation e de formulário ao mesmo tempo, a decisão de annotation reconfere o lock FieldMDP, então a mesma edição vai de permitida a proibida a indeterminada

Um detalhe de relatório importa para código de gate. O caso compartilhado é reportado como Kind = prckAnnotation com Decision = prdIndeterminate, e prrFieldMdpUnresolved é adicionado ao conjunto de riscos só para mudanças classificadas como form fields. Um gate que procura prrFieldMdpUnresolved e ignora o Status perde esse caso por completo

Como o código Delphi deve falhar fechado na análise de revisões?

Código Delphi deve aceitar um documento assinado só quando o status da análise é prasNoLaterChanges ou prasAllowed e nenhum risco estrutural está presente, e deve tratar prasIndeterminate e prasSuspicious como não confiáveis, não como avisos para registrar e deixar passar. Indeterminate significa que o analisador não conseguiu provar que as revisões posteriores eram permitidas; para um atacante, uma entrada que produz Indeterminate de forma confiável é tão útil quanto uma que produz Allowed se o seu código a deixa entrar. O AnalyzePadesSignatureRevisions global recebe qualquer TStream e o lê da posição 0, o que serve para handlers de upload que nunca precisam renderizar o documento

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Definições duplicadas são registradas sem rebaixar o 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 são rejeições, não avisos
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Duas fronteiras valem ser ditas com clareza. O TPadesRevisionAnalysisReport não diz nada sobre integridade CMS ou confiança de certificado, então este gate senta ao lado da validação criptográfica e de confiança, não no lugar delas. E um grafo de ownership correto não torna o P=3 seguro para todo fluxo de trabalho. O P=3 permite annotations de verdade, e uma annotation com um appearance opaco pode cobrir texto assinado sem tocar em um content stream que seja. Se os seus documentos certificados são contratos e não cópias de revisão, ou certifique com P=2 ou encaminhe mudanças de annotation permitidas para um humano, como neste 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 de auditoria de revisões de assinatura

Use esta lista para checar se o seu pipeline de verificação estava exposto e se agora falha fechado:

  • Builds do PDFium Component antes da v3.126.2 podiam reportar prasAllowed para edições de conteúdo de página em documentos DocMDP P=3; rode o TPdf.AnalyzeSignatureRevisions de novo em arquivos P=3 certificados aceitos por builds antigas
  • Reconfira documentos P=3 com locks FieldMDP em que um valor de field e uma annotation possam compartilhar um objeto indireto
  • Aceite só prasNoLaterChanges e prasAllowed; trate prasIndeterminate e prasSuspicious como não confiáveis
  • Teste o Report.Risks além do Report.Status, porque prrDuplicateObjectDefinition não muda o status por si só
  • Não leia um array Changes vazio como resultado limpo quando o status da assinatura é Indeterminate; uma construção de papéis que falhou não reporta mudanças
  • Não dependa só do prrFieldMdpUnresolved para pegar problemas de FieldMDP, já que o caso de annotation compartilhada aparece só pela decisão e pelo status
  • Decida se mudanças de annotation permitidas sob P=3 precisam de revisão humana no seu fluxo de trabalho
  • Analise os bytes originais do arquivo; um documento reescrito pelo SaveAs não contém mais a cadeia de revisões

A análise de revisões é uma camada de uma checagem de assinatura. Emparelhe-a com inspeção de assinaturas digitais PDF e níveis PAdES para o dicionário e o nível baseline, e com uma auditoria de riscos de segurança PDF mais ampla para JavaScript, launch actions e arquivos embutidos. O TPdf.AnalyzeSignatureRevisions, o AnalyzePadesSignatureRevisions e o grafo de papéis ciente de ownership descrito aqui vêm no PDFium Component para Delphi, C++Builder e Lazarus