kenv_init() ingests unbounded bootloader env strings; kgetenv() strcpy() overflows a 258-byte kernel stack buffer, detonable by any unprivileged user via kenv(2) KENV_GET
| Field | Value |
|---|---|
| ID | DF-2894 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-787 / CWE-120 |
| File | sys/kern/kern_environment.c |
| Lines | 580-588 (ingest), :231/:239 (kgetenv stack strcpy) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The dynamic kenv table has exactly one unvalidated ingestion path:
kenv_init() copies every static/bootloader env string into kenv_dynp
checking only the entry COUNT, never the per-entry length β kern_envp
comes straight from loader metadata (loader.conf / loader prompt). Every
later kgetenv() strcpy()s the value into a 258-byte kernel-stack buffer.
A boot-planted value β₯ 258 bytes smashes the kernel stack of whichever
thread fetches it; any unprivileged user triggers via kenv(2) KENV_GET,
as does kenv(1) and every kgetenv_string consumer. Reproduced on the
stock INVARIANTS guest: 700-byte loader.conf value + unpriv KENV_GET β
Fatal trap 9: general protection fault, saved frame pointer =
0x4141414141414141, fault at kgetenv's ret, guest down.
Threat model & preconditions
Plant requires root/loader/console access (loader.conf edit, boot prompt, or boot media); detonation requires only an unprivileged local user. Turns boot-time DATA into ring-0 memory corruption: an integrity-boundary crossing for verified-boot style setups, and a privileged-actor persistent kernel-smash backdoor that any user's GET detonates. Ceiling: byte-controlled stack smash, no NUL constraint, no canary/SMEP/SMAP/KASLR on this class of guest β RIP control β ROP β uid=0 for the detonating user once planted (not unpriv-only privesc because the plant step is privileged).
Proof of contest
VERIFIED (findings/poc/DF-2894/): loader.conf plant + reboot + unpriv
kenv(KENV_GET, "audit.smash", ...) β Fatal trap 9, RBP
0x4141414141414141, stopped at kgetenv's ret, guest down in DDB. Fix
(mirror ksetenv per-entry limits at ingest + guard the copy in kgetenv)
validated on a rebuilt kernel: entry dropped at boot with a warning,
identical PoC returns ENOENT, guest healthy.
Recommended fix
Validated fix.diff in findings/poc/DF-2894/.
Timeline
- 2026-09-02 Discovered during pass-2 audit of kern_environment.c (GLM 5.3); unpriv-detonated stack smash reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2894 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.7 KB | β raw | |
| VERDICT.md | β | 4.3 KB | β raw | |
| trigger.c | β | 1.1 KB | view raw | |
| build.sh | β | 131 B | view raw | |
| run.sh | β | 378 B | view raw | |
| build.log | β | 579 B | view raw | |
| run.log | β | 2.0 KB | view raw | |
| panic.txt | β | 2.0 KB | view raw | |
| env.txt | β | 830 B | view raw | |
| fix.diff | β | 1.2 KB | view raw | |
| fix_build.log | β | 1.8 KB | view raw | |
| fix_run.log | β | 1.0 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-2894 β kenv_init() accepts unbounded bootloader env strings; kgetenv() strcpy()s the value into a 258-byte kernel stack buffer, smashable by any unprivileged user via kenv(2) KENV_GET.
BUILD (in guest, as unprivileged user): cc -O2 -o /tmp/df2894_trigger /tmp/df2894_trigger.c (warning: implicit declaration of kenv() is expected; libc exports it)
SETUP (once, in guest, as root β simulates loader/loader.conf control): cp /boot/loader.conf /boot/loader.conf.df2894bak A=$(awk 'BEGIN{s="";for(i=0;i<700;i++)s=s "A";print s}') printf 'audit.smash="%s"\n' "$A" >> /boot/loader.conf printf 'audit.hello="PANGRAM_CONTROL"\n' >> /boot/loader.conf shutdown -r now
RUN (after reboot, as unprivileged user): kenv | grep -c '^audit.smash=' # 1 => oversize entry made it into # the dynamic table (DUMP is safe) /tmp/df2894_trigger audit.hello # control: returns len + value, exit 0 /tmp/df2894_trigger audit.smash # trigger: NEVER RETURNS
EXPECTED OUTPUT: control -> "kenv GET audit.hello -> len=.. val=PANGRAM_CONTROL..." trigger -> syscall never returns; serial console (vm.sh log) shows:
Fatal trap 9: general protection fault while in kernel mode
frame pointer = 0x10:0x4141414141414141
Stopped at kgetenv.part.4+0xea: ret
i.e. kgetenv()'s stack frame was overwritten with the attacker-supplied
boot-env bytes ('A' = 0x41), faulting on return. Guest is down.
With the fix applied (fix.diff), the oversize entry is dropped at boot
("WARNING: kenv: oversize entry ...") and the trigger instead returns:
/tmp/df2894_trigger: kenv(KENV_GET, audit.smash): No such file or directory
DF-2894 β VERDICT
Status: REPRODUCED (impact: panic; conditional code-exec ceiling β see below) Fix: VALIDATED (fixed kernel drops the oversize entry at boot; PoC then gets ENOENT)
The bug
kenv_init() (sys/kern/kern_environment.c:580-588, invoked at SI_BOOT1_POST
via SYSINIT at :594) copies every bootloader-supplied string from the static
environment (kern_envp, populated at boot from loader metadata β
sys/platform/pc64/x86_64/machdep.c:2669 MD_FETCH(kmdp, MODINFOMD_ENVP, ...),
i.e. anything /boot/loader.conf or the loader prompt defines) into the
dynamic kenv table, validating only the entry count (i < KENV_DYNMAXNUM-1)
β never the per-entry length. This is the one ingestion path with no length
check: ksetenv() (:263-266) enforces namelen β€ KENV_MNAMELEN(128) and
vallen β€ KENV_MVALLEN(128), and sys_kenv()'s KENV_SET clamps its copyin to
129 bytes (:156-157); kenv_init() has no equivalent.
Every later kgetenv() (:229-243) performs
char buf[KENV_MNAMELEN + 1 + KENV_MVALLEN + 1]; /* :231 β 258 bytes, stack */
...
cp = kenv_getstring_dynamic(name, NULL); /* :237 β ptr to value */
if (cp != NULL) {
strcpy(buf, cp); /* :239 β UNBOUNDED */
so a dynamic-table value of β₯ 258 bytes overflows a fixed kernel-stack buffer with fully attacker-chosen bytes (no NUL constraint, strcpy terminates only at the value's end).
The trigger β unprivileged
sys_kenv() KENV_GET (:135-136) calls kgetenv(name) for any unprivileged
user; kenv(1), kenv(3), and every in-kernel kgetenv_string() consumer
reach the same strcpy.
Reproduction (stock guest, kernel #0, INVARIANTS, -fno-stack-protector)
- Plant (root/loader-level, once):
audit.smash="<700 A's>"and controlaudit.hello="PANGRAM_CONTROL"in/boot/loader.conf; reboot. - Verify ingestion:
kenv | grep -c '^audit.smash='β 1 (DUMP path is safe). - As
maxx(uid=1001):/tmp/df2894_trigger audit.helloβ returns value, exit 0./tmp/df2894_trigger audit.smashβ never returns; serial console:
Fatal trap 9: general protection fault while in kernel mode frame pointer = 0x10:0x4141414141414141 current process = 824 (the unpriv trigger process) Stopped at kgetenv.part.4+0xea: ret
Saved RBP == 0x4141414141414141 (the planted bytes); fault on kgetenv's ret.
Guest down in DDB. Full capture: panic.txt, run.log.
Exploit ceiling (honest)
The overflow itself is a clean, byte-controlled kernel-stack smash with no
canary (kernel CFLAGS: -fno-stack-protector), no SMEP/SMAP/KASLR on this
guest β i.e. RIP control and a ROP path to uid=0 exist once the oversized
entry is in the table. But planting the entry requires root
(/boot/loader.conf), loader-prompt, or boot-media control. So this is not
an unprivileged-only privesc; it is:
- a boot-time data β ring-0 corruption boundary crossing (loader.conf is configuration, not code β relevant to verified/secure-boot-style integrity models), and
- a persistent "root plants, unprivileged user detonates" kernel-smash primitive (panic demonstrated; code-exec plausible with ROP since the smashed return address is fully chosen by the planter and detonated by any user).
Impact recorded as panic (what was demonstrated from an unprivileged
trigger); severity Medium because the plant step is privileged.
Fix (fix.diff β validated)
Ingest clamp in kenv_init() (mirror of ksetenv's limits: drop entries longer
than KENV_MNAMELEN + KENV_MVALLEN with a boot warning) plus a defense-in-depth
strlen(cp) >= sizeof(buf) guard in kgetenv().
Validated: vm.sh reset with-src, patch applied in-guest to /usr/src,
make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel, reboot
into kernel #1, replanted the identical loader.conf entries, re-ran the exact
PoC as maxx:
- boot log:
WARNING: kenv: oversize entry (713 bytes), ignoring string audit.smash=... kenv | grep -c '^audit.smash='β 0; control var unaffected- trigger β
kenv(KENV_GET, audit.smash): No such file or directory, exit 1, guest stays up
Baseline (panic) vs patched (ENOENT, healthy guest): fixed. See fix_run.log, fix_build.log.
Not re-reported here (known family)
DF-0120/0121/0122/0123 cover the unpriv env read, the KENV_GET clamp signedness, the kgetenv_quad shift, and the kernenv_next walk.
Fix verification
fixedIdentical loader.conf plant + identical unpriv PoC on baseline kernel #0 -> GPF panic (RBP=0x41..), guest down; on fixed kernel #1 -> boot warning drops the 713-byte entry, KENV_GET returns ENOENT, control var unaffected, guest stays up.
['fix_run.log', 'fix_build.log']
Confirmed kernel references
Detail
Exploit chain
1) privileged/console plant: audit.smash="
Evidence (decisive lines)
['run.log β control GET returns (exit 0); trigger GET never returns (ssh hung)', "panic.txt β 'Fatal trap 9 ... frame pointer = 0x4141414141414141' / 'Stopped at kgetenv.part.4+0xea: ret' / current process = unpriv trigger", 'env.txt β plant lines + stock-kernel identity (kernel #0, X86_64_GENERIC)', "fix_run.log β fixed kernel #1: boot warning 'oversize entry (713 bytes)', table entry absent, trigger -> ENOENT exit 1, guest up", 'fix_build.log β make nativekernel KBUILD_DONE_OK; kernel CFLAGS show -fno-stack-protector', 'fix.diff β ingest clamp in kenv_init + strlen guard in kgetenv']
PoC changes
seed sketch was never runnable: rebuilt as a plain kenv(3) userland trigger (libc exports kenv; implicit-decl warning is cosmetic); plant done via /boot/loader.conf + in-guest awk for the 700-byte value; /tmp is tmpfs so the binary was rebuilt after each reboot.
Verified recommended fix
kenv_init(): drop boot-env entries longer than KENV_MNAMELEN+KENV_MVALLEN (mirror ksetenv limits) with a boot warning; defense-in-depth strlen(cp)>=sizeof(buf) guard in kgetenv().
Verdict
kenv_init() copies bootloader-supplied env strings of unbounded length into the dynamic kenv table (only the entry count is bounded), while kgetenv() strcpy()s the looked-up value into a fixed 258-byte kernel-stack buffer; an unprivileged kenv(2) KENV_GET on a boot-planted >=258-byte value smashed the kernel stack (Fatal trap 9, saved RBP=0x4141414141414141, fault at kgetenv's ret) and took the guest down. Planting requires loader.conf/boot-prompt control, so the demonstrated unpriv impact is panic; the smash itself is byte-controlled with no canary/SMEP/SMAP/KASLR, giving a credible boot-time-plant -> unpriv-detonate code-exec ceiling. Ingest clamp validated by in-guest kernel rebuild: entry dropped at boot, PoC returns ENOENT, guest healthy.
No comments yet.