PCRE2: Hang en la Compilación JIT por Recursión (?0) dentro de un Negative Lookahead

El mismo patrón, dos motores: el intérprete lo compila en 0 milisegundos; el compilador JIT se cuelga para siempre calculando tamaños de frames. Este hallazgo del sistema autónomo Sherlock contra PCRE2 10.48 es una lección de diagnóstico diferencial aplicada a seguridad: cómo demostrar que un hang vive en la compilación y no en el matching, y por qué eso cambia por completo quién está expuesto.

/índice_del_análisis +

00. Resumen Técnico

ObjetivoPCRE2 10.48 — fase de compilación JIT (pcre2_jit_compile.c)
VulnerabilidadRecursión interna sin terminación en get_framesize (pcre2_jit_compile.c:2224) ante una backreference recursiva (?0) anidada en un negative lookahead (?!…)
CWECWE-400 (Uncontrolled Resource Consumption)
SeveridadLOW — consumo indefinido de CPU durante la compilación; sin corrupción de memoria
DisparadorPatrón regex de ~693 bytes; la compilación JIT no termina (timeout reproducible 3/3)
Dato claveEl mismo patrón en modo interpretado: compile OK + match rc=-1 en 0.000 s → el fallo es específico del compilador JIT
ValidaciónDoble herramienta: harness JIT instrumentado (timeout) + binario de control sin JIT (éxito inmediato)
Estado fix upstreamSin confirmación de parche al cierre — divulgación graduada: clase de construcción documentada, patrón completo reservado

01. Contexto: Quién Compila Regex Ajenas

PCRE2 es la biblioteca de expresiones regulares que respiran medio internet: PHP, Apache, nginx vía módulos, miles de productos embebidos. Su modo JIT traduce los patrones a código máquina nativo para acelerar el matching —una victoria de rendimiento que pagan especialmente los servicios donde cada microsegundo cuenta.

Y ahí está el matiz que hace interesante este hallazgo: hay una clase entera de sistemas que compilan patrones escritos por usuarios o derivados de ellos. Un WAF que traduce reglas de filtrado, un motor de búsqueda que acepta queries avanzadas, un firewall de aplicaciones que recompila firmas dinámicas, un SaaS de logs con filtros personalizables. En todos ellos, la fase de compilación es superficie de ataque de primera categoría: recibe texto semiestructurado del exterior y lo procesa con lógica compleja. La mayoría de auditorías miran el matching (backtracking catastrófico, ReDoS); este bug demuestra que el compilador también puede ser el punto de estrangulamiento.

02. La Construcción Venenosa: (?0) dentro de (?!…)

Dos piezas del lenguaje regex se combinan aquí. La primera, (?0), es una backreference recursiva: «todo el patrón completo», una referencia a sí mismo. La segunda, (?!…), es un negative lookahead: coincide solo si lo que sigue no encaja con su interior. Por separado, ambas están perfectamente definidas y el intérprete las maneja sin despeinarse.

El problema aparece cuando la recursividad total del patrón vive dentro del lookahead negativo. Para generar código nativo, el compilador JIT necesita saber cuánto espacio de frames necesita cada subexpresión —y calcularlo exige recorrer la estructura de la recursión. Con (?0) anidada en (?!…), ese recorrido entra en un ciclo que no alcanza condición de parada: la función get_framesize vuelve sobre el mismo subárbol una y otra vez, y la compilación nunca termina.

# Forma de la construcción (esqueleto conceptual — el patrón real, # de ~693 B, se reserva hasta disponer de parche upstream) ^(?! # lookahead negativo... ...anclajes... (?0) ...anclajes... # ...que contiene la recursiva total (?0) ) ...resto del patrón... # Comportamiento observado: pcre2_compile → OK # árbol sintáctico válido, sin quejas match (intérprete) → rc=-1 # termina limpio en 0.000 s: no hay match, como corresponde pcre2_jit_compile → HANG # get_framesize no regresa jamás

Fíjense en el dato más incómodo del caso: el árbol sintáctico es legítimo. No hay trampa sintáctica ni cuantificador absurdo; es una combinación semánticamente válida cuya traducción a máquina es lo que no acaba. Eso explica por qué pasa los checks normales de entrada y por qué el fallo se esconde tan bien.

03. El Experimento Diferencial: Intérprete vs JIT

Cuando un fuzzer reporta un timeout contra un target JIT-enabled, la primera sospecha natural es backtracking catastrófico: el clásico ReDoS donde el matching explota exponencialmente. Pero un buen diagnóstico exige aislar la variable. El sistema montó el experimento controlado que separa ambos mundos:

patrón (?!(...(?0)...)) 693 B · árbol sintáctico VÁLIDO pcre2_compile() → OK árbol intermedio aceptado en ambos builds pcre2_compile() → OK mismo árbol, misma validación ✓ MODO INTERPRETADO match ejecutado: rc=-1 (sin coincidencia) dt = 0.000 s — termina limpio ✗ pcre2_jit_compile() get_framesize (pcre2_jit_compile.c:2224) recursión sin condición de parada → HANG el runtime NO es el problema VEREDICTO: bug del COMPILADOR JIT (CWE-400)
Figura 1 — Diagnóstico diferencial: la única variable que cambia entre ambas columnas es el backend. Si el hang fuera del matching, la rama izquierda también se colgaría. No lo hace: el bug vive exclusivamente en la traducción a código nativo.

04. Anatomía del Hang: get_framesize

El compilador JIT de PCRE2 trabaja en varias pasadas; una de ellas dimensiona los frames de ejecución —cuánta memoria local necesitará cada rama del patrón— recorriendo recursivamente la estructura compilada. Esa función es get_framesize, y el stack capturado durante el hang la señala inequívocamente:

# Tool A — harness JIT instrumentado (timeout reproducible 3/3) $ ./pcre2_fuzzer_jit -runs=1 -timeout=3 poc_jit_hang.bin ... ==== ERROR: libFuzzer: timeout after 3 seconds # stack (gdb attach): #0 get_framesize pcre2_jit_compile.c:2224 # ← bucle caliente #1 ... # marcos de la pasada de dimensionado #… #N LLVMFuzzerTestOneInput

La lectura técnica del síntoma: get_framesize visita el nodo de la recursiva total (?0) desde dentro del contexto del lookahead, y el cálculo de tamaño vuelve a requerir dimensionar el patrón completo —incluido, de nuevo, el lookahead—. Cada nivel de análisis genera otro; ninguna tabla marca el subárbol como «ya medido». Es una recursión estructuralmente infinita: no crece el stack hasta desbordar porque cada ciclo reutiliza marcos equivalentes, simplemente nunca converge.

Terminal mostrando el timeout del fuzzer JIT de PCRE2 con el stack detenido en get_framesize, pcre2_jit_compile.c línea 2224
Evidencia A — Harness JIT: timeout tras 3 segundos, reproducible 3/3, con el stack caliente en get_framesize (pcre2_jit_compile.c:2224).

05. Descarte de Hipótesis Alternativas

La fortaleza del hallazgo está en lo que quedó fuera. Tres explicaciones rivales, tres pruebas de descarte:

Hipótesis alternativaPruebaResultado del descarte
Backtracking catastrófico en runtime (ReDoS clásico)Ejecutar el match sin JIT con el mismo patrónrc=-1 en 0.000 s: el matching no es el problema; el hang ocurre antes, en compile
Límite de tamaño legítimo (patrón «demasiado grande»)Oss-fuzz reduce cuantificadores grandes en sus corpusEl trigger es la estructura recursiva, no el volumen: patrones cortos equivalentes reproducen el ciclo
Falso positivo del harnessRepro manual 3/3 + stack consistente en gdbBug real, determinista y localizado en una función concreta
Terminal mostrando el control sin JIT: el mismo patrón compila correctamente y el match devuelve rc=-1 en 0 milisegundos
Evidencia B — Control experimental: build sin JIT con el mismo patrón. Compila limpio y el match termina en 0.000 s (rc=-1). Esta imagen es la mitad que convierte un crash report en un diagnóstico.

06. Acotación: Por Qué es LOW

07. Impacto Realista y Mitigación

El perfil de víctima ideal: servicios multiusuario donde el patrón llega desde fuera —WAFs con reglas dinámicas, motores de búsqueda con sintaxis avanzada, plataformas de logs con filtros personalizados, IDEs web que validan regex de usuario en vivo—. En todos ellos, un patrón de setecientos bytes convierte un thread de trabajo en un zombie de CPU permanente: no crashea, así que los supervisores de procesos no lo reinician; simplemente deja de responder mientras consume su núcleo asignado.

08. Conclusión

Este hallazgo vale menos por el impacto individual y más por el método: un experimento diferencial bien montado transforma una observación ambigua en un diagnóstico quirúrgico. Sin la rama de control —el mismo patrón, sin JIT, terminando en cero milisegundos— el bug habría quedado archivado como «timeout extraño» en algún issue tracker. Con ella, apunta exactamente a una función, una línea y una causa estructural.

La lección transferible a cualquier equipo de seguridad: cuando algo se cuelga, pregúntense siempre en qué fase se cuelga. Parseo, compilación, optimización, ejecución: cada fase tiene dueño distinto, superficies distintas y fixes distintos. El backtracking catastrófico acapara los titulares de ReDoS, pero los compiladores de regex —esa capa invisible entre el texto y la máquina— merecen el mismo escrutinio.

/Más hallazgos de la serie Sherlock

Siete vulnerabilidades confirmadas en un mes de caza autónoma con modelos locales de IA. Todos los análisis con evidencia y diagramas.

09. Referencias