Artigo Técnico

Análise Segura de Memória de PDF: Defendendo-se Contra Documentos Maliciosos

Um pipeline de entrada de documentos aceita arquivos escritos por desconhecidos. Faturas, digitalizações, anexos de um formulário da web: cada um afirma ser um PDF e carrega centenas de números sobre os quais se espera que o seu analisador atue. Tamanhos de stream, dimensões de imagem, deslocamentos de bytes, referências de objetos — cada um deles foi escolhido por quem produziu o arquivo, e um upload truncado ou um documento deliberadamente malformado acabará colocando um desses números onde causará danos. A diferença entre um analisador que sobrevive a esse arquivo e um que trava, ou continua rodando com a memória corrompida, é um pequeno conjunto de hábitos que não dependem de nenhuma biblioteca PDF em particular

Os hábitos compartilham uma premissa: um valor lido do arquivo é uma afirmação, não uma medição. Ele se torna utilizável apenas após ser verificado em relação a algo que o próprio analisador mediu — o tamanho real do arquivo, o número real de bytes que um decodificador produziu, a profundidade real de uma recursão. O que se segue é essa premissa aplicada aos locais onde os analisadores de documentos realmente quebram

Um tamanho declarado é uma afirmação, não uma medição

A incompatibilidade mais simples é o tamanho do stream. Um objeto stream de PDF declara sua contagem de bytes na chave /Length, e os dados reais ficam entre as palavras-chave stream e endstream. Nada obriga que os dois concordem. Um arquivo truncado contém menos bytes reais do que a contagem declarada; um arquivo de um gerador quebrado pode declarar um tamanho que vai além do final do arquivo ou entra em um objeto vizinho. Aloque a partir do valor declarado e copie até endstream e você sobrecarregará o buffer; leia exatamente a contagem declarada sem verificar a disponibilidade e você passará do final do arquivo. Deixe o valor declarado orientar a alocação apenas depois de limitá-lo pela distância medida até o final dos dados, e trate uma divergência como um ponto de decisão — repare procurando por endstream, ou rejeite o stream — nunca como algo em que se deva acreditar silenciosamente

Parâmetros de imagem que descrevem um raster maior do que o que você alocou

Os streams de imagem aumentam os riscos porque dois conjuntos independentes de números descrevem os mesmos pixels. O dicionário de imagem carrega /Width e /Height, e os buffers de rasterização geralmente são dimensionados a partir desses valores. O filtro de decodificação carrega sua própria geometria: CCITTFaxDecode pega /Columns, /Rows e /K de seus DecodeParms, onde /K seleciona o esquema do Grupo 3 ou do Grupo 4 e o decodificador emite (Columns + 7) div 8 bytes por linha de varredura. Um arquivo que declara /Width 100 mas passa para o filtro /Columns 1728 — o padrão — faz o decodificador produzir mais de dezesseis vezes os bytes por linha que o buffer espera, e o excesso cai uma linha de varredura de cada vez em qualquer lugar que esteja após a alocação. Quando /Rows está ausente, o decodificador roda até os dados mandarem parar, então limite a contagem de linhas também. DCTDecode possui a mesma brecha: os dados JPEG carregam sua própria largura e altura em seu marcador SOF, e nada os obriga a corresponder ao dicionário

A regra defensiva é mecânica: calcule o tamanho de rasterização esperado a partir dos parâmetros de decodificação validados — os próprios /Columns e /Rows do filtro para CCITT, as dimensões SOF para DCT —, verifique-o em relação aos seus limites, aloque a partir dele e, durante a decodificação, certifique-se de que a saída nunca ultrapasse a alocação. Quando o dicionário e o filtro divergirem quanto à geometria, reconcilie-os ou rejeite a imagem. O que um analisador nunca deve fazer é dimensionar o buffer a partir de um conjunto de números e deixar o decodificador rodar no outro

Armadilhas de aritmética e alocação no Delphi

Três comportamentos do Delphi prejudicam até mesmo um analisador que pretende validar. O primeiro é a multiplicação de 32 bits: o Delphi avalia o produto de dois operandos Integer em 32 bits, independentemente da largura do destino, de modo que Width * Height * BytesPerPixel pode dar a volta (wrap) mesmo quando cada fator passa em sua própria verificação de sanidade. Uma varredura de 30.000 por 30.000 a três bytes por pixel é 2,7 bilhões de bytes, que dá um wrap negativo em aritmética de 32 bits com sinal; fatores ligeiramente diferentes dão um wrap para um tamanho positivo pequeno que aloca e subdimensiona o buffer. Force a expressão inteira a ficar larga fazendo o cast do primeiro operando — Size := Int64(Width) * Height * BytesPerPixel — e então compare com um limite explícito antes que qualquer coisa chegue ao SetLength

O segundo é a verificação de intervalo (range checking). A configuração de versão (release) padrão do Delphi desativa isso, então um índice fora dos limites computado a partir de dados de arquivo não gera uma exceção — ele lê ou escreve memória adjacente ao array. Ative novamente com {$R+} (e {$Q+} para transbordamento aritmético) no topo de cada unidade que faz indexação com valores derivados de arquivos. O custo não é mensurável em relação às operações de E/S que um analisador faz de qualquer maneira, e isso converte corrupção silenciosa em um ERangeError que pode ser capturado

O terceiro é TMemoryStream.SetSize com um Int64 fornecido pelo arquivo. Em uma RTL atual, ele aloca o que o arquivo pediu, de modo que um único stream que afirme ter quatro gigabytes se torna uma falha de falta de memória (out-of-memory) no meio da entrada. Em RTLs antigas, onde SetSize recebe um Longint, o valor é primeiro reduzido silenciosamente: um $100000010 declarado se torna 16, a alocação é bem-sucedida e a gravação dos dados reais vai muito além disso. Valide todos os tamanhos em relação ao tamanho de fonte medido e a um limite fixo antes que qualquer chamada de alocação os veja

Deslocamentos que apontam para fora do arquivo

A tabela de referência cruzada mapeia números de objetos para deslocamentos de bytes absolutos, e o analisador faz a busca de onde aponta. Em um arquivo danificado ou hostil, esses deslocamentos caem além do final do arquivo ou dentro de estruturas não relacionadas. TStream torna a falha silenciosa: configurar a Position além do Size não é um erro, e um Read comum após o fim simplesmente retorna menos bytes do que foi solicitado, então códigos que pulam a verificação de contagem continuam analisando bytes antigos do objeto anterior. A defesa é um gargalo (chokepoint) — um ajudante por onde passam todas as buscas e leituras orientadas por arquivo, validando o deslocamento e a contagem contra o tamanho medido do arquivo antes que o stream se mova

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

Encaminhe os deslocamentos de referência cruzada, as extensões do stream e as leituras de arquivos incorporados por ele, e um deslocamento incorreto se torna uma rejeição limpa que nomeia os números, em vez de uma violação de acesso três chamadas depois

Ciclos e profundidade no grafo de objetos

Um PDF é um grafo, não uma árvore. Qualquer valor pode ser uma referência indireta, uma referência pode ser resolvida em outra referência — /Length 12 0 R, em que o objeto 12 contém 13 0 R — e nada impede que uma cadeia feche em si mesma. Um resolvedor que siga referências de forma ingênua recusa até que a pilha nativa se esgote, e o esgotamento de pilha não é algo que se capture; ele encerra o processo. Arrays e dicionários profundamente aninhados chegam ao mesmo fim, sem nenhum ciclo

Use dois guardiões juntos: um contador explícito de profundidade delimita o caso honesto, mas profundo, em um limite em que nenhum arquivo legítimo se aproxima, e um conjunto de visitas captura um ciclo genuíno em sua segunda visita, transformando-o em um erro relatável e preciso em vez de um estouro de limite

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // meaningful when Kind = pvReference
    // ... payload fields for the remaining kinds
  end;

// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('reference chain exceeds depth limit');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'circular reference through object %d', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // e.g. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // siblings may legally share this object
  end;
end;

Descompressão é um amplificador

Alguns kilobytes de entrada do FlateDecode podem inflar para gigabytes; compressão de propósito geral recompensa o texto puro repetitivo, e um atacante pode torná-lo no máximo repetitivo. Limite o tamanho inflado de cada stream ao que seu consumidor possa necessitar de forma plausível, e mantenha um segundo orçamento por documento: quinhentos streams cada um pouco abaixo do limite por stream esgotam a memória com tanta certeza quanto um stream gigantesco. A verificação pertence dentro do laço de inflação, contando bytes de saída assim que são produzidos e interrompendo a quebra de limite, não depois do laço, quando a memória já está esgotada. Um orçamento do documento expresso como um múltiplo do tamanho do arquivo comprimido funciona bem, já que documentos legítimos ficam bem abaixo da proporção que um stream mal-intencionado alcança

Defesa em profundidade além das suas próprias unidades

As mesmas classes de defeitos vivem em bibliotecas. Dois estudos de caso neste blog percorrem instâncias reais: wraps de inteiro, recursões ilimitadas, e buffers não inicializados em um engine nativo em Pascal em Reforçando um Analisador de PDF em Pascal Contra Arquivos Maliciosos, e os perigos de convenção de chamada, largura de inteiro, e domínio associados a C em Reforçando uma Vinculação de Componente PDFium. Para uma entrada puramente não confiável — um formulário público de upload, uma caixa de correio não autenticada — execute também a análise e a decodificação em um processo de baixo privilégio em separado, de modo que o arquivo que derrota toda guarda em processo gere uma falha no trabalho em vez de um serviço inoperante

Uma lista de verificação de preparação

Antes que a próxima versão seja enviada, percorra o analisador em relação a esta lista: todo buffer de stream dimensionado a partir de um tamanho limitado em vez do tamanho declarado; todo raster dimensionado a partir de parâmetros de decodificador validados e verificado em relação à saída do decodificador; todo produto de dimensão avaliado em Int64 e comparado a um limite explícito; {$R+} ativo em toda unidade que indexa com valores derivados de arquivos; toda busca com limites verificados em relação ao tamanho de arquivo medido; toda resolução de referência com limite de profundidade e ciclo verificado; todo laço de inflação contando a saída em relação aos orçamentos por stream e por documento. Nenhuma dessas verificações custa tempo mensurável em um documento legítimo, e cada uma delas converte a corrupção de memória em uma rejeição limpa e registrável

Nota: o Componente HotPDF, a Biblioteca Delphi PDF PDFlibPas e o Componente PDFium da losLab aplicam essas verificações de limites, limites de profundidade e limites de expansão internamente, para que um pipeline de entrada construído sobre eles comece a partir de uma base reforçada