Artigo Técnico

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

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