Technical Article

Reforçar a Segurança de um Analisador de PDFs em Pascal Contra Ficheiros Maliciosos

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 ficheiro, e cada fluxo (stream) chega envolto em filtros cujos parâmetros foram definidos no ficheiro. Nenhum desses números é seu. Provêm de quem produziu o ficheiro, 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 ficheiro malformado para sofrer um crash ou pior.

O PDFlibPas passou por uma fase de reforço de segurança que tratou todo o percurso de descodificação como hostil, abrangendo os programas de tipos de letra (TrueType, Type1, CFF e tabelas CMap), os descodificadores de imagem (PNG, GIF, TIFF, JBIG2 e CCITT Grupo 3 e Grupo 4) e os filtros de fluxo (LZW, ASCII85 e preditores Flate). Seguem-se cinco classes de defeitos que foram corrigidas, cada uma baseada no comportamento específico do Delphi que as viabilizava. Estão resolvidas nos lançamentos atuais, e os mesmos padrões repetem-se em qualquer código Pascal que analise entradas não confiáveis.

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.

O caminho partilhado de imagens já limitava cada dimensão a 65535; os descodificadores autónomos não herdavam todos essa restrição. Uma expressão como ByteCount * FHeight, ou uma expressão por píxel como FWidth * Components * BitDepth, constitui um produto de 32 bits no Delphi quando ambos os operandos são inteiros de 32 bits, independentemente da largura da variável à qual atribui o resultado. Uma largura e uma altura de 60000 são plausíveis para digitalizações de grande formato, mas o seu produto em bytes excede a capacidade do intervalo de 32 bits com sinal e o comprimento resultante é pequeno. A mesma armadilha existia no intervalo do preditor ZLib, BitsPerComponent * Colors * Columns.

A configuração consiste em tornar pelo menos um operando Int64 para que toda a expressão seja avaliada em 64 bits, comparando de seguida com MaxInt e rejeitando o ficheiro antes de reduzir o tipo para invocar SetLength.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
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 ficheiro TIFF é uma cadeia de diretórios de ficheiros de imagem (IFDs), cada um contendo o deslocamento (offset) de bytes do seguinte. Um ficheiro 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 ficheiro legítimo alcançaria.

O contador de páginas estava declarado como Word, que em Delphi armazena valores de 0 a 65535. O ciclo continha uma proteção de paragem do género "parar quando a contagem de páginas exceder 65535", que parece correta até notar que o operando e o limite partilham o mesmo teto máximo. Um valor Word nunca pode ser superior a 65535, pelo que a comparação é estruturalmente sempre falsa: quando o contador chega a 65535, o incremento seguinte recoloca-o a 0, a validação nunca deteta um valor acima do teto limite e uma cadeia circular de IFDs mantém o leitor a correr indefinidamente.

A solução consistiu em alargar a largura do campo para que a proteção possa exprimir um valor que o contador consiga realmente alcançar. Com a propriedade TPDFTIFF.FPageCount declarada como Integer, a mesma comparação FPageCount > 65535 passa a ser viável, o ciclo termina e a propriedade pública PageCount mudou de tipo em conformidade, sem quebrar a compatibilidade com os chamadores. Sempre que uma verificação de limites tem o formato Value > MaxValueOfType(Value) e o operando já é do tipo exato desse valor máximo, a condição é uma constante falsa: alargue o tipo ou teste a igualdade contra o valor máximo para que possa ser acionada.

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 no qual os intérpretes de tipos de letra assentam, TPDFlibStringList.Get, constitui precisamente um desses caminhos. Em Windows, é compilado com a verificação de limites desativada e acede diretamente ao armazenamento de suporte, pelo que um índice fora de intervalo não gera um erro mas sim um acesso direto à memória. Isto é aceitável quando o índice é sempre válido, mas deixa de o ser dentro de um intérprete de charstrings CFF ou Type2, onde o índice pode ter origem no ficheiro. Uma charstring que retire um operando de uma stack vazia produz um índice com valor de menos um; um identificador de glifo desviado por um em relação à contagem de glifos acede a uma posição além do fim. Com a verificação de limites desativada, ambos se traduzem num acesso real fora de limites em vez de uma exceção capturável, e uma vez que as posições contêm valores AnsiString com contagem de referências, uma leitura fora do pretendido também pode corromper a contagem de referências de uma string.

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 ficheiro 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 já se salvaguardava contra isto. Continha um contador de profundidade de chamadas e um teto limite, PLType1MaxCallDepth, recusando descer além dele, o que reflete o limite de profundidade indicado na própria especificação Type1. O intérprete Type2, adicionado mais tarde e estruturalmente semelhante, não continha a mesma proteção, e um tipo de letra criado manualmente com uma sub-rotina que chame o seu próprio número avança diretamente pela validação em falta até sofrer um estouro da stack (stack overflow).

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

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

O defeito mais subtil expunha conteúdos do heap no resultado desencriptado, sendo a causa uma propriedade de SetLength fácil de esquecer. Quando alarga uma AnsiString com SetLength, o Delphi aloca os bytes mas não os limpa a zeros, pelo que a nova região mantém os dados que existissem previamente nessa memória do heap. Se todos os bytes forem subsequentemente escritos, isto nunca terá importância; se um caminho deixar parte do buffer sem escrever e depois o devolver como dados, esses bytes obsoletos serão incluídos no resultado. Trata-se do erro CWE-457, utilização de memória não inicializada, e quando o resultado ultrapassa uma barreira de segurança, transforma-se numa fuga de informação.

O percurso de desencriptação AES-CBC foi afetado exatamente por isto. O buffer de saída era dimensionado com SetLength e o desencriptador processava o texto cifrado um bloco de 16 bytes de cada vez. Quando o comprimento do texto cifrado não era múltiplo de 16 (um comprimento que um atacante pode escolher), o bloco parcial final nunca era escrito, fazendo com que esses bytes finais mantivessem os conteúdos do heap deixados pelo SetLength e o buffer fosse devolvido como o texto limpo desencriptado de um objeto de documento. O remédio consiste em duas proteções, sendo que nenhuma é suficiente por si só: o ponto de entrada de desencriptação agora rejeita qualquer texto cifrado cujo comprimento não seja múltiplo do tamanho do bloco e, como salvaguarda, a saída é limpa com FillChar antes do uso, garantindo que qualquer caminho que falhe ao escrever numa região retorne zeros em vez de resíduos do heap.

O balanço desta auditoria

Os cinco defeitos são erros distintos, mas que se assemelham. Uma largura de inteiro que inverte o sinal de um produto, um tipo de campo que fixa uma validação numa constante falsa, uma verificação de limites desativada onde os índices deixaram de ser seguros, uma recursividade sem teto e um buffer que a linguagem optou por não limpar a zeros. Em cada caso, o Delphi fez exatamente o que está definido, porque a linguagem disponibiliza aritmética que dá a volta, reduções de tipo silenciosas, verificações de limites que podem ser desativadas, recursividade sem limites nativos e alocações sem inicialização. Esse é o contrato, e um analisador Pascal cumpre-o gerindo quatro aspetos manualmente em cada fronteira controlada pelo ficheiro: largura de inteiros, verificação de limites, profundidade de recursividade e inicialização de buffers.

Estes defeitos encontram-se resolvidos nos lançamentos atuais do PDFlibPas, o motor para Delphi e C++Builder. Se o seu trabalho também envolve a análise de como um ficheiro declara estar protegido, as notas complementares sobre auditoria de encriptação e permissões e sobre pré-flight de PDF/A e PDF/UA cobrem a vertente de análise do mesmo leitor, estando tudo incluído na PDFlibPas Delphi PDF Library, a par das APIs de carregamento, renderização e assinatura documentadas noutras secções deste blogue.