Artigo Técnico

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

Em compilações 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 anotação permitida sob DocMDP P=3, porque o seu grafo de roles de revisão tratava a referência inversa /P do widget de assinatura para a sua página como posse. Desde a v3.126.2, o PDFium Component separa arestas de navegação de arestas de payload possuído, por isso o conteúdo de página continua conteúdo de página. O relatório de bug por trás desta correção parece inofensivo em papel. Um contrato certificado permite anotações, a contraparte acrescenta uma gravação incremental, e o analisador diz que toda a mudança posterior é permitida. Depois alguém faz diff das páginas renderizadas e o valor do pagamento na página 2 é diferente

Este artigo é a sequela na perspetiva do atacante do panorama da análise de mudanças de revisões pós-assinatura, por isso salta os básicos da reconstrução de revisões e da classificação DocMDP e vai direto ao grafo de objetos: como a posse era modelada, porque é que a direção de uma aresta decide um veredicto de segurança, o que mudou na v3.126.2, e como auditar a sua própria lógica de aceitação

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

A edição de página passou porque o grafo de roles antigo seguia toda a referência indireta num dicionário como se o objeto referenciado pertencesse ao que o referencia, e o widget de assinatura aponta de volta para a sua página. Um dicionário de anotação transporta /P, uma referência indireta ao objeto de página onde assenta (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 através do seu array /Annots

O analisador atribui a cada objeto um conjunto de bits de role antes de classificar mudanças posteriores: página, anotação, formulário e material de validação. Objetos raiz recebem o seu role do seu próprio dicionário, e o role depois espalha-se a tudo o que referenciam. Na propagação antiga, a corrente corria assim:

  1. O widget de assinatura é um /Subtype /Widget com /FT /Sig, por isso recebe o role de anotação
  2. O /P do widget empurra o role de anotação para o dicionário de página, que já tem o role de página
  3. A página empurra ambos os roles para /Contents, /Resources e, através de /Parent, para cima na árvore Pages e através de todas as páginas irmãs
  4. Um dicionário de content stream como << /Length 812 >> não tem /Type, por isso o classificador caía de volta nos bits de role e verificava o role de anotação antes do role de página
Diagrama PDFium Component do grafo de roles DocMDP anterior à v3.126.2 em que uma referência inversa /P de widget de assinatura empurra o role de anotação para o dicionário de página, a página espalha-o através de /Contents para um content stream sem entrada /Type, o classificador produz prckAnnotation e a classificação P=3 devolve prdAllowed
antes da v3.126.2 o grafo de roles tratava toda a referência indireta como posse, por isso a entrada /P do widget empurrava o role de anotação para a página e uma edição genuína de página saía do analisador como uma mudança de anotação permitida

O content stream modificado por isso saía como prckAnnotation. Sob ISO 32000-1 §12.8.2.2, o DocMDP P=3 permite mudanças de anotação, por isso a decisão era prdAllowed e o reporte agregava para prasAllowed. O mesmo ficheiro sob P=2 era rejeitado, mas só por coincidência: o P=2 proíbe mudanças de anotação, por isso o stream mal rotulado era recusado pela razão errada. Um ciclo de propagação fixo de quatro passagens acrescentava uma segunda fraqueza. Payload alcançado através de um array indireto, ou através de uma corrente comprida cujos números de objeto correm para trás, podia nunca receber role nenhum

Porque é que um validador de assinaturas tem de perguntar quem é dono de um objeto?

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

Várias classes de ataque publicadas trabalham exatamente nessa lacuna. Os incremental saving attacks acrescentam uma revisão que troca o conteúdo da página e contam com o verificador só checar a gama de bytes assinada. Os shadow attacks plantam conteúdo escondido antes de assinar e ativam-no depois com uma pequena mudança de aspeto inocente. Os ataques a documentos certificados abusam do facto de o P=2 e o P=3 permitirem explicitamente algumas edições posteriores, e depois vestem uma edição proibida de permitida. Um verificador que classifique objetos por rótulos como /Type /Annot, ou por qualquer caminho de referência que por acaso os alcance, está exposto à terceira classe: ao atacante só precisa uma estrutura permitida que alcance a proibida

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

Como modela o PDFium Component v3.126.2 a posse?

O PDFium Component v3.126.2 trata referências inversas como navegação, mantém-nas fora da propagação de roles, e decide que chaves contam como navegação a partir do role estrutural do dicionário que as contém, não do nome da chave sozinho. A tabela resume as chaves de navegação que já não transportam posse

Dicionário donoChaves tratadas como navegaçãoReferência à especificação
Nó Page ou Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Anotação ou widget/PISO 32000-1 §12.5.2
Dicionário de widget ou campo/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 roles de página e de anotação, de arestas de navegação como o /P de widget, que não transporta roles, 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 roles: os roles viajam só por posse real, por isso o content stream continua conteúdo de página e a dica /P não decide nada

Filtrar pelo nome da chave globalmente teria criado um buraco novo. Um recurso de fonte ou XObject pode legitimamente chamar-se /P, /Parent ou /Annots, e um dicionário /Resources que largasse a sua entrada /P 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 aplica-se só quando o dicionário dono é de facto uma página, nó Pages, anotação, widget ou campo. Se um desses dicionários transporta uma chave de navegação duplicada, como duas entradas /P num widget, o analisador não adivinha que cópia um viewer usaria; a construção de roles falha e a assinatura fica Indeterminate

Várias regras mais fecham as rotas de re-rotulagem restantes:

  • Os nós Pages são raízes de role de página por direito próprio, por isso recursos herdados da árvore Pages (ISO 32000-1 §7.7.3.4) entram no contexto de página por posse real, não por um passeio de /Parent a partir de uma página filha
  • Um role de anotação ou de formulário que chegue a um catálogo, nó Pages, página, anotação ou dicionário de campo para aí, porque esses objetos estruturais estabelecem os seus próprios roles e um role de payload de entrada não os deve sobrepor
  • O role de página é autoritativo durante a classificação: um objeto de posse da página é prckPageContent mesmo que uma revisão posterior o reescreva com um /FT forjado, um rótulo /Type /Annot, ou o partilhe com um appearance stream
  • Um Form XObject usado só como aparência de campo ou anotação mantém a sua categoria de formulário ou anotação, por isso a regeneração comum de aparência depois de um preenchimento de formulário continua classificada sob as regras de permissão normais
  • Um widget sem /FT próprio resolve o tipo de campo herdado através da corrente /Parent, e uma cadeia irresolúvel falha a construção de roles em vez de recorrer à predefinição de anotação
  • Os bits de role de todas as revisões posteriores são fundidos nos roles da revisão coberta, por isso uma atualização posterior não pode apagar uma relação de posse de página anterior desanexando um stream primeiro e editando-o depois

Ponto fixo em vez de uma contagem fixa de passagens

A alcançabilidade de roles na v3.126.2 corre como uma fila de trabalho que itera até nenhum objeto ganhar um bit de role novo, o que é um verdadeiro ponto fixo independentemente da profundidade das cadeias ou da numeração de objetos. Arrays indiretos como um array /Contents guardado como objeto próprio também são percorridos. Cada objeto pode ganhar no máximo quatro bits de role distintos, por isso 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 desacordo de geração ou um header de objeto partido lança prrMalformedRevisionChain, e payload dentro de um object stream comprimido lança prrCompressedObjectUnresolved. Cada uma destas falhas acaba em prasIndeterminate, nunca num veredicto permitido, e quando a falha acontece enquanto se constroem os roles da revisão coberta a assinatura não reporta Changes nenhum

A rotina seguinte lista as edições de conteúdo de página que sobrevivem a esta análise. Uma mudança prckPageContent nunca é classificada prdAllowed: o DocMDP P=1, 2 ou 3 torna-a prdDisallowed, e uma assinatura sem DocMDP classifica-a 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;   // registo, nada para libertar
    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 o FieldMDP e uma anotação partilham um objeto?

Quando o /V de um campo e o /Contents de uma anotação apontam para o mesmo objeto indireto, a v3.126.2 mantém o cadeado FieldMDP em vigor mesmo que a mudança seja classificada como edição de anotação. O cenário é fácil de construir à mão: um assinante tranca o campo Total com FieldMDP (ISO 32000-1 §12.8.2.4), e o atacante faz o /Contents de uma anotação de texto referenciar o mesmo objeto string que guarda o valor do campo. Sob P=3 a edição de anotação é permitida, por isso antes da correção reescrever essa string partilhada mudava um valor de campo trancado com um veredicto permitido

O objeto agora transporta tanto o role de anotação como o de formulário, e a decisão de anotação reverifica o lado do formulário sempre que a assinatura tem uma transformação FieldMDP:

  • Sob P=2 a mudança de anotação é proibida de todo, exatamente como antes
  • Com FieldMDP All, todos os campos estão trancados, por isso a mudança partilhada é prdDisallowed
  • Com FieldMDP Include ou Exclude, o analisador não consegue rastrear um escalar partilhado até um nome de campo, por isso a decisão é prdIndeterminate em vez de um palpite
  • Sem FieldMDP, a regra de anotação do P=3 aplica-se e a mudança continua permitida
Diagrama de decisão FieldMDP do PDFium Component em que o /V de um campo Total trancado e o /Contents de uma anotação referenciam o mesmo objeto indireto, ramificando sobre DocMDP P=2, FieldMDP All, FieldMDP Include ou Exclude e sem FieldMDP para veredictos prdDisallowed, prdIndeterminate ou prdAllowed para a mesma edição partilhada
quando um objeto indireto transporta tanto o role de anotação como o de formulário, a decisão de anotação reverifica o cadeado FieldMDP, por isso a mesma edição vai de permitida a proibida a indeterminada

Um detalhe de reporte interessa ao código de portões. O caso partilhado é reportado como Kind = prckAnnotation com Decision = prdIndeterminate, e o prrFieldMdpUnresolved é acrescentado ao conjunto de riscos só para mudanças classificadas como campos de formulário. Um portão que procure prrFieldMdpUnresolved e ignore Status perde este caso completamente

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

O código Delphi deve aceitar um documento assinado só quando o estado 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 registar e passar. Indeterminate significa que o analisador não conseguiu provar que as revisões posteriores eram permitidas; para um atacante, um input que produza de forma fiável Indeterminate é tão útil como um que produza Allowed se o seu código o deixar passar. O AnalyzePadesSignatureRevisions global recebe qualquer TStream e lê-o a partir da posição 0, o que serve handlers de upload que nunca precisam renderizar o documento

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Definições duplicadas são registadas sem descer 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 a pena enunciar sem rodeios. O TPadesRevisionAnalysisReport não diz nada sobre integridade CMS ou confiança de certificados, por isso este portão senta-se ao lado da validação criptográfica e de confiança, não no lugar delas. E um grafo de posse correto não torna o P=3 seguro para todos os fluxos de trabalho. O P=3 permite genuinamente anotações, e uma anotação com aparência opaca pode cobrir texto assinado sem tocar num content stream único 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 anotação permitidas para um humano, como neste ajudante:

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 assinaturas

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

  • Compilações 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; corra de novo o TPdf.AnalyzeSignatureRevisions sobre ficheiros P=3 certificados aceites por compilações mais antigas
  • Reverifique documentos P=3 com cadeados FieldMDP em que um valor de campo e uma anotação possam partilhar um objeto indireto
  • Aceite só prasNoLaterChanges e prasAllowed; trate prasIndeterminate e prasSuspicious como não confiáveis
  • Teste o Report.Risks bem como o Report.Status, porque o prrDuplicateObjectDefinition não muda o estado por si só
  • Não leia um array Changes vazio como um resultado limpo quando o estado da assinatura é Indeterminate; uma construção de roles falhada não reporta mudanças
  • Não confie só no prrFieldMdpUnresolved para apanhar problemas FieldMDP, já que o caso de anotação partilhada só aparece através da decisão e do estado
  • Decida se mudanças de anotação permitidas sob P=3 precisam de revisão humana no seu fluxo de trabalho
  • Analise os bytes do ficheiro original; um documento reescrito por SaveAs já não contém a cadeia de revisões

A análise de revisões é uma camada de uma verificação de assinaturas. Emparelhe-a com inspeção de assinaturas digitais PDF e níveis PAdES para o dicionário e o nível de base, e com uma auditoria de riscos de segurança PDF mais lata para JavaScript, ações de lançamento e ficheiros embebidos. O TPdf.AnalyzeSignatureRevisions, o AnalyzePadesSignatureRevisions e o grafo de roles consciente de posse descrito aqui vêm no PDFium Component para Delphi, C++Builder e Lazarus