Artículo técnico

El parser PDF se cuelga en Delphi: el cursor no avanza

Un parser se cuelga ante un PDF malformado cuando alguna rama lee un byte, se niega a tratarlo y devuelve sin mover el cursor. El bucle llamador no ve progreso ni razón para parar, así que gira eternamente. HotPDF llegó a publicar exactamente ese defecto en tres parsers independientes, todos alcanzables desde entradas de menos de cincuenta bytes

Esta clase de denegación de servicio merece nombre propio, porque no se parece a la que todo el mundo ya cubre. No hay descompresión, ni crecimiento de asignaciones, ni presión de memoria que dispare un watchdog. El proceso se queda al cien por cien de CPU en un solo núcleo con un puñado de bytes en un buffer. Si añadiste un presupuesto de decodificación después de leer sobre la amplificación de filtros anidados y las PDF bombs, ese presupuesto no saltará aquí: no se decodifica nada y no se asigna nada. Fallo distinto, arreglo distinto

¿Por qué un archivo de cuarenta bytes cuesta más que una bomba de descompresión?

Porque el coste no tiene ninguna relación con el tamaño de la entrada. Un ataque de amplificación tiene una proporción que puedes acotar: pon tope a los bytes de salida, pon tope a la profundidad de recursión, y el peor caso se vuelve finito. Un bucle de avance cero no tiene proporción que acotar. La condición del bucle es función de un cursor que nunca cambia, así que no es lento: no termina, y ningún límite basado en el tamaño aguas arriba lo va a cazar. Eso además lo hace especialmente desagradable en operación. Un crash te da un stack, un out-of-memory te da una línea de log, y un parser atascado te da un worker thread que nunca devuelve un resultado, una petición que nunca hace timeout porque nada aguas abajo sabe que empezó, y un ticket que dice que el servidor se puso lento el martes. La causa estructural siempre tiene la misma forma. Un tokenizador o un parser de valores tiene una cadena de ramas que dependen del byte actual. Cada rama que reconoce algo consume lo que reconoció. Después hay una rama final, o un guard antes de la cadena, que devuelve por los bytes que nadie reclamó, y el autor la razona como un camino de error, no como un paso de escaneo. Los caminos de error devuelven. Los pasos de escaneo avanzan. Cuando una función tiene que ser las dos cosas, lo que se olvida es el avance

Diagrama del bucle de un parser atascado con avance cero en HotPDF: el llamador pide a ParseValue el siguiente valor, el parser rechaza el byte actual y devuelve nil sin tocar el cursor, y el llamador vuelve a pedir eternamente; a diferencia de una bomba de descompresión, no hay proporción que acotar ni presupuesto de decodificación que pueda saltar
Un parser atascado se queda al cien por cien de CPU con un puñado de bytes, así que toda función que lea el cursor debe consumir al menos un byte o devolver un fallo inconfundible

El parser de objetos: dos delimitadores que nadie consumió

En HPDFParser, el método THPDFSpanObjectParser.ParseValue empieza rechazando dos secuencias de bytes de entrada. Según la sección 7.3 de la ISO 32000-1, un valor no puede empezar por ] ni por >>, porque eso cierra un contenedor en vez de abrir un valor, así que ParseValue devuelve nil para ambas sin consumirlas. Ese contrato es correcto, y deja toda la carga de avanzar en el llamador. La rama de arrays cumplió solo la mitad: ante un nil comprobaba el ], lo dejaba en su sitio para que la siguiente pasada cerrara el array, y saltaba todo lo demás — salvo que la comprobación estaba escrita de forma que un >> suelto también se quedaba en su sitio. Así que [ >> ] se cuelga, porque ParseArray pide un valor, recibe nil, no avanza nada y vuelve a pedir. La rama de diccionarios tenía el agujero en espejo, y << /A ] >> se cuelga igual. Ambas entradas son lo bastante válidas para llegar al parser y lo bastante cortas para caber en un tuit

Diagrama del defecto del parser de objetos de HotPDF donde ParseValue rechaza ambos delimitadores de cierre sin consumirlos: la rama de arrays tolera el corchete de cierre pero deja un doble cierre suelto en su sitio, así que el ejemplo de array gira eternamente, la rama de diccionarios tiene el agujero en espejo, y el arreglo salta explícitamente la secuencia que el bucle no puede tratar
Devolver nil por un byte no reclamado es el contrato correcto, y deja toda la carga de avanzar en el bucle llamador
uses
  HPDFObjs, HPDFParser;

procedure ProbeParserStalls;
var
  Stats: THPDFParserViewStatistics;
  Arr: THPDFArrayObject;
  Dict: THPDFDictionaryObject;
begin
  // Antes del arreglo, ninguna de las dos llamadas de abajo llegaba a devolver
  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;

El arreglo no es ingenioso, y ese es el punto: el camino del nil tiene que decidir, explícitamente, cuál de las dos secuencias sin consumir puede manejar el bucle actual, y saltarse la otra

Value := ParseValue(Depth + 1);
if Value = nil then
begin
  RecordError;
  // ParseValue devuelve nil tanto para ']' como para '>>' sin
  // consumirlos. El ']' cierra el array en la siguiente pasada, pero un
  // '>>' suelto dentro de un array hay que saltarlo o el parseo nunca avanza
  if (FPosition < FLength) and StartsWithPair('>', '>') then
    Inc(FPosition, 2)
  else if (FPosition < FLength) and (CharAt(FPosition) <> ']') then
    Inc(FPosition);
  Continue;
end;

Aquí merece atención una cosa más. THPDFSpanObjectParser.RecordError solo incrementa ErrorCount en THPDFParserViewStatistics; no topea nada. El parser fija HPDFParserMaximumDepth en 64 para acotar el anidamiento, pero no tiene un techo equivalente para el contador de errores, y un contador que nadie lee nunca es un circuit breaker que se te escapó. Aunque el avance esté restaurado, una entrada que produce un error por byte irá devorando un objeto grande a un ritmo que merece una salida temprana, no un recorrido completo. Comprueba Valid en el registro de estadísticas tras cada parseo, y trata un ErrorCount grande como motivo para dejar de fiarte del objeto, no solo como un diagnóstico

La misma forma en el tokenizador CMap y en el lector XFDF

HPDFCMapReader.NextToken llevaba el mismo defecto en su rama de respaldo. La función devuelve un TCMapTok y trata <, ( y / con ramas dedicadas; todo lo demás cae en "leer hasta un espacio o un delimitador". Cuando el byte inicial es en sí un delimitador del conjunto de la sección 7.2.2 de la ISO 32000-1, ese bucle se ejecuta cero veces, el token vuelve vacío, y el llamador pide el mismo token otra vez. De los seis caracteres que pueden caer ahí, solo [ lo interceptaba antes el manejo de bfrange, así que un ), >, ], { o } suelto en cualquier punto de un CMap ToUnicode bastaba para atascar la extracción de texto

// palabra clave o número: leer hasta un espacio o delimitador
StartP := P;
while (P <= Len) and (not IsCMapWhiteSpace(Text[P]))
  and (not IsCMapDelimiter(Text[P])) do
  Inc(P);
// Un delimitador que ninguna de las ramas de arriba reclama (un ')', '>',
// '[', ']', '{' o '}' suelto) dejaría P donde estaba y devolvería una
// palabra clave vacía, así que el llamador pediría el mismo token eternamente
if P = StartP then
  Inc(P);
AKeyword := Copy(Text, StartP, P - StartP);
Result := ctKeyword;

El lado XFDF tenía una variante con otro origen y el mismo efecto. HPDFXFDFXml.ParseElement fija Result := P al principio, cuando P todavía apunta al < de apertura, y solo después incrementa su cursor local, así que toda salida temprana que venga después devuelve la posición de entrada salvo que sobrescriba Result antes. Una declaración de doctype según la sección 2.8 de XML 1.0, o cualquier otra cosa sin nombre de etiqueta parseable como <1a>, tomaba una salida que nunca actualizaba Result, y el bucle de elementos hijos recibía la posición que él mismo había pasado. El bucle de atributos tenía tres más de estas, porque ReadName, el salto de espacios y el manejo de = rechazan un byte que no es ni carácter de nombre, ni espacio, ni uno de =, >, /, y ninguno lo consumía. La reparación vecina de CDATA movió una responsabilidad en vez de añadir un guard: una sección CDATA según la sección 2.7 de XML 1.0 empieza por <, así que el bucle de hijos solía pasársela a ParseElement, que entonces leía un nombre de etiqueta del contenido de la sección. Reconocerla en el bucle de hijos con IsCDATAStart y copiar bytes al buffer de texto hasta IsCDATAEnd es la división correcta, y el salto antiguo también era de un solo byte, porque el marcador tiene ocho bytes contados desde la !

Contadores declarados que sobreviven a los datos

Una versión posterior sacó a la luz el mismo patrón desde la dirección opuesta, y es la variante que más probablemente exista en tu propio código. La cabecera de una subsección de cross-reference según la sección 7.5.4 de la ISO 32000-1 es un par first count, y ambos parsers de xref tradicional de HotPDF hacían un bucle for I := 0 to Count - 1 sobre ella, usando Continue en vez de Break cuando una entrada fallaba al parsearse. Eso se lee como tolerancia a una línea mala. No lo es: HPDFReadLine devuelve una cadena vacía sin avanzar una vez que los datos se agotan, y HPDFParseTraditionalXrefEntry rechaza una entrada vacía de inmediato, así que cada iteración restante del contador declarado es giro puro. La aritmética es despiadada: la cabecera xref\n0 2000000000\ntrailer con un envoltorio mínimo son unos cuarenta bytes, y compran decenas de segundos de un núcleo ocupado sin ninguna comprobación de cancelación en el bucle. La pista estaba en la misma unidad, porque HPDFRevisionParseTraditionalXRef siempre había usado Exit en esa posición. Cuando dos funciones parsean la misma gramática y no se ponen de acuerdo sobre si una entrada fallida termina la subsección, una de ellas es un fork que dejó de recibir los arreglos, la misma firma que aparece en el manejo de cross-reference híbrido donde una tabla tradicional y un stream describen los mismos objetos

Diagrama de una subsección de xref tradicional en HotPDF cuya cabecera declara 2000000000 entradas para unos cuarenta bytes de archivo: HPDFReadLine devuelve una cadena vacía sin avanzar cuando los datos se acaban, un Continue mantiene girando todas las iteraciones restantes, y el bucle corregido rompe en la primera entrada mala con un techo de Stream.Size div 8
Un contador declarado es una afirmación sobre el archivo, no sobre los datos, así que una entrada fallida debe terminar la subsección en vez de saltarse una iteración
if not HPDFParseIntegerPair(Line, First, Count) then
  Exit;
for I := 0 to Count - 1 do
begin
  Entry := HPDFReadLine(XrefData, P);
  // Un contador declarado mayor que la tabla giraría aquí sin datos
  // que leer
  if not HPDFParseTraditionalXrefEntry(Entry, EntryOffset, GenNum, InUse) then
    Break;
  // First viene de la cabecera de la subsección, así que la suma
  // desborda para un inicio declarado cerca del límite de Integer, y
  // el helper de crecimiento rechaza en silencio un í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);
  // ... registrar la entrada ...
end;

Los dos límites que van después del Break pertenecen a la misma reparación. HPDFEnsureDirectXrefLength se niega a crecer el array para un número de objeto negativo y no dice nada al respecto, y First + I desborda para un inicio declarado cerca del límite de Integer, así que un índice negativo llega a los writes de abajo. El techo de número de objeto de Stream.Size div 8 es la respuesta general barata, porque ninguna tabla de cross-reference tradicional puede describir más objetos de los que el archivo tiene sitio para contener, y cualquier cabecera que afirme lo contrario está describiendo un archivo que no existe. Si estás reparando a mano una tabla dañada, el mismo razonamiento se aplica desde el otro lado, que es el territorio que cubren las notas sobre diagnosticar y reparar PDFs editados a mano

¿Cómo demuestras que un arreglo del cuelgue funciona de verdad?

Ejecutando el código viejo, no releyéndolo. Un bloqueo es una de las pocas clases de defecto donde un arreglo se puede verificar con una señal binaria y sin instrumentación, porque el proceso devuelve o no devuelve, y razonar sobre el flujo de control es exactamente la habilidad que produjo el bug en primer lugar. La mecánica merece la pena copiarla. Extrae la revisión pre-fix de las unidades cambiadas a un directorio scratch, y compila un pequeño probe con el path de búsqueda de unidades poniendo ese directorio por delante de la librería real: dcc32 -U<scratchdir>;<Lib> probe.dpr. El compilador toma las fuentes viejas de lo que encuentre primero y las fuentes actuales de todo lo demás, así que un solo binario de probe ejercita el parser viejo contra el resto de la librería. Ejecútalo bajo un timeout — cuarenta segundos es generoso — y trata el exit code 124 como la aserción. Después retira las unidades del directorio scratch una a una y reconstruye. Los tres bloqueos se reprodujeron así, cada uno atribuible a una unidad concreta, y los tres dejaron de bloquearse una vez que la fuente corregida estuvo de vuelta en el path, a costa de una compilación por unidad

Hay dos trampas que conviene conocer antes de intentarlo. El path de búsqueda de unidades es permisivo en silencio: pásale a -U un path estilo Unix desde una shell, como $PWD, y el compilador ignora ese directorio sin decir palabra, lo que significa que tu probe compiló contra las fuentes actuales y pasó por la razón equivocada. Convierte el path a la forma nativa de Windows primero. La segunda es que el directorio scratch no lleva los include files, así que también necesitas -I<Lib> para HotPDF.inc, o el build falla de una forma que parece un problema de fuentes en vez de un problema de path. Y sé honesto sobre lo que el método no te da: confirma que una entrada concreta dejó de colgarse en un build concreto, y no dice nada de las entradas en las que no pensaste. Los defectos de avance cero se encuentran con fuzzing o leyendo cada rama de cada tokenizador, no con tests de regresión escritos a posteriori

Qué auditar en tus propios parsers

La regla reutilizable es un contrato, no un ítem de checklist. Toda función que lea de un cursor compartido debe, en cada camino incluidos los de fallo, o consumir al menos un byte o devolver un valor que el llamador no pueda confundir con "inténtalo otra vez". Mezclar esos dos significados en un solo nil o en un solo token vacío es lo que convierte un rechazo en un bloqueo. Así que audita la forma, no el síntoma. Encuentra cada bucle cuya terminación depende de un cursor que posee otra función, y pregúntate qué pasa cuando esa función rechaza el byte actual. Encuentra cada rama de respaldo escrita como "leer hasta X" y pregúntate qué hace cuando el cursor ya está sobre X. Encuentra cada bucle acotado por un contador que salió del archivo y no de los datos, y haz que un elemento fallido termine el bucle en vez de saltarse una iteración. Donde la misma gramática se parsea en dos sitios, hazles diff, porque una habrá recibido un arreglo que la otra no. Y pon una comprobación de cancelación dentro de todo bucle que pueda correr un número de iteraciones suministrado por el llamador, para que un operador tenga algo a lo que tirar cuando el próximo de estos aparezca en producción

Los parsers descritos aquí se distribuyen en el HotPDF Delphi Component actual para Delphi y C++Builder, con los registros de estadísticas y los límites de profundidad expuestos para que tu propio hardening pueda apoyarse en ellos