
ثغرة RCE من نوع 1day في Chrome Renderer عبر Type Confusion في Async Stack Trace (مشاركة v8ctf)
سمحت هذه الثغرة لمهاجم عن بُعد بتنفيذ كود تعسفي داخل عملية عرض Chrome (renderer process).
كان هناك فحص غير كافٍ للنوع في كود معالجة تتبّع حزمة الاستدعاءات غير المتزامنة (async stack trace).
يؤدي ذلك إلى خلط بين النوعين FunctionContext وNativeContext، مما يسبّب وصولًا غير قانونيًا إلى قيمة JSGlobalProxy->hash.
باستخدام حقن الكومة (heap spraying)، تمكن المهاجم من حقن إطار حزمة استدعاءات غير متزامنة مزيف، وبناء أولية fakeobj.
وباستخدام أولية fakeobj، تمكن المهاجم من تحقيق تنفيذ كود تعسفي في عملية عرض Chrome.
يمكنكم الاطلاع على شرائح عرضنا في TyphoonCon 2024.
تُعد البرمجة غير المتزامنة واحدة من أهم الميزات في JavaScript. في الماضي، كان من الصعب تصحيح أخطاء الكود غير المتزامن باستخدام حزمة الأخطاء لأن الدوال غير المتزامنة لا يتم التقاطها في حزمة الأخطاء. يتم تخزين الدوال غير المتزامنة المعلّقة في قائمة انتظار الاستدعاءات الخاصة بحلقة الأحداث (event loop) وليس في حزمة الاستدعاءات، لذلك لا تحتوي حزمة الأخطاء على الدالة غير المتزامنة. لحل هذه المشكلة، توفر V8 ميزة "async stack trace" (وهي مفعّلة افتراضيًا منذ V8 v7.3) لالتقاط الدوال غير المتزامنة في حزمة الأخطاء. (v8 blog, v8 docs)
"Promise.all Resolve Element Closure" هي دالة مساعدة لحل الوعود المُدخلة في دالة Promise.all.
تأخذ دالة Promise.all مصفوفة من الوعود وتُعيد وعدًا يتم حلّه عند حل جميع الوعود المُدخلة.
"Promise.all Resolve Element Closure" هي معالج حل (resolve handler) لكل وعد مُدخل في دالة Promise.all.
دور الدالة هو حل الوعد المُدخل وتخزين قيمة الإنجاز (fulfillment value) في مصفوفة النتائج.
هناك نقطتان يجب ملاحظتهما حول هذه الدالة:
FunctionContext حتى يتم استدعاؤها، ثم تحتوي على NativeContext بعد استدعائها. (كود v8)فئة الثغرة: خلط بين الأنواع (Type confusion) بين FunctionContext وNativeContext
تفاصيل الثغرة:
يمكن تفعيل الثغرة عن طريق التقاط تتبّع حزمة الاستدعاءات غير المتزامنة مع دالة "Promise.all Resolve Element Closure" المنفّذة بالفعل أو دوال مدمجة داخلية مشابهة. في هذا الاستغلال، استخدمت دالة "Promise.all Resolve Element Closure" كمثال.
عند إلقاء خطأ في كود JavaScript، تلتقط V8 حزمة الأخطاء من المكدس وتُلحق إطارات حزمة الاستدعاءات غير المتزامنة من المهمة الصغيرة الحالية (current microtask) [1].
CallSiteBuilder builder(isolate, mode, limit, caller);
VisitStack(isolate, &builder);
// If --async-stack-traces are enabled and the "current microtask" is a
// PromiseReactionJobTask, we try to enrich the stack trace with async
// frames.
if (v8_flags.async_stack_traces) {
CaptureAsyncStackTrace(isolate, &builder);
}
دالة CaptureAsyncStackTrace [2] تبحث في سلسلة الوعود وتُلحق إطار حزمة الاستدعاءات غير المتزامنة وفقًا لنوع الاستدعاء غير المتزامن (مثل await وPromise.all وPromise.any).
فيما يلي مقتطف من دالة CaptureAsyncStackTrace يعالج حالة Promise.all:
} else if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kPromiseAllResolveElementClosure)) {
Handle<JSFunction> function(JSFunction::cast(reaction->fulfill_handler()),
isolate);
Handle<Context> context(function->context(), isolate);
Handle<JSFunction> combinator(context->native_context()->promise_all(),
isolate);
builder->AppendPromiseCombinatorFrame(function, combinator);
// Now peak into the Promise.all() resolve element context to
// find the promise capability that's being resolved when all
// the concurrent promises resolve.
int const index =
PromiseBuiltins::kPromiseAllResolveElementCapabilitySlot;
Handle<PromiseCapability> capability(
PromiseCapability::cast(context->get(index)), isolate);
if (!IsJSPromise(capability->promise())) return;
promise = handle(JSPromise::cast(capability->promise()), isolate);
} else if (
أثناء البحث في سلسلة الوعود، إذا كان reaction->fulfill_handler هو الدالة المدمجة "Promise.all Resolve Element Closure"، فإنه يُلحق إطار المُجمِّع غير المتزامن (async promise combinator frame) بحزمة الأخطاء.
ثم ينتقل إلى الوعد التالي عن طريق الوصول إلى function->context->capability->promise.
المشكلة هي أن الدالة تفترض أن دالة "Promise.all Resolve Element Closure" لم يتم تنفيذها بعد.
إذا تم تنفيذ دالة "Promise.all Resolve Element Closure" بالفعل، فسيتم تغيير السياق من FunctionContext إلى NativeContext.
يؤدي ذلك إلى خلط بين النوعين FunctionContext وNativeContext في دالة CaptureAsyncStackTrace.
إعداد الإثبات المفاهيمي (PoC):
استراتيجية تفعيل الثغرة هي كما يلي:
FunctionContext إلى NativeContext.استخدمت نمط الحل المتزامن للوعود الخاص بـ Promise.all للحصول على دالة "Promise.all Resolve Element Closure" على مستوى سكربت JS.
استعرت هذا النمط من حالات اختبار test262.
بعد استدعاء الدالة صراحةً، ولتفعيل الثغرة، استخدمت الكود النموذجي في مستند تتبّع حزمة الاستدعاءات غير المتزامنة بدون تكلفة لإعداد سلسلة وعود جديدة وتعيين الدالة المدمجة الداخلية كمعالج إنجاز لأحد الوعود.
أخيرًا، عند إلقاء الخطأ، يتم التقاط تتبّع حزمة الاستدعاءات غير المتزامنة مع دالة "Promise.all Resolve Element Closure" المنفّذة بالفعل كمعالج إنجاز، مما يؤدي إلى خلط بين النوعين FunctionContext وNativeContext.
إليكم كود الإثبات المفاهيمي: poc.js
(تم تعريف مصطلحات أولية الاستغلال (exploit primitive)، واستراتيجية الاستغلال (exploit strategy)، وتقنية الاستغلال (exploit technique)، وتدفق الاستغلال (exploit flow) هنا.)
أولية الاستغلال: أولية fakeobj
استراتيجية الاستغلال: لبناء أولية fakeobj من ثغرة الخلط بين الأنواع، استخدمت الاستراتيجية التالية:
Error.prepareStackTrace مع طريقة getThis لاسترجاع الكائن المزيف.تؤدي الثغرة إلى خلط بين النوعين FunctionContext وNativeContext في دالة CaptureAsyncStackTrace.
تصل إلى Context->PromiseCapability->JSPromise لبناء إطار حزمة الاستدعاءات غير المتزامنة التالي.
عند تفعيل الثغرة، تصل إلى NativeContext->JSGlobalProxy->hash.
لاستغلال الثغرة، استخدمت قيمة التجزئة كمؤشر لكائن JSPromise.
يمكننا التحقق من أن قيمة التجزئة تقع في النطاق (0, 0xfffff) من خلال دالة توليد التجزئة التالية:
int Isolate::GenerateIdentityHash(uint32_t mask) {
int hash;
int attempts = 0;
do {
hash = random_number_generator()->NextInt() & mask;
} while (hash == 0 && attempts++ < 30);
return hash != 0 ? hash : 1;
}
pwndbg> p/x mask
$1 = 0xfffff
قيمة التجزئة موسومة بـ SMI (SMI-tagged)، لذا في الذاكرة سيتم تخزينها بالشكل hash << 1.
وبالتالي، ستكون القيمة في الذاكرة ضمن النطاق (0, 0xfffff << 1) وستكون عددًا زوجيًا.
لمطابقة رقم التجزئة العشوائي مع مؤشر كائن JSPromise صالح، لدينا قيدان:
باتباع هذه القيود، حقنت الكومة بكائنات JSPromise مع إزاحة لليسار بمقدار 8 بتات لجعل العنوان فرديًا، واستخدمت حلقات for صغيرة لتناسب النطاق (0, 0xfffff << 1).
هنا تبدو فرصة مطابقة رقم التجزئة العشوائي مع مؤشر كائن صالح منخفضة إلى حد كبير. لزيادة الموثوقية، استخدمت تقنية iframe. تعمل الصفحات من مواقع مختلفة في عمليات منفصلة بفضل عزل المواقع (site isolation) في Chrome. لذلك، أنشأت iframe بنطاق مختلف، وشغّلت الاستغلال داخل الـ iframe لتجنب تعطّل العملية الرئيسية.
بعد الانتقال إلى الوعد التالي في سلسلة الوعود، يتحقق البرنامج من صحة الوعد ويحاول إلحاق إطار حزمة الاستدعاءات غير المتزامنة وفقًا لنوع الاستدعاء غير المتزامن.
while (!builder->Full()) {
// Check that the {promise} is not settled.
if (promise->status() != Promise::kPending) return;
// Check that we have exactly one PromiseReaction on the {promise}.
if (!IsPromiseReaction(promise->reactions())) return;
Handle<PromiseReaction> reaction(
PromiseReaction::cast(promise->reactions()), isolate);
if (!IsSmi(reaction->next())) return;
// Check if the {reaction} has one of the known async function or
// async generator continuations as its fulfill handler.
if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncFunctionAwaitResolveClosure) ||
IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorAwaitResolveClosure) ||
IsBuiltinFunction(
isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorYieldWithAwaitResolveClosure)) {
// Now peek into the handlers' AwaitContext to get to
// the JSGeneratorObject for the async function.
Handle<Context> context(
JSFunction::cast(reaction->fulfill_handler())->context(), isolate);
Handle<JSGeneratorObject> generator_object(
JSGeneratorObject::cast(context->extension()), isolate);
CHECK(generator_object->is_suspended());
// Append async frame corresponding to the {generator_object}.
builder->AppendAsyncFrame(generator_object);
اخترنا حالة kAsyncFunctionAwaitResolveClosure لأن معامل دالة AppendAsyncFrame، أي generator_object، قابل للتحكم الكامل.
من خلال تعيين كائنات مزيفة مناسبة مثل PromiseReaction وFunction وContext وJSGeneratorObject لاجتياز الشروط، يمكننا حقن إطار حزمة الاستدعاءات غير المتزامنة المزيف الخاص بنا عن طريق استدعاء builder->AppendAsyncFrame(generator_object).
يمكننا التحقق من إطار حزمة الاستدعاءات غير المتزامنة المزيف المُحقون من الطرفية.
Error: Let's have a look...
at bar (../../../../fake_frame.js:168:15)
at async foo (../../../../fake_frame.js:163:9)
at async Promise.all (index 0)
at async Array.sloppy_func (../../../../fake_frame.js:1:1)
إليكم كود fake_frame.js.
بعد حقن إطار حزمة الاستدعاءات غير المتزامنة المزيف، استخدمت Error.prepareStackTrace مع طريقة getThis للحصول على receiver لكائن الخطأ (في هذه الحالة، هو JSGeneratorObject).
باستخدام receiver، يمكننا استرجاع الكائن المزيف من الكومة (أولية fakeobj).
تدفق الاستغلال: استخدمت تدفق الاستغلال النموذجي لاستغلالات V8.
إليكم كود الاستغلال الكامل: index.html وexploit.html تم اختباره على Chrome 118.0.5993.70 والذي كان الإصدار المستهدف في v8CTF M118.
Haein Lee من KAIST Hacking Lab