DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0897

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0897 · 13 files
FileTypeDescriptionSize
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
README.md readme summary and reproduce instructions
↓ download 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/devfs is 0600 root:wheel
  • Jail escape angle — jailed root (cr_uid=0) can trigger this if /dev/devfs is 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.

VERDICT.md verdict full narrative: mechanism, privilege boundary, fix validation
↓ download raw

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)

  1. struct devfs_rule_ioctl (sys/sys/devfs_rules.h:70-84) has three adjacent char[PATH_MAX] arrays: mntpoint (offset 16), name (offset 1040), linkname (offset 2064). sizeof(struct) = 3112.

  2. devfs_rule_alloc() (sys/vfs/devfs/devfs_rules.c:78-139) processes an ioctl-supplied template: - Line 104: len = strlen(templ->name)UNBOUNDED. If name[] is filled with 1024 non-NUL bytes, strlen scans past name into the adjacent linkname[] 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, strlen returns 2071. - Line 109: rule->name = kstrdup(templ->name, M_DEVFS)kstrdup (sys/kern/kern_slaballoc.c:1296-1308) calls strlen(str)+1 and bcopys the full 2072 bytes into a heap allocation. The oversized string now lives in rule->name.

  3. DEVFS_RULE_APPLY ioctl triggers devfs_apply_rules()devfs_apply_reset_rules_caller() (devfs_core.c:1912) → iterates all devfs nodes calling devfs_rule_check_apply() (devfs_rules.c:256).

  4. For rules with DEVFS_RULE_NAME set, 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-byte rule->name and the 1024-byte name_buf.

  5. 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.

  6. No stack canary. DragonFly kernels are compiled with -fno-stack-protector (confirmed in the kernel build CFLAGS — see env.txt and fix_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:

  1. Root cause (devfs_rules.c): Replace the three unbounded strlen() calls on the embedded char[PATH_MAX] arrays (lines 91, 104, 118) with strnlen(..., PATH_MAX) and reject input where len >= PATH_MAX (string not NUL-terminated within the array). This prevents the oversized string from ever being kstrdup'd into rule->name.

  2. Defense in depth (devfs_core.c:2032): In devfs_resolve_name_path(), replace strlen(fullpath) + 1 with strnlen(fullpath, PATH_MAX) + 1 and clamp to PATH_MAX + 1 before the memcpy. This ensures the stack buffer name_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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 7 05:05:35 UTC 2026

Confirmed kernel references

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).