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í:
- El widget de firma es un
/Subtype /Widgetcon/FT /Sig, así que recibe el rol de anotación - La
/Pdel widget empuja el rol de anotación sobre el diccionario de página, que ya tiene el rol de página - La página empuja ambos roles a
/Contents,/Resourcesy, por medio de/Parent, hacia arriba en el árbol Pages y a lo ancho hasta cada página hermana - 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
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ño | Claves tratadas como navegación | Referencia de la especificación |
|---|---|---|
| Nodo Page o Pages | /Parent, /Kids, /Annots | ISO 32000-1 §7.7.3 |
| Anotación o widget | /P | ISO 32000-1 §12.5.2 |
| Diccionario widget o field | /Parent | ISO 32000-1 §12.7.3 |
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
/Parentdesde 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
prckPageContentincluso si una revisión posterior lo reescribe con una/FTfalsificada, 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
/FTpropia 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 esprdDisallowed - Con FieldMDP
IncludeoExclude, el analizador no puede rastrear un escalar compartido hasta un nombre de field, así que la decisión esprdIndeterminatey no una adivinanza - Sin FieldMDP, aplica la regla de anotación de P=3 y el cambio sigue permitido
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
prasAllowedpara ediciones de contenido de página en documentos DocMDP P=3; vuelva a correrTPdf.AnalyzeSignatureRevisionssobre 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
prasNoLaterChangesyprasAllowed; trateprasIndeterminateyprasSuspiciouscomo no confiables - Pruebe
Report.Risksademás deReport.Status, porqueprrDuplicateObjectDefinitionno cambia el estado por sí solo - No lea un array
Changesvací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
prrFieldMdpUnresolvedpara 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
SaveAsya 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