Technical Article

Reforçar a Segurança de um Assinador de PDFs em Delphi Contra PKCS#12 Maliciosos

Quando assina um PDF, pensa normalmente na chave de assinatura como algo que controla. Ela reside num ficheiro .pfx gerado por si, protegido por uma palavra-passe que escolheu. O código que lê esse ficheiro assemelha-se a canalização interna, e não a uma fronteira de segurança. Essa intuição falha assim que o certificado deixa de ser seu. Uma ferramenta de desktop que permita ao utilizador escolher qualquer .pfx, um servidor que aceite credenciais enviadas por upload ou um assinador em lote que receba certificados através da rede: todos entregam bytes influenciados por atacantes a um analisador (parser) antes que um único byte de assinatura seja produzido. Um leitor PKCS#12 é uma superfície de ataque, no mesmo sentido em que um descodificador de imagem ou um carregador de tipos de letra o são.

Este artigo analisa dois defeitos reais que existiam nesse leitor, ambos no caminho de importação de credenciais de assinatura. Nenhum deles é exótico. Ambos partilham a mesma causa raiz que afeta quase todos os analisadores binários escritos numa linguagem com inteiros de largura fixa: confiar no comprimento ou na contagem fornecidos pelo ficheiro um passo além do que deveriam. Um deles causa uma leitura fora de limites (out-of-bounds read) e o outro resulta num processo bloqueado até ser forçado a encerrar.

Por onde viajam os bytes

A importação de um .pfx para assinar um documento não é uma operação única; trata-se de um pequeno pipeline, e cada etapa analisa algo que um atacante pode ter escrito. O contentor é uma estrutura PKCS#12 conforme definida na norma RFC 7292, um conjunto de blocos AuthenticatedSafe que envolvem um invólucro encriptado contendo a chave privada. Lê-lo implica percorrer a estrutura ASN.1, derivar uma chave a partir da palavra-passe, desencriptar e, em seguida, entregar a chave RSA recuperada ao código que constrói a assinatura.

No HotPDF, estas etapas estão mapeadas em unidades distintas. A lógica do contentor PKCS#12 reside em HPDFPFX. Cada etiqueta (tag), comprimento e valor em que toca é descodificado pelo leitor ASN.1 em HPDFASN1. A derivação de chaves e a desencriptação PBES2 situam-se em HPDFCrypt, juntamente com PBKDF2HMACSHA256. Assim que a chave é recuperada, a HPDFRSA e o construtor CMS SignedData em HPDFCMS convertem-na na assinatura destacada incorporada no PDF. O ponto de entrada público que gere toda a cadeia é uma única chamada.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

Cada byte de signer.pfx passa por HPDFASN1 e HPDFPFX antes de ocorrer qualquer operação criptográfica. Se estas duas unidades não forem cautelosas com o que o ficheiro declara, a criptografia a jusante nunca terá oportunidade de fazer a diferença.

Defeito um: um comprimento ASN.1 que contorna a salvaguarda

A estrutura ASN.1 em DER e BER codifica cada elemento como uma etiqueta (tag), um comprimento e essa quantidade de bytes de conteúdo. O comprimento é o campo no qual deve confiar mas verificar, pois indica ao analisador o quanto deve ler, tendo sido escrito por quem produziu o ficheiro. A norma X.690 §8.1.3 define duas codificações. A forma curta agrupa um comprimento de 0 a 127 num único byte. A forma longa, utilizada para tudo o que for maior, consome um byte inicial cujos sete bits inferiores indicam a contagem de bytes de comprimento que se seguem, e depois essa quantidade de bytes em big-endian transporta o valor real. Quatro bytes de comprimento podem, portanto, declarar um tamanho de conteúdo próximo de quatro gigabytes.

Depois de descodificar um valor deste tipo, o analisador tem de verificar se o conteúdo cabe realmente no buffer antes de confiar nele. A verificação natural consiste em confirmar que a posição atual mais o comprimento do conteúdo não ultrapassam o fim dos dados. Escrita de forma intuitiva, com a posição, o comprimento do conteúdo e o total armazenados em inteiros com sinal de 32 bits, essa validação falha:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

O problema está na adição e não na comparação. Quando o ContentLen está próximo de MaxInt (2147483647), Pos + ContentLen excede o limite do tipo de dados de 32 bits com sinal e converte-se num número negativo. Uma soma negativa nunca será superior a Total, pelo que a validação indica que está tudo bem e permite ao analisador avançar com um comprimento de conteúdo de aproximadamente dois gigabytes que o buffer não contém. O dano ocorre de seguida: o leitor aloca um buffer para esse comprimento alegado e copia para ele, efetuando um SetLength seguido de um Move que lê a partir da origem. Sendo que a origem tem apenas algumas centenas de bytes disponíveis, a cópia lê muito além do fim da entrada, uma leitura fora de limites (out-of-bounds read) que, no melhor dos cenários, provoca um crash e, no pior, expõe memória adjacente do processo à análise.

A única validação correta alarga a soma intermédia antes da comparação, para que a adição não possa ultrapassar a capacidade do tipo no qual é calculada. A correção promove ambos os operandos para Int64:

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Um Int64 armazena a soma de dois valores de 32 bits sem perdas, pelo que a comparação avalia o número real e rejeita o comprimento adulterado. A verificação separada de valor não negativo em ContentLen resolve o caso equivalente em que um valor descodificado resulta em negativo por si só. No HotPDF, esta proteção reside em HPDFASN1ParseNode, a função que gera o nó sobre o qual todos os outros auxiliares são criados. Uma vez que o HPDFASN1Content define o tamanho de SetLength e Move diretamente com base no comprimento do conteúdo do nó, um nó que contornasse uma validação incorreta comprometeria todas as leituras subsequentes. Corrigir o limite no ponto de descodificação é o que garante a segurança dos auxiliares a montante.

Defeito dois: uma contagem de iterações PBKDF2 utilizada como arma

A segunda falha não é um erro de memória, é o próprio ficheiro a ditar a intensidade de esforço do seu CPU. A especificação PKCS#12 protege o seu material de chave com o PBES2, o esquema baseado em palavra-passe do PKCS#5, detalhado no RFC 8018. O PBES2 executa uma função de derivação de chave, neste caso a PBKDF2 com HMAC-SHA-256, seguida de uma cifra, aqui AES-256-CBC. A PBKDF2 requer uma contagem de iterações, e essa contagem é um parâmetro contido no ficheiro. O seu único objetivo é ser demorada: mais iterações implicam que cada tentativa de adivinhar a palavra-passe custa mais, o que é benéfico contra um atacante offline. A secção §4.2 da norma RFC 8018 explicita que uma contagem mais elevada é melhor para a segurança, não definindo qualquer teto limite.

Esta abertura é aceitável quando o ficheiro é gerado por si. Passa a ser uma arma se for criado por um atacante. A contagem de iterações passa a ser um fator de esforço controlado pelo atacante, o que se traduz numa negação de serviço por complexidade algorítmica. Um .pfx forjado pode conter uma contagem de iterações na ordem dos milhares de milhões; o analisador lê-o diligentemente e invoca a PBKDF2 para essa quantidade de ciclos de HMAC-SHA-256, e o processo perde-se num ciclo que não terminará em minutos ou horas com um único ficheiro fornecido. Num servidor de assinaturas que processe uma credencial por pedido, um único upload manipulado bloqueia um thread de processamento (worker).

A contagem agrava o problema do estouro de capacidade (wraparound) antes de fazer o CPU processar incessantemente. O valor da iteração reside no ficheiro como um INTEGER ASN.1, que não possui largura fixa, enquanto o campo consumido pela PBKDF2 é um Integer de 32 bits. Se descodificar o INTEGER diretamente para esse campo, um valor elevado será truncado, e um valor desenhado para acionar o bit de sinal resultará num número negativo ou num valor pequeno sem qualquer relação, de modo que a escala de processamento deixa de corresponder à solicitada pelo ficheiro. A solução passa por ler o valor na sua largura total e validar os seus limites antes de reduzir o seu tipo:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

Porque é que ambas as correções são a mesma correção

Os dois defeitos parecem diferentes, um é um transbordo de buffer e o outro um processo bloqueado, mas partilham o mesmo erro. Em ambos os casos, um número proveniente de um ficheiro não confiável foi transferido para um tipo de largura fixa um passo antes do devido, sem verificação prévia da realidade. O comprimento foi somado em 32 bits antes do teste de limites; a contagem de iterações foi reduzida para 32 bits antes do teste de intervalo. Ambos se resolvem com a mesma disciplina: descodificar com a largura total, verificar contra o limite real e apenas depois reduzir o tipo. O Int64 intermédio não é uma escolha de estilo; é a única largura na qual a validação consegue ver o valor efetivamente escrito pelo atacante. Um limite que transborda não é um limite, e uma contagem sem teto não é um parâmetro, é um acelerador remoto no seu próprio CPU.

Orientações práticas para um pipeline de assinatura

A lição imediata é validar entradas de certificados não confiáveis da mesma forma que validaria qualquer upload inseguro. Limite o tamanho de ficheiros .pfx que aceita, uma vez que um ficheiro legítimo tem kilobytes e não megabytes. Trate uma falha de análise como uma rejeição rotineira de dados, e não como um erro a apresentar com stack trace ao utilizador. Se assina num servidor, corra a importação num contexto em que um processo bloqueado não deite abaixo o serviço, e defina um timeout para a operação, para que um ficheiro inesperadamente exigente seja limitado pelo tempo de relógio, para além do teto de iterações.

A lição mais ampla vai além dos certificados. O reforço da segurança de analisadores não é uma auditoria única a uma unidade; é uma propriedade de cada ponto em que a sua biblioteca lê bytes que não escreveu. Uma biblioteca de PDF analisa muitos dados de origens não confiáveis: tipos de letra incorporados num documento, imagens em meia dúzia de codecs, filtros de fluxos e, no caminho de assinatura, certificados. Cada um deles é uma superfície de ataque e todos merecem a mesma desconfiança em relação a comprimentos e contagens. O HotPDF constrói o caminho de importação e de assinatura sobre as unidades seguras HPDFASN1, HPDFPFX, HPDFCrypt e HPDFCMS aqui descritas, assegurando que a credencial que lhe fornece, independentemente da sua origem, seja analisada defensivamente antes de se confiar nela.

O fluxo de trabalho de assinatura protegido por estas validações é abordado de ponta a ponta no nosso guia detalhado de assinaturas digitais PAdES em Delphi, e a mesma postura defensiva aplicada à encriptação de documentos, incluindo o caminho de chaves AES-256 que partilha esta base de código, está descrita no artigo sobre encriptação e segurança AES-256. Tudo isto é disponibilizado como parte do HotPDF Component para Delphi e C++Builder, juntamente com as APIs de carregamento, edição, encriptação e assinatura documentadas noutras secções deste blogue.