Una canalización de recepción de documentos acepta archivos escritos por desconocidos. Facturas, escaneos, adjuntos de un formulario web: cada uno afirma ser un PDF y transporta cientos de números sobre los que se espera que actúe su analizador. Longitudes de flujo, dimensiones de imagen, desplazamientos de bytes, referencias de objeto — cada uno fue elegido por quien produjo el archivo, y una carga truncada o un documento deliberadamente malformado acabará por poner uno de esos números donde causa daño. La diferencia entre un analizador que sobrevive a ese archivo y uno que falla, o que sigue ejecutándose con memoria corrupta, es un pequeño conjunto de hábitos que no dependen de ninguna biblioteca de PDF en particular
Los hábitos comparten una sola 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 propio analizador midió — 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
El desajuste más simple es la longitud del flujo. Un objeto de flujo de PDF declara su recuento de bytes en la clave /Length, y los datos reales se sitúan entre las palabras clave stream y endstream. Nada obliga a que ambos coincidan. Un archivo truncado contiene menos bytes reales que el recuento declarado; un archivo de un generador defectuoso puede declarar una longitud que se extiende más allá del final del archivo o hacia un objeto vecino. Asigne memoria a partir del valor declarado y copie hasta endstream y desbordará el búfer; lea exactamente el recuento declarado sin comprobar la disponibilidad y se saldrá del final del archivo. Deje que el valor declarado dirija la asignación solo después de acotarlo contra la distancia medida hasta el final de los datos, y trate un desacuerdo como un punto de decisión — repare buscando endstream, o rechace el flujo — nunca como algo que se cree en silencio
Parámetros de imagen que describen una trama más grande de la asignada
Los flujos de imagen elevan 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 de trama normalmente se dimensionan a partir de ellos. El filtro de decodificación lleva su propia geometría: CCITTFaxDecode toma /Columns, /Rows y /K de su 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 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 de escaneo a línea de escaneo en lo que sea que esté después de la asignación. Cuando /Rows está ausente, el decodificador se ejecuta hasta que los datos indican parar, así que acote también el recuento de filas. DCTDecode tiene la misma costura: los datos JPEG llevan su propio ancho y alto en su marcador SOF, y nada obliga a que coincidan con el diccionario
La regla defensiva es mecánica: calcule el tamaño de trama esperado 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árelo con sus límites, asigne memoria a partir de él, y verifique durante la decodificación que la salida nunca sobrepasa la asignación. Cuando el diccionario y el filtro discrepan sobre la geometría, concílielos o rechace la imagen. Lo que un analizador nunca debe hacer es dimensionar el búfer a partir de un conjunto de números y dejar que el decodificador se ejecute 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 desborda a negativo en aritmética de 32 bits con signo; factores ligeramente distintos se desbordan hacia una longitud positiva pequeña que se asigna y deja el búfer infradimensionado. Fuerce toda la expresión a ancho amplio convirtiendo el primer operando — Size := Int64(Width) * Height * BytesPerPixel — y compare después contra un límite explícito antes de que nada llegue a SetLength
El segundo es la comprobación de rangos. La configuración de versión (release) predeterminada de Delphi la trae desactivada, así que un índice fuera de rango calculado a partir de datos del archivo no genera una excepción — lee o escribe memoria adyacente al array. Vuelva a activarla con {$R+} (y {$Q+} para el desbordamiento aritmético) al principio de cada unidad que indexa con valores derivados del archivo. El coste es inapreciable frente 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 un RTL actual, asigna lo que sea que el archivo haya pedido, así que un único flujo que declara cuatro gigabytes se convierte en un fallo por falta de memoria a mitad de la recepción. En RTL más antiguos, donde SetSize toma 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 se extiende mucho más allá. Valide todos los tamaños contra el tamaño medido de la fuente y un límite estricto antes de que cualquier llamada de asignación llegue a verlo
Desplazamientos que apuntan fuera del archivo
La tabla de referencias cruzadas asigna números de objeto a desplazamientos de bytes absolutos, y el analizador se posiciona allí donde apunten. En un archivo dañado u hostil, esos desplazamientos caen más allá del final del archivo o dentro de estructuras no relacionadas. TStream hace que el fallo sea silencioso: fijar Position más allá de Size no es un error, y un simple Read más allá del final simplemente devuelve menos bytes de los solicitados, así que el código que se salta la comprobación del recuento sigue analizando bytes obsoletos del objeto anterior. La defensa es un cuello de botella controlado — un único ayudante por el que pasan todas las búsquedas y lecturas guiadas por el archivo, validando el desplazamiento y el recuento 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 individual puede superar los 64 MB
type
EPdfBoundsError = class(Exception);
// Toda búsqueda y lectura guiada por el archivo pasa por aquí. Offset y Count son
// afirmaciones suministradas por el archivo; Source.Size es la medición a la que deben ajustarse.
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;
Encauce por él los desplazamientos de referencias cruzadas, las extensiones de flujo y las lecturas de archivos incrustados, y un desplazamiento incorrecto se convierte en un rechazo limpio que nombra las cifras, en lugar de una violación de acceso tres llamadas más tarde
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 a 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 de forma ingenua recurre hasta que la pila nativa se agota, y el agotamiento de la pila no es algo que se pueda capturar; termina el proceso. Arrays y diccionarios anidados en profundidad llegan al mismo final sin ningún ciclo en absoluto
Use dos salvaguardas juntas: un contador de profundidad explícito acota el caso honesto pero profundo en un límite que ningún archivo legítimo se acerca a alcanzar, y un conjunto de visitados detecta un ciclo genuino en su segunda visita, convirtiéndolo en un error preciso y reportable en lugar de un simple tope de 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; // solo tiene sentido cuando Kind = pvReference
// ... campos de carga útil para el resto de los tipos
end;
// LoadObject es su propia rutina: busca el desplazamiento xref para
// 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); // otros elementos hermanos pueden compartir legítimamente este objeto
end;
end;
La descompresión es un amplificador
Unos pocos kilobytes de entrada FlateDecode pueden inflarse hasta gigabytes; la compresión de propósito general premia el texto plano repetitivo, y un atacante puede hacerlo máximamente repetitivo. Limite el tamaño inflado de cada flujo a lo que su consumidor pueda plausiblemente necesitar, y mantenga un segundo presupuesto por documento: quinientos flujos, cada uno justo por debajo del límite por flujo, agotan la memoria con la misma seguridad que un único flujo gigantesco. La comprobación pertenece dentro del bucle de inflado, contando los bytes de salida a medida que se producen y abortando en cuanto se incumple el límite, no después del bucle cuando la memoria ya se ha gastado. Un presupuesto de documento expresado como un múltiplo del tamaño del archivo comprimido funciona bien, ya que los documentos legítimos se agrupan muy por debajo de las proporciones que alcanza un flujo manipulado
Defensa en profundidad más allá de sus propias unidades
Las mismas clases de defectos viven dentro de las bibliotecas. Dos casos prácticos de 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 Blindar un analizador de PDF en Pascal contra archivos maliciosos, y los riesgos de convención de llamada, anchura de enteros y propiedad de memoria al enlazar un motor en C en Blindar un binding del PDFium Component. Para una recepción genuinamente no confiable — un formulario de carga público, un buzón sin autenticar — ejecute también el trabajo de análisis y decodificación en un proceso separado de bajos privilegios, de modo que el archivo que derrota todas las salvaguardas dentro del proceso cueste un trabajo fallido en lugar de un servicio caído
Una lista de comprobación previa
Antes de que se publique la siguiente compilación, contraste el analizador con esta lista: cada búfer de flujo dimensionado a partir de una longitud acotada y no de la declarada; cada trama dimensionada a partir de parámetros de decodificador validados y comprobada contra la salida del decodificador; cada producto de dimensiones evaluado en Int64 y comparado con un límite explícito; {$R+} activo en cada unidad que indexa con valores derivados del archivo; cada búsqueda con comprobación de 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 un tiempo apreciable en un documento legítimo, y cada una convierte la corrupción de memoria en un rechazo limpio y registrable
Nota: el HotPDF Delphi Component, la PDF Library for Delphi Delphi PDF Library, 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 recepción construida sobre ellos parte de una base ya blindada