
TC39 proposal for mitigating prototype pollution
المؤلفون: Santiago Díaz (Google)
المُتبنّي: Shu-yu Guo (Google)
المرحلة: 1
freeze وseal وpreventExtensions
هذا الاقتراح يسعى إلى التخفيف من ثغرة على مستوى اللغة تُعرف بتلوث النموذج الأولي (Prototype Pollution) عبر آلية تكمل بدائيات التجميد وآلية لجعل معظم قواعد الأكواد متوافقة معها. يصف ميزة اختيارية (opt-in) تجعل النماذج الأولية متاحة فقط من خلال واجهات برمجة الانعكاس (Reflection APIs). وبهذا، لم يعد بإمكان العبارة obj[key] الوصول إلى النماذج الأولية. قواعد الأكواد المتوافقة مع هذه الميزة تكون أكثر قصدية في طريقة استخدامها للنماذج الأولية.
ثغرات تلوث النموذج الأولي (PP) تسمح للمهاجمين بالتلاعب بكائنات لا يتحكمون بها أو لا يمكنهم الوصول إليها أثناء التشغيل. يمكن استخدام هذه البدائية "التأثير الشبحى عن بُعد" لتغيير شكل كائنات أخرى وتجاوز خصائصها، مما يؤدي إلى تلويث الكائنات في بيئة التشغيل.
الكائنات الملوثة تُبطل الافتراضات الأساسية للكود الذي كان سيكون آمنًا/صحيحًا، ويمكن أن تؤدي إلى تنفيذ أكواد عشوائية ومجموعة واسعة من مشكلات الأمان الأخرى في قواعد أكواد JavaScript. غالبًا ما تظهر أخطاء تلوث النموذج الأولي في تطبيقات الويب، ولكنها تؤثر أيضًا على بيئات تشغيل JavaScript غير المرتبطة بالويب.
خصائص الكائنات في JavaScript قابلة للكتابة بواسطة أي كود يمكنه الإشارة إليها. على وجه الخصوص، إذا اعتمدت العديد من الكائنات على خاصية مشتركة، يمكن لأي منها فرض تغييرات على جميع الكائنات الأخرى.
من الخصائص الخاصة لتلوث النموذج الأولي (PP) أنه هجوم يعتمد على البيانات فقط، إذ يسمح بتحقيق تنفيذ الأكواد عبر البيانات البحتة. على سبيل المثال، انظر إلى الكود الثغري التالي والاستغلال المقابل له:
// source is attacker-controlled
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if(target[key] === undefined) {
target[key] = {};
}
target[key] = merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// User input comes as a string
const userSuppliedObj = JSON.parse('{"__proto__": {"polluted": true}}');
// Trigger prototype pollution
merge({}, userSuppliedObj);
// Create a brand new object
const newObj = {};
// Has polluted property
console.log(newObj.polluted); // true
لاحظ أن الاستغلال قادر على تلويث إنشاء كائنات جديدة دون حقن أي كود خارجي.
بسبب هذه الخاصية الخاصة، فإن التخفيفات الحديثة ضد مشكلات تنفيذ الأكواد -مثل سياسة أمان المحتوى (CSP) أو الأنواع الموثوقة (Trusted Types)- تقصر عن الحماية من PP، لأنها تركّز على فرض مصدر الكود.
لاحظ أن هجمات البيانات فقط ذات صلة بالمواقف التي يكون فيها الكود المشغّل على الجهاز الافتراضي موثوقًا ويكون لتنفيذ الأكواد العشوائية تأثير أمني.
freeze وseal وpreventExtensionsتعاني بدائيات التجميد الحالية من مشكلات تصميم كبيرة تجعل من غير المرجح أن يتم اعتمادها على نطاق واسع. يمكن أن تكون مفيدة للمستخدمين الخبراء، لكنها غير مناسبة للاستخدام من قبل غالبية المطورين، الذين يتوقعون بشكل معقول أن تكون النماذج الأولية قابلة للتعديل:
تعاني واجهات برمجة التجميد من خطأ التجاوز وتناقضات أخرى تُدخل أخطاءً في قواعد الأكواد القائمة، مما يجعلها ترمي استثناءات أو، ما هو أسوأ، تفشل بصمت في الوضع المتراخي (sloppy mode). خلص تحقيق سابق في خطأ التجاوز إلى أن خطأ التجاوز يحدث في ~10% من قواعد الأكواد في الوضع الصارم و20% في الوضع المتراخي. أُسقط التحقيق بعد ذلك بوقت قصير.
تمنح واجهات برمجة التجميد المطورين مسؤولية ثقيلة تتمثل في معرفة النماذج الأولية التي يجب تجميدها للحفاظ على قاعدة أكواد آمنة، على افتراض أن المطورين خبراء أمنيون. تصف هذه الواجهات ماذا يجب أن يفعل المرء، وليس كيف يفعل ذلك. تجميد Object ليس جيدًا بالتأكيد، إذ تستغل العديد من الهجمات Array. ماذا عن Error، أو Date، أو Reflect، أو Proxy؟ أو الأنواع المدمجة المستقبلية؟ لا تقدم واجهات برمجة التجميد إجابات عن هذه الأسئلة.
تفترض واجهات برمجة التجميد وجود نقطة تجميد مستقرة: لحظة ثابتة أثناء التشغيل تكون فيها النماذج الأولية قد استقرت ويمكن تجميدها. عمليًا، هذه النقطة متقلبة وتتغير بمرور الوقت في قواعد الأكواد التي تُطوَّر بنشاط. وفي حين يمكن العثور على مثل هذه النقطة في العديد من التطبيقات اليوم، فإن إضافة تبعيات جديدة، وpolyfills، وتغييرات بنية الكود، والميزات القوية مثل الاستبدال الساخن (hotswapping) وأدوات المطورين تجعل نقاط التجميد هدفًا متحركًا.
لا يمكن لواجهات برمجة التجميد حماية سلسلة النماذج الأولية بالكامل. في JavaScript، يمكن إضافة الكائنات إلى سلسلة النماذج الأولية أو إزالتها منها في أي وقت. لحماية السلسلة بالكامل، يجب على المرء أن يتذكر دائمًا تجميد الكائنات المضافة إلى السلسلة، وهي عملية عرضة للخطأ. وعند إزالتها من السلسلة، لا يمكن إلغاء تجميدها بعد الآن.
باختصار: ميزة تُعرّض النماذج الأولية لواجهات برمجة الانعكاس فقط. إذا لم تكن النماذج الأولية متاحة عبر خصائص مثل __proto__ أو prototype، فلن تكون معرّضة لمشكلات البيانات فقط.
يُفهم هذا بشكل أفضل من خلال مثال: العبارة obj[one][two] = value معرّضة لتلوث النموذج الأولي عبر obj.__proto__.polluted. إذا حُذفت خاصية Object.prototype.__proto__، لم تعد العبارة نفسها معرّضة للثغرة لأنها لا تستطيع استخدام الطريقة الأخرى الوحيدة للوصول إلى النماذج الأولية، وهي obj.constructor.prototype.polluted. لاحظ أنه لا يمكن حذف النموذج الأولي.
يمكن تنفيذ هذا الاقتراح من خلال توفير واجهات برمجة الانعكاس وإنشاء ميزة تغليف (encapsulation) اختيارية جديدة تعمل على حذف خصائص النماذج الأولية. فيما يلي وصف لكل خطوة.
__proto__ هو اسم خاصية قديم يمكن حذفه، لكن الفتحة الداخلية (internal slot) خلفه لا تزال قابلة للقراءة عبر Object/Reflect.getPrototypeOf والكتابة عبر Object/Reflect.setPrototypeOf، مما سيجعل هذه الخاصية متاحة ببساطة للكود الذي يعمل بالفعل.
نقترح إنشاء واجهات برمجة جديدة لـ prototype، مثل getClassPrototypeOf وsetClassPrototypeOf، والتي من شأنها السماح بحذف اسم الخاصية دون تغيير طريقة عمل هذه الخاصية الخاصة ودعمها للجهاز الافتراضي بأي شكل من الأشكال.
يمكن تزويد واجهات برمجة الانعكاس بـ polyfill، مما يسمح لقواعد الأكواد المحصّنة بالعمل في جميع المتصفحات، بما في ذلك الإصدارات الأقدم.
ميزة "تغليف" اختيارية جديدة لا تُنشئ أي أسماء خصائص لدالتي الجلب (getter) والتعيين (setter) الخاصتين بفتحات النماذج الأولية، وهو أمر أصبح ممكنًا الآن لأن الإشارات إلى تلك الخصائص يمكن أن تستخدم واجهات برمجة الانعكاس بدلاً من ذلك.
يتم تفعيل الميزة عبر علامة خارج النطاق (out-of-band flag):
X-Encapsulate-Prototype: true--encapsulate-prototypeعندما يكون التغليف معطلاً، تكون النماذج الأولية متاحة عبر الخصائص وواجهات برمجة الانعكاس معًا.
عندما يكون التغليف مفعلاً، تكون النماذج الأولية متاحة فقط عبر واجهات برمجة الانعكاس، بعد حذف كل من __proto__ وprototype.
يتضمن التغليف أيضًا ميزة إعادة الهيكلة التلقائية التالية:
عند تفعيل التغليف، تقوم محركات JavaScript التي تحمّل كودًا مصدريًا جديدًا بتمكين خطوة إضافية في مراحل التحليل (parse) الخاصة بها تسجّل جميع عمليات الوصول بترميز النقطة (dot-notation) إلى خصائص النماذج الأولية كما لو كانت استدعاءات لواجهات برمجة الانعكاس الخاصة بها. يمكن تنفيذ هذه الخطوة بكفاءة وتسمح لقواعد الأكواد التي تحتوي على تبعيات خارجية أو انتقالية أو تُحمَّل ديناميكيًا بالتوافق مع التغليف.
في المستقبل، سيمهد هذا التغيير الطريق لوضع علامة "مهمل" (deprecated) على prototype.
يمكن أن تكون خصائص النماذج الأولية ببساطة undefined عند تفعيل التغليف، لكنها قد ترمي خطأً عند محاولة قراءتها أو كتابتها. وهذا يعني فشلًا أسرع وأكثر وضوحًا، ويسمح باختبار عمليات الترحيل إلى واجهات برمجة الانعكاس/التغليف.
هذا يتضمن جعل دالتي الجلب والتعيين لـ __proto__ وprototype مشروطتين بالتغليف، باستخدام خطاف مضيف (host hook)، بنفس الطريقة التي ترمي بها دالة eval خطأً في ظل سياسة أمان المحتوى.
الكود الذي يعتمد على الوصول المحسوب للخصائص للإشارة إلى النماذج الأولية غير متوافق مع التغليف أو مع إعادة الهيكلة التلقائية. يجب إعادة هيكلته ليشير صراحةً إلى النماذج الأولية عند استخدامها. هذه إعادة الهيكلة تجعل الكود في الواقع يعبّر عن القصد، مما يجعل الأنماط الخطيرة مرئية للتحليل الثابت. عمليًا، قواعد الأكواد التي تمتلك هذه الخاصية هي عادةً أطر عمل انعكاسية، وأدوات تصحيح، وحالات استخدام أخرى تعتمد بشكل كبير على الانعكاس، ومن المرجح أنها تدرك جيدًا كيفية استخدامها للنماذج الأولية.
قواعد الأكواد التي تستخدم كلمة prototype لتعريف خصائص مخصصة غير متوافقة. يمكن جعل قواعد الأكواد هذه متوافقة مع التغليف إذا كانت هذه الخاصية تُعيَّن وتُقرأ دائمًا عبر ترميز الأقواس (bracket notation). تاريخيًا واستنادًا إلى استعلامات HTTP Archive، هناك نسبة صغيرة من قواعد الأكواد غير متوافقة لهذا السبب.
يمكن أن يكون لبعض التغييرات على خاصية constructor أيضًا تأثير شبحي عن بُعد. خلال بحثنا، لم نعثر على ثغرات عملية تتأثر بهذا الأمر.
المعيار مرتفع جدًا لنجاح هذا الهجوم: كما هو الحال في تلوث النموذج الأولي، يجب العثور على تطبيق يحتوي على أدوات (gadgets) لكتابة و قراءة الخصائص العشوائية معًا. لكن في تلوث المُنشئ، يجب أن تقرأ أداة القراءة من constructor.polluted بدلاً من polluted. وهذا يقلل بشكل كبير من عدد الأدوات المفيدة.
قد تكون بعض أكواد JavaScript المُصغّرة غير متوافقة مع وضع التغليف، لأن الوصول الثابت إلى الخصائص قد يُصغَّر إلى وصول محسوب. استعلمنا من HTTP Archive للحصول على تقدير لهذا الأمر عمليًا. يوضح الجدول التالي أن الصفحات التي تظهر عليها هذه السلوكيات تقل باستمرار عن 1% خلال آخر 12 شهرًا لجميع الصفحات التي زُحفت باستخدام متصفح سطح المكتب:
شهدت Google اتجاهًا تصاعديًا في عدد الأخطاء المُرسلة إلى برنامج مكافآت الثغرات (Vulnerability Rewards Program) لدينا: 1 في 2020، و3 في 2021، و5 حتى الآن في 2022. وقد حددنا العديد من الثغرات الإضافية في أبحاثنا الداخلية.
تتضمن أمثلة الثغرات ما يلي:
نتوقع أن ينمو عدد التطبيقات الثغرية مع نشر تطبيقات JavaScript في بيئات أكثر (مثل Electron، وCloudflare Workers، وغيرها). لذلك، يلزم حل على مستوى اللغة للتخفيف من الهجمات في جميع البيئات.
| الجدول | المستندات التي تصل إلى __proto__ أو constructor ديناميكيًا | إجمالي عدد المستندات التي تم الزحف إليها | النسبة |
|---|
| 2023_03_01_desktop | 5,407,936 | 609,469,458 | 0.89% |
| 2023_02_01_desktop | 4,842,383 | 549,089,708 | 0.88% |
| 2023_01_01_desktop | 5,283,826 | 589,519,160 | 0.90% |
| 2022_12_01_desktop | 5,161,471 | 577,073,883 | 0.89% |
| 2022_11_01_desktop | 5,023,169 | 561,726,239 | 0.89% |
| 2022_10_01_desktop | 4,393,377 | 476,880,624 | 0.92% |
| 2022_09_01_desktop | 4,239,257 | 466,278,762 | 0.91% |
| 2022_08_01_desktop | 4,259,814 | 463,784,047 | 0.92% |
| 2022_07_01_desktop | 3,011,137 | 339,468,615 | 0.89% |
| 2022_06_01_desktop | 2,301,317 | 257,501,222 | 0.89% |
| 2022_04_01_desktop | 2,368,577 | 263,144,657 | 0.90% |
| 2022_03_01_desktop | 2,319,518 | 259,249,013 | 0.89% |