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)
PoC verification
Evidence pack
findings/poc/DF-0873 Β· 24 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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_ROOTcontainsDF0873 uid=0, owned by root:wheel.- The all-wide image instead panics with the smashed return address
0xff80ff80ff80ff80visible 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).
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:
:517char 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.diffto/usr/src;git apply --checkOK. - Rebuilt
ntfs.ko(cd sys/vfs/ntfs && make obj && make, rc=0); hot-swapped over/boot/kernel/ntfs.ko(sha2567d196d3cβ¦vs originalaa8d8384β¦). - 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 stablegetdentsnow returns the dirent cleanly (24bytes = 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 calllwkt_reltoken_all()to release the leaked tokens cleanly; this does not affect the demonstrated primitive or the fix. - The shellcode hardcodes offsets for THIS
#0GENERIC build (verified viagdbon/boot/kernel/kernel.debug); they are stable for this kernel (KASLR off).
Fix verification
fixedVALIDATED 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.
Confirmed kernel references
- sys/vfs/ntfs/ntfs_vnops.c:517
- sys/vfs/ntfs/ntfs_vnops.c:590
- sys/vfs/ntfs/ntfs_vnops.c:593
- sys/vfs/ntfs/ntfs_vnops.c:594
- sys/vfs/ntfs/ntfs_vnops.c:596
- sys/vfs/ntfs/ntfs_subr.c:2044
- sys/vfs/ntfs/ntfs_subr.c:1943
- sys/vfs/ntfs/ntfs_subr.c:2002
- sys/vfs/ntfs/ntfs.h:51
- sys/vfs/ntfs/ntfs.h:182
- sys/conf/files:2219
- sys/platform/pc64/x86_64/trap.c:1374
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).
No comments yet.