||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

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:

529029745-5c10a9f8-c7b5-4746-a215-c12e37a029d6
$ ./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
529029574-e8a9d086-0555-47f4-bd14-e666b0c0af15
==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 ?? ()
image image
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

image

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():

image

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



← Back to Home