Un PDF de 20 KB que inmoviliza un proceso de servicio hasta que el OOM killer lo mata no es un fallo en tu código, es una bomba de descompresión. HotPDF, el componente VCL nativo para PDF en Delphi y C++Builder, acota una de ellas con DecodeBudgetBytes, un techo 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 genera miniaturas recoge una subida, la memoria residente supera los 12 GB en menos de dos segundos, y el proceso desaparece sin rastro de pila. El archivo pesa 20 KB. Tiene una página, un flujo de contenido, y un array /Filter con cinco entradas. Cada nombre de ese array es un filtro que define la especificación, cada etapa se decodifica sin error, y nada en el archivo está malformado. Eso es lo que hace incómoda a esta clase de entrada: no hay ningún byte corrupto que rechazar
Este no es el mismo problema que decodificar correctamente un único filtro. Acertar con LZWDecode y el predictor de /DecodeParms es un asunto en sí mismo, tratado en el recorrido por LZW, predictores y DecodeParms en documentos cargados. Aquí cada decodificador ya es correcto. El fallo es lo que hacen decodificadores correctos cuando ejecutas cinco seguidos y nadie cuenta el total. La norma ISO 32000-1 §7.4 es explícita en que /Filter puede ser un único nombre o un array de nombres, y que un array se aplica en secuencia, primera entrada primero. No dice nada sobre cuánto puede expandir su entrada una etapa, y nada sobre el agregado a lo largo de la cadena. Una etapa ASCIIHexDecode aproximadamente reduce a la mitad su entrada, lo que suena inofensivo. Una etapa FlateDecode sobre una tirada de bytes cero alcanza ratios de miles. Encadénalas y la aritmética es multiplicativa: 20 KB se convierten en 20 MB, se convierten en 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 descompresión?
Porque un límite por filtro se rearma en cada elemento del array /Filter. Una cadena de cinco etapas bajo un tope de 256 MiB por etapa autoriza 1,25 GiB, y la etapa final sigue empezando con una asignación completamente nueva sin importar lo que hayan producido 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 un segundo hueco junto a él. 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 quedarse sin entrada o hasta que el asignador se rendía. Así que una cadena hostil tenía dos caminos. Podía usar un filtro completamente sin protección, o podía usar filtros protegidos y simplemente añadir más de ellos
Hay un tercer detalle que una corrección ingenua pasa por alto. El número que importa no es el tamaño de la salida decodificada final. Es el pico, y el pico suele vivir en un búfer intermedio. Una cadena que termina en un flujo de contenido modesto de 4 MB puede asignar 8 GB en la etapa tres y devolver algo que parece perfectamente razonable. Comprobar la longitud del resultado a posteriori no 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 consiste en hacer que la contabilidad abarque la cadena en lugar de la etapa. Cada cadena de filtros construye un único 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 antiguo. Ese orden es todo el sentido: una comprobación realizada después de que el búfer ya haya crecido 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 fija Decoder.MaxOutputBytes := Budget.RemainingBytes, así que su techo privado es lo que le quede a la cadena en lugar de una asignación independiente. La etapa del predictor de imagen se abre con BeginFilter y carga su requisito de fila 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 especialmente en la ruta de imágenes, donde la cadena de filtros y el predictor son las dos mitades de una operación, como se cubre en extraer imágenes de documentos cargados a través de sus filtros de decodificación
¿Qué ve el llamador cuando el presupuesto rechaza?
En la base de la pila, un rechazo lanza EHPDFDecodeBudgetError. Por encima de eso, la respuesta depende del contrato que ya tuviera la API llamante. Los métodos de lectura de alto nivel que informaban del fallo mediante False o nil siguen haciendo exactamente eso, porque convertir un resultado booleano documentado en una excepción rompería a los llamadores que ya gestionaban correctamente la entrada malformada. La ruta de contenido de página cargada es la excepción deliberada: relanza EHPDFDecodeBudgetError en lugar 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 simple False 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 decodificó la instancia
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 sola etapa hizo todo el daño y estás ante un único filtro de ratio alto. Cuando PeakStageBytes es una fracción pequeña de DecodedBytes y FilterCount es alto, ninguna etapa individual fue escandalosa y la cadena se fue acumulando hasta superar el techo, que es precisamente el caso que un límite por filtro no puede ver. Vale la pena escribir una advertencia 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 prueba 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 flujo, no un documento, y ese límite es deliberado pero fácil de malinterpretar. Cada flujo de contenido, cada archivo incrustado, cada flujo de referencias cruzadas y cada flujo de objetos empieza con 256 MiB nuevos. Un documento de 4000 páginas tiene por tanto 4000 oportunidades independientes de gastar el techo completo, y los flujos de objetos multiplican aún más el recuento porque cada uno es en sí mismo 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 sobre la memoria total del proceso, esta propiedad es una entrada para eso, no todo, y debería situarse detrás de un tope a nivel de trabajo o de contenedor
Cero significa ilimitado, y es un ajuste legítimo y no una vía de escape. Fíjalo cuando eres el propietario de la entrada: un pipeline de reprocesamiento de archivo sobre documentos que produjo tu propio sistema, o un paso de rasterización donde una única cadena de escaneo a color de 600 ppp genuinamente necesita más de cualquier techo con el que te sentirías cómodo codificando de forma fija. Los valores negativos se rechazan de entrada con ERangeError, porque un presupuesto negativo no tiene ningún sentido coherente y limitarlo silenciosamente ocultarí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 normalmente recibe, porque un presupuesto fijado demasiado bajo es una interrupción autoinfligida. Ejecuta tu corpus existente con el valor por defecto, registra PeakStageBytes y DecodedBytes para cada cadena, y fija el techo por encima del máximo observado con un margen real. Un número redondo elegido porque sonaba seguro rechazará un escaneo grande y legítimo en el peor momento posible, y el fallo se parecerá exactamente a un ataque en tus registros
La copia que ya no ocurre
Enrutar cada etapa a través de un envoltorio de presupuesto resultó abaratar la cadena en lugar de encarecerla. Cuando un flujo tiene filtros, la primera etapa ahora lee el flujo de origen directamente en lugar 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 en bruto sobrevive en los dos casos que la necesitan, a saber, un flujo sin ningún filtro en absoluto y una imagen donde el llamador quiere conservar la última codificación, porque ambos devuelven un flujo que el llamador posee y puede posicionar de forma independiente. La versión sin protección de este código asignaba más y acotaba menos, que es la relación habitual entre ambas cosas. Vale la pena decirlo con claridad, sin embargo: nada de esto hace que sea seguro cargar un PDF arbitrario. Cierra un vector de denegación de servicio específico y muy barato, aquel en el que 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 protege por separado, y la cuestión más amplia de analizar documentos hostiles sin confiar en sus desplazamientos internos es una disciplina distinta. Un presupuesto de decodificación es un límite entre varios, y su valor está en que es el que puedes fijar desde una sola propiedad antes de tocar siquiera el archivo
El presupuesto por cadena, su registro de diagnóstico y las rutas de decodificación de documentos cargados que protege se incluyen todos como parte del propio componente, sin ninguna dependencia externa de descompresión que configurar o parchear. Si estás evaluando cómo acotar la entrada PDF no confiable dentro de un servicio Delphi o C++Builder, la página de componente PDF para Delphi HotPDF enumera el conjunto de herramientas para documentos cargados al que se aplican estos límites