缓存时间:
2026/05/09 00:32
# React2Shell 漏洞发现始末
来源: https://lachlan.nz/blog/the-react2shell-story/
2025年11月30日,我向 Meta 报告了一个严重的远程代码执行漏洞("React2Shell")。12月3日,Meta 发布了修复方案和公开通报(CVE-2025-55182),敦促开发者立即更新。
有趣的是,我最初的目的并不是要在 React 中寻找漏洞。我只是想理解一个协议,以便更好地学习如何攻击现代 Web 应用。但最终,我掉进了一个深坑,发现了一个影响数百万网站的严重漏洞。
我还建议阅读 Sylvie 关于 React2Shell 的博客文章,以及随后发生的一系列有趣事件。(https://sylvie.fyi/posts/react2shell/)
> 本文中的日期以 NZDT(GMT +13)显示。这与 Sylvie 的文章(GMT -7)和 Meta 的(GMT -8)形成对比。
## 周一 出发
作为职业黑客,2025年11月24日星期一的开始和往常一样:完成报告、启动新项目等。但那天下午,在好奇心和挫败感的驱使下,我感到大脑中有什么东西被触发了,于是我一头扎进了一个兔子洞,再也没有回头。
### 一些背景
近年来,我渗透测试过很多基于 Next.js 构建的 Web 应用——这是一个非常流行的 React 框架。Next.js 使用 React Server Components(RSC)在服务器上高效渲染内容并发送到用户的浏览器,还使用 React Server Functions(之前的 Server Actions)让用户交互能够无缝调用服务器端的 JavaScript 代码。
Server Actions 刚推出时被很多人嘲笑(你可能记得这张广为流传的图片?),但它确实是一个相当酷的功能,逐渐流行起来。在一个代码库中,开发者可以编写服务器端代码并从客户端代码调用它。
为了实现这些功能,你的浏览器和服务器需要一种奇特的的方式来来回回发送消息,而现有技术并不太适合这种情况。因此,React 团队必须构建一些新的东西。
任何渗透测试过使用 Server Functions 的 Web 应用的人都会熟悉这种稍微奇怪的请求格式:
```
0=[{ "a": "$$undefined", "b": "$1:foo:bar" }]&1=...
```
作为整个行业,我认为我们都会想"嗯,这只是加了花哨功能的 JSON",然后像测试传统应用一样测试它,尽管这里显然有更多的攻击面。我当时确实也是这样做的。
### 开关被触发的那一天
在这个星期一,我产生了强烈的欲望去了解这个格式。我*必须*了解它。认识我的人可以证明,我做事从来不会半途而废,会竭尽全力把一个问题研究透彻。大多数 Web 应用黑客攻击只是在向应用投掷开发者没有预料到的东西。如果这个协议能给我更多独特的攻击 Web 应用的方法,我*必须*了解它;我甚至可以为它建立一个完整的方法论!
### 等等,这到底是什么?
于是我开始查看文档——哦……没有关于这个协议的规范,现有的文档也少得可怜。这个协议到底叫什么名字?我花了不少功夫才发现它的名字叫"Flight"。现在很容易找到*了*,但在 React2Shell 披露之前,令人惊讶的是连名字都很难找到,更别说协议的格式了。我能找到的最好信息只是 X 上一些讨论 RSC 的帖子。
我已经被理解 Flight 深深吸引,但看到"没有文档,只有代码"更是激起了我的斗志。我的命运已经注定;不成为 Flight 专家我绝不罢休。我几乎没有睡过觉,到了第二天早上,我对协议的基本原理有了很好的理解,但这只是开始。
### Flight 101
Next.js 允许开发者神奇地在客户端和服务器端代码之间传递复杂的 JavaScript 对象,但这包括无法用简单 JSON 表示的对象。Flight 解决了这个问题,添加了对更复杂数据类型(如 `Date`、`BigInt` 和 `Map`)、引用(包括循环引用)和 Promise(异步到达的数据)的支持,等等。它仍然基于 JSON,但 Flight 消息被分解为"数据块"。每个数据块通常作为表单元素发送,可以异步到达,可能无序到达。特殊的 `$` 语法表示 Flight 类型。如下所示,`$D` 表示日期,`$x` 是对另一个数据块的引用,`$x:y` 允许属性选择。
```
0={"email":"[email protected]","updated":"$D04 Dec 1995 00:12:00 GMT","details":"$1"}&1={"firstName":"$2","lastName":"$3:foo"}&2="John"&3={"foo":"Doe"}
```
解析后,上述内容在服务器内存中解析为:
```
{"email":"[email protected]","updated":Date(Mon Dec 04 1995 13:12:00 GMT+1300 (New Zealand Daylight Time)),"details":{"firstName":"John","lastName":"Doe"}}
```
### "一个明显的安全检查缺失"
关键的是,Flight 允许引用对象的属性。那么如果我们尝试引用一个不在对象本身而在其原型上的属性,会发生什么?果不其然,如果我们向服务器发送一个引用继承属性的 Flight 消息(在这种情况下是 `Number.prototype.toString`),它会成功检索到它并放在我们的攻击者可控对象上。
```
0={"foo":"$1:toString"}&1=123
```
解析后:
```
0={"foo":Number.prototype.toString}
```
这被 Guillermo Rauch(Next.js 的原始作者和 Vercel 的创始人)描述为"一个明显的安全检查缺失"(https://www.linkedin.com/pulse/react2shell-guillermo-rauch-cwl2c)。然而在当时,我实际上并没有想太多。它看起来……过于宽松,但它遵循标准 JavaScript 属性查找语义。React 是经过最严格测试的框架之一,所以它一定是安全的,*对吧*?
## 周二 将 Flight 武器化
### 最初的目标
通过我的初步研究,我有两个关键想法:
1. 开发者经常忽略验证用户输入。
2. Flight 允许攻击者发送比普通 JSON 复杂得多的对象。
这可能是一个强有力的组合。我不是在寻找 Flight 本身的漏洞(还),而是看看 Flight 是否可以被*滥用*来利用验证不足的 Next.js 应用。我审查过的几乎每个 Next.js 应用都包含这种攻击面,包括许多开源项目,所以我希望将 Flight 武器化,建立一个漏洞利用方法论并获得一些 CVE。
(附带说明:当 React2Shell 被修复时,它也关闭了这些很酷的攻击向量,使这些最初的想法变得毫无意义。)
### 示例 1 - 类型强制转换
这是一个开发者可能实现的非常简单的 React Server Function,但有一个微妙的漏洞:
```typescript
async function sayHello(name: string): string {
'use server'
return 'Hello, ' + name + '!'
}
```
这错误地假设 `name` 是一串——但这只是一厢情愿的想法。它实际上可能是攻击者可以用 Flight 发送的任何对象。那么,如果我們发送一个自定义对象,其 `toString` 被引用,会发生什么?当尝试将其与 `'Hello',` 连接时,服务器将隐式调用 `toString` 函数!特别容易忽略这个错误,因为 TypeScript 注解 `: string` 提供了类型安全的假象;然而,这实际上在运行时并不强制执行。
### 示例 2 - 显式函数调用
`toString` 示例简单明了,但似乎最难利用,因为只有一次函数调用且没有可控参数。相比之下,这个示例有更多攻击面。开发者再次假设 `str` 是一串,但攻击者可以在 `replaceAll` 上放置一个恶意函数并控制两个参数。
```typescript
async function replaceStuff(str: string, before: string, after: string): string {
'use server'
return str.replaceAll(before, after)
}
```
### 类型安全的假象
*"但是 Lachlan,这些示例中的参数显然有类型!"* 我可以听到你们中的一些人大声说。然而,TypeScript 只执行构建时分析——它无法在运行时验证或强制执行不可信数据的类型。这把双刃剑可以非常令人信服地让用户输入看起来是有效的类型,但实际上什么都没有检查。攻击者仍然可以发送任意对象,即使开发者期望的是特定类型。
### 漏洞利用 - 游戏规则
Flight 提供了弹药,但实际将其转化为有用的武器很困难。就像一个 CTF 谜题一样……
假设应用开发者编写了不安全的代码(如前面的示例):
- 攻击者指定的函数被调用一次
- 攻击者控制 0 到 N 个参数
- 无论我们指定什么函数,无论我们的参数采取什么形状,都必须来自 Flight 消息。
浏览 React 文档和 Flight 代码表明我们可以使用以下数据类型:
1. 普通对象、数组、字符串
2. JSON 无法表示的常量(`Infinity`、`BigInt`、`NaN` 等)
3. `Date`
4. `Set`、`Map` 和类型化数组,如 `Uint8Array`
5. 一个 Promise,用于将在未来到达的数据块
6. 对 Server Functions 的引用
7. 上述任何属性或方法
最后一点是最重要的,因为这是我们可以开始引用函数的地方。例如,我们可以创建一个 `Array`,然后引用 `Array.prototype.join`。或者,我们可以创建一个 `Date` 来引用 `Date.prototype.getYear`,等等。
对于任何函数,我们可以使用 `.constructor` 访问 `Function` 构造函数,这是动态执行任意 JavaScript 代码的少数几种方式之一。首先,你用要执行的代码调用它,它返回一个函数。当你执行*那个*函数时,你的任意代码就会执行。如果我们能强制执行 `Function("console.log('evil')")()`,我们就赢了。
通过游戏规则,调用一次函数很容易。然而,随后调用结果似乎几乎不可能。
在这一点上,我向不少朋友寻求建议和想法。但特别要感谢 Sylvie Mayer(https://x.com/_sy1vi3),她是我多年的朋友。知道她在类似性质的 CTF 挑战中技术高超,我向她展示了我到目前为止对 Flight 的研究和上述谜题的"规则"。她有关于这个故事的自己的博客文章(https://sylvie.fyi/posts/react2shell/),以及她博客上的一些很棒的文章,比如 Python 沙盒挑战,与此有相当相似的技能组合。
## 周三 荒谬的循环
我仍然必须围绕这个安排正常的日常工作,但在这一周,我忍不住把每一个醒着的时刻都花在这个新发现的痴迷上。除此之外,多巴胺使我无法入睡。我已经认为自己对 JavaScript 非常了解,但我不断学到更多关于它的特性、Flight 给予我们的访问权限,以及我们如何让它行为不端。
Sylvie 和我在这里花了相当多的时间试图将 Flight 武器化,但每次我们发现看起来有希望的东西,我们尝试并失败使其对实现 RCE 有用。但在每次迭代中,我对这种奇怪的协议以及它给予我们的访问权限变得更加流利。
然而有趣的是,我们两个都不断陷入这种心理循环:
很难回首并个人反思这一点。显然,我有一个巨大的认知盲点。React 在安全方面近乎完美的记录使找到这样的漏洞的想法看起来很荒谬。我上面给出的不安全应用代码示例(将不可信用户输入当作字符串处理)实际上出现在 React 本身内部!这使我几乎不考虑这些情况作为利用的潜在途径。
在这一点上,我们最好的希望是 React/Flight 让我们构建一个恶意函数来执行,然后易受攻击的应用实际上会调用它。
## 周四 第一次突破
在星期四晚上的某个时候,我发现了导致我开发 React2Shell 的最关键成分之一。当高级框架(如 Next.js)要求 React 解码传入的 Flight 有效载荷时,它调用 `await decodeReply(...)`。它看起来非常合理,但隐藏着一个棘手的秘密……
### 快速历史课 - 回调和 Promise
服务器端 JavaScript 代码在单个线程中运行,允许 I/O 由引擎在后台线程中用原生代码处理。在 JS 世界中,你传递一个"回调"函数,让引擎在后台操作完成时调用。
```javascript
doTheThing(123, function(result) {
doTheNextThing(result.blah, function() {
moreStuff(...)
})
})
```
然而,这变得非常混乱。如果你需要 10 个顺序数据库调用来处理传入的请求,你需要 10 层缩进,导致"回调地狱"。这促使了"Promise"的发明——一种表示未来值承诺的标准格式。使用 Promise,你调用 `.then(...callback...)` 而不是直接传递回调函数。如果你的 `.then` 处理器返回另一个 Promise,你可以继续链接 `.then` 调用。你不需要为每个调用缩进,只需链接另一个 `.then(...)`。
```javascript
doThething(123)
.then((result) => doTheNextThing(result.blah))
.then(() => moreStuff())
.then(() => andEvenMoreStuff())
```
这是一个很大的改进,但仍然不是很人体工学。但然后,ECMAScript 2017 终于将 `async/await` 引入 JS,允许像同步代码一样编写异步代码:
```javascript
let result = await doTheThing(123)
await doTheNextThing(result.blah)
await moreStuff()
await andEvenMoreStuff()
```
标记为 `async` 的函数在调用时只返回一个 `Promise`,而 `await` 只是对 `Promise` 调用 `.then(...)`,使 `async/await` 与 Promise 完全互操作!
然而……在 Promise 成为 ECMAScript 2015 的标准之前,有一堆社区实现的 Promise 实现,通常具有不同的行为。但它们都有一个共同点:`.then(...)` 约定。
所以,我们称任何遵循这个约定的对象为"thenable"——字面上任何可以 `.then()` 的对象。在底层,`await` 调用 `Promise.resolve`,它宽松地调用 thenable。
```javascript
> await { then: console.log }
function (), function ()
// logged injected resolve, reject callbacks
```
但还有一个细节:你曾经需要 `await await foo()` 吗?不!如果一个 thenable 解析为*另一个 thenable*,它也会被自动调用。
```javascript
> await { then: resolve1 => resolve1({ then: resolve2 => resolve2(123) }) }
123
```
### 滥用 Thenable
那么如果我们将以下内容序列化和发送到 Flight,会发生什么?
```javascript
{ then: Array.prototype.push }
```
好吧,`await decodeReply(...)` 将首先运行 Flight 解析器。但是,它然后看到它得到了另一个 thenable,并调用我们的攻击者提供的函数!请求将挂起,因为我们的假 Promise 永远不解析。但如果我们检查内存中的内容,我们发现运行时确实在我们的精心设计的对象上调用了 `.then(resolve, reject)`:
```javascript
{ then: Array.prototype.push, 0: [Function (anonymous)], 1: [Function (anonymous)], length: 2 }
```
这是第一个感觉不像是 React 故意设计的行为。到目前为止,Flight 提供的所有东西感觉……很过度,但从我的外部角度来看,它看起来就像你给定它的设计。
再进一步,如果我们精心制作我们的 thenable 有效载荷以解析为另一个 thenable,我们可以链接无限次函数调用。然而在当时,我没有充分…