Artículo técnico

Analizar diccionarios PDF de forma segura en Delphi: tokens de nombre

Las búsquedas de subcadenas como Pos('/Length', Dict) son la herramienta incorrecta para leer un diccionario PDF, porque las claves de nombre de PDF comparten prefijos: /Length es un prefijo de /Length1, y /Encrypt es un prefijo de /EncryptMetadata. La norma ISO 32000-1 §7.3.5 define un nombre como un token que finaliza únicamente en un delimitador o espacio en blanco, por lo que una clave se considera encontrada solo cuando el byte posterior a ella es uno de esos caracteres. Un lector de diccionario que omita esa única comprobación terminará leyendo el valor incorrecto de un archivo perfectamente válido

El error que nos enseñó esto no parecía en absoluto un problema del analizador léxico (lexer). Un análisis de conformidad comenzó a reportar que un flujo FontFile estaba dañado: el programa de fuentes descomprimido era un fragmento recortado a mitad de la tabla. El archivo se abría bien en cualquier visor. Los datos del flujo en el disco estaban intactos. La causa principal residía en una sola línea de nuestro lector de diccionario compartido: Pos('/Length', ...) había coincidido con /Length1, una clave estándar que contienen los diccionarios de flujo FontFile según la Tabla 127 de la norma ISO 32000-1, y el lector tomó el entero después de /Length1 como la longitud del flujo. El escritor de ese archivo en particular serializó /Length1 antes de /Length, lo cual es libre de realizar, dado que las entradas del diccionario no tienen orden según §7.3.7. El flujo se truncó a un recuento de bytes inválido, y cada comprobación posterior que lo consumía falló silenciosamente

¿Por qué la coincidencia de subcadenas interrumpe el análisis de diccionarios PDF?

La coincidencia de subcadenas falla porque el espacio de nombres de PDF está lleno de familias de prefijos deliberadas, y porque el orden de las entradas del diccionario no está especificado. La Tabla 127 de la norma ISO 32000-1 define /Length1, /Length2 y /Length3 para flujos de fuentes incrustados, todos ubicados junto a /Length en el mismo diccionario. El diccionario de cifrado asocia /Encrypt en el trailer con /EncryptMetadata en su interior. Las claves cortas son peores: una búsqueda construida como Pos('/' + Key, ...) con Key = 'N' coincide fácilmente con /Name o /Nums. Ninguna de estas colisiones requiere un archivo malformado. Un generador que ordene /Length1 antes de /Length cumple plenamente con la norma, lo que significa que el error de subcadena no es una falta de robustez frente a entradas dañadas; es una falta de corrección frente a entradas válidas

El modo de fallo es además del tipo silencioso. Un valor /Length incorrecto no produce una excepción; le entrega una fracción de bytes más corta o más larga de la que ocupa realmente el flujo. Si esa fracción alimenta una comprobación de subconjunto de fuentes, un análisis de CMap o un escaneo de metadatos, el consumidor observa datos corruptos y generalmente no reporta nada, porque la mitad de un flujo zlib simplemente no logra descomprimirse y el código continúa. Distribuimos exactamente esta clase de defecto y lo solucionamos en la versión v2.14.3 de nuestro lector compartido, luego de que una auditoría cláusula por cláusula de la norma ISO 32000-1 §7.2–§7.3 señalara como sospechosa cada búsqueda de clave estilo Pos

Lo que la norma ISO 32000-1 §7.3.5 define realmente como un nombre

La sección 7.3.5 es corta y precisa: un objeto de nombre es una barra oblicua (solidus) seguida de una secuencia de caracteres regulares, y el token finaliza con el primer carácter delimitador o de espacio en blanco. Los delimitadores son los ocho caracteres de corchete más la barra oblicua y el signo de porcentaje (( ) < > [ ] { } / %) y el espacio en blanco es nulo, tabulador, salto de línea, avance de página, retorno de carro y espacio (§7.2.2–§7.2.3). Esa regla de terminación es toda la historia. /Length1 no es "/Length seguido de un 1"; es un único token indivisible, exactamente de la misma manera que LengthOne y Length son identificadores distintos en Pascal. Cualquier lector que localice claves mediante una búsqueda binaria directa está reimplementando el analizador léxico con la regla de terminación eliminada

Esta es la forma del defecto, reducido a lo esencial. Esta versión se compila, supera las pruebas con archivos cuyos generadores ordenan /Length al principio, y corrompe los flujos para los que no lo hacen

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Coincidencia de token completo: comprobar el byte posterior a la clave

El predicado correcto se deriva directamente de §7.3.5: una coincidencia candidata es una clave real solo si el carácter inmediatamente posterior es un delimitador, espacio en blanco o el final del búfer. Cualquier otra cosa representa un nombre más largo que meramente comparte un prefijo, por lo que la búsqueda debe continuar más allá en lugar de rendirse. La solución en nuestro lector reemplazó cada búsqueda Pos directa con una única rutina compartida basada en esta regla

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

Dos detalles en ese bucle son importantes. Primero, sigue buscando en lugar de reportar un fallo en la primera colisión de prefijos, porque /Length1 120 /Length 4076 es un orden válido y la clave real sigue adelante. Segundo, el caso de fin de búfer cuenta como un terminador, ya que un fragmento de diccionario puede terminar legítimamente justo después de un nombre. Un detalle más sutil que vale la pena auditar en su propio código: la misma regla se aplica en el lado izquierdo de la coincidencia si su cadena de búsqueda no incluye la barra oblicua, de lo contrario Pos('Length', ...) puede coincidir dentro de /PieceLength. Anclar la cadena de búsqueda con el / inicial, como se muestra arriba, maneja el borde izquierdo porque / es en sí mismo un delimitador que finaliza el token anterior

¿Cómo puede un PDF hostil convertir un error del analizador en una asignación de gigabytes?

Un archivo malformado o malicioso escala estos errores léxicos hacia el agotamiento de recursos, porque los enteros del diccionario frecuentemente alimentan los tamaños de asignación de memoria. Nuestra auditoría detectó una cadena con esta forma exacta en la expansión de flujos de objetos (object-stream). La entrada /N de un diccionario ObjStm indica cuántos objetos comprimidos contiene el flujo, y el código de expansión llamaba a SetLength en una matriz dimensionada por esta. Sin embargo, el analizador de enteros dejaba su parámetro de salida (out-parameter) intacto en caso de fallo mientras lo devolvía, por lo que un valor /N no numérico entregaba a SetLength un valor de pila no inicializado. Un entero positivo inválido allí representa una solicitud de asignación de memoria en gigabytes, provocada por unos pocos bytes de entrada corrupta, al realizar el simple escaneo de un documento que ni siquiera ha aceptado como confiable aún

La reparación constó de dos partes independientes, y ambas se generalizan. El analizador ahora devuelve un 0 explícito en caso de fallo, nunca memoria no inicializada. Y el consumidor ya no confía en el valor /N sin aplicar aritmética: la región de cabecera de ObjStm antes de /First almacena un par de enteros (número de objeto y desplazamiento) para cada objeto comprimido, y cada par ocupa al menos cuatro bytes incluyendo los separadores. Cualquier valor /N superior a FirstVal div 4 + 1 es por lo tanto físicamente imposible para el tamaño de cabecera declarado y se rechaza antes de que ocurra cualquier asignación. El límite requiere una sola comparación y se deriva de datos que ya se tienen, que es el patrón a buscar: un límite que el propio archivo demuestra, no una constante arbitraria

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Dos límites adicionales completan el perímetro defensivo en nuestro lector, ambos incluidos en la versión v2.12.0. El lector de flujo rechaza cualquier /Length mayor que el archivo completo antes de asignar memoria para el búfer de resultado (un flujo no puede ser más grande que el contenedor donde reside, por lo que la comprobación está libre de falsos positivos). Y la ruta de descompresión (inflate) limita el resultado descomprimido a 256 MiB, lo que detiene la clásica "bomba zlib" donde unos pocos kilobytes de entrada se expanden sin límite; el límite es generoso para cualquier flujo PDF real manteniendo el peor caso manejable. El tema común en los tres casos es el mismo: cada tamaño que declara un archivo es una afirmación, y el analizador verifica cada afirmación frente a algo que puede medir antes de comprometer memoria para ello. La misma postura de auditoría se aplica una capa más abajo en el límite de los enlaces (bindings), analizada en el endurecimiento de la ABI de PDFium y la seguridad de memoria en Delphi

Donde la regla del token completo no es suficiente

Límites sinceros, para no confiar excesivamente en la rutina anterior. La coincidencia de token completo soluciona la identificación de claves, pero una búsqueda binaria plana sobre un tramo de diccionario no puede determinar si una coincidencia reside dentro de un diccionario anidado, una cadena literal o un comentario: FindDictKey en un objeto de página puede coincidir con una clave dentro de su subdiccionario /Resources si se le entrega un tramo demasiado amplio. Nuestro lector restringe primero el tramo al cuerpo de un solo objeto y trata los contextos de cadena y comentario como un elemento de auditoría independiente aún abierto. La seguridad ante subcadenas es un peldaño de una escalera, no la escalera completa: la consistencia de las referencias cruzadas representa su propia disciplina, analizada en la validación de flujos de objetos y xref, y el catálogo de amenazas más amplio para documentos que no son de su autoría se encuentra en la auditoría de riesgos de seguridad en PDF

Si mantiene un lector de diccionarios escrito a mano en Delphi o Lazarus, la lista de tareas de este incidente es corta. Busque con grep cada Pos('/ en la base de código y envíe los resultados a través de un único asistente de token completo. Enumere las familias de prefijos en las que participan sus claves: /Length, /Encrypt, /N, /Type frente a /Type1 aparecen en archivos reales. Luego recorra cada entero que llegue a SetLength, GetMem or un bucle de copia y pregúntese qué lo delimita: el tamaño del archivo, un límite derivado de la cabecera o nada. La capa de análisis descrita aquí representa la base bajo nuestro PDFium Component, donde el lector a nivel de bytes y el motor de renderizado se cruzan entre sí en cada documento que procesan