β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2894

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2894 Β· 13 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

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)

  1. Plant (root/loader-level, once): audit.smash="<700 A's>" and control audit.hello="PANGRAM_CONTROL" in /boot/loader.conf; reboot.
  2. Verify ingestion: kenv | grep -c '^audit.smash=' β†’ 1 (DUMP path is safe).
  3. 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Identical 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 22:58:18 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

1) privileged/console plant: audit.smash="=258-byte value>" in /boot/loader.conf (data-only persistence); 2) kenv_init (SI_BOOT1_POST) ingests it unvalidated; 3) any unprivileged user: kenv(2) KENV_GET audit.smash -> kgetenv -> strcpy past buf[258] -> saved frame/return address overwritten with planter-chosen bytes (strcpy payload, NUL-free); 4) demonstrated: GPF panic at kgetenv ret; on this no-SMEP/SMAP/KASLR/no-canary guest, steps 3-4 are RIP control -> ROP -> uid=0 (chain start still requires the privileged plant, hence impact=panic).

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.