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, alto y profundidad de bits que el archivo eligió, y cada flujo llega envuelto en filtros cuyos parámetros estableció el archivo. Ninguno de esos números es suyo. Provienen de quien produjo el archivo, que en un flujo 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 en ese punto está a un archivo malformado de distancia de un bloqueo o algo peor.
PDFlibPas pasó por una fase de protección que trató a 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). A continuación, se detallan cinco clases de defectos que cerró, cada uno basado en el comportamiento específico de Delphi que lo hizo posible. Están corregidos en las versiones actuales y las mismas estructuras se repiten en cualquier código Pascal que analice entradas no confiables.
Un desbordamiento de enteros que le entrega un búfer de tamaño insuficiente
El clásico error de seguridad de memoria en un decodificador de imágenes es el producto de las dimensiones que se desborda. Un decodificador lee el ancho, el alto, 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 operaciones aritméticas de 32 bits, el producto puede desbordarse y convertirse en un valor pequeño incluso cuando cada factor individual esté dentro de un rango razonable, por lo que la asignación tiene éxito pero resulta demasiado pequeña, y la decodificación escribe fuera de sus límites. Esto es un desbordamiento de enteros (CWE-190) que conduce a una escritura fuera de los límites del montón (heap out-of-bounds write, CWE-787) un paso más adelante.
La ruta de imagen compartida ya limitaba cada dimensión a 65535; los decodificadores independientes no heredaron todos ese límite. 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 qué tan amplia sea la variable a la que asigne el resultado. Un ancho y un alto de 60000 son plausibles para un escaneo grande, pero su producto en bytes supera el rango firmado de 32 bits y la longitud resulta pequeña. La misma trampa existía en el paso del predictor ZLib, BitsPerComponent * Colors * Columns.
La solución consiste en hacer que al menos un operando sea Int64 para que toda la expresión se evalúe en 64 bits, luego compararlo con MaxInt y rechazar el archivo antes de reducir el tipo para llamar a SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
Lo que hace de esto un problema de Delphi en lugar de uno genérico es la reducción silenciosa de tipos. Asignar una expresión demasiado amplia en un destino de 32 bits es una conversión legal sobre la cual el compilador no advertirá de forma predeterminada, y la comprobación de rango no captura un desbordamiento que ocurre antes de que el valor se use como índice. Deje el producto en 32 bits y el lenguaje le entregará silenciosamente una longitud incorrecta sobre cuánta memoria está a punto de tocar la decodificación.
Un tipo de campo que hace imposible que una protección se active
Un archivo TIFF es una cadena de directorios de archivos de imagen, cada uno de los cuales transporta el desplazamiento de bytes del siguiente. Un archivo malicioso puede apuntar esa cadena hacia sí misma, y un lector que la recorra sin una condición de parada funcionará indefinidamente. Eso representa un bucle infinito impulsado por entradas controladas por el atacante (CWE-835), y la defensa es un contador que se detiene una vez que supera un límite que ningún archivo legítimo alcanzaría.
El contador de páginas se declaró como Word, que en Delphi admite valores de 0 a 65535. El bucle contenía una protección de finalización del tipo "detenerse cuando el recuento de páginas supere 65535", la cual parece correcta hasta que nota que el operando y el umbral comparten un límite superior. Un Word nunca puede ser mayor que 65535, por lo que la comparación estructuralmente siempre es falsa: cuando el contador llega a 65535, el siguiente incremento lo devuelve a 0, la protección nunca detecta un valor por encima del tope y una cadena de directorios IFD en bucle mantiene al lector girando sin fin.
La solución fue ampliar el campo para que la protección pueda expresar un valor que el contador realmente pueda contener. Con TPDFTIFF.FPageCount declarado como Integer, la misma comparación FPageCount > 65535 se vuelve alcanzable, el bucle termina y la propiedad pública PageCount cambió de tipo para coincidir sin alterar a ningún llamador. Cada vez que una comprobación de límites tiene la forma Value > MaxValueOfType(Value) y el operando ya tiene asignado precisamente ese máximo como tipo, la condición es una constante falsa: amplíe el tipo o pruebe la igualdad contra el máximo para que pueda activarse.
Comprobación de rango desactivada en una ruta crítica
Con la comprobación de rango activada, Delphi inserta una verificación de límites en cada índice de arreglo y de cadena, que es la diferencia entre que un índice fuera de rango genere un ERangeError capturable y que ese mismo índice lea o escriba en memoria que no pertenece a la estructura. Las rutas críticas (hot paths) a veces la desactivan con una directiva local {$R-}, lo cual es defendible hasta el momento en que los índices dejan de ser confiables.
El acceso de lista del que dependen los intérpretes de fuentes, TPDFlibStringList.Get, es exactamente esa clase de 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 no es un error sino un acceso a memoria directo. Eso es aceptable cuando el índice siempre es válido, y deja de serlo dentro de un intérprete de charstrings de 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 contra el recuento de glifos indexa un espacio más allá del final. Con la comprobación de rango desactivada, ambos se convierten en un acceso real fuera de los límites en lugar de una excepción capturable y, debido a que los espacios contienen valores AnsiString con recuento de referencias, una lectura errática también puede corromper el recuento de referencias de una cadena.
La protección no volvió a activar la comprobación de rango para la ruta crítica. Primero hizo que los índices fueran demostrablemente válidos: 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 se escribió como un menor estricto contra el recuento en lugar de un menor o igual que admita el desvío por uno. La directiva traslada la responsabilidad de los límites del compilador hacia usted, y la validación que eliminó debe volver a colocarse a mano en cada punto de entrada.
Recursión ilimitada en un intérprete de charstring
Una charstring de Type2 puede llamar a una subrutina, y una subrutina es en sí misma una charstring que puede llamar a otra, por lo que los operadores de llamada de subrutina locales y globales permiten que el archivo decida qué tan profundo llegar. Una subrutina que se llama a sí misma, directamente o a través de un ciclo, se repite sin fin hasta que la pila nativa se agota y el proceso muere. Eso representa una recursión no controlada (CWE-674).
El intérprete de Type1 ya se protegía contra esto. Contaba con un contador de profundidad de llamadas y un tope, PLType1MaxCallDepth, y rechazaba descender más allá de este, lo que refleja el límite de profundidad que la propia especificación Type1 indica. El intérprete de Type2, agregado más tarde y estructuralmente similar, no contaba con la misma protección, y una fuente construida a mano con una subrutina que llama a su propio número pasa directamente a través de la comprobación ausente hacia un desbordamiento de pila.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
Memoria no inicializada que se filtra a la salida
El defecto más sutil filtraba el contenido del montón (heap) en la salida descifrada, y la causa es una propiedad de SetLength que es fácil de olvidar. Cuando incrementa el tamaño de un AnsiString con SetLength, Delphi asigna los bytes pero no los pone a cero, por lo que la nueva región contiene lo que estuviera anteriormente en esa memoria del montón. Si posteriormente se escriben todos los bytes, 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 el uso de memoria no inicializada (CWE-457), y cuando el resultado cruza un límite de confianza, se convierte en una filtración de información.
La ruta de descifrado AES-CBC experimentó exactamente esto. El búfer de salida se dimensionó con SetLength y el descifrador procesó el texto cifrado a razón de un bloque de 16 bytes a la vez. Cuando la longitud del texto cifrado no era un múltiplo de 16 (una longitud que un atacante puede elegir), el bloque parcial final nunca se escribía, por lo que esos bytes finales conservaban el contenido del montón que SetLength dejó atrás y el búfer se entregaba como el texto plano descifrado de un objeto de documento. El remedio consiste en dos protecciones, y ninguna por sí sola es suficiente: el punto de entrada de descifrado ahora rechaza cualquier texto cifrado cuya longitud no sea un múltiplo del tamaño del bloque, y como respaldo, el búfer de salida se limpia con FillChar antes de su uso, de modo que cualquier ruta que no logre escribir en una región devuelva ceros en lugar de residuos del montón.
Con qué le deja esta fase de protección
Los cinco defectos son errores diferentes, pero guardan relación. Un ancho de entero que desborda un producto, un tipo de campo que fija una protección en una constante falsa, una comprobación de rango desactivada donde los índices dejaron de ser seguros, una recursión sin piso y un búfer que el lenguaje declinó poner a cero. En cada uno de ellos, Delphi hizo exactamente lo que tiene definido, porque el lenguaje le brinda operaciones aritméticas que se desbordan, reducciones silenciosas de tipo, comprobaciones de rango que se pueden desactivar, recursión sin límites integrados y asignaciones de memoria que no se inicializan. Ese es el acuerdo, y un analizador Pascal lo cumple controlando cuatro elementos a mano en cada límite que el archivo controla: ancho de enteros, comprobación de rango, profundidad de recursión e inicialización del búfer.
Estos defectos están cerrados en las versiones actuales de PDFlibPas, el motor para Delphi y C++Builder. Si su trabajo también abarca cómo declara un archivo estar protegido, las notas complementarias sobre auditoría de cifrado y permisos y sobre preflight de PDF/A y PDF/UA cubren el aspecto de análisis del mismo analizador, y todo ello se incluye dentro de la PDFlibPas Delphi PDF Library junto con las API de carga, renderizado y firma cubiertas en otras partes de este blog.