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

Anatomia de uma bomba de descodificação PDF em Delphi: um carregamento de vinte quilobytes com cinco estágios /Filter aninhados legais multiplica a sua expansão até serem alocados gigabytes e o processo worker morrer
Cinco filtros conformes multiplicam as suas expansões até um carregamento de 20 KB alocar gigabytes, sem nenhum byte corrupto para rejeitar

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

// Simplificado a partir do descodificador de cadeia do HotPDF: um rastreador para todo
// o array /Filter, um stream wrapper limitado por etapa
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // lê a origem, não a copia
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter nomeia a etapa e incrementa FilterCount; o stream
    // wrapper chama Budget.Consume antes de escrever em 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;   // mais apertado do que a predefinição
    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

Comparação entre tetos por filtro que se rearman em cada estágio de um array /Filter, e o THPDFDecodeBudgetTracker partilhado do HotPDF que limita toda a cadeia de filtros através de Budget.Consume antes de os bytes serem reencaminhados em Delphi
Um tracker partilhado cobra cada fase contra um único teto, e cada fluxo wrapper chama Budget.Consume antes de reencaminhar um único byte

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

// Pipeline de arquivo fiável: declare a intenção em vez de adivinhar um teto
ArchivePdf.DecodeBudgetBytes := 0;              // ilimitado explícito

// Upload não fiável: dimensione o teto a partir do que o seu corpus realmente precisa
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Erros de configuração falham ruidosamente em vez de serem limitados silenciosamente
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

Fluxo de decisão mostrando como quem chama lê GetLastDecodeBudgetInfo depois de o HotPDF recusar uma descodificação de filtros aninhados em Delphi: um pico de bytes do estágio próximo dos bytes descodificados indica um filtro ganancioso, enquanto um pico baixo com muitos filtros indica uma cadeia acumulada
Bytes descodificados por fase de pico contra o total distingue um filtro guloso de uma cadeia que acumulou para além do teto

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