Artigo Técnico

Fortalecendo um analisador PDF em Pascal contra arquivos

Um PDF não é um mero documento que se abre. Trata-se de um pequeno programa que corre. Cada tipo de letra incorporado é um intérprete baseado em stack à espera de charstrings, cada imagem é um descodificador alimentado com campos de largura, altura e profundidade de bits escolhidos pelo arquivo, e cada fluxo (stream) chega envolto em filtros cujos parâmetros foram definidos no arquivo. Nenhum desses números é seu. Provêm de quem produziu o arquivo, o que num cenário real pode ser a fatura de um cliente ou um anexo de um remetente desconhecido. Os descodificadores que convertem esses bytes em píxeis e glifos constituem a superfície de ataque, e um analisador que confie nos dados aí contidos está à distância de um arquivo malformado para sofrer um crash ou pior

O PDF Library for Delphi trata todo o caminho de decodificação como hostil, através dos programas de fonte (TrueType, Type1, CFF e as tabelas CMap), dos decodificadores de imagem (PNG, GIF, TIFF, JBIG2 e CCITT Group 3 e Group 4) e dos filtros de stream (LZW, ASCII85 e os preditores Flate). O que segue são cinco armadilhas de memory safety que esse caminho precisa fechar, cada uma fundamentada no comportamento específico do Delphi que a torna possível, e as mesmas formas reaparecem em qualquer código Pascal que analise entrada não confiável

PDF Library for Delphi: mapa de superfície de ataque de um parser de PDF em Pascal alimentando intérpretes de fonte, decodificadores de imagem e filtros de stream rumo a cinco classes de armadilhas protegidas
Larguras, profundidades, offsets, comprimentos e flags de filtro vêm todos do produtor, o que torna os decodificadores de fonte, imagem e filtro a superfície de ataque que um parser precisa tratar como hostil

Um estouro de inteiros que resulta num buffer subdimensionado

O bug clássico de segurança de memória num descodificador de imagem é a multiplicação das dimensões cujo resultado dá a volta (wrap). Um descodificador lê a largura, a altura, a contagem de componentes e a profundidade de bits, multiplica-os para dimensionar o seu resultado, aloca essa quantidade de bytes e depois escreve a imagem com as suas dimensões reais. Se a multiplicação for efetuada em aritmética de 32 bits, o produto pode dar a volta para um valor pequeno, mesmo que cada fator individual esteja num intervalo aceitável, pelo que a alocação é bem-sucedida, resulta num buffer demasiado pequeno e a descodificação ultrapassa os limites da memória alocada. Trata-se do erro CWE-190, estouro de inteiros (integer overflow), que resulta numa escrita fora de limites no heap (CWE-787) uma etapa à frente

Limitar cada dimensão a 65535 em um caminho de imagem compartilhado não é suficiente quando um decodificador autônomo calcula seus próprios tamanhos. Uma expressão de bytes por linha vezes altura como ByteCount * FHeight, ou uma expressão por pixel como FWidth * Components * BitDepth, é um produto de 32 bits no Delphi quando ambos os operandos são inteiros de 32 bits, independente de quão larga seja a variável à qual você atribui o resultado. Uma largura e uma altura de 60000 são plausíveis para uma digitalização grande, mas o produto delas em bytes estoura o intervalo de 32 bits com sinal e o comprimento sai pequeno. A mesma armadilha está em um stride de preditor como BitsPerComponent * Colors * Columns

A defesa é tornar pelo menos um operando Int64 para que a expressão inteira seja avaliada em 64 bits, depois comparar com MaxInt e recusar o arquivo antes de estreitar o tipo de volta para chamar SetLength

PDF Library for Delphi: comparação lado a lado de um produto de dimensões de 32 bits com overflow causando alocação subdimensionada e escrita fora dos limites versus uma verificação de alargamento Int64 que recusa o arquivo
Avaliar o produto da contagem de bytes em Int64 permite ao parser comparar contra MaxInt e rejeitar dimensões hostis antes que SetLength esculpa um buffer mentiroso
// Rejeitar antes de alocar, não depois de escrever.
// Avaliar o produto em Int64 para que não possa dar a volta em 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // dimensões hostis ou inviáveis; recusa a imagem
SetLength(Buffer, RowBytes * FHeight);

O que torna este problema específico do Delphi, e não um cenário genérico, é a redução silenciosa do tipo (narrowing). Atribuir uma expressão demasiado larga a um destino de 32 bits constitui uma conversão permitida sobre a qual o compilador não alerta por predefinição, e a verificação de limites (range checking) não deteta uma inversão que ocorra antes de o valor ser usado como índice. Se deixar o produto em 32 bits, a linguagem fornece silenciosamente um comprimento incorreto em relação à memória que a descodificação vai aceder

Um tipo de campo que impede o disparo de uma validação de segurança

Um arquivo TIFF é uma cadeia de diretórios de arquivos de imagem (IFDs), cada um contendo o deslocamento (offset) de bytes do seguinte. Um arquivo malicioso pode apontar essa cadeia de volta para si mesma, e um leitor que a percorra sem uma condição de paragem ficará em execução contínua. Trata-se do erro CWE-835, um ciclo infinito induzido por dados controlados pelo atacante, sendo a defesa um contador que para assim que ultrapassa um limite que nenhum arquivo legítimo alcançaria

Agora declare o contador de páginas como Word, que no Delphi guarda valores de 0 a 65535, e dê ao loop uma condição de parada do tipo "parar quando a contagem de páginas exceder 65535". Parece correto até você notar que o operando e o limite compartilham o mesmo teto. Um Word nunca pode ser maior que 65535, então a comparação é estruturalmente sempre falsa: quando o contador chega a 65535, o próximo incremento o traz de volta a 0, a guarda nunca vê um valor acima do teto, e uma cadeia de IFDs em loop mantém o leitor girando

O remédio é alargar o campo para que a guarda possa expressar um valor que o contador de fato consegue guardar. TPDFTIFF.FPageCount é declarado como Integer, então a comparação FPageCount > 65535 é alcançável e o loop termina. Sempre que uma checagem de limite tem a forma Value > MaxValueOfType(Value) e o operando já é tipado exatamente nesse máximo, a condição é uma constante falsa: alargue o tipo, ou teste igualdade contra o máximo para que ela possa disparar

Verificação de limites desativada num caminho crítico (hot path)

Com a verificação de limites (range checking) ativada, o Delphi insere uma verificação de limites em cada índice de array e string, o que representa a diferença entre um índice fora de limites gerar uma exceção capturável ERangeError e esse mesmo índice ler ou escrever em memória que não pertence à estrutura. Os caminhos críticos (hot paths) por vezes desativam-na com uma diretiva local {$R-}, o que é justificável até ao momento em que os índices deixam de ser confiáveis

O acessor de lista do qual os intérpretes de fonte dependem, TPDFlibStringList.Get, é exatamente um caminho assim. No Windows ele é compilado com range checking desligado e indexa seu armazenamento de suporte diretamente, então um índice fora do intervalo seria um acesso cru à memória em vez de um erro. Isso é aceitável quando o índice é sempre válido, e exige cuidado dentro de um intérprete de charstrings CFF ou Type2, onde o índice pode vir do arquivo. Uma charstring que desempilha um operando de uma stack vazia produz um índice de menos um; um identificador de glifo deslocado em um em relação à contagem de glifos indexa uma posição além do fim. Sem checagem, ambos seriam um acesso genuíno fora dos limites em vez de uma exceção capturável, e como as posições guardam valores AnsiString com contagem de referências, uma leitura perdida também pode corromper a contagem de referências de uma string

O PDF Library for Delphi mantém o range checking desligado nesse hot path e torna os índices comprovadamente válidos: antes de tomar o topo da stack de operandos, o intérprete verifica que a stack não está vazia, e toda guarda de índice é um menor-que estrito contra a contagem em vez de um menor-ou-igual que admite o off-by-one. A diretiva transfere a responsabilidade pelos limites do compilador para você, e a validação que ela removeu precisa ser recolocada à mão em todo ponto de entrada

Recursividade sem limites num intérprete de charstrings

Uma charstring Type2 pode chamar uma sub-rotina, e uma sub-rotina é ela própria uma charstring que pode chamar outra, pelo que os operadores de chamada de sub-rotinas locais e globais permitem ao arquivo ditar a profundidade do processo. Uma sub-rotina que se chame a si mesma, diretamente ou através de um ciclo, entra em recursividade contínua até esgotar a stack nativa e encerrar o processo. Trata-se da falha CWE-674, recursividade descontrolada (uncontrolled recursion)

O intérprete Type1 se protege disso com um contador de profundidade de chamadas e um teto, PLType1MaxCallDepth, e se recusa a descer além dele, o que reflete o limite de profundidade que a própria especificação Type1 nomeia. A armadilha é que um intérprete estruturalmente semelhante escrito depois, como o Type2, não herda essa guarda por conta própria, e uma fonte construída à mão com uma sub-rotina que chama o próprio número atravessa direto uma checagem ausente até um stack overflow

// A forma da guarda de profundidade que ambos os intérpretes de charstring precisam.
// Acompanha a profundidade entre chamadas aninhadas e se recusa a recursar além dela.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // sub-rotina hostil autorreferente; para de descer
// ... interpreta a sub-rotina, depois Dec(CallDepth) na saída

O PDF Library for Delphi dá ao caminho Type2 a mesma profundidade limitada do seu irmão Type1. Qualquer descida recursiva sobre estrutura controlada pelo atacante, sejam sub-rotinas de fonte, um array aninhado ou uma cadeia de cross-reference, precisa de um teto de profundidade que a entrada não consiga levantar

Fuga de memória não inicializada para o resultado

A armadilha mais sutil vaza conteúdo do heap para a saída descriptografada, e a causa é uma propriedade do SetLength fácil de esquecer. Quando você cresce uma AnsiString com SetLength, o Delphi aloca os bytes mas não os zera, então a região nova guarda o que estava antes naquela memória do heap. Se todo byte é escrito depois, isso nunca importa; se um caminho deixa parte do buffer sem escrever e depois o retorna como dados, esses bytes obsoletos saem junto com o resultado. Isso é CWE-457, uso de memória não inicializada, e quando o resultado cruza uma fronteira de confiança vira um vazamento de informação

Um caminho de descriptografia AES-CBC é exatamente onde isso morde. Dimensione o buffer de saída com SetLength, processe o ciphertext um bloco de 16 bytes por vez, e quando o comprimento do ciphertext não é múltiplo de 16, um comprimento que o atacante pode escolher, o bloco parcial final nunca é escrito, então esses bytes finais guardam o conteúdo do heap que o SetLength deixou e o buffer volta como o plaintext descriptografado de um objeto de documento. O remédio são duas guardas, e nenhuma delas sozinha é suficiente: o ponto de entrada de descriptografia do PDF Library for Delphi rejeita qualquer ciphertext cujo comprimento não é múltiplo do tamanho de bloco, e como rede de segurança a saída é limpa com FillChar antes do uso, para que qualquer caminho que falhe em escrever uma região retorne zeros em vez de resíduo de heap

Diagrama do PDF Library for Delphi da descriptografia AES-CBC em que um bloco final não terminado deixa bytes de heap não inicializados, a menos que verificações de comprimento de entrada e limpeza de buffer com FillChar protejam o caminho
Duas proteções independentes fecham o vazamento: recusar ciphertext cujo comprimento não esteja no limite de 16 bytes e pré-limpar a saída para que até uma escrita perdida produza zeros em vez de resíduo de heap

O que as cinco armadilhas têm em comum

As cinco armadilhas são bugs diferentes, mas rimam. Uma largura de inteiro que faz um produto dar a volta, um tipo de campo que prende uma guarda a uma constante falsa, um range check desligado onde os índices deixaram de ser seguros, uma recursão sem piso, e um buffer que a linguagem se recusou a zerar. Em cada uma o Delphi faz exatamente o que define, porque a linguagem te dá aritmética que dá a volta, estreitamento silencioso, range checks que você pode desligar, recursão sem limite embutido, e alocação que não inicializa. Esse é o contrato, e um parser Pascal o cumpre assumindo quatro coisas à mão em cada fronteira que o arquivo controla: largura de inteiros, range checking, profundidade de recursão e inicialização de buffer

O PDF Library for Delphi, o motor para Delphi e C++Builder, protege seu caminho de decodificação contra as cinco. Se o seu trabalho também alcança como um arquivo declara estar protegido, as notas complementares sobre auditoria de criptografia e permissões e sobre preflight de PDF/A e PDF/UA cobrem o lado de análise do mesmo parser, e tudo isso vem dentro da PDF Library for Delphi Delphi PDF Library junto com as APIs de carregamento, renderização e assinatura cobertas em outros textos deste blog