Use-After-Free in libuv's uv_spawn() Error Path
TL;DR
I Found a use-after-free in libuv’s uv_spawn(). When spawn fails after handle initialization, the handle stays in the event loop’s queue but the function returns an error. If the handle goes out of scope or gets freed, any subsequent loop operation crashes. Maintainer said it’s a legitimate bug but not a security vulnerability. PR merged.
Background
libuv is the async I/O library behind Node.js, Julia, Neovim, and a bunch of other projects. It handles event loops, file operations, networking, and process spawning. Millions of applications depend on it
I was auditing the codebase looking for memory safety issues. Process spawning caught my attention since it involves complex state management and error handling , perfect place for bugs to hide
Finding the Bug
I started looking at handle lifecycle management. In libuv, handles get added to a queue when initialized and must be explicitly closed with uv_close() before being freed. The question was: what happens when initialization fails halfway?
In src/unix/process.c, uv_spawn() does this:
// line 991 - handle added to queue
uv__handle_init(loop, (uv_handle_t*)process, UV_PROCESS);
// ... bunch of setup code ...
// line 1012-1014 - can fail after handle is already in queue
for (i = 0; i < options->stdio_count; i++) {
err = uv__process_init_stdio(options->stdio + i, pipes[i]);
if (err)
goto error;
}
See the problem? Handle gets added to loop->handle_queue first, then stdio initialization can fail. Let’s check the error path:
// line 1075
error:
if (pipes != NULL) {
// ... pipe cleanup only ...
}
return err;
// handle still in queue!
No uv__queue_remove(). The handle stays in the queue but the function returns an error, telling the caller “spawn failed, don’t use this handle.”
Now if the handle was stack-allocated and goes out of scope, or heap-allocated and freed, we have a dangling pointer in the queue. Next time something iterates the queue - boom.
The Evidence
Here’s the thing , libuv already knows about this pattern. In src/unix/tcp.c, uv_tcp_init_ex() handles the exact same scenario:
// src/unix/tcp.c:125-136
uv__stream_init(loop, (uv_stream_t*)tcp, UV_TCP);
/* If anything fails beyond this point we need to remove the handle from
* the handle queue, since it was added by uv__handle_init in uv_stream_init.
*/
if (err) {
uv__queue_remove(&tcp->handle_queue); // <-- this is missing in uv_spawn
// ...
}
Same pattern, same problem, but uv_spawn doesn’t have the fix. That comment is basically a confession that the bug exists.
The PoC
I needed to trigger the error path after uv__handle_init(). Looking at uv__process_init_stdio():
// src/unix/process.c:223
if (fd == -1)
return UV_EINVAL;
Invalid fd = instant error. Simple trigger:
#include <uv.h>
#include <stdio.h>
static void walk_cb(uv_handle_t* handle, void* arg) {
(void)arg;
printf(" handle: %p, type: %d\n", (void*)handle, handle->type);
}
static void exit_cb(uv_process_t* proc, int64_t exit_status, int term_signal) {
(void)proc; (void)exit_status; (void)term_signal;
}
static int spawn_with_error(uv_loop_t* loop) {
uv_process_t process; // stack allocated
uv_process_options_t options;
uv_stdio_container_t stdio[3];
char* args[2];
int r;
args[0] = "/bin/true";
args[1] = NULL;
stdio[0].flags = UV_INHERIT_FD;
stdio[0].data.fd = -1; // invalid fd triggers error
stdio[1].flags = UV_INHERIT_FD;
stdio[1].data.fd = 1;
stdio[2].flags = UV_INHERIT_FD;
stdio[2].data.fd = 2;
options.exit_cb = exit_cb;
options.file = "/bin/true";
options.args = args;
options.env = NULL;
options.cwd = NULL;
options.flags = 0;
options.stdio_count = 3;
options.stdio = stdio;
options.uid = 0;
options.gid = 0;
r = uv_spawn(loop, &process, &options);
printf("uv_spawn returned: %d (%s)\n", r, uv_strerror(r));
return r;
} // process goes out of scope, but still in queue!
int main(void) {
uv_loop_t* loop;
loop = uv_default_loop();
printf("libuv version: %s\n\n", uv_version_string());
printf("[1] Calling uv_spawn() with invalid stdio...\n");
spawn_with_error(loop);
printf("\n[2] uv_spawn() failed but handle is still in queue\n");
printf("\n[3] Calling uv_walk() - triggers UAF:\n");
uv_walk(loop, walk_cb, NULL);
return 0;
}
Build and run:
# Build libuv
mkdir build && cd build
cmake .. -DCMAKE_C_FLAGS="-g"
make -j$(nproc)
cd ..
# Compile PoC
gcc -g -I./include -L./build -Wl,-rpath,./build -o poc poc.c -luv
# Run
./poc
Results
Without ASAN:
$ ./poc
libuv version: 1.51.1-dev
[1] Calling uv_spawn() with invalid stdio...
uv_spawn returned: -22 (invalid argument)
[2] uv_spawn() failed but handle is still in queue
[3] Calling uv_walk() - triggers UAF:
[1] 612345 segmentation fault ./poc
With ASAN:
# Build with ASAN
mkdir asan_build && cd asan_build
cmake .. -DCMAKE_C_FLAGS="-fsanitize=address -g -fno-omit-frame-pointer"
make -j$(nproc)
cd ..
# Compile PoC with ASAN
gcc -fsanitize=address -g -I./include -L./asan_build -Wl,-rpath,./asan_build -o poc_asan poc.c -luv
./poc_asan
==614759==ERROR: AddressSanitizer: stack-use-after-return on address 0x7fefb0600110
WRITE of size 8 at 0x7fefb0600110 thread T0
#0 uv__queue_split src/queue.h:55
#1 uv__queue_move src/queue.h:66
#2 uv_walk src/uv-common.c:559
Stack-use-after-return. The process variable went out of scope but the queue still has a pointer to it
GDB Analysis
Wanted to see exactly what’s happening:
$ gdb -q ./poc_noasan
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x00007fffffffdc10 in ?? ()
pwndbg> info registers rip rsp rbp
rip 0x7fffffffdc10 0x7fffffffdc10
rsp 0x7fffffffdc70 0x7fffffffdc70
rbp 0x7ffff7fbde48 0x7ffff7fbde48 <default_loop_struct+648>
pwndbg> info proc mappings
...
0x00007ffffffde000 0x00007ffffffff000 0x21000 0x0 rw-p [stack]
RIP = 0x7fffffffdc10 is within stack range (0x7ffffffde000 - 0x7ffffffff000). Execution jumped to a stack address , classic sign of stack corruption from UAF write
The queue operation tried to dereference the dangling pointer, corrupted the stack frame, and ret jumped to garbage
The Fix
One line:
error:
uv__queue_remove(&process->handle_queue); // add this
if (pipes != NULL) {
// existing cleanup
}
return err;
Same pattern as uv_tcp_init_ex():
Disclosure
Reported via GitHub Security Advisory. Maintainer (bnoordhuis) responded:
“Thanks for the report. It’s a legitimate bug but it doesn’t seem like an exploitable security vulnerability to me. The error path is only taken when malloc fails or creating file descriptors fails. Neither seem like practical exploitable scenarios.”
Fair point. I agreed that practical exploitation is limited , you’d need the application to pass attacker-controlled invalid arguments to uv_spawn(), which isn’t common. But the bug is real and the fix makes the code consistent with other handle initialization functions.
Offered to open a PR, maintainer said go ahead.
Conclusion
This UAF is real and causes crashes, but triggering it requires specific conditions that aren’t typically attacker-controlled. The maintainer was right to classify it as a regular bug rather than a security vulnerability.
What I learned:
- libuv handle lifecycle management
- How to trace UAF bugs with ASAN and GDB
- When to accept “not a security issue” and just fix the code
Status: PR #4980 merged
Timeline:
- December 23, 2025: Bug discovered
- December 23, 2025: Security advisory submitted
- December 24, 2025: Maintainer response , “legitimate bug, not security issue”
- December 24, 2025: PR opened with fix
- December 24, 2025: PR merged
Affected: All libuv versions with uv_spawn() (unix)
Commit tested in this writeup : 309b28bdbedeeb52f0d3791bee0a1df3e4bc9c68
Fix: PR #4980