Artigo Técnico

Parser PDF encravado em Delphi: o cursor que nunca avança

Um parser encrava num PDF malformado quando algum ramo lê um byte, recusa-se a tratá-lo e retorna sem mover o cursor. O loop do chamador não vê progresso nem razão para parar, e por isso roda para sempre. O HotPDF veio precisamente com esse defeito em três parsers independentes, todos eles alcançáveis a partir de inputs com menos de cinquenta bytes

É uma classe de denial-of-service que merece nome próprio, porque não se parece com aquela contra a qual toda a gente já se protege. Não há descompressão, não há crescimento de alocação, não há pressão de memória que dispare um watchdog. O processo fica simplesmente a cem por cento de CPU num único núcleo com um punhado de bytes num buffer. Se adicionou um orçamento de decode depois de ler sobre amplificação de filtros aninhados e PDF bombs, esse orçamento não dispara aqui: nada está a ser decodificado e nada está a ser alocado. Falha diferente, correção diferente

Porque é que um ficheiro de quarenta bytes custa mais do que uma bomba de descompressão?

Porque o custo não tem qualquer relação com o tamanho do input. Um ataque de amplificação tem um rácio que se consegue limitar: cap nos bytes de saída, cap na profundidade de recursão, e o pior caso torna-se finito. Um loop de avanço zero não tem rácio para limitar. A condição do loop é uma função de um cursor que nunca muda, portanto não é lento, é não-terminante, e nenhum limite baseado em tamanho a montante o apanha. Isso também o torna operacionalmente desagradável à sua maneira. Um crash deixa uma stack, um out-of-memory deixa uma linha de log, e um parser encravado deixa uma worker thread que nunca retorna um resultado, um request que nunca faz timeout porque nada a jusante sabe que começou, e um ticket que diz que o servidor ficou lento na terça-feira. A causa estrutural tem sempre a mesma forma. Um tokenizer ou parser de valores tem uma cadeia de ramos chaveados no byte atual. Cada ramo que reconhece algo consome aquilo que reconheceu. Depois há um ramo final, ou um guard antes da cadeia, que retorna para os bytes que ninguém reclamou, e o autor raciocina sobre ele como um caminho de erro em vez de um passo de scanning. Caminhos de erro retornam. Passos de scanning avançam. Quando uma função tem de ser as duas coisas, o avanço é o que fica esquecido

Diagrama de loop de um stall de parser de avanço zero no HotPDF: o chamador pede a ParseValue o próximo valor, o parser recusa o byte atual e retorna nil com o cursor intocado, e o chamador volta a pedir para sempre; ao contrário de uma bomba de descompressão não há rácio para limitar nem orçamento de decode que possa disparar
Um parser encravado fica a cem por cento de CPU sobre um punhado de bytes, pelo que toda a função que lê o cursor tem de consumir pelo menos um byte ou retornar uma falha inequívoca

O parser de objetos: dois delimitadores que ninguém consumiu

No HPDFParser, o método THPDFSpanObjectParser.ParseValue começa por recusar duas sequências de bytes sem rodeios. Segundo a secção 7.3 da ISO 32000-1, um valor não pode começar com ] nem com >>, porque estes fecham um contentor em vez de abrir um valor, pelo que ParseValue retorna nil para ambos sem os consumir. Esse é o contrato correto, e coloca todo o fardo de avançar no chamador. O ramo de arrays honrou metade dele: num resultado nil verificava ], deixava-o no lugar para a passagem seguinte fechar o array, e saltava por cima de tudo o resto — exceto que a verificação estava escrita de modo a que um >> isolado também ficasse no lugar. Assim, [ >> ] encrava, porque ParseArray pede um valor, recebe nil, não avança nada e volta a pedir. O ramo de dicionários tinha o buraco em espelho, e << /A ] >> encrava da mesma forma. Ambos os inputs são válidos o suficiente para chegarem ao parser e curtos o suficiente para caberem num tweet

Diagrama do defeito do parser de objetos do HotPDF em que ParseValue recusa os dois delimitadores de fecho sem os consumir: o ramo de arrays tolera um colchete de fecho mas deixa um duplo-fecho isolado no lugar, pelo que a amostra de array roda para sempre, o ramo de dicionários tem o buraco em espelho, e a correção salta explicitamente por cima da sequência sobre a qual o loop não consegue atuar
Retornar nil para um byte não reclamado é o contrato correto, e coloca todo o fardo de avançar no loop do chamador
uses
  HPDFObjs, HPDFParser;

procedure ProbeParserStalls;
var
  Stats: THPDFParserViewStatistics;
  Arr: THPDFArrayObject;
  Dict: THPDFDictionaryObject;
begin
  // Antes da correção, nenhuma das chamadas abaixo retornava
  Arr := HPDFParserParseSimpleArrayWithStatistics('[ >> ]', Stats);
  Arr.Free;
  WriteLn('array errors=', Stats.ErrorCount, ' valid=', Stats.Valid);

  Dict := HPDFParserParseSimpleDictionaryWithStatistics('<< /A ] >>', Stats);
  Dict.Free;
  WriteLn('dict errors=', Stats.ErrorCount, ' valid=', Stats.Valid);
end;

A correção não é engenhosa, e é esse o ponto: o caminho nil tem de decidir, explicitamente, qual das duas sequências não consumidas o loop atual consegue tratar, e saltar por cima da outra

Value := ParseValue(Depth + 1);
if Value = nil then
begin
  RecordError;
  // ParseValue retorna nil para ']' e para '>>' sem os consumir.
  // O ']' termina o array na próxima passagem, mas um '>>' isolado
  // dentro de um array tem de ser saltado ou o parse nunca avança
  if (FPosition < FLength) and StartsWithPair('>', '>') then
    Inc(FPosition, 2)
  else if (FPosition < FLength) and (CharAt(FPosition) <> ']') then
    Inc(FPosition);
  Continue;
end;

Mais uma coisa merece atenção aqui. O THPDFSpanObjectParser.RecordError apenas incrementa o ErrorCount no THPDFParserViewStatistics; não limita nada. O parser define HPDFParserMaximumDepth a 64 para limitar o aninhamento, mas não tem teto equivalente para a contagem de erros, e um contador que ninguém lê é um circuit breaker falhado. Mesmo com o avanço restaurado, um input que produz um erro por byte vai devorar um objeto grande a um ritmo que merece uma saída antecipada em vez de uma travessia completa. Verifique o Valid no registo de estatísticas depois de cada parse e trate um ErrorCount grande como uma razão para deixar de confiar no objeto, e não apenas como um diagnóstico

A mesma forma no tokenizer CMap e no leitor XFDF

O HPDFCMapReader.NextToken trazia o defeito idêntico no seu ramo fallback. A função retorna um TCMapTok e trata <, ( e / com ramos dedicados; tudo o resto cai em "ler até whitespace ou delimitador". Quando o byte inicial é ele próprio um delimitador do conjunto da secção 7.2.2 da ISO 32000-1, esse loop corre zero vezes, o token volta vazio, e o chamador pede o mesmo token outra vez. Dos seis caracteres que podem lá parar, apenas [ era intercetado mais cedo pelo tratamento de bfrange, pelo que um ), >, ], { ou } isolado em qualquer ponto de um CMap ToUnicode bastava para encravar a extração de texto

// keyword ou número: ler até whitespace ou delimitador
StartP := P;
while (P <= Len) and (not IsCMapWhiteSpace(Text[P]))
  and (not IsCMapDelimiter(Text[P])) do
  Inc(P);
// Um delimitador que nenhum dos ramos acima reclama (um ')', '>',
// '[', ']', '{' ou '}' isolado) deixaria P onde estava e retornaria uma
// keyword vazia, pelo que o chamador pediria o mesmo token para sempre
if P = StartP then
  Inc(P);
AKeyword := Copy(Text, StartP, P - StartP);
Result := ctKeyword;

O lado XFDF tinha uma variante com origem diferente e o mesmo efeito. O HPDFXFDFXml.ParseElement define Result := P no topo, enquanto P ainda aponta para o < de abertura, e só depois incrementa o seu cursor local, pelo que cada saída antecipada que se segue retorna a posição de entrada a menos que sobrescreva Result primeiro. Uma declaração de doctype segundo a secção 2.8 do XML 1.0, ou qualquer outra coisa sem um nome de tag parsável como <1a>, seguia uma saída que nunca atualizava Result, e o loop de elementos-filhos recebia a posição que tinha passado. O loop de atributos tinha mais três destas, porque o ReadName, o skip de whitespace e o tratamento de = declinam todos um byte que não é caractere de nome, nem whitespace, nem um de =, >, /, e nenhum deles o consumia. A reparação de CDATA vizinha moveu uma responsabilidade em vez de adicionar um guard: uma secção CDATA segundo a secção 2.7 do XML 1.0 começa com <, pelo que o loop de filhos costumava passá-la ao ParseElement, que então lia um nome de tag a partir do conteúdo da secção. Reconhecê-la no loop de filhos com IsCDATAStart e copiar bytes para o buffer de texto até IsCDATAEnd é a divisão correta, e o skip antigo também avançava apenas um byte, já que o marcador tem oito bytes contados a partir do !

Contagens declaradas que sobrevivem aos dados

Uma release posterior trouxe o mesmo padrão pela direção oposta, e é a variante com maior probabilidade de existir no seu próprio código. Um cabeçalho de subsecção de cross-reference segundo a secção 7.5.4 da ISO 32000-1 é um par first count, e os dois parsers de xref tradicional do HotPDF faziam loop for I := 0 to Count - 1 sobre ele, usando Continue em vez de Break quando uma entrada falhava o parse. Isso lê-se como tolerância para uma linha má. Não é: o HPDFReadLine retorna uma string vazia sem avançar assim que os dados se esgotam, e o HPDFParseTraditionalXrefEntry rejeita uma entrada vazia imediatamente, pelo que cada iteração restante da contagem declarada é puro spin. A aritmética é cruel — o cabeçalho xref\n0 2000000000\ntrailer mais um wrapper mínimo tem cerca de quarenta bytes, e compra dezenas de segundos de um núcleo ocupado sem qualquer verificação de cancelamento no loop. A pista estava na mesma unit, porque o HPDFRevisionParseTraditionalXRef tinha usado sempre Exit nessa posição. Quando duas funções fazem parse da mesma gramática e discordam sobre se uma entrada falhada termina a subsecção, uma delas é um fork que deixou de receber as correções, a mesma assinatura que aparece no tratamento de hybrid cross-reference onde uma tabela tradicional e um stream descrevem os mesmos objetos

Diagrama de uma subsecção xref tradicional no HotPDF cujo cabeçalho declara 2000000000 entradas para cerca de quarenta bytes de ficheiro: o HPDFReadLine retorna uma string vazia sem avançar assim que os dados se esgotam, um Continue mantém todas as iterações restantes a rodar, e o loop corrigido faz break na primeira entrada má com um teto de Stream.Size div 8
Uma contagem declarada é uma afirmação sobre o ficheiro, não sobre os dados, pelo que uma entrada falhada tem de terminar a subsecção em vez de saltar uma iteração
if not HPDFParseIntegerPair(Line, First, Count) then
  Exit;
for I := 0 to Count - 1 do
begin
  Entry := HPDFReadLine(XrefData, P);
  // Uma contagem declarada maior do que a tabela faria spin
  // aqui sem sobrar dados para ler
  if not HPDFParseTraditionalXrefEntry(Entry, EntryOffset, GenNum, InUse) then
    Break;
  // First vem do cabeçalho da subsecção, pelo que a soma faz wrap para um
  // início declarado perto do limite de Integer, e o helper de crescimento
  // recusa silenciosamente um índice negativo
  if (First < 0) or (Int64(First) + I > MaxInt) then
    Break;
  ObjNumber := First + I;
  if (ObjNumber < 0) or (Int64(ObjNumber) > Stream.Size div 8) then
    Break;
  HPDFEnsureDirectXrefLength(State, ObjNumber);
  // ... registar a entrada ...
end;

Os dois limites depois do Break pertencem à mesma reparação. O HPDFEnsureDirectXrefLength recusa-se a crescer o array para um número de objeto negativo e não diz nada sobre isso, e First + I faz wrap para um início declarado perto do limite de Integer, pelo que um índice negativo chega aos writes abaixo. O teto de número de objeto de Stream.Size div 8 é a resposta geral barata, já que nenhuma tabela de cross-reference tradicional pode descrever mais objetos do que o ficheiro tem espaço para conter, e qualquer cabeçalho que afirme o contrário está a descrever um ficheiro que não existe. Se estiver a reparar à mão uma tabela danificada, o mesmo raciocínio aplica-se do outro lado, que é o território coberto nas notas sobre diagnóstico e reparação de PDFs editados à mão

Como provar que uma correção de hang funciona mesmo?

A correr o código antigo, não a relê-lo. Um stall é uma das poucas classes de defeito em que uma correção pode ser verificada com um sinal binário e sem instrumentação, porque o processo ou retorna ou não retorna, e raciocinar sobre control flow é exatamente a competência que produziu o bug em primeiro lugar. A mecânica vale a pena copiar. Extraia a revisão pré-correção das units alteradas para um diretório scratch, e compile uma probe pequena com o unit search path a colocar esse diretório à frente da biblioteca real: dcc32 -U<scratchdir>;<Lib> probe.dpr. O compilador usa as fontes antigas para aquilo que encontra primeiro e as fontes atuais para tudo o resto, pelo que um único binário de probe exercita o parser antigo contra o resto da biblioteca. Corra-o com um timeout — quarenta segundos é generoso — e trate o exit code 124 como a asserção. Depois retire as units do diretório scratch uma a uma e volte a compilar. Os três stalls reproduziram-se assim, cada um atribuível a uma unit específica, e os três desapareceram assim que a fonte corrigida voltou ao path, ao custo de uma compilação por unit

Vale a pena conhecer duas armadilhas antes de tentar. O unit search path é silenciosamente permissivo: entregue ao -U um path em estilo Unix vindo de uma shell, como $PWD, e o compilador ignora esse diretório sem dizer palavra, o que significa que a sua probe foi compilada contra as fontes atuais e passou pela razão errada. Converta o path para a forma nativa do Windows primeiro. A segunda é que o diretório scratch não traz os ficheiros de include, pelo que também precisa de -I<Lib> para o HotPDF.inc, ou o build falha de uma forma que parece um problema de fontes em vez de um problema de path. E seja honesto sobre aquilo que o método não lhe dá: confirma que um input específico deixou de encravar num build específico, e não diz nada sobre os inputs em que não pensou. Defeitos de avanço zero encontram-se por fuzzing ou lendo todos os ramos de todos os tokenizers, não por regression tests escritos a posteriori

O que auditar nos seus próprios parsers

A regra reutilizável é um contrato, não um item de checklist. Qualquer função que leia de um cursor partilhado tem de, em todos os caminhos incluindo os de falha, ou consumir pelo menos um byte ou retornar um valor que o chamador não possa confundir com "tenta outra vez". Misturar esses dois significados num único nil ou num único token vazio é o que transforma uma recusa num stall. Portanto audite pela forma e não pelo sintoma. Encontre todos os loops cuja terminação depende de um cursor que outra função possui, e pergunte o que acontece quando essa função recusa o byte atual. Encontre todos os ramos fallback escritos como "lê até X" e pergunte o que fazem quando o cursor já está em X. Encontre todos os loops limitados por uma contagem que veio do ficheiro e não dos dados, e faça com que um elemento falhado termine o loop em vez de saltar uma iteração. Onde a mesma gramática é parsed em dois sítios, faça diff deles, porque um terá recebido uma correção que o outro não recebeu. E ponha uma verificação de cancelamento dentro de qualquer loop que possa correr um número de iterações fornecido pelo chamador, para que um operador tenha algo a que puxar quando o próximo destes aparecer em produção

Os parsers descritos aqui estão incluídos no atual HotPDF Delphi Component para Delphi e C++Builder, com os registos de estatísticas e os limites de profundidade expostos para que o seu próprio hardening possa assentar neles