Artículo técnico

Parser PDF en Pascal robusto frente a archivos maliciosos

Un PDF no es un documento que simplemente se abre. Es un pequeño programa que se ejecuta. Cada fuente incrustada es un intérprete basado en pila que espera cadenas de caracteres (charstrings), cada imagen es un decodificador alimentado con campos de ancho, altura y profundidad de bits elegidos por el archivo, y cada flujo llega envuelto en filtros cuyos parámetros estableció el archivo. Ninguno de esos números es suyo. Provienen de quienquiera que haya producido el archivo, que en una carga de trabajo real es la factura de un cliente o un archivo adjunto de un remitente desconocido. Los decodificadores que convierten esos bytes en píxeles y glifos son la superficie de ataque, y un analizador que confía en su entrada allí está a un archivo malformado de distancia de una caída o algo peor

PDF Library for Delphi trata toda la ruta de decodificación como hostil, a través de los programas de fuentes (TrueType, Type1, CFF y las tablas CMap), los decodificadores de imágenes (PNG, GIF, TIFF, JBIG2 y CCITT Grupo 3 y Grupo 4) y los filtros de flujo (LZW, ASCII85 y los predictores Flate). Lo que sigue son cinco trampas de seguridad de memoria que esa ruta tiene que cerrar, cada una basada en el comportamiento específico de Delphi que la hace posible, y las mismas formas se repiten en cualquier código de Pascal que analice entradas no confiables

PDF Library for Delphi: Mapa de superficie de ataque de un parser PDF en Pascal que alimenta intérpretes de fuentes, decodificadores de imagen y filtros de stream hacia cinco clases de trampas protegidas
Anchuras, profundidades, offsets, longitudes e indicadores de filtro provienen todos del productor, lo que convierte los decodificadores de fuentes, imágenes y filtros en la superficie de ataque que un parser tiene que tratar como hostil

Un desbordamiento de enteros que le entrega un búfer de tamaño insuficiente

El error clásico de seguridad de memoria en un decodificador de imágenes es el producto de dimensiones que se desborda. Un decodificador lee el ancho, la altura, el recuento de componentes y la profundidad de bits, los multiplica para dimensionar su salida, asigna esa cantidad de bytes y luego escribe la imagen en sus dimensiones reales. Si la multiplicación se realiza en aritmética de 32 bits, el producto puede desbordarse a un valor pequeño incluso cuando cada factor individual está dentro de un rango sensato, por lo que la asignación tiene éxito, resulta demasiado pequeña y la decodificación se sale de sus límites. Esto es CWE-190, desbordamiento de enteros, que conduce a una escritura fuera de límites en el montículo (CWE-787) un paso después

Limitar cada dimensión a 65535 en una ruta de imagen compartida no basta cuando un decodificador independiente calcula sus propios tamaños. Una expresión de bytes de fila por altura como ByteCount * FHeight, o una expresión por píxel como FWidth * Components * BitDepth, es un producto de 32 bits en Delphi cuando ambos operandos son enteros de 32 bits, independientemente de lo ancha que sea la variable a la que asignas el resultado. Un ancho y una altura de 60000 son cada uno plausibles para un escaneo grande, pero su producto en bytes rebasa el rango con signo de 32 bits y la longitud sale pequeña. La misma trampa está en un stride de predictor como BitsPerComponent * Colors * Columns

La defensa consiste en hacer que al menos un operando sea Int64 para que toda la expresión se evalúe en 64 bits, y luego comparar contra MaxInt y rechazar el archivo antes de volver a reducir el tamaño para llamar a SetLength

PDF Library for Delphi: Comparación lado a lado de un producto de dimensiones de 32 bits con desbordamiento que provoca una asignación insuficiente y una escritura fuera de límites frente a una comprobación de ampliación Int64 que rechaza el fichero
Evaluar el producto del recuento de bytes en Int64 permite al parser comparar contra MaxInt y rechazar dimensiones hostiles antes de que SetLength llegue a tallar un búfer mentiroso
// Rechaza antes de asignar memoria, no después de escribir.
// Evalúa el producto en Int64 para que no pueda desbordarse en 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // dimensiones hostiles o no admitidas; rechaza la imagen
SetLength(Buffer, RowBytes * FHeight);

Lo que hace que esto sea un problema de Delphi en lugar de uno genérico es la reducción silenciosa de tipo. Asignar una expresión demasiado ancha a un destino de 32 bits es una conversión legal sobre la cual el compilador no advertirá por defecto, y la comprobación de rango no detecta un desbordamiento que ocurre antes de que el valor se use como índice. Deja el producto en 32 bits y el lenguaje te dará silenciosamente una longitud que miente sobre cuánta memoria está a punto de tocar la decodificación

Un tipo de campo que hace imposible activar una protección

Un archivo TIFF es una cadena de directorios de archivos de imagen, cada uno de los cuales lleva el desplazamiento de bytes del siguiente. Un archivo malicioso puede apuntar esa cadena hacia sí mismo, y un lector que la recorra sin una condición de parada se ejecutará indefinidamente. Eso es CWE-835, un bucle infinito impulsado por una entrada controlada por el atacante, y la defensa es un contador que se detiene una vez que supera un límite que ningún archivo legítimo alcanzaría

Declara ahora el contador de páginas como Word, que en Delphi contiene valores de 0 a 65535, y dale al bucle una protección de terminación de la forma "detener cuando el recuento de páginas supere 65535". Se lee como correcto hasta que te fijas en que el operando y el umbral comparten un límite superior. Un Word nunca puede ser mayor que 65535, así que la comparación es estructuralmente siempre falsa: cuando el contador llega a 65535, el siguiente incremento lo devuelve a 0, la protección nunca ve un valor por encima del techo, y una cadena de IFD en bucle mantiene al lector girando

El remedio es ampliar el campo para que la protección pueda expresar un valor que el contador realmente pueda contener. TPDFTIFF.FPageCount está declarado como Integer, así que la comparación FPageCount > 65535 es alcanzable y el bucle termina. Siempre que una comprobación de límite tenga la forma Value > MaxValueOfType(Value) y el operando ya esté tipado exactamente en ese máximo, la condición es una constante falsa: amplía el tipo, o comprueba la igualdad contra el máximo para que pueda activarse

Comprobación de rango desactivada en una ruta caliente

Con la comprobación de rango activada, Delphi inserta una comprobación de límites en cada índice de matriz y de cadena, lo que marca la diferencia entre que un índice fuera de rango genere un error capturable ERangeError o que ese mismo índice lea o escriba en memoria que no pertenece a la estructura. Las rutas calientes a veces la desactivan con una directiva local {$R-}, lo cual es defendible justo hasta que los índices dejan de ser confiables

El descriptor de acceso de lista en el que se apoyan los intérpretes de fuentes, TPDFlibStringList.Get, es exactamente esa ruta. En Windows, se compila con la comprobación de rango desactivada e indexa su almacenamiento de respaldo directamente, por lo que un índice fuera de rango sería un acceso a memoria en bruto en lugar de un error. Eso está bien cuando el índice siempre es válido, y exige cuidado dentro de un intérprete de charstrings CFF o Type2, donde el índice puede provenir del archivo. Una charstring que extrae un operando de una pila vacía produce un índice de menos uno; un identificador de glifo desviado por uno con respecto al recuento de glifos indexa una ranura más allá del final. Sin control, ambos serían un acceso real fuera de límites en lugar de una excepción capturable, y debido a que las ranuras contienen valores AnsiString con recuento de referencias, una lectura desviada también puede corromper el recuento de referencias de una cadena

PDF Library for Delphi mantiene la comprobación de rango desactivada en esa ruta caliente y hace que los índices sean demostrablemente válidos en su lugar: antes de tomar la parte superior de la pila de operandos, el intérprete comprueba que la pila no esté vacía, y cada protección de índice es un menor estricto contra el recuento en lugar de un menor o igual que admite el desvío por uno. La directiva traslada la responsabilidad de los límites del compilador a ti, y la validación que eliminó tiene que volver a colocarse a mano en cada punto de entrada

Recursividad ilimitada en un intérprete de cadenas de caracteres

Una cadena de caracteres Type2 puede llamar a una subrutina, y una subrutina es en sí misma una cadena de caracteres que puede llamar a otra, por lo que los operadores de llamada a subrutinas locales y globales permiten al archivo decidir qué tan profundo llega. Una subrutina que se llama a sí misma, directamente o a través de un ciclo, realiza una recursividad sin fin hasta que la pila nativa se agota y el proceso muere. Eso es CWE-674, recursividad descontrolada

El intérprete Type1 se protege contra esto con un contador de profundidad de llamada y un techo, PLType1MaxCallDepth, y se niega a descender más allá, lo que refleja el límite de profundidad que la propia especificación de Type1 menciona. La trampa es que un intérprete estructuralmente similar escrito más tarde, como el Type2, no hereda esa protección por sí solo, y una fuente construida a mano con una subrutina que se llama a su propio número pasa directamente a través de la comprobación ausente hasta un desbordamiento de pila

// La forma del guard de profundidad que ambos intérpretes de charstrings necesitan.
// Rastrea la profundidad a través de llamadas anidadas y rehúsa recursar más allá de ella.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // subrutina autorreferencial hostil; deja de descender
// ... interpreta la subrutina, luego Dec(CallDepth) al salir

PDF Library for Delphi da a la ruta Type2 la misma profundidad acotada que su hermano Type1. Cualquier descenso recursivo sobre una estructura controlada por el atacante, ya sean subrutinas de fuente, un array anidado o una cadena de cross-reference, necesita un techo de profundidad que la entrada no pueda levantar

Memoria no inicializada que se filtra en la salida

La trampa más sutil filtra contenido del montículo en la salida descifrada, y la causa es una propiedad de SetLength que es fácil de olvidar. Cuando haces crecer una cadena AnsiString con SetLength, Delphi asigna los bytes pero no los pone a cero, así que la región nueva contiene lo que hubiera previamente en esa memoria del montículo. Si después se escribe cada byte, esto nunca importa; si una ruta deja parte del búfer sin escribir y luego lo devuelve como datos, esos bytes obsoletos salen con el resultado. Eso es CWE-457, uso de memoria no inicializada, y cuando el resultado cruza un límite de confianza se convierte en una filtración de información

Una ruta de descifrado AES-CBC es justo donde esto muerde. Dimensiona el búfer de salida con SetLength, procesa el texto cifrado de un bloque de 16 bytes cada vez, y cuando la longitud del texto cifrado no es múltiplo de 16 — una longitud que puede elegir un atacante —, el bloque parcial final nunca se escribe, así que esos bytes finales conservan el contenido del montículo que SetLength dejó ahí y el búfer vuelve como el texto plano descifrado de un objeto de documento. El remedio son dos protecciones, y ninguna de ellas por sí sola basta: el punto de entrada de descifrado de PDF Library for Delphi rechaza cualquier texto cifrado cuya longitud no sea múltiplo del tamaño de bloque, y como respaldo la salida se limpia con FillChar antes de usarla, de modo que cualquier ruta que no llegue a escribir una región devuelva ceros en lugar de residuos del montículo

Diagrama de PDF Library for Delphi del descifrado AES-CBC donde un bloque final sin terminar deja bytes del heap sin inicializar, salvo que las comprobaciones de longitud de entrada y la limpieza del búfer con FillChar protejan la ruta
Dos guardas independientes cierran la fuga: rechazar el texto cifrado cuya longitud no alcanza el límite de 16 bytes, y prelimpiar la salida para que incluso una escritura omitida produzca ceros en lugar de residuos del heap

Lo que las cinco trampas tienen en común

Las cinco trampas son errores diferentes, pero riman. Un ancho de entero que desborda un producto, un tipo de campo que fija una protección a una constante falsa, una comprobación de rango desactivada donde los índices dejaron de ser seguros, una recursividad sin fondo y un búfer que el lenguaje declinó poner a cero. En cada uno de ellos, Delphi hace exactamente lo que define, porque el lenguaje le brinda aritmética que se desborda, reducción de tipo silenciosa, comprobaciones de rango que puedes desactivar, recursividad sin límite incorporado y asignación que no inicializa. Ese es el contrato, y un analizador de Pascal lo cumple asumiendo cuatro tareas a mano en cada límite que el archivo controla: ancho de entero, comprobación de rango, profundidad de recursividad e inicialización del búfer

PDF Library for Delphi, el motor para Delphi y C++Builder, protege su ruta de decodificación contra las cinco. Si tu trabajo también alcanza a cómo un archivo afirma estar protegido, las notas complementarias sobre auditoría de cifrado y permisos y sobre preflight de PDF/A y PDF/UA cubren el lado del análisis del mismo parser, y todo ello se distribuye dentro de la PDF Library for Delphi Delphi PDF Library junto con las API de carga, renderizado y firma cubiertas en otros artículos de este blog