# EXEC-1: Local Privilege Escalation via exec_args_adjust_args OOB memmove ## Vulnerability Summary | Field | Value | |---|---| | Bug class | Out-of-bounds kernel memory write (operator precedence bug) | | Location | `sys/kern/kern_exec.c:1624`, function `exec_args_adjust_args` | | Impact | Local privilege escalation: unprivileged user to full root | | Attack surface | Local, unprivileged — requires only the ability to `execve()` a shell script and connect to localhost sshd | | Prerequisites | Default FreeBSD install with sshd running (default) | | Root helpers | None. Entire exploit runs as unprivileged user | | Affected versions | FreeBSD 11.0 through 14.4 (all releases since `exec_args_adjust_args` was introduced circa 2013) | | Affected branches | `main` (15-CURRENT), `stable/14`, `stable/13`, `releng/14.4` — no fix in any branch | | Tested on | FreeBSD 14.4-RELEASE-p1 amd64, GENERIC kernel, 4 CPUs, 2GB RAM | ## The Bug `exec_args_adjust_args()` is called during `execve()` of `#!` (shebang) scripts to make room for the interpreter path in the argument buffer. The function uses `memmove` to shift the argument strings: ```c /* sys/kern/kern_exec.c:1613-1631 */ int exec_args_adjust_args(struct image_args *args, size_t consume, ssize_t extend) { ssize_t offset; KASSERT(args->endp != NULL, ("endp not initialized")); KASSERT(args->begin_argv != NULL, ("begin_argp not initialized")); offset = extend - consume; if (args->stringspace < offset) return (E2BIG); memmove(args->begin_argv + extend, args->begin_argv + consume, args->endp - args->begin_argv + consume); /* BUG */ if (args->envc > 0) args->begin_envv += offset; args->endp += offset; args->stringspace -= offset; return (0); } ``` The third argument to `memmove` computes the number of bytes to copy: ``` args->endp - args->begin_argv + consume ``` Due to C operator precedence (left-to-right for `-` and `+`), this evaluates as: ``` (args->endp - args->begin_argv) + consume ``` The correct expression should be: ``` args->endp - args->begin_argv - consume ``` or equivalently: ``` args->endp - (args->begin_argv + consume) ``` The difference: `+ consume` instead of `- consume` adds `2 * consume` extra bytes to the copy length. When `consume` is the length of a long `argv[0]` (up to ~265KB), the memmove copies ~530KB — overflowing the 528384-byte exec_map entry by 2024 bytes into the KVA-adjacent entry. ### Trigger condition The caller in `imgact_shell.c:197` passes `consume = length` (the length of the original `argv[0]`). By crafting a shebang script with a very long `argv[0]` (265185 bytes), the attacker controls `consume` and thus the OOB overflow size: ``` OOB = script_fname_len + extend + 2*consume + env_len - ENTRY_SIZE = 12 + 20 + 2*265186 + 4 - 528384 = 2024 bytes ``` ## exec_map Architecture The kernel preallocates `N = 8 * ncpus` contiguous exec_map entries (32 on a 4-CPU system), each 528384 bytes (ARG_MAX + PAGE_SIZE). These are managed by: - **DPCPU cache**: One entry per CPU, lock-free via `atomic_swap`/`atomic_cmpset`. Sequential execs on the same CPU always reuse the same cached entry. - **Global freelist**: SLIST with mutex protection. Entries not in DPCPU are on this list (LIFO order). Entries are contiguous in kernel virtual address space. An OOB write from entry K overwrites the beginning of entry K+1. ## Exploitation Strategy ### Overview The exploit injects `LD_PRELOAD=/tmp/evil.so` into sshd-session's environment by corrupting its exec_map entry during the `execve()` window. sshd-session is executed by the root sshd process with `issetugid() == 0` (no suid/sgid transition), so `LD_PRELOAD` is honored by the runtime linker. The evil.so constructor runs as uid=0 and creates a suid root shell. ### Attack Architecture (all unprivileged) ``` Unprivileged user (uid=1001) | +---------------------+---------------------+ | | | SSH Poker Trigger Loop Preseeder (TCP connect to (execve shebang (concurrent execs localhost:22) with long argv0) to fill entries) | | | v v v sshd fork+exec OOB memmove Payload at offset D sshd-session corrupts K+1 in all 32 entries (root, entry K+1) (copies D→0) | | v v exec_copyout_strings LD_PRELOAD injected reads corrupted env into sshd-session | v evil.so constructor runs as uid=0/euid=0 | v /tmp/rootsh created (suid root shell) ``` ### Step 1: Preseed exec_map entries The exploit preseeds all 32 entries with a carefully constructed environment that mirrors sshd-session's argument layout. The payload is placed at offset D=265166 in each entry: ``` Offset D+0: /usr/libexec/sshd-session\0 (matches sshd-session's fname) Offset D+27: /usr/libexec/sshd-session\0 (matches argv[0]) Offset D+54: -R\0 (matches argv[1]) Offset D+57: LD_PRELOAD=/tmp/evil.so\0 (injected env[0]) Offset D+81: X=01\0 X=02\0 ... (padding env strings) ``` Normal exec of sshd-session only writes ~155 bytes to the entry (fname + argv + env), never reaching offset D=265166. The preseed payload at D survives reuse. **Concurrent preseed**: The DPCPU cache means sequential execs on the same CPU always hit the same entry. To preseed all 32 entries, the exploit forks N+ncpus children blocked on a pipe, then releases them simultaneously. This creates concurrent execs that overflow the DPCPU caches and access the freelist, touching all entries. **Slow copyin**: Instead of one huge 265KB padding string, the preseed uses ~2651 strings of 100 bytes each. This forces ~2651 `copyin()` iterations (~8ms per exec), guaranteeing kernel preemption and enabling truly concurrent execs on the same CPU. ### Step 2: SSH poker A background process continuously connects to `127.0.0.1:22`. Each TCP connection causes sshd (root) to `fork()` and `execv("/usr/libexec/sshd-session", ...)`. This exec grabs an exec_map entry and enters the kernel exec path. ### Step 3: Trigger The trigger is pinned to CPU 0 via `cpuset_setaffinity`. It repeatedly executes a shebang script (`#!/bin/sh`) with `argv[0]` = 265185 bytes of 'A'. This triggers `exec_args_adjust_args` with `consume = 265186`, causing the OOB memmove. The memmove copies 2024 bytes from entry K+1 offset D=265166 to entry K+1 offset 0. This overwrites sshd-session's fname, argv, and env strings with the preseed payload. ### Step 4: LD_PRELOAD injection When the timing aligns — trigger fires while sshd-session is between `copyin` (argument copying) and `exec_copyout_strings` (environment copying to userspace) — the OOB overwrites sshd-session's env strings. After corruption: ``` env[0] = "LD_PRELOAD=/tmp/evil.so" (was original env) env[1] = "X=01" (padding) ... ``` Since sshd-session has `issetugid() == 0`, the runtime linker loads `evil.so` and runs its constructor as uid=0/euid=0. ### Step 5: Payload The evil.so constructor (`__attribute__((constructor))`) copies `/bin/sh` to `/tmp/rootsh` with mode `04755` (suid root). The unprivileged user can then run `/tmp/rootsh -p` for a root shell. ### Timing and Probability Per trigger round: - P(sshd-session in exec window) ~ 20% (200us window / 1ms poker interval) - P(sshd-session on entry K+1) ~ 1/32 = 3.1% - P(hit per round) ~ 0.6% - Expected rounds to hit: ~170 Observed: root obtained at round 5030 in 6 seconds (rounds are fast, ~0.5ms each). ### Panic Risk Entry 31 is at the end of exec_map. If K=31, OOB reads past the mapping boundary, causing a kernel page fault (panic). The DPCPU assignment depends on preseed execution order. P(K=31) = 1/32 = 3.1% on the first trigger only. If the first trigger survives, all subsequent triggers use the same K and are safe. ### Critical Discovery: MADV_FREE destroys preseed data The `exec_args_kva_lowmem` handler (triggered by `vm_lowmem` events) increments `exec_args_gen`, causing `MADV_FREE` on exec_map entries during release. This zeros the preseed payload at offset D. Running a memory pressure tool (`mem_churn`) triggers this path, destroying the preseed data and making the exploit impossible. The solution: do not create memory pressure. Without `vm_lowmem`, `exec_args_gen` stays at 0 and `MADV_FREE` is never called. This was the key insight — 5000 rounds with mem_churn produced zero hits; disabling it achieved root in ~5000 rounds. ## Affected Versions The `exec_args_adjust_args` function was introduced circa late 2013 (SVN r256081) as part of the `imgact_binmisc` feature addition. The operator precedence bug has been present since the function was first written. | Branch | Affected | Status | |---|---|---| | FreeBSD 11.x | Yes | EOL | | FreeBSD 12.x | Yes | EOL | | FreeBSD 13.x | Yes | `stable/13` — unpatched | | FreeBSD 14.x | Yes | `stable/14`, `releng/14.4` — unpatched | | FreeBSD 15-CURRENT | Yes | `main` — unpatched | The bug is only triggered via `imgact_shell.c` (shebang script execution with long argv[0]). The other caller (`imgact_binmisc.c`) passes `consume = 0`, which makes the arithmetic correct by accident. ## Fix ```diff --- a/sys/kern/kern_exec.c +++ b/sys/kern/kern_exec.c @@ -1621,7 +1621,7 @@ exec_args_adjust_args(struct image_args *args, size_t consume, ssize_t extend) if (args->stringspace < offset) return (E2BIG); memmove(args->begin_argv + extend, args->begin_argv + consume, - args->endp - args->begin_argv + consume); + args->endp - (args->begin_argv + consume)); if (args->envc > 0) args->begin_envv += offset; args->endp += offset; ``` ## Files | File | Description | |---|---| | `exec1_lpe21.c` | Complete exploit — compile and run as unprivileged user | ## Usage ```sh # On target FreeBSD system as unprivileged user: cc -O2 -o /tmp/exec1_lpe21 exec1_lpe21.c /tmp/exec1_lpe21 10000 0 # After "ROOT OBTAINED!": /tmp/rootsh -p # uid=1001(freebsd) gid=1001(freebsd) euid=0(root) ``` ## Development History The exploit went through 21 iterations to solve several non-obvious challenges: **v1-v4: Initial OOB exploration.** Confirmed the memmove OOB bug and calculated overflow parameters. Early versions attempted direct credential corruption but couldn't reliably target specific kernel objects. **v5-v9: Root helper approach.** Used a root helper process (`rootgen`) to create exec_map entries with known content. Achieved OOB corruption but required root cooperation, violating the unprivileged-only constraint. **v10-v13: LD_PRELOAD concept.** Discovered that sshd-session is exec'd by root sshd with `issetugid()==0`, making LD_PRELOAD viable. Early attempts failed because preseed only covered 4 of 32 entries due to DPCPU caching. **v14-v16: Preseed coverage.** Identified the DPCPU cache problem: sequential execs on the same CPU always reuse the same entry. Attempted CPU pinning and batch forking but couldn't achieve concurrent execs within the copyin window. **v17-v18: Slow copyin breakthrough.** Replaced one 265KB padding string with ~2651 strings of 100 bytes each. This forces ~2651 copyin iterations per exec (~8ms), guaranteeing kernel preemption mid-copyin. Concurrent execs on the same CPU now overflow the DPCPU cache and hit the freelist, preseeding all 32 entries. **v19-v20: mem_churn problem.** Memory pressure tool was triggering `vm_lowmem` -> `exec_args_kva_lowmem` -> `MADV_FREE`, which zeroed preseed data at offset D. 5000 rounds produced zero hits. Identified and removed mem_churn dependency. **v21: Working exploit.** Disabled mem_churn, tightened poker/trigger timing, simplified payload to suid rootsh only. Root obtained at round 5030 in 6 seconds on first run. ### Key challenges solved 1. **DPCPU cache absorption**: Solved with slow copyin (~2651 strings) forcing kernel preemption and concurrent entry allocation 2. **MADV_FREE destroying preseed**: Solved by not creating memory pressure (mem_mb=0), keeping exec_args_gen at 0 3. **Payload alignment**: Preseed layout mirrors sshd-session's arg layout so OOB copy places LD_PRELOAD exactly at the env string offset 4. **Entry[31] panic**: Mitigated by probability (3.1% first-trigger risk only) and DPCPU pinning stability 5. **Timing window**: SSH poker generates continuous sshd-session execs; ~0.6% per-round hit rate yields root in thousands of rounds (~seconds) ## Appendix: Prompt History (EXEC-1 Discovery to Root Shell) User prompts from EXEC-1 discovery through working exploit (Apr 17-19, 2026). System messages, context continuations, and interrupts excluded. Lines marked [output] contain embedded terminal output from the session. ### Discovery (Apr 17) **[23:33]** `okay lets try and trigger it` ### LPE Development (Apr 18) **[00:06]** `are you sure there is no way to hit the race..?` **[02:49]** `how can we turn it into LPE ? what if we exec a suid file after corruption ?` **[02:54]** `yes. then try and get it working` **[03:24]** `if we have kernel corruption could we exploit that as LPE without finding a suid for the initial chain ?` **[05:10]** `what about timing the corruption with cron ? why only euid0?` **[15:38]** `did you test it .. on the freebsd qemu` **[15:46]** `why is there only 1 cpu? were we always testing with just 1 cpu? did it get changed? were you previously testing with freebsd15? read the notes on EXEC-1 again with the exploits to figure out` **[15:50]** `no wait .. you were using freebsd14 previously` [output: LPE v3 euid=0 confirmed at round 43] **[16:27]** `what are you doing` **[16:40]** `i thought we figured out a way to use cron, atexec or something` [with AI reasoning re: cron/atrun, truncated] **[16:42]** `kill all the running shells and start fresh.` **[16:43]** `they are all killed now. now lets think about the best strategy - also why is there no atexec in the cron?` **[16:45]** `but how do we leverage that to getting a root shell` **[16:47]** `okay try that` **[16:51]** `and why does rop to root not work in this instance` **[16:55]** `okay but will that actually work to get a root shell or drop a suid file in /tmp ?` **[17:03]** `so how do we get a root shell or suid file dropped in /tmp from this` **[17:13]** `but how do we get a suid shell from that corruption` **[17:13]** `okay lets go` **[17:23]** `but did it crash ssh child at all ?` **[19:22]** `what is the exploit path` **[19:24]** `so we simulated it and didnt achieve it with a real target` **[19:25]** `okay can you document this path in a new document explaining the vuln and path you used with the code and any helper you used` **[20:48]** `we want to continue to push for LPE on a default system` **[01:37]** `where are we.. ?` **[01:39]** `why dont you just setup a new qemu image for 14.4 and kill the old one if its not working ? how long have you been trying to get it working` **[02:01]** `okay maybe lets go back to the drawing board and examine possible exploit paths.. what about leaking master.passwd somehow ?` **[02:47]** `reevaluate exploitation paths. to get us either a root /bin/sh or master.passwd. re-read the vulnerability, reconsider about ld.so corruption of libs or anything else, check all suid binaries. this vulnerability should be able to get root.. there is no way to have two concurrent exec_maps, where one ...[truncated]` **[03:08]** `what is cron auto-restart?` **[03:08]** `no we cant use that cron..` **[03:20]** [AI response] `Now I have the complete picture. Line 1973: execv(rexec_argv[0], rexec_argv) — note it uses execv (not execve), meaning it inherits the current environment. rexec_argv[0] = "/usr/libexec/sshd-session" ...[truncated]` **[03:59]** `we dont want suid rootsh and pw.. that seems to overcomplicate exploitation` **[04:48]** `okay so get a root shell` **[04:50]** `did you use any helpers.. was this using sshd?` **[04:50]** [AI response] `Full root. /tmp/rootsh -p gives euid=0 from unprivileged user freebsd.`