Stack buffer overflow via non-NUL-terminated name/linkname in devfs_rule_ioctl
Summary
devfs_rules.c:104 len=strlen(templ->name) UNBOUNDED. struct devfs_rule_ioctl has 3 adjacent char[PATH_MAX] arrays mntpoint/name/linkname. Fill name+linkname with non-NUL: strlen walks 2048B past PATH_MAX. kstrdup copies 2049B into rule->name. :365 devfs_rule_checkname char name_buf[PATH_MAX] on stack. :368 devfs_resolve_name_path(rule->name,name_buf,...) -> devfs_core.c:2032 memcpy(buf,fullpath,strlen(fullpath)+1) = 2049B into 1024B stack buffer. ~1025B stack smash with attacker-controlled bytes (originally linkname payload). DragonFly kernel NO stack canary. Root via /dev/devfs 0600. Jail-escape potential. Fix: strnlen(PATH_MAX) check.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0897 · 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | PoC: non-NUL-terminated name/linkname → stack smash via DEVFS_RULE_ADD+APPLY ioctls | 4.8 KB | view raw |
| build.sh | build-script | cc -O0 -o trigger trigger.c | 115 B | view raw |
| run.sh | run-script | runs trigger as root | 113 B | view raw |
| fix.diff | suggested-fix | strnlen(PATH_MAX) in devfs_rules.c + bounded memcpy in devfs_core.c | 1.8 KB | view raw |
| run.log | run-log | unpatched run: ADD succeeds, APPLY triggers stack smash panic | 1.5 KB | view raw |
| fix_run.log | run-log | patched run: ADD returns EINVAL, no crash, guest stays up | 1.3 KB | view raw |
| fix_build.log | build-log | single-fix kernel build output (nativekernel, rc=0) | 5.6 MB | ↓ download |
| panic.txt | panic-signature | Fatal trap 9, frame pointer 0x4141414141414141, crash at devfs_rule_checkname ret | 582 B | view raw |
| env.txt | environment | uname, cc version, canary check (-fno-stack-protector), devfs perms | 644 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, privilege boundary, fix validation | 7.7 KB | ↓ raw |
| README.md | readme | summary and reproduce instructions | 2.2 KB | ↓ raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | ↓ download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0897 — Stack buffer overflow in devfs_rules ioctl
Summary
Stack buffer overflow via non-NUL-terminated name/linkname in the
struct devfs_rule_ioctl passed to the DEVFS_RULE_ADD ioctl on
/dev/devfs. The unbounded strlen(templ->name) at
devfs_rules.c:104 scans past name[PATH_MAX] into the adjacent
linkname[PATH_MAX] array, causing a ~2072-byte string to be stored.
When rules are later applied, devfs_resolve_name_path() does
memcpy(name_buf, rule->name, 2072) into a char name_buf[PATH_MAX]
(1024-byte) stack buffer — a ~1048-byte stack smash with
attacker-controlled content. DragonFly kernels have no stack canary
(-fno-stack-protector), so this is a code-execution primitive.
Impact
- panic on default GENERIC (demonstrated: Fatal trap 9, frame pointer overwritten with attacker-controlled bytes)
- The underlying primitive is a root→kernel stack smash with code-execution potential (no canary, no SMEP, no SMAP, no KASLR on this guest)
- Not an unprivileged LPE —
/dev/devfsis0600 root:wheel - Jail escape angle — jailed root (cr_uid=0) can trigger this if
/dev/devfsis exposed in the jail's devfs mount (non-default)
Files
| File | Description |
|---|---|
trigger.c |
PoC: constructs non-NUL-terminated name, issues ADD+APPLY ioctls |
build.sh |
Build script |
run.sh |
Run script |
fix.diff |
Git-apply-able fix (strnlen + bounded memcpy) |
VERDICT.md |
Full narrative analysis |
run.log |
Unpatched run log (panic) |
fix_run.log |
Patched run log (EINVAL, no crash) |
fix_build.log |
Single-fix kernel build log |
panic.txt |
Kernel panic signature from boot.log |
env.txt |
Guest environment (uname, cc, canary check, devfs perms) |
manifest.json |
Machine-readable catalog |
Reproduce
./build.sh # cc -O0 -o trigger trigger.c
./run.sh # must run as root (device is 0600 root:wheel)
Expected on unpatched kernel: DEVFS_RULE_ADD succeeds, then
DEVFS_RULE_APPLY triggers a kernel panic (Fatal trap 9, frame pointer
0x4141414141414141).
Expected on fixed kernel: DEVFS_RULE_ADD returns EINVAL, no crash.
DF-0897 — Stack buffer overflow via non-NUL-terminated name/linkname in devfs_rules ioctl
Verdict: REPRODUCED (stack smash confirmed, fix validated)
Impact: panic on default GENERIC. The underlying primitive is a ~1048-byte
attacker-controlled kernel stack buffer overflow with no stack canary — a real
code-execution primitive. However, the direct trigger requires root (/dev/devfs
is 0600 root:wheel), so on a default non-jailed system this is a root→kernel
hardening gap, not an unprivileged LPE. The realistic escalation vector is
jail escape (jailed root → host kernel) if /dev/devfs is exposed in a jail's
devfs mount — a non-default but plausible configuration.
Mechanism (confirmed, path:line at each hop)
-
struct devfs_rule_ioctl(sys/sys/devfs_rules.h:70-84) has three adjacentchar[PATH_MAX]arrays:mntpoint(offset 16),name(offset 1040),linkname(offset 2064).sizeof(struct) = 3112. -
devfs_rule_alloc()(sys/vfs/devfs/devfs_rules.c:78-139) processes an ioctl-supplied template: - Line 104:len = strlen(templ->name)— UNBOUNDED. Ifname[]is filled with 1024 non-NUL bytes,strlenscans pastnameinto the adjacentlinkname[]array (another 1024 non-NUL bytes) and continues through the remaining fields until it finds a NUL byte. With the ioctl buffer filled with non-NUL and a NUL at the struct's last byte,strlenreturns 2071. - Line 109:rule->name = kstrdup(templ->name, M_DEVFS)—kstrdup(sys/kern/kern_slaballoc.c:1296-1308) callsstrlen(str)+1andbcopys the full 2072 bytes into a heap allocation. The oversized string now lives inrule->name. -
DEVFS_RULE_APPLYioctl triggersdevfs_apply_rules()→devfs_apply_reset_rules_caller()(devfs_core.c:1912) → iterates all devfs nodes callingdevfs_rule_check_apply()(devfs_rules.c:256). -
For rules with
DEVFS_RULE_NAMEset,devfs_rule_checkname()is called (devfs_rules.c:306-308): - Line 365:char name_buf[PATH_MAX]— a 1024-byte stack buffer. - Line 368:devfs_resolve_name_path(rule->name, name_buf, &path, &name)passes the 2072-byterule->nameand the 1024-bytename_buf. -
devfs_resolve_name_path()(sys/vfs/devfs/devfs_core.c:2027-2058): - Line 2032:size_t len = strlen(fullpath) + 1= 2072 - Line 2038:memcpy(buf, fullpath, len)— writes 2072 bytes into a 1024-byte stack buffer. ~1048-byte stack smash with attacker-controlled content. -
No stack canary. DragonFly kernels are compiled with
-fno-stack-protector(confirmed in the kernel build CFLAGS — seeenv.txtandfix_build.log). The smash overwrites the saved RBP and return address undetected.
Reproduction evidence
On 6.5-DEVELOPMENT #0 (unpatched baseline), running the trigger as root:
- DEVFS_RULE_ADD returns 0 (rule with oversized name added to kernel)
- DEVFS_RULE_APPLY triggers the stack smash → Fatal trap 9 (general protection
fault):
frame pointer = 0x10:0x4141414141414141 ← ATTACKER-CONTROLLED ('AAAAAAAA')
Stopped at devfs_rule_checkname.isra.0+0x95: ret ← crash on function return
- Guest panics (DDB prompt), SSH dies. Full panic in panic.txt.
No stack canary — code-execution primitive
The frame pointer 0x4141414141414141 is proof the attacker-controlled 'A' bytes
(0x41) overwrote saved RBP. The crash on ret proves the return address was also
overwritten. With:
- SMEP OFF (user pages executable from kernel mode)
- SMAP OFF (kernel can read/write user-mapped pages)
- KASLR OFF (kernel addresses are fixed)
...this is a clean ret2usr code-execution primitive: overwrite the return
address with the address of userspace shellcode that calls
commit_creds(prepare_kernel_cred(0)) and returns via swapgs; iretq.
However: the trigger requires root to open /dev/devfs (0600 root:wheel).
On a default non-jailed GENERIC, root already has full kernel access (kldload,
/dev/mem), so this does not cross a privilege boundary.
Privilege boundary analysis (Phase 6)
| Path | Reachable by | Boundary crossed? |
|---|---|---|
/dev/devfs direct (DEVFS_RULE_ADD/APPLY) |
root only (uid 0) | None on default system (root→kernel) |
Jailed root (cr_uid=0) opening /dev/devfs |
jailed root, IF exposed in jail devfs | Jail→host escape (non-default) |
Unprivileged user (maxx, uid 1001) |
EPERM — confirmed | No path |
Valid hard blocker for uid0 escalation on this guest: the primitive is
reachable only from an already-root context (/dev/devfs is 0600 root:wheel).
The unprivileged user maxx (uid 1001, not in wheel) gets EPERM when opening
/dev/devfs (verified). No setuid helper exposes devfs rule ioctls. On a default
GENERIC without jails, this is a root→kernel hardening gap (with potential
for jail escape and securelevel bypass), not an unprivileged LPE.
Jail escape angle: jail.defaults.vfs_mount_devfs = 0 by default (processes
in jail cannot mount devfs). However, a host admin typically mounts devfs inside
jails with a restricted ruleset. If /dev/devfs is exposed inside a jail
(non-default), jailed root (cr_uid=0) can open it and trigger the stack smash →
host kernel code execution → jail escape with code execution. This is a
realistic but non-default configuration that was not demonstrated on this guest
(no jail environment).
PoC changes
Authored trigger.c from scratch (no prior PoC existed). The trigger:
1. Opens /dev/devfs as root (O_RDWR)
2. Fills a struct devfs_rule_ioctl entirely with non-NUL 'A' bytes, sets
rule_type = DEVFS_RULE_NAME, rule_cmd = DEVFS_RULE_PERM, mntpoint = "/dev",
and places a NUL at the struct's last byte so strlen returns 2071.
3. Issues DEVFS_RULE_ADD (succeeds on unpatched kernel — stores the oversized name)
4. Issues DEVFS_RULE_APPLY with mntpoint = "/dev" — triggers the stack smash
in devfs_resolve_name_path
Fix
Authored fix.diff — two-part fix:
-
Root cause (
devfs_rules.c): Replace the three unboundedstrlen()calls on the embeddedchar[PATH_MAX]arrays (lines 91, 104, 118) withstrnlen(..., PATH_MAX)and reject input wherelen >= PATH_MAX(string not NUL-terminated within the array). This prevents the oversized string from ever beingkstrdup'd intorule->name. -
Defense in depth (
devfs_core.c:2032): Indevfs_resolve_name_path(), replacestrlen(fullpath) + 1withstrnlen(fullpath, PATH_MAX) + 1and clamp toPATH_MAX + 1before thememcpy. This ensures the stack buffername_buf[PATH_MAX]is never overflowed even if a caller passes an oversized string.
Supersedes the finding proposal — the finding suggested strnlen(PATH_MAX) at
line 104 only. My fix applies the same approach to all three arrays (mntpoint at
:91, name at :104, linkname at :118) and adds the defense-in-depth bound in
devfs_core.c. The devfs_rule_clear function at line 194 also has an unbounded
strlen(templ->mntpoint) — left as-is since it reads from a properly terminated
kstrdup'd mount point during CLEAR, but worth noting for future hardening.
Fix validation (Phase 8)
Built and booted a single-fix kernel (6.5-DEVELOPMENT #1):
- Before (#0 unpatched): DEVFS_RULE_ADD returns 0, DEVFS_RULE_APPLY
triggers Fatal trap 9 (GPF), frame pointer 0x4141414141414141, guest panics.
- After (#1 patched): DEVFS_RULE_ADD returns EINVAL (the strnlen check
rejects the non-NUL-terminated name at len >= PATH_MAX). Rule NOT added, no
APPLY, no crash. Guest stays up. Deterministic across 2 runs.
The fix closes the bug completely.
Fix verification
fixedVALIDATED the fix: on the unpatched #0 baseline, DEVFS_RULE_ADD returns 0 (success) and DEVFS_RULE_APPLY triggers Fatal trap 9 (GPF) with frame pointer 0x4141414141414141 -- guest panics and goes down. On the single-fix #1 kernel (same source + fix.diff only), DEVFS_RULE_ADD returns EINVAL (the strnlen check rejects the non-NUL-terminated name at len >= PATH_MAX), the rule is never added, and no APPLY is triggered -- guest stays up. Deterministic across 2 runs. Fix closes the bug completely.
BEFORE (#0 unpatched): DEVFS_RULE_ADD returned 0; DEVFS_RULE_APPLY -> Fatal trap 9, frame pointer 0x4141414141414141, crash at devfs_rule_checkname ret. Guest DOWN. AFTER (#1 patched): DEVFS_RULE_ADD returned -1 (errno=22: Invalid argument). Rule NOT added. No crash. Guest UP. (Run 2: same result, guest still UP.)
Confirmed kernel references
- sys/vfs/devfs/devfs_rules.c:104
- sys/vfs/devfs/devfs_rules.c:91
- sys/vfs/devfs/devfs_rules.c:118
- sys/vfs/devfs/devfs_rules.c:365
- sys/vfs/devfs/devfs_rules.c:368
- sys/vfs/devfs/devfs_core.c:2032
- sys/vfs/devfs/devfs_core.c:2038
- sys/sys/devfs_rules.h:70
- sys/sys/devfs_rules.h:74
- sys/sys/devfs_rules.h:76
- sys/sys/devfs_rules.h:78
- sys/vfs/devfs/devfs_rules.c:470
Detail
Exploit chain
Memory-corruption primitive (stack buffer overflow, ~1048 attacker-controlled bytes, no stack canary, no SMEP/SMAP/KASLR on this guest). BLOCKED from uid0 by a VALID hard blocker: the write primitive is reachable only from an already-root context. /dev/devfs is created at make_dev(..., UID_ROOT, GID_WHEEL, 0600) (devfs_rules.c:470-471); the unprivileged user maxx (uid 1001, not in wheel) gets EPERM opening it (verified). No setuid helper exposes devfs rule ioctls (checked all setuid binaries -- none relate to devfs). On a default GENERIC without jails, root already has full kernel access via kldload//dev/mem, so there is no privilege boundary to cross. Jail escape angle: jailed root (cr_uid=0) could trigger this IF /dev/devfs is exposed in the jail's devfs mount -- a non-default configuration not present on this guest (jail.defaults.vfs_mount_devfs=0, no jail environment). The stack smash primitive itself was fully demonstrated (frame pointer overwritten with attacker bytes, crash on ret -- a clean ret2usr target if a privilege boundary existed). No exploit.c written -- no escalation chain possible given the root-only hard blocker.
Evidence (decisive lines)
Fatal trap 9: general protection fault while in kernel mode / instruction pointer = 0x8:0xffffffff80924605 / frame pointer = 0x10:0x4141414141414141 (ATTACKER-CONTROLLED 'AAAAAAAA') / current process = Idle / Stopped at devfs_rule_checkname.isra.0+0x95: ret. (PoC output: DEVFS_RULE_ADD returned 0, DEVFS_RULE_APPLY sent -> kernel panic, guest down)
PoC changes
Authored trigger.c from scratch (no prior PoC existed). The trigger opens /dev/devfs as root, fills struct devfs_rule_ioctl with non-NUL 'A' bytes (name+linkname each 1024 non-NUL), sets rule_type=DEVFS_RULE_NAME and mntpoint='/dev', then issues DEVFS_RULE_ADD (stores oversized name via kstrdup) followed by DEVFS_RULE_APPLY (triggers the stack smash in devfs_resolve_name_path). A NUL byte is planted at the struct's last byte so strlen terminates cleanly at 2071.
Verified recommended fix
Two-part fix in fix.diff: (1) Root cause -- replace the three unbounded strlen() calls on the embedded char[PATH_MAX] arrays in devfs_rule_alloc (devfs_rules.c lines 91, 104, 118) with strnlen(..., PATH_MAX) and reject when len >= PATH_MAX (string not NUL-terminated within the array). (2) Defense in depth -- in devfs_resolve_name_path (devfs_core.c:2032), replace strlen(fullpath)+1 with strnlen(fullpath, PATH_MAX)+1 and clamp to PATH_MAX+1 before the memcpy. Supersedes the finding proposal (which suggested strnlen at line 104 only) -- my fix covers all three arrays and adds the memcpy bound.
Verdict
REPRODUCED. devfs_rules.c:104 len=strlen(templ->name) is unbounded -- struct devfs_rule_ioctl has 3 adjacent char[PATH_MAX] arrays (sys/sys/devfs_rules.h:74-78), so a non-NUL-terminated name[] causes strlen to scan 2071 bytes through name+linkname+trailing fields. kstrdup copies this oversized string into rule->name. When DEVFS_RULE_APPLY triggers devfs_rule_checkname (devfs_rules.c:360), the local char name_buf[PATH_MAX] (1024-byte stack buffer) is overflowed by devfs_resolve_name_path's memcpy(buf, fullpath, strlen(fullpath)+1) at devfs_core.c:2038 -- writing 2072 bytes into 1024 bytes. Confirmed by Fatal trap 9 with frame pointer = 0x4141414141414141 (attacker-controlled 'A' bytes) at devfs_rule_checkname.isra.0+0x95: ret. DragonFly kernels compile with -fno-stack-protector (confirmed in build CFLAGS), so no canary trips -- this is a real ~1048-byte attacker-controlled stack smash with code-execution potential. The direct trigger is root-only (/dev/devfs is 0600 root:wheel); unprivileged user maxx gets EPERM. On a default non-jailed GENERIC this is a root->kernel hardening gap; jail escape is plausible if /dev/devfs is exposed in a jail devfs mount (non-default).
No comments yet.