Artículo técnico

DocMDP de PDFium Component: un /P de widget ocultó ediciones

En builds de PDFium Component anteriores a v3.126.2, TPdf.AnalyzeSignatureRevisions podía calificar una edición real de contenido de página como un cambio de anotación permitido bajo DocMDP P=3, porque su grafo de roles de revisiones trataba la /P del widget de firma —su referencia de vuelta a su página— como propiedad. Desde v3.126.2, PDFium Component separa las aristas de navegación de las aristas de payload propio, así que el contenido de página sigue siendo contenido de página. El reporte de bug detrás de esta corrección parece inofensivo en el papel. Un contrato certificado permite anotaciones, la contraparte agrega un guardado incremental, y el analizador dice que todo cambio posterior está permitido. Entonces alguien hace diff de las páginas renderizadas y el monto del pago en la página 2 es distinto

Este artículo es la secuela con ojos de atacante de la visión general del análisis de cambios de revisiones post-firma, así que se salta lo básico de la reconstrucción de revisiones y la calificación DocMDP y va directo al grafo de objetos: cómo se modeló la propiedad, por qué la dirección de una arista decide un veredicto de seguridad, qué cambió en v3.126.2, y cómo auditar su propia lógica de aceptación

¿Por qué una edición de página pasaba como cambio de anotación bajo DocMDP P=3?

La edición de página pasaba porque el grafo de roles antiguo seguía cada referencia indirecta en un diccionario como si el objeto referido perteneciera al que lo refiere, y el widget de firma apunta de vuelta a su página. Un diccionario de anotación lleva /P, una referencia indirecta al objeto de página sobre el que se asienta (ISO 32000-1 §12.5.2). Esa entrada es una pista de navegación. El widget no es dueño de la página; la página es dueña del widget por medio de su array /Annots

El analizador asigna a cada objeto un set de bits de rol antes de calificar los cambios posteriores: página, anotación, formulario y material de validación. Los objetos raíz reciben su rol de su propio diccionario, y el rol después se expande a todo lo que referencian. En la propagación antigua, la cadena iba así:

  1. El widget de firma es un /Subtype /Widget con /FT /Sig, así que recibe el rol de anotación
  2. La /P del widget empuja el rol de anotación sobre el diccionario de página, que ya tiene el rol de página
  3. La página empuja ambos roles a /Contents, /Resources y, por medio de /Parent, hacia arriba en el árbol Pages y a lo ancho hasta cada página hermana
  4. Un diccionario de content stream como << /Length 812 >> no tiene /Type, así que el clasificador recurría a los bits de rol y verificaba el rol de anotación antes que el de página
Diagrama de PDFium Component del grafo de roles DocMDP previo a v3.126.2 donde la referencia de vuelta /P de un widget de firma empuja el rol de anotación sobre el diccionario de página, la página lo expande por /Contents hasta un content stream sin entrada /Type, el clasificador produce prckAnnotation y la calificación P=3 devuelve prdAllowed
Antes de v3.126.2 el grafo de roles trataba cada referencia indirecta como propiedad, así que la entrada /P del widget empujaba el rol de anotación sobre la página y una edición genuina de página salía del analizador como un cambio de anotación permitido

El content stream modificado salía por lo tanto como prckAnnotation. Bajo ISO 32000-1 §12.8.2.2, DocMDP P=3 permite cambios de anotación, así que la decisión era prdAllowed y el reporte se acumulaba en prasAllowed. El mismo archivo bajo P=2 era rechazado, pero solo por coincidencia: P=2 prohíbe los cambios de anotación, así que el stream mal etiquetado se rechazaba por la razón equivocada. Un loop de propagación fijo de cuatro pasadas agregaba una segunda debilidad. El payload que llegaba por un array indirecto, o por una cadena larga cuyos números de objeto corrían hacia atrás, podía no recibir rol alguno

¿Por qué un validador de firmas debe preguntar quién es dueño de un objeto?

Un validador de firmas debe preguntar quién es dueño de un objeto porque las actualizaciones incrementales de PDF (ISO 32000-1 §7.5.6) permiten que cualquiera agregue una revisión que redefina un número de objeto existente, y el cuerpo redefinido no anuncia lo que es. La firma sigue verificando, porque cubre solo los bytes de su propia revisión. Toda defensa contra la manipulación posterior a la firma depende por lo tanto de mapear cada objeto cambiado a la estructura que lo usa, y luego preguntar si el firmante permitió que esa estructura cambiara

Varias clases de ataque publicadas trabajan justo en ese hueco. Los incremental saving attacks agregan una revisión que cambia el contenido de la página y confían en que el verificador solo revise el rango de bytes firmado. Los shadow attacks siembran contenido oculto antes de firmar y lo activan después con un cambio pequeño de apariencia inocente. Los ataques a documentos certificados abusan de que P=2 y P=3 permiten explícitamente algunas ediciones posteriores, y luego disfrazan una edición prohibida de permitida. Un verificador que clasifica objetos por etiquetas como /Type /Annot, o por cualquier ruta de referencia que casualmente los alcance, está expuesto a la tercera clase: al atacante le basta una estructura permitida que pueda alcanzar la prohibida

Por eso la pregunta no es qué objetos cambiaron sino quién es dueño de ellos. Un content stream alcanzado desde una página por /Contents es contenido de página sin importar qué más apunte a él. Una anotación que apunta de vuelta a la página por /P dice dónde vive la anotación, no qué posee

¿Cómo modela PDFium Component v3.126.2 la propiedad?

PDFium Component v3.126.2 trata las referencias de vuelta como navegación, las mantiene fuera de la propagación de roles, y decide qué claves cuentan como navegación a partir del rol estructural del diccionario que las contiene, no del nombre de la clave solo. La tabla resume las claves de navegación que ya no cargan propiedad

Diccionario dueñoClaves tratadas como navegaciónReferencia de la especificación
Nodo Page o Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Anotación o widget/PISO 32000-1 §12.5.2
Diccionario widget o field/ParentISO 32000-1 §12.7.3
Grafo de objetos de PDFium Component v3.126.2 que separa las aristas de payload propio como /Contents y /Annots, que expanden los roles de página y anotación, de las aristas de navegación como la /P del widget, que no cargan roles, con las claves de navegación por diccionario dueño y prckPageContent conservado en el content stream incluso bajo una /Type falsificada
v3.126.2 mantiene las referencias de vuelta fuera de la propagación de roles: los roles viajan solo por propiedad real, así que el content stream sigue siendo contenido de página y la pista /P no decide nada

Filtrar por nombre de clave a nivel global habría creado un hueco nuevo. Un recurso de fuente o XObject puede legítimamente llamarse /P, /Parent o /Annots, y un diccionario /Resources que quitara su entrada /P de la propagación dejaría a un atacante esconder un XObject propiedad de la página detrás de un nombre de recurso inocente. En v3.126.2 el filtro de navegación aplica solo cuando el diccionario dueño es de verdad una página, un nodo Pages, una anotación, un widget o un field. Si uno de esos diccionarios lleva una clave de navegación duplicada, como dos entradas /P en un widget, el analizador no adivina qué copia usaría un visor; la construcción de roles falla y la firma pasa a Indeterminate

Varias reglas más cierran las rutas de reetiquetado restantes:

  • Los nodos Pages son raíces de rol de página por derecho propio, así que los recursos heredados del árbol Pages (ISO 32000-1 §7.7.3.4) entran al contexto de página por propiedad real, no por un recorrido de /Parent desde una página hija
  • Un rol de anotación o de formulario que llega a un catálogo, nodo Pages, página, anotación o diccionario field se detiene ahí, porque esos objetos estructurales establecen sus propios roles y un rol de payload entrante no debe sobrescribirlos
  • El rol de página es autoridad durante la clasificación: un objeto propiedad de la página es prckPageContent incluso si una revisión posterior lo reescribe con una /FT falsificada, una etiqueta /Type /Annot, o lo comparte con un appearance stream
  • Un Form XObject usado solo como apariencia de un field o de una anotación conserva su categoría de formulario o anotación, así que la regeneración ordinaria de apariencias tras llenar un formulario sigue calificándose bajo las reglas de permiso normales
  • Un widget sin /FT propia resuelve el tipo de field heredado por la cadena /Parent, y una cadena irresoluble hace fallar la construcción de roles en vez de quedarse por defecto en anotación
  • Los bits de rol de cada revisión posterior se fusionan en los roles de la revisión cubierta, así que una actualización posterior no puede borrar una relación de propiedad de página anterior desprendiendo primero un stream y editándolo después

Punto fijo en vez de una cantidad fija de pasadas

La alcanzabilidad de roles en v3.126.2 corre como una cola de trabajo que itera hasta que ningún objeto gane un bit de rol nuevo, lo cual es un punto fijo verdadero sin importar la profundidad de la cadena ni la numeración de objetos. Los arrays indirectos, como un array /Contents guardado como objeto propio, también se recorren. Cada objeto puede ganar a lo sumo cuatro bits de rol distintos, así que la cola acota cuatro entradas por número de objeto; exceder ese presupuesto lanza prrResourceLimitExceeded. Una referencia a un objeto libre, un mismatch de generación o una cabecera de objeto rota lanzan prrMalformedRevisionChain, y payload dentro de un object stream comprimido lanza prrCompressedObjectUnresolved. Cada uno de estos fallos termina en prasIndeterminate, jamás en un veredicto de permitido, y cuando el fallo ocurre mientras se construyen los roles de la revisión cubierta la firma no reporta ningún Changes

La rutina siguiente lista las ediciones de contenido de página que sobreviven a este análisis. Un cambio prckPageContent jamás se califica prdAllowed: DocMDP P=1, 2 o 3 lo vuelve prdDisallowed, y una firma sin DocMDP lo califica 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 que 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;

¿Qué pasa cuando FieldMDP y una anotación comparten un objeto?

Cuando la /V de un field y la /Contents de una anotación apuntan al mismo objeto indirecto, v3.126.2 mantiene el candado FieldMDP en vigor aunque el cambio se clasifique como edición de anotación. El escenario es fácil de armar a mano: un firmante bloquea el field Total con FieldMDP (ISO 32000-1 §12.8.2.4), y el atacante hace que la /Contents de una anotación de texto referencie el mismo objeto string que guarda el valor del field. Bajo P=3 la edición de anotación está permitida, así que antes de la corrección reescribir ese string compartido cambiaba el valor de un field bloqueado con un veredicto de permitido

El objeto ahora lleva tanto el rol de anotación como el de formulario, y la decisión de anotación reverifica el lado del formulario siempre que la firma tenga una transformación FieldMDP:

  • Bajo P=2 el cambio de anotación se rechaza de plano, exactamente como antes
  • Con FieldMDP All, todo field está bloqueado, así que el cambio compartido es prdDisallowed
  • Con FieldMDP Include o Exclude, el analizador no puede rastrear un escalar compartido hasta un nombre de field, así que la decisión es prdIndeterminate y no una adivinanza
  • Sin FieldMDP, aplica la regla de anotación de P=3 y el cambio sigue permitido
Diagrama de decisión FieldMDP de PDFium Component donde la /V de un field Total bloqueado y la /Contents de una anotación referencian el mismo objeto indirecto, ramificando sobre DocMDP P=2, FieldMDP All, FieldMDP Include o Exclude y sin FieldMDP hacia veredictos prdDisallowed, prdIndeterminate o prdAllowed para la misma edición compartida
Cuando un objeto indirecto lleva tanto el rol de anotación como el de formulario, la decisión de anotación reverifica el candado FieldMDP, así que la misma edición va de permitida a rechazada a indeterminada

Un detalle de reporteo importa para el código de compuerta. El caso compartido se reporta como Kind = prckAnnotation con Decision = prdIndeterminate, y prrFieldMdpUnresolved se agrega al set de riesgos solo para los cambios clasificados como form fields. Una compuerta que busque prrFieldMdpUnresolved e ignore Status se pierde este caso por completo

¿Cómo debe fallar cerrado el código Delphi ante el análisis de revisiones?

El código Delphi debe aceptar un documento firmado solo cuando el estado del análisis es prasNoLaterChanges o prasAllowed y no hay riesgo estructural presente, y debe tratar prasIndeterminate y prasSuspicious como no confiables, no como advertencias que registrar y dejar pasar. Indeterminate significa que el analizador no pudo probar que las revisiones posteriores estaban permitidas; para un atacante, un input que produce Indeterminate de manera confiable es tan útil como uno que produce Allowed si su código lo deja pasar. El AnalyzePadesSignatureRevisions global toma cualquier TStream y lo lee desde la posición 0, lo cual va bien para handlers de carga que jamás necesitan renderizar el documento

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Las definiciones duplicadas se registran sin degradar 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 y prasSuspicious son rechazos, no advertencias
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Dos fronteras vale la pena enunciar sin rodeos. TPadesRevisionAnalysisReport no dice nada sobre la integridad CMS ni la confianza del certificado, así que esta compuerta se sienta al lado de la validación criptográfica y de confianza, no en su lugar. Y un grafo de propiedad correcto no vuelve P=3 seguro para todo flujo de trabajo. P=3 genuinamente permite anotaciones, y una anotación con apariencia opaca puede cubrir texto firmado sin tocar un solo content stream. Si sus documentos certificados son contratos y no copias de revisión, o certifique con P=2 o enrute los cambios de anotación permitidos a un humano, como en este 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 auditoría de revisiones de firmas

Use esta lista para verificar si su pipeline de verificación estaba expuesto y si ahora falla cerrado:

  • Los builds de PDFium Component anteriores a v3.126.2 podían reportar prasAllowed para ediciones de contenido de página en documentos DocMDP P=3; vuelva a correr TPdf.AnalyzeSignatureRevisions sobre los archivos P=3 certificados que builds antiguos aceptaron
  • Revise de nuevo los documentos P=3 con candados FieldMDP donde un valor de field y una anotación puedan compartir un objeto indirecto
  • Acepte solo prasNoLaterChanges y prasAllowed; trate prasIndeterminate y prasSuspicious como no confiables
  • Pruebe Report.Risks además de Report.Status, porque prrDuplicateObjectDefinition no cambia el estado por sí solo
  • No lea un array Changes vacío como resultado limpio cuando el estado de la firma es Indeterminate; una construcción de roles fallida no reporta cambios
  • No se apoye solo en prrFieldMdpUnresolved para cazar problemas de FieldMDP, ya que el caso de anotación compartida asoma solo por la decisión y el estado
  • Decida si los cambios de anotación permitidos bajo P=3 requieren revisión humana en su flujo de trabajo
  • Analice los bytes del archivo original; un documento reescrito por SaveAs ya no contiene la cadena de revisiones

El análisis de revisiones es una capa de un chequeo de firmas. Emparéjelo con inspeccionar firmas digitales PDF y niveles PAdES para el diccionario y el nivel baseline, y con una auditoría de riesgos de seguridad PDF más amplia para JavaScript, acciones de lanzamiento y archivos embebidos. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions y el grafo de roles consciente de la propiedad descrito aquí vienen en el PDFium Component para Delphi, C++Builder y Lazarus