Artigo Técnico

Análise Segura de Dicionários PDF em Delphi: Tokens de Nome

A utilização de pesquisas de substring (como Pos('/Length', Dict)) é inadequada para a leitura de dicionários PDF, dado que as chaves de nome (names) partilham prefixos comuns (ex: /Length é prefixo de /Length1, e /Encrypt de /EncryptMetadata). A norma ISO 32000-1 §7.3.5 define que um nome constitui um token delimitado exclusivamente por delimitadores ou espaços em branco, pelo que a chave só deve ser considerada válida se o byte subsequente coincidir com um destes caracteres. Ignorar esta verificação causará a leitura de valores incorretos mesmo em ficheiros em conformidade com as especificações

O erro que nos alertou para esta questão não parecia associado ao analisador léxico. Uma validação de conformidade assinalou um stream do tipo FontFile como corrompido, surgindo a fonte cortada a meio de uma tabela. O ficheiro abria corretamente em todos os visualizadores e os dados no disco estavam íntegros. A causa residia numa linha do nosso analisador de dicionários: a função Pos('/Length', ...) mapeou incorretamente a chave /Length1 (um campo padrão em streams FontFile conforme a Tabela 127 da norma ISO 32000-1), assumindo o número após /Length1 como o comprimento (length) do stream. Adicionalmente, o gerador do ficheiro serializou a chave /Length1 antes de /Length (procedimento válido, visto que os elementos do dicionário não possuem ordem definida nos termos do §7.3.7). Isto resultou no truncamento do stream por leitura de valores incorretos e falhas nos processos subsequentes

Por que razão a pesquisa de substrings corrompe a leitura de dicionários PDF?

As pesquisas de substring falham porque o espaço de nomes do PDF inclui diversas famílias de prefixos e os dicionários não possuem ordenação obrigatória. A Tabela 127 da norma ISO 32000-1 define as chaves /Length1, /Length2 e /Length3 em streams de fontes embutidas, coexistindo com a chave /Length no mesmo dicionário. O dicionário de encriptação associa o campo /Encrypt do trailer à chave interna /EncryptMetadata. Chaves curtas ampliam o risco: uma pesquisa estruturada como Pos('/' + Key, ...) (sendo Key = 'N') mapearia elementos como /Name ou /Nums. Estas colisões ocorrem em ficheiros conformes; a presença de /Length1 antes de /Length é válida, pelo que esta vulnerabilidade não decorre de ficheiros corrompidos mas sim de lógica de leitura incorreta

Este comportamento induz em erro sem gerar exceções visíveis. Uma chave /Length incorreta não interrompe a execução; devolve apenas um comprimento de bytes desfasado do real. Se estes dados forem passados para validações de fontes, processamentos de CMap ou metadados, as rotinas falharão silenciosamente (visto que o processamento parcial de um stream zlib impede a sua descompressão e o fluxo continua). Corrigimos esta classe de falhas na versão v2.14.3 do nosso analisador, após auditorias às secções §7.2 a §7.3 da norma ISO 32000-1 que assinalaram pesquisas por Pos como vulneráveis

O que define a norma ISO 32000-1 §7.3.5 sobre nomes?

A secção 7.3.5 estabelece de forma clara: um objeto de nome consiste numa barra oblíqua (solidus) seguida por uma sequência de caracteres normais, terminando o token no primeiro delimitador ou espaço em branco. Os delimitadores válidos são os parênteses, parênteses retos e chavetas, a barra oblíqua e a percentagem (( ) < > [ ] { } / %), sendo o espaço em branco definido por null, tabulação, quebra de linha, form feed, carriage return e espaço (§7.2.2 a §7.2.3). Esta lógica de fim de token é fulcral: o termo /Length1 constitui um token indivisível e diferente de /Length (tal como os identificadores LengthOne e Length são distintos em Pascal). Efetuar pesquisas de chaves por correspondência parcial de bytes equivale a reescrever o analisador omitindo as regras de terminação

// INCORRETO: também mapeia /Length1, /Length2, /Length3
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Validação de tokens completos: verificar o byte seguinte

A validação correta baseia-se no §7.3.5: a correspondência só é válida se o carácter a seguir ao termo procurado coincidir com um delimitador, espaço ou fim do buffer. Caso contrário, trata-se de um nome mais longo com prefixo comum, devendo a pesquisa continuar. A nossa correção substituiu as pesquisas por Pos diretas por uma função comum assente neste princípio:

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correto: correspondência de token completo segundo a ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // o token termina aqui: chave autêntica
    P := PosEx(Key, Dict, P + 1);    // prefixo de um nome mais longo: continuar a procurar
  end;
end;

Particularidades do ciclo: primeiro, a pesquisa continua após a primeira colisão de prefixo, visto que sequências como /Length1 120 /Length 4076 são permitidas e a chave correta pode surgir depois. Segundo, o fim do buffer atua como terminador válido, já que fragmentos de dicionários podem terminar após um nome. Outro aspeto: a mesma regra de delimitação aplica-se à esquerda do termo se a pesquisa omitir a barra oblíqua (caso contrário, a procura por 'Length' encontraria /PieceLength). Ancorar a pesquisa com a barra oblíqua (/) inicial resolve o limite esquerdo, pois a própria barra oblíqua constitui um delimitador que encerra o token anterior

Como um ficheiro PDF hostil pode explorar falhas no analisador?

Ficheiros corrompidos ou maliciosos podem explorar estas falhas de análise para esgotar recursos do sistema, uma vez que os valores numéricos dos dicionários costumam determinar o dimensionamento de buffers. Identificámos uma vulnerabilidade deste tipo na expansão de streams de objetos. O campo /N de um dicionário ObjStm indica o número de objetos compactados no stream, sendo este valor usado na declaração SetLength de um array. Contudo, em caso de erro, o analisador mantinha o parâmetro de saída inalterado mas retornava com sucesso, fazendo um campo /N inválido passar valores da pilha de memória não inicializados para SetLength. Valores inteiros residuais excessivamente altos desencadeavam pedidos de alocação de gigabytes de memória a partir de pequenos ficheiros corrompidos na fase de leitura preliminar

A correção abrangeu duas frentes: o analisador passou a retornar explicitamente 0 em caso de erro (prevenindo leitura de resíduos de memória) e a rotina deixou de confiar cegamente no valor de /N. O cabeçalho ObjStm antes da chave /First regista pares de inteiros (número do objeto e offset) para cada item compactado, ocupando cada par pelo menos quatro bytes (incluindo delimitadores). Qualquer valor de /N superior a FirstVal div 4 + 1 é logicamente incompatível com a dimensão do cabeçalho declarada, sendo rejeitado antes da alocação de memória. Este limite baseia-se em dados fidedignos da própria estrutura do ficheiro e não em constantes arbitrárias:

// /N é definido externamente; limitar pelo valor suportado em /First
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // zero explícito, sem resíduos de memória na pilha
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // o cabeçalho não pode conter tantos pares

// /Length nunca pode exceder a dimensão do ficheiro que contém o stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // rejeitar antes de alocar o buffer

Adicionámos duas salvaguardas adicionais na versão v2.12.0: o leitor rejeita comprimentos /Length superiores à dimensão do próprio ficheiro antes de alocar memória (prevenindo falsos positivos). E o fluxo de descompressão limita o output descompactado a 256 MiB, anulando potenciais ataques do tipo "zlib bomb" (onde ficheiros pequenos geram descompressões massivas), um limite seguro mas que previne o esgotamento de recursos. A lógica consiste em tratar as dimensões declaradas no ficheiro como alegações a validar pelo analisador antes do pedido de memória. Este controlo aplica-se também no mapeamento da API, conforme analisado no artigo sobre robustez de memória em Delphi

Limitações da validação de token único

Nota de limitação: a correspondência de tokens completos corrige a identificação de chaves, mas uma pesquisa sequencial simples de bytes não distingue se o termo reside em sub-dicionários aninhados, strings literais ou comentários (ex: FindDictKey num objeto de página pode mapear chaves sob /Resources se o intervalo de pesquisa for demasiado alargado). O nosso analisador restringe primeiro o intervalo ao corpo de um único objeto e isola comentários e strings. A robustez de strings é apenas uma etapa: a integridade de referências cruzadas é abordada no artigo sobre integridade de referências cruzadas e xref, e a análise de segurança global consta no artigo sobre auditoria de riscos em ficheiros PDF

Se mantém rotinas personalizadas de análise de dicionários em Delphi ou Lazarus, a lista de validações é curta: pesquise por chamadas a Pos('/ no código e canalize-as para uma função de validação de token; catalogue as chaves com prefixos comuns (ex: /Length, /Encrypt, /N, ou /Type contra /Type1); e audite as variáveis numéricas de SetLength, GetMem ou ciclos de cópia para garantir que possuem limites lógicos. Esta estrutura lógica suporta o PDFium Component