Artigo Técnico

Análise de PDF com Segurança de Memória: Defendendo Contra Documentos Maliciosos

Um pipeline de entrada de documentos aceita ficheiros 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 ficheiro, 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 ficheiro 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 ficheiro é 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 ficheiro, 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 ficheiro truncado contém menos bytes reais do que a contagem declarada; um ficheiro de um gerador quebrado pode declarar um tamanho que vai além do final do ficheiro 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 ficheiro. 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 ficheiro 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 executar 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 ficheiro 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 ficheiros. 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 ficheiro. Em uma RTL atual, ele aloca o que o ficheiro 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 ficheiro

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 ficheiro danificado ou hostil, esses deslocamentos caem além do final do ficheiro 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 ficheiro, validando o deslocamento e a contagem contra o tamanho medido do ficheiro antes que o stream se mova

__CODE_BLOCK_

Encaminhe os deslocamentos de referência cruzada, as extensões do stream e as leituras de ficheiros 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 ficheiro 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

__CODE_BLOCK_

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 ficheiro 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 ficheiro 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 ficheiros; toda busca com limites verificados em relação ao tamanho de ficheiro 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