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

Stack buffer overflow in ntfs_readdir convname via double-wide NTFS_U28 expansion of crafted ie_fnamelen

Summary

ntfs_vnops.c:517 char convname[NTFS_MAXFILENAME+1]=256B on stack. :590-595 for(i=0,j=0;i<iep->ie_fnamelen;i++,j++){c=NTFS_U28(iep->ie_fname[i]); if(c&0xFF00)convname[j++]=(char)(c>>8); convname[j]=(char)c&0xFF;} NTFS_U28 returns sign-extended wchar for bytes>=0x80 => c&0xFF00 true => writes 2 bytes per char. ie_fnamelen u8 max 255 from disk UNVALIDATED. ie_fnamelen=255 all wide => j=510 convname[510]=NUL 255B past buffer. DragonFly kernel has NO stack canary (stack_protector.c commented out). Crafted NTFS image mount+getdents = stack smash potential code exec.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0873 Β· 24 files
FileTypeDescriptionSize
VERDICT.md verdict full narrative: bug, stack frame, panic, uid0 chain, fix validation 8.8 KB ↓ raw
craft_img.py trigger-source NTFS image crafter; escalation mode places a chosen ret addr at convname[312..319] with j=320 14.1 KB view raw
exploit.c exploit-chain uid0 chain: mmap shellcode + getdents -> ret2usr -> cr_uid=0 -> iretq -> win() 7.2 KB view raw
harness.c trigger-source deterministic transcription of the convname loop on a poisoned frame model 5.5 KB view raw
trigger.c trigger-source minimal opendir+readdir trigger 1.6 KB view raw
show_segs.c trigger-source reveals DragonFly user CS=0x2b/SS=0x23 (swapped vs Linux) 419 B view raw
diag_halt.c trigger-source diagnostic: cli;hlt shellcode -> proved the smashed ret reaches the shellcode page 1.5 KB view raw
diag_cred.c trigger-source diagnostic: cred escalation + halt -> proved ring-0 cred writes run clean 1.6 KB view raw
diag_swapgs.c trigger-source diagnostic: cred + cli + swapgs + halt -> swapgs OK 1.5 KB view raw
diag_frame.c trigger-source diagnostic: cred + frame build + halt -> frame build OK 2.3 KB view raw
diag_iretq.c trigger-source diagnostic: iretq on kernel stack -> token KASSERT panic (iretq frame selectors valid) 2.6 KB view raw
build.sh build-log builds trigger + harness + exploit 261 B view raw
run.sh run-log end-to-end: craft image, mount, exploit -> uid0 1.8 KB view raw
run.log run-log decisive uid0 run: marker 'DF0873 uid=0' owned by root:wheel, EXPLOIT_RC=0 1.7 KB view raw
panic.txt panic-signature all-wide image panic: smashed ret 0xff80ff80ff80ff80 + double fault 1.8 KB view raw
env.txt environment uname, cc, sysctl, mount 228 B view raw
fix.diff suggested-fix git-apply-able: bound j to sizeof(convname)-1 in the convname loop 587 B view raw
fix_build.log build-log patched ntfs.ko module build (rc=0) + sha256 518 B view raw
fix_run.log run-log patched-module run: getdents returns 24, uid=1001, no marker (fix closes bug) 1.5 KB view raw
ntfs_escalate.img trigger-source crafted NTFS image: ie_fnamelen=255, ret->0x1337000 at convname[312..319] 512.0 KB ↓ download
ntfs_evil.img trigger-source crafted NTFS image: all-wide, full j=510 overflow (panic repro) 512.0 KB ↓ download
README.md readme human reproduce doc 2.0 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 human reproduce doc
↓ download raw

DF-0873 β€” ntfs_readdir convname stack overflow -> uid=0

Stack buffer overflow in ntfs_readdir() (sys/vfs/ntfs/ntfs_vnops.c:517,590-595). The convname[256] buffer is filled from on-disk INDEX entries with no bound on the write index j; because NTFS_U28() sign-extends bytes >=0x80 to a wide value, a crafted entry with ie_fnamelen=255 (u8 max, unvalidated) and all-wide name chars makes j reach 510 β€” a 255-byte stack overflow that smashes 5 callee-saved registers, saved rbp and the return address on the DEFAULT GENERIC kernel (DragonFly has no kernel stack canary).

This PoC escalates an unprivileged user to uid=0 on the default #0 GENERIC kernel (INVARIANTS ON, no SMAP/SMEP/KASLR).

Build / run

./build.sh                       # trigger, harness, exploit
# end-to-end (needs the DragonFly guest up via dfbsd-qemu/vm.sh):
python3 craft_img.py ntfs_escalate.img 0x80 0x1337000    # escalation image
python3 craft_img.py ntfs_evil.img 0x80                  # all-wide panic-repro image
./run.sh

The guest-side flow (root mounts the image β€” the acceptable admin-mount precondition β€” then the unprivileged user reads the directory):

# as root:
vnconfig -c vn0 /root/ntfs_escalate.img
mount -t ntfs -o ro,-u=1001,-g=1001 /dev/vn0 /mnt/evil
# as maxx (uid 1001):
./exploit /mnt/evil        # -> win() writes /tmp/DF0873_ROOT (root:wheel), exit 0

Expected (bug present, GENERIC #0)

  • EXPLOIT_RC=0 (win's _exit(getuid()) == 0).
  • /tmp/DF0873_ROOT contains DF0873 uid=0, owned by root:wheel.
  • The all-wide image instead panics with the smashed return address 0xff80ff80ff80ff80 visible in the backtrace and a double fault.

Expected (fixed ntfs.ko)

  • getdents returned 24 (normal .. dirent), uid=1001, no marker, no panic.

Files

See VERDICT.md for the full root-cause + stack-frame + chain analysis and manifest.json for the artifact catalog. fix.diff is the git-apply-able fix (bound j to sizeof(convname)-1).

VERDICT.md verdict full narrative: bug, stack frame, panic, uid0 chain, fix validation
↓ download raw

DF-0873 β€” VERDICT

Verdict: REPRODUCED. uid=0 ESCALATION ACHIEVED on the DEFAULT GENERIC kernel. Impact: uid0 (unprivileged local user -> root) Confidence: certain. Fix: VALIDATED (fixed) on a hot-swapped patched ntfs.ko.


1. The bug (confirmed line-by-line)

sys/vfs/ntfs/ntfs_vnops.c β€” ntfs_readdir() declares a stack buffer and fills it from on-disk INDEX entries without any bound:

  • :517 char convname[NTFS_MAXFILENAME + 1]; β†’ 256-byte stack buffer (NTFS_MAXFILENAME == 255, sys/vfs/ntfs/ntfs.h:51).
  • :590-596 β€” the conversion loop: c for(i=0, j=0; i < iep->ie_fnamelen; i++, j++) { /* ie_fnamelen: u8, UNVALIDATED */ c = NTFS_U28(iep->ie_fname[i]); /* :591 */ if (c&0xFF00) convname[j++] = (char)(c>>8); /* :593 WIDE write, +1 to j */ convname[j] = (char)c&0xFF; /* :594 NO bound on j */ } convname[j] = '\0'; /* :596 */

NTFS_U28 (sys/vfs/ntfs/ntfs_subr.c:2044) on the default mount (!NTFS_MFLAG_KICONV) uses the table populated by ntfs_u28_init() (ntfs_subr.c:1943-1972) from _82u[i]=i (ntfs_82u_init, ntfs_subr.c:2002-2021). For an input wchar v in 0x80..0xFF the table cell is (char)v (negative); the function returns it as wchar (u_int16_t), which sign-extends to 0xFF00|v β†’ c & 0xFF00 is non-zero β†’ the loop emits two bytes per input char. With ie_fnamelen = 255 (the u8 maximum, never checked against the buffer) and all 255 name wchars >= 0x80, j runs to 2*255 = 510 and convname[510] = '\0' β€” a 255-byte stack overflow.

2. Stack frame (disassembled ntfs_readdir in ntfs.ko, gcc 8.3, GENERIC)

1770: push %rbp; mov %rsp,%rbp
1774: push r15/r14/r13/r12/rbx        # 5 callee-saved regs
177d: sub $0x148,%rsp                 # frame
...
18e5: mov %sil,-0x130(%rbp,%rcx,1)    # convname[j] = c>>8   (convname @ rbp-0x130)
18f6: mov %al,-0x130(%rbp,%r14,1)     # convname[j] = c&0xFF

So convname base = rbp-0x130. Map of the overflowed region (index = byte offset from convname[0]):

convname idx frame slot
0..255 convname[256]
256..263 ntfs_readdir local/pad
264..271 saved rbx
272..279 saved r12
280..287 saved r13
288..295 saved r14
296..303 saved r15
304..311 saved rbp
312..319 RETURN ADDRESS
320..510 caller frames

DragonFly has NO kernel stack canary (sys/conf/files:2219 β€” libkern/stack_protector.c is commented out), so nothing catches the smash before ret. The epilogue add $0x148,%rsp; pop rbx/r12/r13/r14/r15/rbp; retq loads the smashed return address.

3. Reproduction β€” panic (all-wide image, default GENERIC #0)

The all-wide image (ntfs_evil.img, ie_fnamelen=255, all wchar 0x0080) mount+ls produces (full excerpt in panic.txt):

panic: ntfs_readdir: unexpected uio from NFS server
ntfs_readdir() at ntfs_readdir+0x3c3
(null)() at ... 0xff80ff80ff80ff80          <-- SMASHED RET (FF 80 FF 80 ...)
Fatal trap 9: general protection fault
DOUBLE FAULT ; rip=... ; rsp = 0x0 ; rbp = 0x0

The smashed return address (0x80FF80FF80FF80FF, the byte pattern the loop writes) is visible in the backtrace; the destroyed frame double-faults.

4. Exploit chain -> uid=0 (escalation image, default GENERIC #0)

The deterministic harness.c proves the loop places a chosen 8-byte value at convname[312..319] (the ret slot). The escalation image (craft_img.py ... 0x1337000) builds the wchar stream 182 single chars + 65 wide chars + 8 (shellcode-addr LE bytes) so that j stops at exactly 320: convname[312..319] = 0x1337000 (shellcode page), convname[320]=NUL. Stopping at 320 leaves the caller's a_ncookies field (which lives at convname[320+]) intact (NULL), which bypasses the cookies-path panic at ntfs_vnops.c:631 and lets ntfs_readdir reach its ret with our smashed address. A tiny getdents buffer (64 B) makes vop_write_dirent reject the huge evil dirent (resid too small) β†’ goto readdone with error==0 and a_ncookies==NULL β†’ skip cookies β†’ vn_unlock β†’ return β†’ ret.

The chain (exploit.c): 1. mmap shellcode at the fixed low user address 0x1337000 (all bytes <0x80, encodable as single wchars) + a fresh user stack at 0x1400000. Binary built -no-pie so win (0x405000) and the shellcode immediates are fixed absolute addresses. 2. getdents(dirfd, buf, 64) β†’ overflow β†’ ret β†’ RIP 0x1337000 (userspace; executable from ring 0: no SMEP). 3. Ring-0 shellcode (straight-line, immediates only β€” safe to relocate): * curthread = *(%gs:0 + 8); curproc = curthread->td_proc (+0xa8); p_ucred = curproc->p_ucred (+0x10). * zero cr_uid(0x40), cr_ruid(0xa0), cr_svuid(0xa4), cr_rgid(0xa8), cr_groups[0](0x48); cr_ngroups(0x44)=1. * reset the lwkt token ring td->td_toks_stop(+0x1d0) = &td_toks_array[0] (+0x1d8) to satisfy the INVARIANTS KASSERT (trap.c:1374, "extra tokens held after trap") that the hijacked getdents would otherwise trip. * cli; build the iretq frame on the kernel stack; swapgs; iretq with CS=0x2b / SS=0x23 (DragonFly's user CODE/DATA selectors β€” note they are swapped vs. the Linux convention; getting this wrong triple- faults, see show_segs.c), RFLAGS=0x246, RSP=0x1408000-8 (ABI alignment), RIP=win. 4. win() (CPL3) calls getuid(); if 0, writes /tmp/DF0873_ROOT and _exit(getuid()).

Result (fresh vm.sh reset with-src, kernel #0)

[*] maxx uid before: 1001
[*] running exploit as maxx:
EXPLOIT_RC=0                              <-- _exit(getuid()) == 0
--ROOTMARKER--
DF0873 uid=0
-rw-r--r--  1 root  wheel  12 ... /tmp/DF0873_ROOT   <-- owned by root:wheel
12:22PM  up 2 mins  ...                                  <-- guest stable

The marker file is owned by root:wheel β€” an unprivileged user (uid 1001) can only create a root-owned file with elevated credentials, confirming cr_uid was set to 0 in ring 0. EXPLOIT_RC=0 is win's _exit(getuid()).

5. Bright-line rule (realism)

The escalation is exercisable by an unprivileged user end-to-end: * the readdir/getdents is the unprivileged trigger (maxx, uid 1001, not in wheel); * no kldload by the user (ntfs.ko is loaded by root, the standard NTFS- mount prerequisite); * no setuid-root helper; no custom kernel module; * default GENERIC kernel #0 (INVARIANTS ON) β€” the token-ring reset defeats the INVARIANTS KASSERT from userspace shellcode, not by disabling INVARIANTS.

The only non-user action is root mounting the crafted NTFS image (mount_ntfs is privileged). This is the explicitly-acceptable precondition in the realistic threat model ("an admin has mounted a filesystem image and chowned it to the user"). A real admin mounting an attacker- controlled NTFS volume (USB stick, downloaded image, multi-user box with vfs.usermount) and the attacker then merely listing the directory is enough.

6. Fix

fix.diff bounds j to sizeof(convname)-1 in the loop, skips the wide write when j >= sizeof(convname)-2, and the trailing convname[j]='\0' is then always in-bounds. Minimal, targeted at the root cause.

7. Fix validation (Phase 8)

  • Applied fix.diff to /usr/src; git apply --check OK.
  • Rebuilt ntfs.ko (cd sys/vfs/ntfs && make obj && make, rc=0); hot-swapped over /boot/kernel/ntfs.ko (sha256 7d196d3c… vs original aa8d8384…).
  • Re-ran the SAME escalation image + exploit on the patched module: [*] opened /mnt/evil fd=3; getdents(64) -> ntfs_readdir overflow... [*] getdents returned 24; uid=1001 <-- normal return, still uid 1001 cat: /tmp/DF0873_ROOT: No such file or directory <-- NO root marker 12:35PM up 9 mins ... <-- guest stable getdents now returns the dirent cleanly (24 bytes = the .. entry) with no overflow, no privilege change, no panic β€” clean before/after.

8. Caveats

  • The token-ring reset (td_toks_stop = &td_toks_base) satisfies the INVARIANTS KASSERT but leaves the two leaked per-CPU token refcounts bumped. This makes the post-escalation state slightly impure: on a minority of runs a delayed instability can occur after the chain completes (the uid0 is already achieved and the marker written before any such effect). A production-quality exploit would call lwkt_reltoken_all() to release the leaked tokens cleanly; this does not affect the demonstrated primitive or the fix.
  • The shellcode hardcodes offsets for THIS #0 GENERIC build (verified via gdb on /boot/kernel/kernel.debug); they are stable for this kernel (KASLR off).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: applied fix.diff to /usr/src (git apply --check OK), rebuilt ntfs.ko (cd sys/vfs/ntfs && make obj && make, rc=0), hot-swapped /boot/kernel/ntfs.ko and kldload'd it. On the UNPATCHED #0 baseline the same escalation image + exploit yields uid=0 (/tmp/DF0873_ROOT 'DF0873 uid=0' owned by root:wheel, EXPLOIT_RC=0). On the PATCHED ntfs.ko the SAME trigger returns normally: 'getdents returned 24; uid=1001', NO /tmp/DF0873_ROOT marker, guest stable (uptime 9 min) => the bounded-j loop prevents the overflow and closes the bug.

BEFORE (unpatched #0): EXPLOIT_RC=0; /tmp/DF0873_ROOT = 'DF0873 uid=0' owned by root:wheel => uid=0. AFTER (patched ntfs.ko): '[*] opened /mnt/evil fd=3; getdents(64) -> ntfs_readdir overflow...' / '[*] getdents returned 24; uid=1001' / 'cat: /tmp/DF0873_ROOT: No such file or directory' / uptime stable => no overflow, no privilege change.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (same GENERIC baseline; hot-swapped patched ntfs.ko, sha256 7d196d3c..., over the unpatched original aa8d8384...)

Confirmed kernel references

Detail

Exploit chain

uid0 ACHIEVED (root shell / root-owned marker). Bucket = stack frame of ntfs_readdir (no slab). Primitive = controlled stack smash of saved rbx/r12/r13/r14/r15/rbp + RETURN ADDRESS (convname[312..319]); no canary, no SMAP/SMEP, no KASLR. Grooming/conversion: craft_img.py builds a wchar stream of 182 singles + 65 wide + 8 (shellcode-addr LE bytes) so j stops at EXACTLY 320 -- placing the shellcode page (0x1337000) at convname[312..319] while leaving convname[320+] intact, which keeps the caller's a_ncookies NULL and BYPASSES the cookies-path panic at ntfs_vnops.c:631; a tiny getdents buffer makes vop_write_dirent reject the evil dirent so ntfs_readdir reaches its ret. The chain (exploit.c): mmap shellcode at fixed 0x1337000 + a user stack at 0x1400000 -> getdents -> ret to userspace shellcode (no SMEP) -> ring-0 cred escalation (curthread=*(%gs:0+8) -> td_proc+0xa8 -> p_ucred+0x10 -> zero cr_uid/cr_ruid/cr_svuid/cr_rgid/cr_groups[0], cr_ngroups=1) -> lwkt token-ring reset (td_toks_stop+0x1d0 = &td_toks_array[0]+0x1d8) to satisfy the INVARIANTS KASSERT -> swapgs + iretq (CS=0x2b/SS=0x23, DragonFly's SWAPPED user selectors) -> win() in CPL3 -> getuid()==0 -> writes /tmp/DF0873_ROOT owned by root:wheel and _exit(0). Reproduced from a fresh vm.sh reset with-src. Realism: only root-mounts the NTFS image (the explicitly-acceptable admin-mount precondition); the readdir/getdents is the unprivileged trigger; default GENERIC #0 (INVARIANTS ON); no kldload/setuid-helper/non-default-kernel. The chain file is exploit.c; diagnostics diag_halt/diag_cred/diag_swapgs/diag_frame/diag_iretq.c bisected it (cli;hlt froze => ret reached shellcode; iretq-on-kernel-stack => token KASSERT => selectors valid).

Evidence (decisive lines)

[*] maxx uid before: 1001 / [*] running exploit as maxx: / EXPLOIT_RC=0 / --ROOTMARKER-- / DF0873 uid=0 / -rw-r--r-- 1 root wheel 12 ... /tmp/DF0873_ROOT (unprivileged maxx created a root-owned file => cr_uid was set 0) / Guest stable (uptime 2 min). Baseline panic (all-wide image): 'panic: ntfs_readdir: unexpected uio from NFS server' / '(null)() at ... 0xff80ff80ff80ff80' / 'DOUBLE FAULT rsp=0 rbp=0'.

PoC changes

Created the DF-0873 evidence pack from scratch: craft_img.py (NTFS image crafter reusing the DF-0785 scaffolding, with a build_pattern() that emits 182 singles+65 wide+8 addr-bytes so j stops at 320, placing the ret addr while preserving a_ncookies); exploit.c (ret2usr uid0 chain); harness.c (deterministic loop transcription); trigger.c; show_segs.c (revealed DragonFly CS=0x2b/SS=0x23); five diag_*.c files that bisected the chain (ret-reached, cred-clean, swapgs-ok, frame-ok, iretq-selectors-valid); build.sh/run.sh; fix.diff; full logs; VERDICT.md; manifest.json.

Verified recommended fix

In sys/vfs/ntfs/ntfs_vnops.c ntfs_readdir convname loop (~line 590): add 'j < sizeof(convname) - 1' to the for-condition, and before the wide write convname[j++]=(char)(c>>8) add 'if (j >= sizeof(convname) - 2) break;'; the trailing convname[j]='\0' is then always in-bounds. Minimal, targets the root cause (unbounded j). The full git-apply-able diff is in findings/poc/DF-0873/fix.diff. Supersedes the finding proposal (the finding markdown did not exist; this is the runner-authored verified fix).

Verdict

REPRODUCED, uid0 achieved on the default GENERIC #0 kernel. ntfs_readdir (sys/vfs/ntfs/ntfs_vnops.c:590-596) fills the 256-byte stack buffer convname[] from on-disk INDEX entries with NO bound on j; NTFS_U28 (ntfs_subr.c:2044) sign-extends name chars >=0x80 (default-mount table from ntfs_u28_init/ntfs_82u_init, _82u[i]=i) to a wide value so the loop writes 2 bytes per char. With ie_fnamelen=255 (u8 max, unvalidated) all-wide, j reaches 510 -> a 255-byte stack overflow. Disassembly of ntfs_readdir (gcc 8.3) shows convname at rbp-0x130, so the return address lives at convname[312..319]; DragonFly has NO kernel stack canary (sys/conf/files:2219), so ret loads the smashed address. Confirmed live: the all-wide image panics with the smashed ret 0xff80ff80ff80ff80 in the backtrace and a double-fault (panic.txt).