Artigo Técnico

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

Buscas de substring como Pos('/Length', Dict) são a ferramenta errada para ler um dicionário PDF, porque as chaves de nome do PDF compartilham prefixos: /Length é um prefixo de /Length1, e /Encrypt é um prefixo de /EncryptMetadata. A ISO 32000-1 §7.3.5 define um nome como um token que termina apenas em um delimitador ou espaço em branco, portanto, uma chave só é considerada encontrada quando o byte posterior a ela é um desses caracteres. Um leitor de dicionário que ignora essa única verificação eventualmente lerá o valor incorreto de um arquivo perfeitamente válido

O bug que nos ensinou isso não se parecia em nada com um problema de analisador léxico. Uma varredura de conformidade começou a relatar um fluxo FontFile como corrompido: o programa de fonte descompactado era um fragmento, cortado no meio da tabela. O arquivo abria normalmente em todos os visualizadores. Os dados do fluxo no disco estavam intactos. A causa raiz estava em uma linha do nosso leitor de dicionário compartilhado: Pos('/Length', ...) havia correspondido a /Length1, uma chave padrão que os dicionários de fluxo FontFile carregam conforme a Tabela 127 da ISO 32000-1, e o leitor considerou o número inteiro após /Length1 como o comprimento do fluxo. O gravador desse arquivo específico por acaso serializou o /Length1 antes de /Length, o que é inteiramente livre para fazer, já que as entradas do dicionário não possuem ordem definida conforme o §7.3.7. O fluxo foi truncado para uma contagem de bytes inválida, e toda verificação subsequente que o consumia falhou silenciosamente

Por que a correspondência de substring quebra a análise de dicionários PDF?

A correspondência de substring quebra porque o espaço de nomes do PDF está repleto de famílias de prefixos deliberadas, e porque a ordem das entradas do dicionário não é especificada. A Tabela 127 da ISO 32000-1 define /Length1, /Length2 e /Length3 para fluxos de fontes incorporados, todos situados ao lado de um /Length no mesmo dicionário. O dicionário de criptografia emparelha o /Encrypt no trailer com o /EncryptMetadata dentro dele. Chaves curtas são piores: uma busca construída como Pos('/' + Key, ...) com Key = 'N' cai alegremente em /Name ou /Nums. Nenhuma dessas colisões exige um arquivo malformado. Um gravador que ordena o /Length1 antes de /Length é totalmente em conformidade, o que significa que o bug de substring não é uma lacuna de robustez contra entradas corrompidas — é uma lacuna de correção contra entradas válidas

O modo de falha também é silencioso. Um /Length incorreto não gera uma exceção; ele simplesmente entrega uma fatia de bytes mais curta ou mais longa do que o fluxo realmente ocupa. Se essa fatia alimenta uma verificação de subconjunto de fontes, uma análise CMap ou uma varredura de metadados, o consumidor vê dados corrompidos e normalmente não relata nada, porque metade de um fluxo zlib simplesmente falha ao inflar e o código prossegue. Enviamos exatamente essa classe de defeito e a corrigimos na v2.14.3 do nosso leitor compartilhado, após uma auditoria cláusula por cláusula da ISO 32000-1 §7.2–§7.3 sinalizar cada busca de chave no estilo Pos como suspeita

O que a ISO 32000-1 §7.3.5 realmente define como um nome

A Seção 7.3.5 é curta e precisa: um objeto de nome é uma barra sólida (solidus) seguida por uma sequência de caracteres normais, e o token é terminado pelo primeiro caractere delimitador ou espaço em branco. Os delimitadores são os oito caracteres de colchetes mais a barra sólida e o sinal de porcentagem — ( ) < > [ ] { } / % — e o espaço em branco é nulo, tabulação, alimentação de linha, alimentação de formulário, retorno de carro e espaço (§7.2.2–§7.2.3). Essa regra de terminação é toda a história. O /Length1 não é "/Length seguido por um 1"; é um token único e indivisível, exatamente como LengthOne e Length são identificadores diferentes no Pascal. Qualquer leitor que encontre chaves por busca de bytes brutos está reimplementando o analisador léxico sem a regra de terminação

Aqui está a forma do defeito, reduzida aos seus elementos essenciais. Esta versão compila, passa nos testes com arquivos cujos gravadores ordenam o /Length primeiro, e corrompe fluxos para gravadores que não o fazem

// WRONG: matches /Length1, /Length2, /Length3 as well
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;

Correspondência de token completo: verifique o byte após a chave

O predicado correto segue diretamente do §7.3.5: uma correspondência candidata é uma chave real apenas se o caractere imediatamente posterior a ela for um delimitador, espaço em branco ou o final do buffer. Qualquer outra coisa é um nome mais longo que apenas compartilha um prefixo, portanto a busca deve continuar além dele em vez de desistir. A correção em nosso leitor substituiu cada busca bruta com Pos por uma única rotina compartilhada baseada nesta regra

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

// Correct: whole-token match per 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);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

Dois detalhes nesse loop são importantes. Primeiro, ele continua a busca em vez de retornar falha na primeira colisão de prefixo, porque /Length1 120 /Length 4076 é uma ordenação legal e a chave real ainda está à frente. Segundo, o caso de fim de buffer conta como um terminador, pois um fragmento de dicionário pode legitimamente terminar logo após um nome. Um ponto mais sutil que vale a pena auditar em seu próprio código: a mesma regra se aplica ao lado esquerdo da correspondência se sua string de busca não incluir a barra sólida, caso contrário, Pos('Length', ...) pode cair dentro de /PieceLength. Ancorar a string de busca com a barra / no início, como mostrado acima, lida com a borda esquerda porque a barra / é por si só um delimitador que finaliza o token anterior

Como um PDF hostil pode transformar um bug de analisador em uma alocação de gigabytes?

Um arquivo malformado ou malicioso escala esses erros léxicos para o esgotamento de recursos, porque números inteiros de dicionário frequentemente alimentam tamanhos de alocação. Nossa auditoria encontrou uma cadeia exatamente desse formato na expansão de fluxos de objetos (object-stream). A entrada /N de um dicionário ObjStm informa quantos objetos compactados o fluxo contém, e o código de expansão chamou SetLength em uma matriz dimensionada por ele. O analisador de inteiros, no entanto, deixou seu parâmetro de saída (out-parameter) intocado em caso de falha, enquanto ainda o retornava — de modo que um /N não numérico entregou ao SetLength um valor de pilha não inicializado. Um número inteiro positivo inválido ali significa uma solicitação de alocação em gigabytes, acionada por alguns bytes de entrada corrompida, apenas ao verificar um documento que você ainda nem concordou em confiar

A correção teve duas partes independentes, e ambas se generalizam. O analisador agora retorna um 0 explícito em caso de falha, nunca memória não inicializada. E o consumidor não confia mais no /N sem validar a aritmética: a região do cabeçalho ObjStm antes de /First armazena um par de inteiros — número do objeto e deslocamento — para cada objeto compactado, e cada par ocupa pelo menos quatro bytes incluindo os separadores. Qualquer /N acima de FirstVal div 4 + 1 é, portanto, fisicamente impossível para o tamanho de cabeçalho declarado e é rejeitado antes que ocorra qualquer alocação. O limite custa uma comparação e é derivado de dados já disponíveis, que é o padrão a ser procurado: um teto que o próprio arquivo comprova, não uma constante arbitrária

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Mais dois limites completam o perímetro defensivo em nosso leitor, ambos integrados na v2.12.0. O leitor de fluxo recusa qualquer /Length maior do que o arquivo inteiro antes de alocar o buffer de resultado — um fluxo não pode ser maior do que o contêiner no qual ele reside, portanto a verificação é livre de falsos positivos. E o caminho de descompactação (inflate) limita a saída descompactada em 256 MiB, o que impede a clássica bomba zlib, onde alguns kilobytes de entrada se expandem sem limite; o limite é generoso para qualquer fluxo PDF real, mantendo o pior caso sobrevivível. O tema nos três casos é o mesmo: cada tamanho que um arquivo declara é uma alegação, e o analisador verifica cada alegação em relação a algo que pode medir antes de comprometer memória para ele. A mesma postura de auditoria se aplica um nível abaixo, no limite do binding, que é abordado no artigo sobre endurecendo a ABI do PDFium e a segurança de memória no Delphi

Onde a regra de correspondência de token completo não é suficiente

Limites honestos, para que você não confie demais na rotina acima. A correspondência de token completo corrige a identificação da chave, mas uma busca de bytes simples em uma extensão de dicionário ainda não consegue distinguir se uma correspondência reside dentro de um dicionário aninhado, de uma string literal ou de um comentário — o FindDictKey em um objeto de página pode cair em uma chave dentro de seu subdicionário /Resources se você fornecer a ele um intervalo amplo demais. Nosso leitor restringe o intervalo primeiro a um corpo de objeto único e trata os contextos de string e comentário como um item de auditoria separado e ainda aberto. A segurança de substring é um degrau de uma escada, não a escada inteira: a consistência de referência cruzada é uma disciplina à parte, abordada em validando fluxos de objeto e xref, e o catálogo mais amplo de ameaças para documentos dos quais você não é o autor está em auditando riscos de segurança de PDF

Se você mantém um leitor de dicionário escrito à mão em Delphi ou Lazarus, la lista de verificação deste incidente é curta. Procure (grep) por cada Pos('/ na base de código e direcione as ocorrências por um único utilitário de token completo. Liste as famílias de prefixos das quais suas chaves participam — /Length, /Encrypt, /N, /Type contra /Type1 aparecem em arquivos reais. Em seguida, percorra cada número inteiro que chega a SetLength, GetMem ou a um loop de cópia e pergunte o que o limita: o tamanho do arquivo, um teto derivado do cabeçalho ou nada. A camada de análise descrita aqui é a base do nosso Componente PDFium, onde o leitor em nível de bytes e o mecanismo de renderização cruzam dados em cada documento que tocam