Um PDF de 20 KB que prende um processo de serviço até o OOM killer o abater não é um bug no seu código, é uma bomba de descompressão. O HotPDF, o componente VCL nativo de PDF para Delphi e C++Builder, limita uma com DecodeBudgetBytes, um teto por cadeia de filtro que tem por predefinição 268435456 bytes e cobra cada etapa de descodificação contra um único orçamento partilhado
O ficheiro de 20 KB que devorou um processo trabalhador
A forma do incidente é sempre a mesma. Um worker de fila que renderiza miniaturas recebe um upload, a memória residente sobe para além de 12 GB em menos de dois segundos, e o processo desaparece sem qualquer rasto de pilha. O ficheiro tem 20 KB. Tem uma página, um stream de conteúdo, e um array /Filter com cinco entradas. Cada nome nesse array é um filtro que a especificação define, cada etapa descodifica sem erro, e nada no ficheiro está malformado. É isso que torna esta classe de entrada incómoda: não há um byte corrompido a rejeitar
Este não é o mesmo problema que descodificar um único filtro corretamente. Acertar em LZWDecode e no preditor /DecodeParms é um assunto à parte, coberto em o percurso pelo LZW, preditores e DecodeParms em documentos carregados. Aqui, cada descodificador já está correto. A falha é aquilo que descodificadores corretos fazem quando se executam cinco deles seguidos e ninguém está a contar o total. A ISO 32000-1 §7.4 é explícita ao afirmar que /Filter pode ser um único nome ou um array de nomes, e que um array é aplicado em sequência, primeira entrada primeiro. Não diz nada sobre quanto uma etapa pode expandir a sua entrada, e nada sobre o agregado ao longo da cadeia. Uma etapa ASCIIHexDecode reduz aproximadamente a metade a sua entrada, o que parece inofensivo. Uma etapa FlateDecode sobre uma sequência de bytes zero atinge rácios na ordem dos milhares. Encadeie-os e a aritmética é multiplicativa: 20 KB torna-se 20 MB torna-se 20 GB, e cada etapa individual é uma descodificação conforme de um stream legal
Por que falha um limite por filtro em travar uma bomba de descompressão?
Porque um limite por filtro é rearmado em cada elemento do array /Filter. Uma cadeia de cinco etapas sob um teto de 256 MiB por etapa autoriza 1,25 GiB, e a etapa final ainda começa com uma tolerância completamente nova independentemente do que as quatro anteriores produziram. O limite é aplicado honestamente e não restringe nada que importe. O HotPDF tinha exatamente essa forma antes da v2.447.0, e tinha uma segunda lacuna ao lado dela. O descompressor LZW transportava um teto MaxOutputBytes e o percurso do preditor de imagem contabilizava as suas próprias linhas, pelo que esses dois estavam limitados localmente. FlateDecode, ASCIIHexDecode, ASCII85Decode, e RunLengthDecode não tinham qualquer teto: cada um escrevia num TMemoryStream até ficar sem entrada ou o alocador desistir. Assim, uma cadeia hostil tinha duas formas de passar. Podia usar um filtro inteiramente desprotegido, ou podia usar filtros protegidos e simplesmente acrescentar mais deles
Há um terceiro detalhe que uma correção ingénua não vê. O número que importa não é o tamanho do output final descodificado. É o pico, e o pico normalmente vive num buffer intermédio. Uma cadeia que termina num modesto stream de conteúdo de 4 MB pode alocar 8 GB na terceira etapa e devolver algo que parece inteiramente razoável. Verificar o comprimento do resultado depois do facto não lhe diz nada sobre a alocação que matou o processo
Um rastreador de orçamento por cadeia de filtro
A correção no HotPDF v2.447.0 é fazer a contabilidade abranger a cadeia em vez da etapa. Cada cadeia de filtro constrói um THPDFDecodeBudgetTracker, e cada descodificador escreve através de um THPDFBudgetWriteStream que envolve o alvo real. O wrapper chama Budget.Consume(Count) antes de encaminhar um único byte, pelo que a recusa acontece enquanto o stream alvo ainda tem o seu tamanho antigo. Essa ordenação é todo o objetivo: uma verificação executada depois de o buffer já ter crescido é um diagnóstico, não uma defesa
// 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;
Os tetos locais não desapareceram, tornaram-se projeções do orçamento partilhado. A etapa LZW define agora Decoder.MaxOutputBytes := Budget.RemainingBytes, pelo que o seu teto privado é aquilo que resta à cadeia em vez de uma tolerância independente. A etapa do preditor de imagem abre com BeginFilter e cobra o seu requisito de linhas através de Consume antes de alocar, o que significa que o output do preditor é cobrado ao mesmo orçamento que os filtros genéricos que o alimentaram. Isto importa particularmente no percurso de imagens, onde a cadeia de filtro e o preditor são duas metades de uma operação, como coberto em extrair imagens de documentos carregados através dos seus filtros de descodificação
O que vê quem chama quando o orçamento recusa?
No fundo da pilha, uma recusa lança EHPDFDecodeBudgetError. Acima disso, a resposta depende do contrato que a API de chamada já tinha. Os métodos de leitura de alto nível que reportavam falha através de False ou nil continuam a fazer exatamente isso, porque transformar um resultado booleano documentado numa exceção quebraria chamadores que já estavam a tratar entrada malformada corretamente. O percurso de conteúdo de página carregada é a exceção deliberada: relança EHPDFDecodeBudgetError em vez de deixar um stream de conteúdo truncado ser renderizado como uma página que meramente saiu vazia. Esse desenho significa que um simples False é ambíguo por si só, pelo que o orçamento publica um registo de diagnóstico ao lado dele: THotPDF.GetLastDecodeBudgetInfo devolve o estado da última cadeia que a instância descodificou
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;
Leia esses campos em conjunto e eles separam as duas formas de ataque. Quando PeakStageBytes está próximo de DecodedBytes, uma única etapa fez todo o estrago e está a olhar para um filtro isolado de rácio elevado. Quando PeakStageBytes é uma pequena fração de DecodedBytes e FilterCount é elevado, nenhuma etapa individual foi excessiva e a cadeia acumulou o seu caminho para além do teto, que é precisamente o caso que um limite por filtro não consegue ver. Uma ressalva que vale a pena escrever no seu handler: GetLastDecodeBudgetInfo devolve False até a instância ter descodificado pelo menos um filtro, pelo que um False vindo dele não é prova de que o documento estava limpo
Onde reinicia o orçamento, e quando zero é a resposta honesta
DecodeBudgetBytes limita uma cadeia de stream, não um documento, e essa fronteira é deliberada mas fácil de ler mal. Cada stream de conteúdo, cada ficheiro incorporado, cada cross-reference stream e cada object stream começa com um 256 MiB novo em folha. Um documento de 4.000 páginas tem por isso 4.000 hipóteses independentes de gastar o teto completo, e os object streams multiplicam ainda mais a contagem porque cada um é por si só um contentor comprimido que contém muitos objetos, como descrito em as notas sobre object streams e atualizações incrementais. Se o seu requisito real for um limite à memória total do processo, esta propriedade é uma entrada para isso, não a totalidade, e deve estar por detrás de um teto ao nível do trabalho ou do contentor
Zero significa ilimitado, e é uma definição legítima em vez de uma saída de emergência. Defina-o quando é dono da entrada: um pipeline de reprocessamento de arquivo sobre documentos que o seu próprio sistema produziu, ou um passo de rasterização onde uma única cadeia de digitalização a cores de 600 dpi genuinamente precisa de mais do que qualquer teto com que se sentiria confortável a codificar de forma fixa. Valores negativos são rejeitados à partida com ERangeError, porque um orçamento negativo não tem qualquer significado coerente e limitá-lo silenciosamente esconderia um erro de configuração
// 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;
Escolher o número merece mais cuidado do que normalmente recebe, porque um orçamento definido demasiado baixo é uma paragem autoinfligida. Execute o seu corpus existente com a predefinição, registe PeakStageBytes e DecodedBytes para cada cadeia, e defina o teto acima do máximo observado com margem real. Um número redondo escolhido porque parecia seguro vai rejeitar uma digitalização grande legítima no pior momento possível, e a falha vai parecer exatamente um ataque nos seus registos
A cópia que já não acontece
Encaminhar cada etapa através de um wrapper de orçamento acabou por tornar a cadeia mais barata em vez de mais cara. Quando um stream tem filtros, a primeira etapa lê agora o stream de origem diretamente em vez de copiar primeiro os bytes codificados para um buffer de rascunho, e a partir daí apenas dois buffers estão vivos ao mesmo tempo: a entrada atual e o output da etapa a ser escrito. A cópia em bruto sobrevive nos dois casos que a precisam, nomeadamente um stream sem quaisquer filtros e uma imagem onde quem chama quer que a última codificação seja preservada, porque ambos devolvem um stream que quem chama possui e pode posicionar de forma independente. A versão desprotegida deste código alocava mais e limitava menos, que é a relação habitual entre os dois. Vale a pena declarar com clareza, no entanto: nada disto torna um PDF arbitrário seguro para carregar. Fecha um vetor específico e muito barato de negação de serviço, aquele em que um ficheiro pequeno compra uma alocação grande através de filtros aninhados. O overflow de inteiros na contabilidade de bytes é protegido separadamente, e a questão mais ampla de analisar documentos hostis sem confiar nos seus deslocamentos internos é uma disciplina diferente. Um orçamento de descodificação é um limite entre vários, e o seu valor está em ser aquele que pode definir a partir de uma única propriedade antes de tocar no ficheiro
O orçamento por cadeia, o seu registo de diagnóstico, e os percursos de descodificação de documentos carregados que protege fazem todos parte do próprio componente, sem qualquer dependência externa de descompressão a configurar ou corrigir. Se estiver a avaliar como limitar entrada de PDF não fiável dentro de um serviço Delphi ou C++Builder, a página do componente HotPDF Delphi PDF lista o conjunto de ferramentas de documento carregado a que estes limites se aplicam