Una canalización de ingreso de documentos acepta archivos escritos por desconocidos. Facturas, escaneos, adjuntos de un formulario web: cada uno afirma ser un PDF y trae cientos de números sobre los que se espera que tu analizador actúe. Longitudes de flujo, dimensiones de imagen, desplazamientos de bytes, referencias a objetos — cada uno fue elegido por quien produjo el archivo, y una carga truncada o un documento deliberadamente malformado terminará poniendo uno de esos números donde hace daño. La diferencia entre un analizador que sobrevive a ese archivo y uno que se cae, o que sigue ejecutándose con memoria corrupta, es un pequeño conjunto de hábitos que no dependen de ninguna biblioteca PDF en particular
Los hábitos comparten una premisa: un valor leído del archivo es una afirmación, no una medición. Solo se vuelve utilizable después de comprobarlo contra algo que el analizador midió por sí mismo — el tamaño real del archivo, el número real de bytes que produjo un decodificador, la profundidad real de una recursión. Lo que sigue es esa premisa aplicada a los lugares donde los analizadores de documentos realmente se rompen
Una longitud declarada es una afirmación, no una medición
La discrepancia más simple es la longitud del flujo. Un objeto de flujo PDF declara su conteo de bytes en la clave /Length, y los datos reales están entre las palabras clave stream y endstream. Nada obliga a que ambos coincidan. Un archivo truncado contiene menos bytes reales que el conteo declarado; un archivo de un generador roto puede declarar una longitud que se extiende más allá del final del archivo o dentro de un objeto vecino. Asigna a partir del valor declarado y copia hasta endstream y desbordarás el búfer; lee exactamente el conteo declarado sin comprobar la disponibilidad y te saldrás del final del archivo. Deja que el valor declarado dirija la asignación solo después de acotarlo contra la distancia medida hasta el final de los datos, y trata un desacuerdo como un punto de decisión — repara escaneando en busca de endstream, o rechaza el flujo — nunca como algo que creer en silencio
Parámetros de imagen que describen un ráster más grande del que asignaste
Los flujos de imagen suben la apuesta porque dos conjuntos independientes de números describen los mismos píxeles. El diccionario de imagen lleva /Width y /Height, y los búferes ráster suelen dimensionarse a partir de ellos. El filtro de decodificación lleva su propia geometría: CCITTFaxDecode toma /Columns, /Rows y /K de sus DecodeParms, donde /K selecciona el esquema Grupo 3 o Grupo 4 y el decodificador emite (Columns + 7) div 8 bytes por línea de escaneo. Un archivo que declara /Width 100 pero le entrega al filtro /Columns 1728 — el valor predeterminado — hace que el decodificador produzca más de dieciséis veces los bytes por fila que espera el búfer, y el desbordamiento aterriza línea por línea en lo que sea que esté después de la asignación. Cuando /Rows está ausente el decodificador corre hasta que los datos digan que pare, así que acota también el conteo de filas. DCTDecode tiene la misma costura: los datos JPEG llevan su propio ancho y alto en su marcador SOF, y nada los obliga a coincidir con el diccionario
La regla defensiva es mecánica: calcula el tamaño esperado del ráster a partir de los parámetros de decodificación validados — los propios /Columns y /Rows del filtro para CCITT, las dimensiones SOF para DCT —, compáralo con tus límites, asigna a partir de él y verifica durante la decodificación que la salida nunca sobrepase la asignación. Cuando el diccionario y el filtro discrepan sobre la geometría, concílialos o rechaza la imagen. Lo que un analizador nunca debe hacer es dimensionar el búfer con un conjunto de números y dejar que el decodificador corra con el otro
Trampas de aritmética y asignación en Delphi
Tres comportamientos de Delphi socavan incluso a un analizador que pretende validar. El primero es la multiplicación de 32 bits: Delphi evalúa el producto de dos operandos Integer a 32 bits sin importar el ancho del destino, así que Width * Height * BytesPerPixel puede desbordarse aunque cada factor pase su propia comprobación de cordura. Un escaneo de 30000 por 30000 a tres bytes por píxel son 2.7 mil millones de bytes, que se vuelven negativos en aritmética con signo de 32 bits; factores ligeramente distintos se desbordan hacia una longitud positiva pequeña que se asigna y deja el búfer corto. Fuerza toda la expresión a ancho completo convirtiendo el primer operando — Size := Int64(Width) * Height * BytesPerPixel — y luego compara contra un tope explícito antes de que nada llegue a SetLength
El segundo es la comprobación de rangos. La configuración de release predeterminada de Delphi viene con ella desactivada, así que un índice fuera de rango calculado a partir de datos del archivo no lanza excepción — lee o escribe memoria adyacente al arreglo. Vuelve a activarla con {$R+} (y {$Q+} para el desbordamiento aritmético) al principio de cada unidad que indexe con valores derivados del archivo. El costo es inmedible junto a la E/S que un analizador hace de todos modos, y convierte una corrupción silenciosa en un ERangeError capturable
El tercero es TMemoryStream.SetSize con un Int64 suministrado por el archivo. En una RTL actual asigna lo que sea que el archivo pidió, así que un solo flujo que afirma tener cuatro gigabytes se convierte en un fallo por falta de memoria a mitad del ingreso. En RTL más antiguas, donde SetSize recibe un Longint, el valor se estrecha primero en silencio: un $100000010 declarado se convierte en 16, la asignación tiene éxito y la escritura de los datos reales corre mucho más allá. Valida cada tamaño contra el tamaño medido del origen y un tope duro antes de que cualquier llamada de asignación lo vea
Desplazamientos que apuntan fuera del archivo
La tabla de referencias cruzadas asigna números de objeto a desplazamientos absolutos de bytes, y el analizador busca dondequiera que apunte. En un archivo dañado u hostil esos desplazamientos caen más allá del final del archivo o dentro de estructuras sin relación. TStream hace silencioso el fallo: fijar Position más allá de Size no es un error, y un Read simple más allá del final solo devuelve menos bytes de los solicitados, así que el código que omite la comprobación del conteo sigue analizando bytes rancios del objeto anterior. La defensa es un punto de estrangulamiento — un único ayudante por el que pasa cada búsqueda y lectura dirigida por el archivo, validando desplazamiento y conteo contra el tamaño medido del archivo antes de que el flujo se mueva
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // ningún objeto puede superar 64 MB
type
EPdfBoundsError = class(Exception);
// Cada búsqueda y lectura dirigida por el archivo pasa por aquí. Offset y Count son
// afirmaciones suministradas por el archivo; Source.Size es la medición en la que deben caber.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Enruta por él los desplazamientos de referencias cruzadas, las extensiones de flujo y las lecturas de archivos incrustados, y un desplazamiento malo se convierte en un rechazo limpio que nombra los números en lugar de una violación de acceso tres llamadas después
Ciclos y profundidad en el grafo de objetos
Un PDF es un grafo, no un árbol. Cualquier valor puede ser una referencia indirecta, una referencia puede resolverse en otra referencia — /Length 12 0 R, donde el objeto 12 contiene 13 0 R — y nada impide que una cadena se cierre sobre sí misma. Un resolutor que sigue referencias ingenuamente recurre hasta agotar la pila nativa, y el agotamiento de la pila no es algo que puedas capturar; termina el proceso. Los arreglos y diccionarios profundamente anidados llegan al mismo final sin ningún ciclo
Usa dos guardias juntas: un contador de profundidad explícito acota el caso honesto pero profundo en un límite al que ningún archivo legítimo se acerca, y un conjunto de visitados atrapa un ciclo genuino en su segunda visita, convirtiéndolo en un error preciso y reportable en lugar de un tropiezo con el límite
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // mucho más profundo que cualquier cadena de referencias legítima
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // significativo cuando Kind = pvReference
// ... campos de carga útil para los demás tipos
end;
// LoadObject es tu propia rutina: busca el desplazamiento xref de
// ObjNumber, lee el objeto con ReadBounded y lo analiza.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // p. ej. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // los hermanos pueden compartir legalmente este objeto
end;
end;
La descompresión es un amplificador
Unos pocos kilobytes de entrada FlateDecode pueden inflarse a gigabytes; la compresión de propósito general premia el texto claro repetitivo, y un atacante puede hacerlo máximamente repetitivo. Limita el tamaño inflado de cada flujo a lo que su consumidor pueda necesitar plausiblemente, y mantén un segundo presupuesto por documento: quinientos flujos cada uno justo por debajo del tope por flujo agotan la memoria tan seguro como un solo flujo gigante. La comprobación va dentro del bucle de inflado, contando los bytes de salida a medida que se producen y abortando al superarlo, no después del bucle cuando la memoria ya se gastó. Un presupuesto por documento expresado como múltiplo del tamaño del archivo comprimido funciona bien, ya que los documentos legítimos se agrupan muy por debajo de las razones que alcanza un flujo fabricado
Defensa en profundidad más allá de tus propias unidades
Las mismas clases de defecto viven dentro de las bibliotecas. Dos casos de estudio en este blog recorren instancias reales: los desbordamientos de enteros, la recursión sin límite y los búferes sin inicializar cerrados en un motor Pascal nativo en Endurecer un analizador PDF en Pascal contra archivos maliciosos, y los peligros de convención de llamada, ancho de enteros y propiedad al enlazar un motor en C en Endurecer un enlace del PDFium Component. Para un ingreso genuinamente no confiable — un formulario de carga público, un buzón sin autenticar — ejecuta además el trabajo de análisis y decodificación en un proceso separado de bajos privilegios, para que el archivo que derrota a todas las guardias dentro del proceso cueste un trabajo fallido en lugar de un servicio caído
Una lista de verificación de preflight
Antes de que salga la próxima compilación, recorre el analizador contra esta lista: cada búfer de flujo dimensionado a partir de una longitud acotada y no de la declarada; cada ráster dimensionado a partir de parámetros de decodificador validados y comprobado contra la salida del decodificador; cada producto de dimensiones evaluado en Int64 y comparado con un tope explícito; {$R+} activo en cada unidad que indexe con valores derivados del archivo; cada búsqueda comprobada en límites contra el tamaño medido del archivo; cada resolución de referencias limitada en profundidad y comprobada contra ciclos; cada bucle de inflado contando la salida contra presupuestos por flujo y por documento. Ninguna de estas comprobaciones cuesta tiempo medible en un documento legítimo, y cada una convierte la corrupción de memoria en un rechazo limpio y registrable
Nota: el componente HotPDF para Delphi, la biblioteca PDF Library for Delphi y el PDFium Component de losLab aplican internamente estas comprobaciones de límites, límites de profundidad y topes de expansión, así que una canalización de ingreso construida sobre ellos parte de una base endurecida