Um PDF não é apenas um documento que você abre. É um pequeno programa que você executa. Cada fonte incorporada é um intérprete baseado em pilha aguardando charstrings, cada imagem é um decodificador alimentado com campos de largura, altura e profundidade de bits escolhidos pelo arquivo, e cada fluxo chega envolvido em filtros cujos parâmetros o próprio arquivo definiu. Nenhum desses números é seu. Eles vieram de quem produziu o arquivo, o que em um fluxo de trabalho real corresponde à fatura de um cliente ou a um anexo de um remetente desconhecido. Os decodificadores que transformam esses bytes em pixels e glifos são a superfície de ataque, e um analisador que confia em sua entrada nesse ponto está a um arquivo malformado de distância de um travamento ou algo pior.
O PDFlibPas passou por uma etapa de fortalecimento de segurança que tratou todo o caminho de decodificação como hostil, abrangendo os programas de fontes (TrueType, Type1, CFF e as tabelas CMap), os decodificadores de imagens (PNG, GIF, TIFF, JBIG2 e CCITT Grupo 3 e Grupo 4) e os filtros de fluxo (LZW, ASCII85 e os preditores Flate). O que se segue são cinco classes de defeitos fechadas por ela, cada uma baseada no comportamento específico do Delphi que as tornava possíveis. Eles estão corrigidos nos lançamentos atuais, e os mesmos cenários se repetem em qualquer código Pascal que analise entradas não confiáveis.
Um estouro de inteiro que entrega um buffer subdimensionado
O clássico bug de segurança de memória em um decodificador de imagem é um produto de dimensões que estoura (wraps). O decodificador lê a largura, altura, contagem de componentes e profundidade de bits, multiplica-os para dimensionar sua saída, aloca essa quantidade de bytes e grava a imagem em suas dimensões reais. Se a multiplicação for realizada em aritmética de 32 bits, o produto pode estourar para um valor pequeno mesmo quando cada fator individual estiver dentro de uma faixa aceitável, de modo que a alocação é bem-sucedida, resulta em algo pequeno demais e a decodificação avança além do limite alocado. Trata-se do CWE-190, estouro de inteiros (integer overflow), levando a uma gravação fora dos limites do heap (CWE-787) uma etapa depois.
O caminho de imagem compartilhado já limitava cada dimensão a 65535; nem todos os decodificadores independentes herdavam esse limite. Uma expressão do tipo largura de linha em bytes vezes a altura, como ByteCount * FHeight, ou uma expressão por pixel, como FWidth * Components * BitDepth, representa um produto de 32 bits no Delphi quando ambos os operandos são inteiros de 32 bits, independentemente da largura da variável para a 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 ultrapassa o intervalo de 32 bits com sinal e o comprimento resultante é pequeno. A mesma armadilha existia no stride do preditor ZLib, BitsPerComponent * Colors * Columns.
A correção consiste em tornar pelo menos um operando Int64 para que toda a expressão seja avaliada em 64 bits, comparar com MaxInt e recusar o arquivo antes de reduzir o valor para chamar 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 isso um problema específico do Delphi e não geral é a redução silenciosa (silent narrowing). Atribuir uma expressão larga demais a um destino de 32 bits é uma conversão legal sobre a qual o compilador não emitirá avisos por padrão, e a verificação de intervalo não captura um estouro que acontece antes de o valor ser usado como índice. Deixe o produto em 32 bits e a linguagem silenciosamente fornecerá a você um comprimento que mente sobre quanta memória a decodificação está pretas a acessar.
Um tipo de campo que impede o disparo de uma proteção
Um arquivo TIFF é uma cadeia de diretórios de arquivos de imagem, cada um contendo o deslocamento em bytes do seguinte. Um arquivo malicioso pode apontar essa cadeia de volta para si mesmo, e um leitor que a percorre sem uma condição de parada rodará indefinidamente. Isso é o CWE-835, um loop infinito guiado por entrada controlada pelo invasor, e a defesa é um contador que para assim que ultrapassa um limite que nenhum arquivo legítimo alcançaria.
O contador de páginas estava declarado como Word, que no Delphi armazena de 0 a 65535. O loop continha uma proteção de encerramento do tipo "parar quando a contagem de páginas exceder 65535", o que parece correto até você notar que o operando e o limite compartilham o mesmo teto. Um Word nunca pode ser maior que 65535, portanto a comparação é estruturalmente sempre falsa: quando o contador atinge 65535, o próximo incremento o redefine para 0, a proteção nunca visualiza um valor acima do limite e uma cadeia de IFD em loop mantém o leitor rodando indefinidamente.
A correção foi expandir o campo para que a proteção possa expressar um valor que o contador realmente consiga armazenar. Com o TPDFTIFF.FPageCount declarado como Integer, la mesma comparação FPageCount > 65535 torna-se alcançável, o loop termina e a propriedade pública PageCount teve seu tipo alterado para corresponder, sem quebrar os chamadores. Sempre que uma verificação de limite tem o formato Value > MaxValueOfType(Value) e o operando já é do tipo daquele máximo exato, a condição é uma constante falsa: expanda o tipo ou teste a igualdade em relação ao máximo para que ela possa ser acionada.
Verificação de intervalo desativada em um caminho crítico
Com a verificação de intervalo (range checking) ativada, o Delphi insere uma verificação de limites em cada índice de array e string, o que faz a diferença entre um índice fora da faixa gerar um ERangeError capturável ou esse mesmo índice ler ou gravar em memória que não pertence à estrutura. Caminhos críticos (hot paths) às vezes a desativam com uma diretiva local {$R-}, o que é justificável até o momento em que os índices deixam de ser confiáveis.
O acessador de lista no qual os intérpretes de fontes se apoiam, TPDFlibStringList.Get, é exatamente um desses caminhos. No Windows, ele é compilado com a verificação de intervalo desativada e indexa seu armazenamento de suporte diretamente, de modo que um índice fora do intervalo não representa um erro, mas sim um acesso direto à memória. Isso é aceitável quando o índice é sempre válido, mas deixa de ser aceitável dentro de um intérprete de charstrings CFF ou Type2, onde o índice pode vir do arquivo. Uma charstring que retira um operando de uma pilha vazia produz um índice igual a menos um; um identificador de glifo deslocado por um em relação à contagem de glifos indexa uma posição além do final. Com a verificação de intervalo desativada, ambos tornam-se acessos reais fora dos limites em vez de uma exceção capturável, e pelo fato de as posições armazenarem valores de AnsiString com contagem de referências, uma leitura incorreta também pode corromper a contagem de referências de uma string.
O fortalecimento de segurança não reativou a verificação de intervalo para o caminho crítico. Ele tornou os índices comprovadamente válidos primeiro: antes de retirar o topo da pilha de operandos, o intérprete verifica se ela não está vazia, e cada proteção de índice foi escrita como uma comparação estritamente menor que a contagem, em vez de menor ou igual, a qual admitiria o erro por um. A diretiva transfere a responsabilidade pelos limites do compilador para você, e a validação que ela removeu precisa ser recolocada manualmente em cada ponto de entrada.
Recursão ilimitada em um 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, de modo que os operadores locais e globais de chamada de sub-rotinas permitem que o arquivo decida a profundidade do processo. Uma sub-rotina que chama a si mesma, diretamente ou por meio de um ciclo, realiza recursão infinita até que a pilha nativa se esgote e o processo seja encerrado. Trata-se do CWE-674, recursão não controlada (uncontrolled recursion).
O intérprete Type1 já se protegia contra isso. Ele carregava um contador de profundidade de chamada e um teto, PLType1MaxCallDepth, e recusava-se a descer além dele, o que reflete o limite de profundidade indicado pela própria especificação do Type1. O intérprete Type2, adicionado posteriormente e estruturalmente semelhante, não carregava a mesma proteção, e uma fonte montada manualmente com uma sub-rotina que chama a si mesma passaria direto pelo teste ausente, resultando em um estouro de pilha (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
Memória não inicializada que vaza para a saída
O defeito mais sutil vazava conteúdos do heap para a saída descriptografada, e a causa é uma propriedade do SetLength que é fácil de esquecer. Quando você aumenta uma AnsiString com o SetLength, o Delphi aloca os bytes mas não os define como zero, de modo que a nova região armazena o que quer que estivesse anteriormente nessa memória heap. Se cada byte for subsequentemente gravado, isso nunca importa; mas se um caminho deixar parte do buffer sem gravação e depois retorná-lo como dados, esses bytes obsoletos sairão com o resultado. Trata-se do CWE-457, uso de memória não inicializada, e quando o resultado cruza uma fronteira de confiança, torna-se um vazamento de informações.
O caminho de descriptografia AES-CBC enfrentava exatamente isso. O buffer de saída era dimensionado com SetLength e o descriptografador 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 tamanho que um invasor pode escolher), o bloco parcial final nunca era gravado, de modo que esses bytes finais retinham os conteúdos do heap deixados pelo SetLength e o buffer era retornado como o texto simples descriptografado de um objeto de documento. A solução consiste em duas proteções, e nenhuma delas sozinha é suficiente: o ponto de entrada de descriptografia agora rejeita qualquer texto cifrado cujo comprimento não seja múltiplo do tamanho do bloco e, como retaguarda, a saída é limpa com FillChar antes do uso, de forma que qualquer caminho que falhe em gravar uma região retorne zeros em vez de resíduos do heap.
O que esta etapa deixa para você
Os cinco defeitos são bugs diferentes, mas eles compartilham semelhanças. Uma largura de inteiro que provoca estouro em um produto, um tipo de campo que fixa uma proteção para uma constante falsa, uma verificação de intervalo desativada onde os índices deixavam de ser seguros, uma recursão sem base de parada e um buffer que a linguagem recusou-se a zerar. Em cada um deles, o Delphi fez exatamente o que define, porque a linguagem fornece aritmética com estouro, redução silenciosa, verificações de intervalo que você pode desativar, recursão sem limite embutido e alocação que não inicializa os dados. Esse é o contrato, e um analisador Pascal o atende assumindo quatro itens manualmente em cada limite que o arquivo controla: largura do inteiro, verificação de intervalo, profundidade de recursão e inicialização do buffer.
Estes defeitos estão fechados nos lançamentos atuais do PDFlibPas, o mecanismo para Delphi e C++Builder. Se o seu trabalho também envolve a forma como um arquivo alega ser 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 analisador, e tudo isso é fornecido dentro da PDFlibPas Delphi PDF Library, junto com as APIs de carregamento, renderização e assinatura descritas em outras seções deste blog.