Artículo técnico

Bombas de decodificación PDF en Delphi: presupuestos de HotPDF

Un PDF de 20 KB que satura un proceso de servicio hasta que el asesino de OOM lo mata no es un error en tu código, es una bomba de descompresión. HotPDF, el componente VCL nativo de PDF para Delphi y C++Builder, acota una con DecodeBudgetBytes, un límite por cadena de filtros que por defecto es 268435456 bytes y carga cada etapa de decodificación contra un único presupuesto compartido

El archivo de 20 KB que se comió un proceso worker

La forma del incidente es siempre la misma. Un worker de cola que renderiza miniaturas recoge una carga, la memoria residente sube más de 12 GB en menos de dos segundos, y el proceso desaparece sin rastro de pila. El archivo tiene 20 KB. Tiene una página, un flujo de contenido, y un arreglo /Filter con cinco entradas. Cada nombre en ese arreglo es un filtro que define la especificación, cada etapa decodifica sin error, y nada en el archivo está malformado. Eso es lo que hace incómoda esta clase de entrada: no hay ningún byte corrupto que rechazar

Este no es el mismo problema que decodificar correctamente un solo filtro. Acertar con LZWDecode y el predictor /DecodeParms es su propio tema, cubierto en el recorrido de LZW, predictores y DecodeParms en documentos cargados. Aquí cada decodificador ya es correcto. El fallo es lo que hacen los decodificadores correctos cuando ejecutas cinco de ellos seguidos y nadie está contando el total. ISO 32000-1 §7.4 es explícito en que /Filter puede ser un solo nombre o un arreglo de nombres, y que un arreglo se aplica en secuencia, primera entrada primero. No dice nada sobre cuánto puede expandir su entrada una etapa, y nada sobre el agregado de toda la cadena. Una etapa ASCIIHexDecode aproximadamente reduce a la mitad su entrada, lo que suena inofensivo. Una etapa FlateDecode sobre una serie de bytes cero alcanza proporciones de miles. Encadénalos y la aritmética es multiplicativa: 20 KB se vuelven 20 MB se vuelven 20 GB, y cada paso individual es una decodificación conforme de un flujo legal

¿Por qué un límite por filtro no detiene una bomba de decodificación?

Porque un límite por filtro se rearma en cada elemento del arreglo /Filter. Una cadena de cinco etapas bajo un tope de 256 MiB por etapa autoriza 1.25 GiB, y la etapa final aún comienza con una asignación completamente fresca sin importar lo que produjeran las cuatro anteriores. El límite se aplica honestamente y no restringe nada que importe. HotPDF tenía exactamente esa forma antes de la v2.447.0, y tenía una segunda brecha junto a ella. El descompresor LZW llevaba un tope MaxOutputBytes y la ruta del predictor de imagen contabilizaba sus propias filas, así que esos dos estaban acotados localmente. FlateDecode, ASCIIHexDecode, ASCII85Decode, y RunLengthDecode no tenían ningún tope en absoluto: cada uno escribía en un TMemoryStream hasta agotar la entrada o hasta que el asignador se rindiera. Así que una cadena hostil tenía dos caminos. Podía usar un filtro completamente sin guardia, o podía usar filtros con guardia y simplemente agregar más de ellos

Hay un tercer detalle que una corrección ingenua pasa por alto. El número que te importa no es el tamaño de la salida decodificada final. Es el pico, y el pico generalmente vive en un búfer intermedio. Una cadena que termina en un modesto flujo de contenido de 4 MB puede asignar 8 GB en la etapa tres y devolver algo que se ve perfectamente razonable. Verificar la longitud del resultado después del hecho no te dice nada sobre la asignación que mató al proceso

Un rastreador de presupuesto por cadena de filtros

La corrección en HotPDF v2.447.0 es hacer que la contabilidad abarque toda la cadena en vez de la etapa. Cada cadena de filtros construye un THPDFDecodeBudgetTracker, y cada decodificador escribe a través de un THPDFBudgetWriteStream que envuelve el destino real. El envoltorio llama a Budget.Consume(Count) antes de reenviar un solo byte, así que el rechazo ocurre mientras el flujo de destino todavía tiene su tamaño anterior. Ese orden es el punto entero: una verificación realizada después de que el búfer ya creció es un diagnóstico, no una defensa

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

Los topes locales no desaparecieron, se convirtieron en proyecciones del presupuesto compartido. La etapa LZW ahora establece Decoder.MaxOutputBytes := Budget.RemainingBytes, así que su tope privado es lo que le queda a la cadena en vez de una asignación independiente. La etapa del predictor de imagen se abre con BeginFilter y carga su requisito de filas a través de Consume antes de asignar, lo que significa que la salida del predictor se factura al mismo presupuesto que los filtros genéricos que la alimentaron. Eso importa particularmente en la ruta de imágenes, donde la cadena de filtros y el predictor son dos mitades de una sola operación, como se cubre en extraer imágenes de documentos cargados a través de sus filtros de decodificación

¿Qué ve quien llama cuando el presupuesto rechaza?

En la parte inferior de la pila, un rechazo lanza EHPDFDecodeBudgetError. Por encima de eso, la respuesta depende del contrato que ya tenía la API que llama. Los métodos de lectura de alto nivel que reportaban el fallo a través de False o nil siguen haciendo exactamente eso, porque convertir un resultado booleano documentado en una excepción rompería a quienes ya estaban manejando entrada malformada correctamente. La ruta de contenido de página cargada es la excepción deliberada: relanza EHPDFDecodeBudgetError en vez de dejar que un flujo de contenido truncado se renderice como una página que simplemente salió vacía. Ese diseño significa que un False desnudo es ambiguo por sí solo, así que el presupuesto publica un registro de diagnóstico junto a él: THotPDF.GetLastDecodeBudgetInfo devuelve el estado de la cadena más reciente que la instancia decodificó

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

Lee esos campos juntos y separan las dos formas de ataque. Cuando PeakStageBytes está cerca de DecodedBytes, una etapa causó todo el daño y estás viendo un solo filtro de alta proporción. Cuando PeakStageBytes es una pequeña fracción de DecodedBytes y FilterCount es alto, ninguna etapa individual fue escandalosa y la cadena se acumuló hasta pasar el límite, que es precisamente el caso que un límite por filtro no puede ver. Una advertencia que vale la pena escribir en tu manejador: GetLastDecodeBudgetInfo devuelve False hasta que la instancia haya decodificado al menos un filtro, así que un False de esa función no es evidencia de que el documento estuviera limpio

Dónde se reinicia el presupuesto, y cuándo cero es la respuesta honesta

DecodeBudgetBytes acota una cadena de flujos, no un documento, y ese límite es deliberado pero fácil de malinterpretar. Cada flujo de contenido, cada archivo incrustado, cada flujo de referencia cruzada y cada flujo de objetos comienza con 256 MiB frescos. Un documento de 4,000 páginas por lo tanto tiene 4,000 oportunidades independientes de gastar el tope completo, y los flujos de objetos multiplican aún más el conteo porque cada uno es en sí un contenedor comprimido que contiene muchos objetos, como se describe en las notas sobre flujos de objetos y actualizaciones incrementales. Si tu requisito real es un límite en la memoria total del proceso, esta propiedad es una entrada para eso, no la totalidad, y debería estar detrás de un tope a nivel de trabajo o de contenedor

Cero significa ilimitado, y es una configuración legítima en vez de una vía de escape. Establécelo cuando eres dueño de la entrada: un pipeline de reprocesamiento de archivo sobre documentos que tu propio sistema produjo, o un paso de rasterización donde una sola cadena de escaneo a color de 600 dpi genuinamente necesita más de cualquier tope con el que te sentirías cómodo codificando de forma fija. Los valores negativos se rechazan de inmediato con ERangeError, porque un presupuesto negativo no tiene significado coherente y ajustarlo silenciosamente escondería un error de configuración

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

Elegir el número merece más cuidado del que suele recibir, porque un presupuesto establecido demasiado bajo es una interrupción autoinfligida. Ejecuta tu corpus existente con el valor por defecto, registra PeakStageBytes y DecodedBytes para cada cadena, y establece el tope por encima del máximo observado con margen real. Un número redondo elegido porque sonaba seguro rechazará un escaneo grande legítimo en el peor momento posible, y el fallo se verá exactamente como un ataque en tus registros

La copia que ya no sucede

Enrutar cada etapa a través de un envoltorio de presupuesto resultó hacer la cadena más barata en vez de más cara. Cuando un flujo tiene filtros, la primera etapa ahora lee el flujo de origen directamente en vez de copiar primero los bytes codificados en un búfer de trabajo, y a partir de ahí solo hay dos búferes vivos a la vez: la entrada actual y la salida de la etapa que se está escribiendo. La copia cruda sobrevive en los dos casos que la necesitan, es decir un flujo sin ningún filtro y una imagen donde quien llama quiere que se preserve la última codificación, porque ambos devuelven un flujo que quien llama posee y puede buscar de forma independiente. La versión sin guardia de este código asignaba más y acotaba menos, que es la relación habitual entre las dos cosas. Vale la pena decirlo claramente, sin embargo: nada de esto hace que un PDF arbitrario sea seguro de cargar. Cierra un vector de denegación de servicio específico y muy barato, aquel donde un archivo pequeño compra una asignación grande a través de filtros anidados. El desbordamiento de enteros en la contabilidad de bytes se guarda por separado, y la pregunta más amplia de analizar documentos hostiles sin confiar en sus desplazamientos internos es una disciplina diferente. Un presupuesto de decodificación es un límite entre varios, y su valor es que es el que puedes establecer desde una sola propiedad antes de tocar el archivo

El presupuesto por cadena, su registro de diagnóstico, y las rutas de decodificación de documentos cargados que protege vienen todos incluidos como parte del componente mismo, sin ninguna dependencia externa de descompresión que configurar o parchar. Si estás evaluando cómo acotar entrada de PDF no confiable dentro de un servicio Delphi o C++Builder, la página del componente PDF HotPDF para Delphi lista el kit de herramientas de documentos cargados al que se aplican estos límites