Artículo técnico

Análisis seguro de diccionarios PDF en Delphi: tokens de nombre

Las búsquedas por subcadena como Pos('/Length', Dict) son la herramienta incorrecta para leer un diccionario PDF, debido a que 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 diccionarios que omita esa comprobación única terminará leyendo un valor incorrecto en un archivo perfectamente válido

El error que nos enseñó esto no parecía en absoluto un problema del analizador léxico. Un escaneo de conformidad comenzó a reportar un flujo FontFile como 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 raíz residía en una sola línea de nuestro lector de diccionarios 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 número entero posterior a /Length1 como la longitud del flujo. El escritor de ese archivo en particular serializó /Length1 antes de /Length, lo cual es totalmente válido, dado que las entradas del diccionario no tienen un orden específico según la sección §7.3.7. El flujo se truncó a un recuento de bytes incorrecto y cada comprobación posterior que lo consumía falló silenciosamente

¿Por qué la coincidencia por subcadena corrompe el análisis de diccionarios PDF?

La coincidencia por subcadena 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 incrustadas, todos ubicados junto a /Length en el mismo diccionario. El diccionario de cifrado empareja /Encrypt en el trailer con /EncryptMetadata en su interior. Las claves cortas son peores: una búsqueda estructurada como Pos('/' + Key, ...) con Key = 'N' coincide sin problemas con /Name o /Nums. Ninguna de estas colisiones requiere un archivo malformado. Un escritor que ordene /Length1 antes de /Length es totalmente conforme, lo que significa que el error de subcadena no es una falta de robustez frente a entradas corruptas: es una falta de corrección frente a entradas válidas

El modo de fallo también es del tipo silencioso. Un valor /Length incorrecto no genera una excepción; le proporciona una sección de bytes más corta o más larga de la que ocupa realmente el flujo. Si esa sección alimenta una comprobación de subconjunto de fuentes, un análisis CMap o un escaneo de metadatos, el consumidor recibe datos erróneos y típicamente no reporta nada, porque la mitad de un flujo zlib simplemente no logra inflarse 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, tras una auditoría cláusula por cláusula de la norma ISO 32000-1 §7.2–§7.3 que marcó como sospechosa cada búsqueda de clave al estilo Pos

Qué define realmente la sección §7.3.5 de la norma ISO 32000-1 para un nombre

La sección 7.3.5 es corta y precisa: un objeto de nombre es una barra inclinada (solidus) seguida de una secuencia de caracteres regulares, y el token finaliza con el primer delimitador o carácter de espacio en blanco. Los delimitadores son los Include caracteres de corchete más la barra inclinada y el signo de porcentaje (( ) < > [ ] { } / %) y el espacio en blanco incluye 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 clave. /Length1 no es "/Length seguido de un 1"; es un token único e indivisible, exactamente de la misma manera que LengthOne and Length son identificadores diferentes en Pascal. Cualquier lector que localice claves mediante búsqueda de bytes brutos está reimplementando el analizador léxico sin la regla de terminación

Este es el aspecto del defecto, reducido a lo esencial. Esta versión compila, supera las pruebas frente a archivos cuyos escritores ordenan /Length primero y corrompe los flujos para los escritores que no lo hacen

// INCORRECTO: también coincide con /Length1, /Length2, /Length3
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 la sección §7.3.5: una coincidencia candidata es una clave real únicamente si el carácter inmediatamente posterior a ella es un delimitador, un espacio en blanco o el final del búfer. Cualquier otra cosa representa un nombre más largo que simplemente comparte un prefijo, por lo que la búsqueda debe continuar en lugar de detenerse. La solución en nuestro lector sustituyó cada búsqueda básica Pos por una única rutina compartida basada en esta regla

// Correcto: coincidencia de token completo según la norma 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);                       // el token termina aquí: clave auténtica
    P := PosEx(Key, Dict, P + 1);    // prefijo de un nombre más largo: continuar buscando
  end;
end;

Dos detalles en ese bucle resultan importantes. En primer lugar, continúa buscando en lugar de devolver un fallo en la primera colisión de prefijo, debido a que /Length1 120 /Length 4076 representa un orden legal y la clave real aún se encuentra más adelante. En segundo lugar, el caso del final del búfer cuenta como un terminador, ya que un fragmento de diccionario puede terminar legítimamente justo después de un nombre. Un punto más sutil que conviene 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 inclinada, ya que de lo contrario Pos('Length', ...) puede acabar dentro de /PieceLength. Anclar la cadena de búsqueda con la / inicial, como se muestra arriba, gestiona el extremo izquierdo porque la / es en sí misma 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 eleva estos errores léxicos a un problema de agotamiento de recursos, porque los números enteros de los diccionarios alimentan frecuentemente los tamaños de asignación de memoria. Nuestra auditoría detectó una cadena con este formato exacto 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 entrada. Sin embargo, el analizador de enteros dejaba su parámetro de salida intacto en caso de fallo mientras seguía devolviéndolo; de este modo, un valor /N no numérico proporcionaba a SetLength un valor de pila no inicializado. Un entero positivo erróneo en esa posición significa una solicitud de asignación de gigabytes, provocada por unos pocos bytes de entrada corrupta, durante el simple escaneo de un documento en el que ni siquiera ha aceptado confiar todavía

La reparación tuvo dos partes independientes, y ambas son generalizables. 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 realizar operaciones aritméticas: la región del encabezado 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 por encima de FirstVal div 4 + 1 resulta por tanto físicamente imposible para el tamaño de encabezado declarado y se rechaza antes de que ocurra cualquier asignación. El límite cuesta una comparación y se deriva de datos de los que ya se dispone, que es el patrón a buscar: un límite superior que demuestra el propio archivo, no una constante arbitraria

// /N está controlado por el atacante; limitarlo por lo que /First pueda contener
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // cero explícito, nunca datos basura de la pila
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // el encabezado no puede contener tantos pares

// /Length nunca puede superar el archivo que contiene el flujo
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // rechazar antes de asignar el búfer

Dos límites superiores más completan el perímetro defensivo en nuestro lector, ambos incluidos en la versión v2.12.0. El lector de flujos rechaza cualquier /Length mayor que todo el archivo antes de asignar el búfer de resultados (un flujo no puede ser mayor que el contenedor en el que reside, por lo que la comprobación está libre de falsos positivos). Y la ruta de inflado limita la salida descomprimida 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, al tiempo que mantiene la supervivencia en el peor de los casos. El tema central en los tres casos es idéntico: 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 asignarle memoria. Esta misma postura de auditoría se aplica un nivel por debajo en el límite de vinculación (binding), cubierto en la guía sobre el robustecimiento de la ABI de PDFium y la seguridad de la memoria en Delphi

Dónde no basta con la regla del token completo

Límites honestos, para que no confíe en exceso en la rutina anterior. La coincidencia de token completo soluciona la identificación de la clave, pero una búsqueda de bytes planos sobre un tramo de diccionario sigue sin poder discernir si una coincidencia se encuentra 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 proporciona un tramo demasiado amplio. Nuestro lector restringe primero el tramo al cuerpo de un solo objeto y trata los contextos de cadenas y comentarios como un elemento de auditoría independiente y aún abierto. La seguridad de la subcadena es un peldaño de la escalera, no la escalera completa: la consistencia de las referencias cruzadas es su propia disciplina, cubierta en validación de flujos de objetos y referencias cruzadas (xref), y el catálogo de amenazas más amplio para documentos que no ha escrito usted mismo se encuentra en auditoría de riesgos de seguridad en PDF

Si mantiene un lector de diccionarios escrito a mano en Delphi o Lazarus, la lista de verificación de este incidente es corta. Busque con grep cada Pos('/ en la base de código y canalice las coincidencias a través de una única función de ayuda para token completo. Enumere las familias de prefijos en las que participan sus claves (/Length, /Encrypt, /N y /Type frente a /Type1 aparecen en archivos reales). Luego, recorra cada entero que llegue a SetLength, GetMem o un bucle de copia y pregúntese qué lo limita: el tamaño del archivo, un límite superior derivado del encabezado o nada. La capa de análisis descrita aquí es la base de nuestro Componente PDFium, donde el lector a nivel de bytes y el motor de renderizado se verifican mutuamente en cada documento que tocan