Artigo Técnico

Bombas de Decodificação PDF em Delphi: Orçamentos do HotPDF

Um PDF de 20 KB que trava um processo de serviço até o OOM killer eliminá-lo 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 delas com DecodeBudgetBytes, um teto por cadeia de filtros que tem padrão de 268435456 bytes e cobra todo estágio de decodificação contra um único orçamento compartilhado

O arquivo de 20 KB que devorou um processo worker

O formato do incidente é sempre o mesmo. Um worker de fila que renderiza miniaturas pega um upload, a memória residente sobe para além de 12 GB em menos de dois segundos, e o processo desaparece sem stack trace. O arquivo tem 20 KB. Ele tem uma página, um content stream, e um array /Filter com cinco entradas. Cada nome nesse array é um filtro que a especificação define, cada estágio decodifica sem erro, e nada no arquivo está malformado. É isso que torna essa classe de entrada complicada: não há nenhum byte corrompido para rejeitar

Este não é o mesmo problema de decodificar corretamente um único filtro. Acertar LZWDecode e o preditor de /DecodeParms é seu próprio assunto, coberto em o roteiro de LZW, preditores e DecodeParms em documentos carregados. Aqui todo decodificador já está correto. A falha é o que decodificadores corretos fazem quando você roda cinco deles em sequência e ninguém está contando o total. A ISO 32000-1 §7.4 é explícita ao dizer que /Filter pode ser um único nome ou um array de nomes, e que um array é aplicado em sequência, primeira entrada primeiro. Ela não diz nada sobre quanto um estágio pode expandir sua entrada, e nada sobre o agregado ao longo da cadeia. Um estágio ASCIIHexDecode aproximadamente reduz sua entrada pela metade, o que soa inofensivo. Um estágio FlateDecode sobre uma sequência de bytes zero alcança proporções na casa dos milhares. Encadeie-os e a aritmética é multiplicativa: 20 KB vira 20 MB vira 20 GB, e cada passo individual é uma decodificação conforme de um stream legal

Por que um limite por filtro falha em parar uma bomba de decodificação?

Porque um limite por filtro é rearmado em cada elemento do array /Filter. Uma cadeia de cinco estágios sob um teto de 256 MiB por estágio autoriza 1,25 GiB, e o estágio final ainda começa com uma cota inteiramente nova não importando o que os 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 brecha ao lado dela. O descompressor LZW carregava um teto MaxOutputBytes e o caminho de preditor de imagem contabilizava suas próprias linhas, então esses dois eram limitados localmente. FlateDecode, ASCIIHexDecode, ASCII85Decode e RunLengthDecode não tinham nenhum teto: cada um escrevia em um TMemoryStream até acabar a entrada ou o alocador desistir. Então uma cadeia hostil tinha dois caminhos. Podia usar um filtro inteiramente desprotegido, ou podia usar filtros protegidos e simplesmente adicionar mais deles

Há um terceiro detalhe que uma correção ingênua deixa passar. O número que importa não é o tamanho da saída final decodificada. É o pico, e o pico geralmente vive em um buffer intermediário. Uma cadeia que termina em um content stream modesto de 4 MB pode alocar 8 GB no estágio três e devolver algo que parece inteiramente razoável. Verificar o comprimento do resultado depois do fato não diz nada sobre a alocação que matou o processo

Um rastreador de orçamento por cadeia de filtros

A correção na HotPDF v2.447.0 é fazer a contabilidade abranger a cadeia em vez do estágio. Cada cadeia de filtros constrói um THPDFDecodeBudgetTracker, e cada decodificador escreve através de um THPDFBudgetWriteStream que envolve o destino real. O wrapper chama Budget.Consume(Count) antes de encaminhar um único byte, então a recusa acontece enquanto o stream de destino ainda está no seu tamanho antigo. Essa ordenação é o ponto inteiro: uma verificação feita depois que o buffer já cresceu é 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, eles se tornaram projeções do orçamento compartilhado. O estágio LZW agora define Decoder.MaxOutputBytes := Budget.RemainingBytes, então seu teto privado é o que sobrou da cadeia em vez de uma cota independente. O estágio de preditor de imagem abre com BeginFilter e cobra sua exigência de linhas através de Consume antes de alocar, o que significa que a saída do preditor é debitada do mesmo orçamento que os filtros genéricos que a alimentaram. Isso importa em particular no caminho de imagem, onde a cadeia de filtros e o preditor são duas metades de uma operação, como coberto em extração de imagens de documentos carregados através de seus filtros de decodificação

O que o chamador vê quando o orçamento recusa?

No fundo da pilha, uma recusa dispara EHPDFDecodeBudgetError. Acima disso, a resposta depende do contrato que a API chamadora já tinha. Métodos de leitura de alto nível que reportavam falha através de False ou nil continuam fazendo exatamente isso, porque transformar um resultado booleano documentado em uma exceção quebraria chamadores que já estavam tratando corretamente entrada malformada. O caminho de conteúdo de página carregada é a exceção deliberada: ele redispara EHPDFDecodeBudgetError em vez de deixar um content stream truncado renderizar como uma página que simplesmente saiu vazia. Esse design significa que um simples False é ambíguo por si só, então o orçamento publica um registro de diagnóstico ao lado dele: THotPDF.GetLastDecodeBudgetInfo retorna o estado da cadeia mais recente que a instância decodificou

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 juntos e eles separam as duas formas de ataque. Quando PeakStageBytes está próximo de DecodedBytes, um único estágio causou todo o dano e você está olhando para um único filtro de alta proporção. Quando PeakStageBytes é uma fração pequena de DecodedBytes e FilterCount é alto, nenhum estágio individual foi escandaloso e a cadeia acumulou sua passagem além do teto, que é precisamente o caso que um limite por filtro não consegue enxergar. Uma ressalva que vale a pena escrever no seu handler: GetLastDecodeBudgetInfo retorna False até que a instância tenha decodificado pelo menos um filtro, então um False vindo dele não é evidência de que o documento estava limpo

Onde o orçamento reinicia, e quando zero é a resposta honesta

DecodeBudgetBytes limita uma cadeia de stream, não um documento, e esse limite é deliberado mas fácil de interpretar mal. Todo content stream, todo arquivo incorporado, todo cross-reference stream e todo object stream começa com 256 MiB novos. Um documento de 4.000 páginas portanto tem 4.000 chances independentes de gastar o teto completo, e object streams multiplicam ainda mais a contagem porque cada um é ele mesmo um contêiner comprimido contendo muitos objetos, como descrito em as notas sobre object streams e atualizações incrementais. Se sua exigência real é um limite sobre a memória total do processo, essa propriedade é uma entrada para isso, não o todo, e deveria ficar atrás de um teto no nível de tarefa ou de contêiner

Zero significa ilimitado, e é uma configuração legítima, não uma válvula de escape. Defina-o quando você é dono da entrada: um pipeline de reprocessamento de arquivo sobre documentos que seu próprio sistema produziu, ou uma etapa de rasterização onde uma única cadeia de digitalização colorida a 600 dpi genuinamente precisa de mais do que qualquer teto que você se sentiria confortável de fixar. Valores negativos são rejeitados de imediato com ERangeError, porque um orçamento negativo não tem nenhum 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 costuma receber, porque um orçamento definido baixo demais é uma interrupção autoinfligida. Rode seu corpus existente com o padrão, registre PeakStageBytes e DecodedBytes para cada cadeia, e defina o teto acima do máximo observado com folga real. Um número redondo escolhido porque soava seguro vai rejeitar uma digitalização grande e legítima no pior momento possível, e a falha vai parecer exatamente um ataque nos seus registros

A cópia que não acontece mais

Rotear cada estágio através de um wrapper de orçamento acabou tornando a cadeia mais barata em vez de mais cara. Quando um stream tem filtros, o primeiro estágio agora lê o stream de origem diretamente em vez de copiar os bytes codificados para um buffer temporário primeiro, e a partir daí apenas dois buffers ficam vivos ao mesmo tempo: a entrada atual e a saída do estágio sendo escrita. A cópia bruta sobrevive nos dois casos que precisam dela, ou seja, um stream sem nenhum filtro e uma imagem onde o chamador quer a última codificação preservada, porque ambos devolvem um stream que o chamador possui e pode buscar de forma independente. A versão desprotegida deste código alocava mais e limitava menos, que é a relação usual entre os dois. Vale dizer claramente, porém: nada disso torna um PDF arbitrário seguro para carregar. Isso fecha um vetor específico e muito barato de negação de serviço, aquele em que um arquivo pequeno compra uma alocação grande através de filtros aninhados. O estouro de inteiro na contabilidade de bytes é protegido separadamente, e a questão mais ampla de analisar documentos hostis sem confiar em seus offsets internos é uma disciplina diferente. Um orçamento de decodificação é um limite entre vários, e seu valor está em ser o que você pode definir a partir de uma única propriedade antes de tocar no arquivo

O orçamento por cadeia, seu registro de diagnóstico, e os caminhos de decodificação de documentos carregados que ele protege são todos fornecidos como parte do próprio componente, sem nenhuma dependência externa de descompressão para configurar ou corrigir. Se você está avaliando como limitar entrada de PDF não confiável dentro de um serviço Delphi ou C++Builder, a página do componente PDF HotPDF para Delphi lista o kit de ferramentas de documento carregado ao qual esses limites se aplicam