Artículo técnico

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

En compilaciones 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 retroreferencia /P del widget de firma a su página como propiedad. Desde v3.126.2, PDFium Component separa las aristas de navegación de las aristas de payload poseído, así que el contenido de página sigue siendo contenido de página. El reporte de bug detrás de este arreglo parece inofensivo sobre el papel. Un contrato certificado permite anotaciones, la contraparte añade un guardado incremental, y el analizador dice que cada cambio posterior está permitido. Luego alguien hace diff de las páginas renderizadas y el importe 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 tras 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 modelaba 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 toda referencia indirecta de un diccionario como si el objeto referenciado perteneciera al que lo referencia, 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 está (ISO 32000-1 §12.5.2). Esa entrada es una pista de navegación. El widget no posee la página; la página posee el widget a través de su array /Annots

El analizador asigna a cada objeto un conjunto de bits de rol antes de calificar los cambios posteriores: página, anotación, formulario y material de validación. Los objetos raíz toman su rol de su propio diccionario, y el rol se propaga luego 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. El /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 hacia /Contents, /Resources y, vía /Parent, hacia arriba en el árbol Pages y a través de cada página hermana
  4. Un diccionario de content stream como << /Length 812 >> no tiene /Type, así que el clasificador caía de vuelta a los bits de rol y comprobaba el rol de anotación antes que el de página
Diagrama de PDFium Component del grafo de roles DocMDP anterior a v3.126.2 donde una retroreferencia /P del widget de firma empuja el rol de anotación sobre el diccionario de página, la página lo propaga 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 toda 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 cambio de anotación permitido

El content stream modificado salía por 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 agrupaba en prasAllowed. El mismo archivo bajo P=2 se rechazaba, pero solo por coincidencia: P=2 prohíbe cambios de anotación, así que el stream mal etiquetado se rechazaba por la razón equivocada. Un bucle de propagación fijo de cuatro pasadas añadía una segunda debilidad. Un payload alcanzado por un array indirecto, o por una cadena larga cuyos números de objeto corren hacia atrás, podía no recibir rol alguno

¿Por qué un validador de firmas debe preguntar quién posee un objeto?

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

Varias clases de ataque publicadas trabajan justo en ese hueco. Los incremental saving attacks añaden una revisión que cambia el contenido de página y se apoyan en que el verificador compruebe solo el rango de bytes firmado. Los shadow attacks plantan 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 clasifique objetos por etiquetas como /Type /Annot, o por cualquier camino de referencia que resulte alcanzarlos, está expuesto a la tercera clase: al atacante le basta con una estructura permitida que pueda alcanzar la prohibida

Por eso la pregunta no es qué objetos cambiaron sino quién los posee. Un content stream alcanzado desde una página por /Contents es contenido de página apunte lo que apunte hacia él. Una anotación que señala de vuelta a la página por /P dice dónde vive la anotación, no qué posee

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

PDFium Component v3.126.2 trata las retroreferencias 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 a secas. La tabla resume las claves de navegación que ya no llevan propiedad

Diccionario propietarioClaves tratadas como navegaciónReferencia de la especificación
Página o nodo Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Anotación o widget/PISO 32000-1 §12.5.2
Diccionario widget o campo/ParentISO 32000-1 §12.7.3
Grafo de objetos de PDFium Component v3.126.2 separando las aristas de payload poseído como /Contents y /Annots, que propagan roles de página y anotación, de las aristas de navegación como el /P de widget, que no llevan roles, con las claves de navegación por diccionario propietario y prckPageContent mantenido en el content stream incluso bajo un /Type falsificado
v3.126.2 mantiene las retroreferencias 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 globalmente habría creado un agujero 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 página tras un nombre de recurso inocente. En v3.126.2 el filtro de navegación aplica solo cuando el diccionario propietario es de verdad una página, un nodo Pages, una anotación, un widget o un campo. 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 en contexto de página por propiedad real, no por un recorrido de /Parent desde una página hija
  • Un rol de anotación o formulario que llega a un catálogo, nodo Pages, página, anotación o diccionario de campo se detiene allí, porque esos objetos estructurales establecen sus propios roles y un rol de payload entrante no debe imponerse sobre ellos
  • El rol de página es autoritario durante la clasificación: un objeto propiedad de página es prckPageContent aunque una revisión posterior lo reescriba con un /FT falsificado, una etiqueta /Type /Annot, o lo comparta con un appearance stream
  • Un Form XObject usado solo como apariencia de campo o anotación conserva su categoría de formulario o anotación, así que la regeneración de apariencia ordinaria tras un relleno de formulario sigue calificándose bajo las reglas de permiso normales
  • Un widget sin /FT propio resuelve el tipo de campo heredado por la cadena /Parent, y una cadena irresoluble hace fallar la construcción de roles en lugar de caer por defecto en anotación
  • Los bits de rol de toda 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 lugar de una cuenta fija de pasadas

La alcanzabilidad de roles en v3.126.2 corre como una cola de trabajo que itera hasta que ningún objeto gana un bit de rol nuevo, lo cual es un punto fijo genuino sin importar la profundidad de la cadena o la numeración de objetos. Los arrays indirectos, como un array /Contents guardado como objeto propio, también se recorren. Cada objeto puede ganar como mucho cuatro bits de rol distintos, así que la cola está acotada a cuatro entradas por número de objeto; superar ese presupuesto lanza prrResourceLimitExceeded. Una referencia a un objeto libre, un desajuste 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, nunca en un veredicto de permitido, y cuando el fallo ocurre construyendo los roles de la revisión cubierta la firma no reporta Changes alguno

La siguiente rutina lista las ediciones de contenido de página que sobreviven a este análisis. Un cambio prckPageContent nunca se califica prdAllowed: DocMDP P=1, 2 o 3 lo convierte en 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;   // registro, 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 el /V de un campo y el /Contents de una anotación apuntan al mismo objeto indirecto, v3.126.2 mantiene el bloqueo FieldMDP en vigor aunque el cambio se clasifique como edición de anotación. El escenario es fácil de montar a mano: un firmante bloquea el campo Total con FieldMDP (ISO 32000-1 §12.8.2.4), y el atacante hace que el /Contents de una anotación de texto referencie el mismo objeto de cadena que guarda el valor del campo. Bajo P=3 la edición de anotación está permitida, así que antes del arreglo reescribir esa cadena compartida cambiaba un valor de campo bloqueado con un veredicto de permitido

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

  • Bajo P=2 el cambio de anotación se rechaza sin más, exactamente como antes
  • Con FieldMDP All, todo campo está bloqueado, así que el cambio compartido es prdDisallowed
  • Con FieldMDP Include o Exclude, el analizador no puede remontar un escalar compartido hasta un nombre de campo, así que la decisión es prdIndeterminate en lugar de una conjetura
  • 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 el /V de un campo Total bloqueado y el /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 reexamina el bloqueo FieldMDP, así que la misma edición va de permitida a prohibida a indeterminada

Un detalle de reporte importa para el código de compuerta. El caso compartido se reporta como Kind = prckAnnotation con Decision = prdIndeterminate, y prrFieldMdpUnresolved se añade al conjunto de riesgos solo para cambios clasificados como campos de formulario. 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 avisos que registrar y dejar pasar. Indeterminate significa que el analizador no pudo probar que las revisiones posteriores estaban permitidas; para un atacante, una entrada que produce Indeterminate de forma fiable es tan útil como una que produce Allowed si su código la deja pasar. La global AnalyzePadesSignatureRevisions acepta cualquier TStream y lo lee desde la posición 0, lo que le va a los handlers de subida que nunca 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 avisos
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Dos fronteras valen la pena enunciarlas sin rodeos. TPadesRevisionAnalysisReport no dice nada sobre la integridad del CMS ni la confianza del certificado, así que esta compuerta va junto a la validación criptográfica y de confianza, no en su lugar. Y un grafo de propiedad correcto no hace que P=3 sea seguro para todo flujo de trabajo. P=3 de verdad permite anotaciones, y una anotación con una apariencia opaca puede tapar texto firmado sin tocar un solo content stream. Si sus documentos certificados son contratos en lugar de copias de revisión, o certifique con P=2 o derive los cambios de anotación permitidos a una persona, 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;

Lista de comprobación de auditoría de revisiones de firmas

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

  • Las compilaciones 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 ejecutar TPdf.AnalyzeSignatureRevisions sobre los archivos P=3 certificados aceptados por compilaciones antiguas
  • Vuelva a revisar los documentos P=3 con bloqueos FieldMDP donde un valor de campo y una anotación puedan compartir un objeto indirecto
  • Acepte solo prasNoLaterChanges y prasAllowed; trate prasIndeterminate y prasSuspicious como no confiables
  • Compruebe Report.Risks además de Report.Status, porque prrDuplicateObjectDefinition no cambia el estado por sí sola
  • 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 solo aflora por la decisión y el estado
  • Decida si los cambios de anotación permitidos bajo P=3 necesitan 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 una comprobación 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 incrustados. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions y el grafo de roles consciente de la propiedad descrito aquí vienen con el PDFium Component para Delphi, C++Builder y Lazarus