Heap buffer overflow in ntfs_ntlookupfile: rdbuf allocated to ir_size but filled with va_datalen bytes
Summary
ntfs_subr.c:867 blsize=vap->va_a_iroot->ir_size (uint32 on-disk). :868 rdsize=vap->va_datalen (uint32 separate on-disk field). :888 rdbuf=kmalloc(blsize). :890-891 ntfs_readattr(... rdsize ... rdbuf) copies rdsize bytes into blsize buffer. NO check rdsize<=blsize. Crafted image ir_size<va_datalen overflows by attacker-controlled amount. Compare ntfs_ntreaddir:1105 correctly uses max(va_datalen,f_dirblsz). Trigger: crafted NTFS image mount then any name lookup (ls/stat). Fix: kmalloc(max(blsize,rdsize)).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0785 Β· 25 files| File | Type | Description | Size | |
|---|---|---|---|---|
| chain.c | exploit-chain | ntfs overflow -> socket so_port hijack -> forged lwkt_port.mp_putport -> shellcode -> uid0 | 7.7 KB | view raw |
| sc.S | exploit-chain | kernel shellcode source (157 B): procglob walk + ucred uid-zero + return 0 | 1.5 KB | view raw |
| craft_img.py | trigger-source | NTFS image crafter; bakes the overflow payload at data[ir_size..] | 14.4 KB | view raw |
| ntfs_evil.img | trigger-image | original panic-demo image ir_size=256 va_datalen=896 | 512.0 KB | β download |
| harness.c | trigger-source | deterministic guard-paged overflow transcription (unfixed) | 3.4 KB | view raw |
| harness_fixed.c | trigger-source | same with kmalloc(max) fix -> no overflow | 1.6 KB | view raw |
| build_chain.sh | reproduce | exact chain build command | 210 B | view raw |
| run_chain.sh | reproduce | exact chain run invocation + preconditions | 572 B | view raw |
| build.sh | reproduce | harness build (original panic demo) | 563 B | view raw |
| run.sh | reproduce | original panic-demo run (mount + 200 lookups) | 2.1 KB | view raw |
| run.chain1.log | run-log | uid=0 reproduction #1 (fresh boot) | 1.2 KB | view raw |
| run.chain2.log | run-log | uid=0 reproduction #2 (fresh reset) - proves not leaked state | 884 B | view raw |
| fix_build.log | build-log | single-fix ntfs.ko build (cc 8.3, -Werror, rc=0) | 1.0 KB | view raw |
| fix_run.log | run-log | fixed-module re-test: no escalation, uid stays 1001, guest UP | 1.2 KB | view raw |
| fix.diff | suggested-fix | kmalloc(max(blsize,rdsize)) - one line, matches ntfs_ntreaddir:1105 | 424 B | view raw |
| run.log | run-log | original live panic reproduction narrative | 3.1 KB | view raw |
| panic.txt | panic-signature | chunk_mark_free:1675 / BADFREE2 INVARIANTS panic (overflow under churn) | 1.8 KB | view raw |
| harness_run.log | run-log | unfixed harness SIGSEGV(139) | 415 B | view raw |
| harness_compare.log | run-log | unfixed-vs-fixed harness before/after | 752 B | view raw |
| build.log | build-log | harness build output | 575 B | view raw |
| env.txt | environment | guest uname / kern.version / cc / sysctls | 251 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, escalation chain, fix before/after | 9.4 KB | β raw |
| README.md | readme | reproduction instructions | 4.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-0785 β ntfs_ntlookupfile heap buffer overflow β PoC
Reproduction package for DragonFlyBSD ntfs_ntlookupfile heap OOB write
(CWE-787). Verified on 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS ON).
Bug
sys/vfs/ntfs/ntfs_subr.c ntfs_ntlookupfile:
blsize = vap->va_a_iroot->ir_size; /* :867 allocation size */
rdsize = vap->va_datalen; /* :868 copy size */
...
rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK); /* :888 */
error = ntfs_readattr(ntmp, ip, NTFS_A_INDXROOT, "$I30",
0, rdsize, rdbuf, NULL); /* :890-891 copies rdsize */
ir_size (an on-disk u_int32_t inside the resident $INDEX_ROOT header)
sizes the buffer; va_datalen (a separate on-disk field, the resident
attribute data length) sizes the copy. No check rdsize <= blsize exists.
A crafted NTFS image sets ir_size < va_datalen, so ntfs_readattr's
memcpy writes rdsize attacker-controlled bytes into a blsize-byte slab
object β heap overflow.
The sibling reader ntfs_ntreaddir sizes correctly:
fp->f_dirblbuf = kmalloc(max(vap->va_datalen, fp->f_dirblsz), M_NTFSDIR, M_WAITOK); /* :1105 */
Note ntfs_readattr's own guard (ntfs_subr.c:1671-1676,
roff + rsize > va_datalen) does not stop this: the buggy call passes
rsize = rdsize = va_datalen, so rdsize > va_datalen is always false. The
guard never sees the buffer size.
Trigger
Mount a crafted NTFS image (root $INDEX_ROOT with ir_size < va_datalen),
then issue any non-. / non-.. name lookup into the volume β stat /mnt/x,
ls /mnt/realfile, etc. β which reaches ntfs_lookup β ntfs_ntlookupfile.
Mount is privileged (SYSCAP_RESTRICTEDROOT); the post-mount name lookup is
unprivileged β same threat model as the ext2/hammer image findings
(vfs.usermount=1 + a root-created attacker-owned image is a realistic
precondition).
Reproduce
./build.sh # craft image + compile harnesses (guest cc)
./run.sh # A) harness before/after, B) live kernel overflow
run.sh does two things:
A. Deterministic harness (harness.c / harness_fixed.c) β transcribes the
exact kmalloc(blsize) + ntfs_readattr(rdsize) copy with a guard-paged
allocator and faults byte-exactly at the overflow. No slab luck required.
- unfixed: Segmentation fault (exit 139) β overflow into PROT_NONE page.
- fixed: clean completion (exit 0) β kmalloc(max(blsize,rdsize)) fits.
B. Live kernel overflow (unfixed ntfs.ko) β mount the crafted image then
~200 unprivileged name lookups; the heap overflow corrupts the M_TEMP slab
free-list and the periodic slab_cleanup timer trips the INVARIANTS
zone-alignment assertion:
panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0"
failed in chunk_mark_free at kern_slaballoc.c:1675
chunk_mark_free() -> slab_cleanup() -> slotimer_callback() -> softclock_handler()
Expected
| kernel / module | harness | live (mount + 200 lookups) |
|---|---|---|
unfixed #0 GENERIC |
SIGSEGV (139) | panic in chunk_mark_free |
fixed ntfs.ko |
clean (0) | clean ENOENT, guest UP, 0 panics |
Impact
Heap OOB write, fully attacker-controlled content (the crafted resident
$INDEX_ROOT data), attacker-controlled size (ir_size picks the slab bucket,
va_datalen picks the overflow extent). On GENERIC INVARIANTS a single lookup
corrupts silently (DragonFly slab tracks allocation in a zone bitmap and has no
content canary); under slab churn it panics. Either way it is a groomable
arbitrary-write primitive in a heavily-used zone (M_TEMP) β the classic
material for uid=0 given the audit guest's SMAP/SMEP/KASLR-off posture.
Fix
findings/poc/DF-0785/fix.diff β one line, matching the sibling ntfs_ntreaddir:
- rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);
+ rdbuf = kmalloc(max(blsize, rdsize), M_TEMP, M_WAITOK);
Validated by rebuilding ntfs.ko with the fix, hot-swapping it, and re-running
the identical workload: no panic, guest stays up (fix_run.log).
DF-0785 β Verdict
REPRODUCED β uid=0 (local unprivileged β root) on the default GENERIC kernel.
A heap OOB write in ntfs_ntlookupfile (CWE-787) is a groomable, fully
attacker-controlled arbitrary heap write that was escalated to a reliable
maxx(uid 1001) β uid=0(root) privilege escalation on 6.5-DEVELOPMENT #0
(X86_64_GENERIC, INVARIANTS ON, SMAP/SMEP/KASLR all OFF), via a userspace-only
chain (no kldload, no setuid helper, no INVARIANTS-OFF). Reproduced on two
independent fresh-reset boots (run.chain1.log, run.chain2.log). The prior
panic/corruption impact is superseded β uid0 is the demonstrated ceiling.
Root cause (confirmed, path:line)
sys/vfs/ntfs/ntfs_subr.c, ntfs_ntlookupfile:
:867blsize = vap->va_a_iroot->ir_size;βir_sizeis an on-disku_int32_tinside the resident$INDEX_ROOTattribute header.:868rdsize = vap->va_datalen;β a separate on-disk value (resident attribute data length,a_r.a_datalen).:888rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);β buffer sized byir_size.:890-891ntfs_readattr(... rdsize ... rdbuf)β copiesrdsizebytes. Nordsize <= blsizecheck. Whenir_size < va_datalen, the resident$INDEX_ROOTdata (attacker-controlled) overflows theblsize-byte slab object byva_datalen - ir_sizebytes into the next slab chunk in the same zone page.ntfs_readattr's own guard (:1671,roff+rsize>va_datalen) cannot catch this β the buggy call passesrsize=rdsize=va_datalen, so the test isva_datalen>va_datalen(always false).
The sibling reader already does it right: ntfs_ntreaddir:1105
kmalloc(max(vap->va_datalen, fp->f_dirblsz), M_NTFSDIR, M_WAITOK);
The primitive: write size = va_datalen, overflow extent = va_datalen-ir_size,
content of every byte attacker-chosen, slab bucket attacker-chosen via ir_size,
destination = the physically-next slab chunk in the same zone. DragonFly's slab
(kern_slaballoc.c) selects zones by size only (shared across all M_*
types), has no content canary/redzone (state is a per-zone bitmap,
:1654-1683; use_weird_array only poisons freed chunks, never verifies), so a
single overflow into a live victim is silent on GENERIC (INVARIANTS catches
only free-list corruption later, under churn β that is the panic path).
Threat model / reachability
mount_ntfs is SYSCAP_RESTRICTEDROOT β the mount itself needs root (the
standard filesystem-image threat model: an admin mounts / makes mountable an
attacker-owned NTFS image β e.g. a USB stick, forensic image, or
vfs.usermount=1 + a root-created image chowned to the user). Post-mount, the
name-lookup trigger (ntfs_lookup β ntfs_ntlookupfile) is unprivileged β every
step of the chain after the mount runs as maxx (uid 1001). The goal and result:
maxx β uid=0.
The escalation chain (chain.c, sc.S)
Bucket / victim selection. Seat rdbuf in slab zone 34 (704-byte chunks)
β the same zone as struct socket (socreate: kmalloc(sizeof(struct socket),
M_SOCKET); sizeof(struct socket)=696 β zone 34). Set ir_size/blsize=704,
va_datalen=744 β a 40-byte overflow into the next live socket covering
so_pcb(8)=0, so_proto(16)=&forged_protosw, so_head(24)=0,
so_port(32)=&forged_lwkt_port. (struct file/f_ops is in the separate
kmalloc_obj objcache pool and is unreachable; struct socket is plain
kmalloc β same zones as M_TEMP.)
Why so_port, not so_proto. The DragonFly socket layer dispatches
so_proto->pr_usrreqs->pru_* and pr_ctloutput via lwkt netmsg to the
netisr thread (uipc_msg.c so_pr_ctloutput β lwkt_domsg(so->so_port, β¦),
and lwkt_thread_putport always returns EASYNC β the handler runs in the
netisr thread context, not the caller's). netisr has no mapping of our user-space
shellcode page, so hijacking so_proto's function pointers cannot fetch our
shellcode. Instead we hijack so_port (a struct lwkt_port *):
lwkt_domsg β lwkt_beginmsg(port,msg) = port->mp_putport(port,msg)
(msgport.h, lwkt_msgport.c:80) β a synchronous direct call in the
originating process context (maxx's getsockopt), where our user-space
mappings are live. We forge so_port->mp_putport = &shellcode.
Grooming. Spray 5000 UDP sockets to densely fill zone-34 across CPUs, then
close every 5th to punch holes each flanked by live sockets. Each stat /mnt/x
runs ntfs_ntlookupfile β one 40-byte overflow that, when rdbuf lands in a
hole, corrupts the live socket immediately after it (so_port β forged port).
Then getsockopt(fd, IPPROTO_IP, IP_TTL) on every live socket: the corrupted
one routes sogetopt (sopt->sopt_level != SOL_SOCKET &&
so->so_proto->pr_ctloutput != NULL) β so_pr_ctloutput β
lwkt_domsg(forged_port, msg) β forged_port->mp_putport = shellcode.
Conversion shellcode (sc.S, 157 bytes, in maxx context). Walks
procglob[mypid & 0xff].allproc (procglob=0xffffffff81193640, allproc.lh_first@+0x20),
finds our proc (p_list.le_next@0, p_pid@92), and zeroes its
p_ucred(16) uid fields β cr_uid@64, cr_ngroups@68, cr_groups@72,
cr_ruid@160, cr_svuid@164, cr_rgid@168, cr_svgid@172, cr_caps@176 β
then returns 0 (non-EASYNC) so lwkt_domsg marks the msg done and
getsockopt returns cleanly. No SMAP β kernel reads our forged protosw/port
pages; no SMEP β kernel executes our shellcode page; no KASLR β all kernel
addresses fixed. mypid is patched into a fixed user page (0x13372000) read
by the shellcode.
Landing. On the next syscall entry the kernel re-syncs td_ucred from
p_ucred (thread.h:279) β geteuid()==0 β setresuid(0,0,0) normalises β
exec /bin/sh β root shell.
[chain] pid=2218 uid=1001 euid=1001 [chain] shellcode @ 0x13371000 (157 B); forged protosw @ 0x13372800; forged port @ 0x13373000 (mp_putport->shellcode) [chain] sprayed 5000 sockets (zone-34 / struct socket) [chain] punched holes; 4000 live sockets remain [chain] *** ROOT ACQUIRED *** uid=0 euid=0 uid=0(root) gid=0(root) groups=0(root) after setresuid: uid=0(root) gid=0(wheel) groups=0(wheel) CHAIN_SUCCESS_UID0
Reproduced from a fresh vm.sh reset with-src (run.chain2.log) β not
leaked state. The guest goes down shortly after success (post-exploitation
collateral: zone-34 sockets whose so_port was redirected to user pages get
touched by kernel cleanup / process exit), but the escalation (idβroot,
setresuid success) is already demonstrated. The grooming is probabilistic and
typically succeeds within ~24 overflows; on GENERIC, overflows that instead hit
free chunks corrupt the free list and surface as the chunk_mark_free:1675 /
BADFREE2 INVARIANTS panic (the original panic impact) β that is the same bug
manifesting destructively rather than exploitatively.
Fix
fix.diff β one line, matching the sibling ntfs_ntreaddir:1105:
- rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);
+ rdbuf = kmalloc(max(blsize, rdsize), M_TEMP, M_WAITOK);
Fix validation (Phase 8) β fixed
Same #0 GENERIC kernel, hot-swapped a single-fix ntfs.ko built from the
patched /usr/src (fix_build.log, cc 8.3, -Werror, rc=0; loaded-module
disasm shows the max() materialise as cmovae). Re-ran the identical
chain + crafted image:
| ntfs module | chain result | guest |
|---|---|---|
| unfixed (original) | uid=0(root) (run.chain1/2.log) |
down (post-success collateral) |
fixed (kmalloc(max)) |
did not acquire root, uid=1001 throughout |
UP, no panic (fix_run.log) |
max(blsize,rdsize)=max(704,744)=744 β kmalloc(744)β768-byte buffer, 744-byte
copy β no overflow β no socket is ever corrupted β no so_port hijack β no
shellcode β no escalation. fix_status = fixed. The fix supersedes/matches
the finding markdown's proposal.
(Note: a separate, minor ntfs robustness issue exists under extreme malformed-
image lookup churn β after ~60β80 lookups the scan path faults in
ntfs_ntlookupfile; it is present with and without the fix, is a DoS not an
escalation, and is out of scope for DF-0785's overflow. It would warrant its own
finding.)
Files
| file | purpose |
|---|---|
craft_img.py |
hand-crafts a mountable NTFS image; poisons root $INDEX_ROOT ir_size; now accepts the overflow payload (ir_size datalen payload) |
ntfs_evil.img |
original panic-demo image (ir_size=256,va_datalen=896 β 640-byte overflow) |
ntfs_chain.img |
escalation image (ir_size=704,va_datalen=744 β 40-byte overflow into the next socket: so_proto/so_port hijack) β re-craft via craft_img.py ntfs_chain.img 704 744 <payload> |
chain.c |
the escalation chain: shellcode embed + socket spray/groom + getsockopt trigger |
sc.S |
the kernel shellcode source (assembled β 157 bytes embedded in chain.c) |
build_chain.sh/run_chain.sh |
exact build/run for the chain |
harness.c/harness_fixed.c |
deterministic overflow transcription (guard-paged) β unfixed SIGSEGV(139), fixed clean(0) |
run.chain1.log/run.chain2.log |
two fresh-reset uid=0 reproductions |
fix_build.log/fix_run.log |
single-fix ntfs.ko build + hot-swap re-test (no escalation, guest up) |
run.log/panic.txt |
original live panic reproduction (chunk_mark_free:1675 / BADFREE2) |
fix.diff |
git-apply-able one-line fix |
env.txt/manifest.json |
guest env + artifact catalog |
Fix verification
fixedVALIDATED the fix: the identical chain + crafted image (ir_size=704/va_datalen=744) yields uid=0 on the UNFIXED ntfs (run.chain1/2.log) and does NOT escalate on the single-fix ntfs.ko (kmalloc(max)=768-byte buffer >= 744-byte copy => no overflow => no socket corrupted => no so_port hijack => no shellcode) with the guest staying UP (fix_run.log). Note: a separate, minor ntfs robustness DoS (scan-path fault in ntfs_ntlookupfile after ~60-80 malformed-image lookups) is present with and without the fix and is out of scope for this overflow finding.
BEFORE (unfixed ntfs): [chain] *** ROOT ACQUIRED *** uid=0 euid=0 / uid=0(root). AFTER (fixed ntfs.ko): [chain] did not acquire root in 12 rounds / round 0 uid=1001 euid=1001 / guest UP, no panic.
Confirmed kernel references
Detail
Exploit chain
uid=0 ACHIEVED (root shell), twice on the default GENERIC kernel from fresh resets. (1) Bucket: ir_size=704 -> slab zone 34 (704-byte), same zone as struct socket (sizeof=696). (2) Victim: struct socket (kmalloc/M_SOCKET, same slab pool as M_TEMP); struct file/f_ops rejected (separate kmalloc_obj objcache, unreachable). (3) Overflow: va_datalen=744 -> 40-byte overflow into the next live socket overwriting so_pcb(8), so_proto(16)=forged_protosw, so_head(24), so_port(32)=forged_lwkt_port. (4) Grooming: spray 5000 UDP sockets, close every 5th to punch holes each flanked by a live socket; each stat /mnt/x lookup's rdbuf grabs a hole and overflows the live neighbour's so_port. (5) Conversion: getsockopt(IPPROTO_IP) on each live socket -> sogetopt -> so_pr_ctloutput -> lwkt_domsg(forged_port) -> forged_port->mp_putport = shellcode (synchronous in maxx ctx). (6) Shellcode zeroes maxx's p_ucred uid fields via procglob pid walk; returns 0; setresuid(0,0,0) -> root. NOTABLE design decision: hijack so_port (lwkt_port.mp_putport, synchronous caller-context dispatch) rather than so_proto (which dispatches via netmsg to the netisr thread where the user shellcode VA is unmapped) -- this is what makes the chain work in caller context with no SMAP/SMEP. File: chain.c + sc.S. After-success guest-down is post-exploitation collateral (corrupted sockets touched by cleanup), not a chain failure.
Evidence (decisive lines)
[chain] *** ROOT ACQUIRED *** uid=0 euid=0 uid=0(root) gid=0(root) groups=0(root) after setresuid: uid=0(root) gid=0(wheel) groups=0(wheel) CHAIN_SUCCESS_UID0 (run.chain1.log + run.chain2.log, the latter a fresh vm.sh reset with-src). Primitive panic: 'panic: BADFREE2' / 'chunk_mark_free ... kern_slaballoc.c:1675' under churn. Fix after: '[chain] did not acquire root in 12 rounds ... uid=1001' with guest UP (fix_run.log).
PoC changes
Added chain.c (the escalation chain: shellcode embed + 5000-socket zone-34 spray + hole-punch grooming + getsockopt trigger) and sc.S (157-byte kernel shellcode source). Extended craft_img.py to bake an arbitrary overflow payload at data[ir_size:...] (signature now craft_img.py out ir_size va_datalen overflow_hex). Added ntfs_chain.img geometry (ir_size=704/va_datalen=744, 40-byte so_port-hijack payload). Added build_chain.sh/run_chain.sh. Updated VERDICT.md/manifest.json to uid0 + fix before/after.
Verified recommended fix
fix.diff: sys/vfs/ntfs/ntfs_subr.c:888 change 'rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);' to 'rdbuf = kmalloc(max(blsize, rdsize), M_TEMP, M_WAITOK);' so the buffer always fits the copy (matches the sibling ntfs_ntreaddir:1105). Validated: built single-fix ntfs.ko (cc 8.3, -Werror, rc=0), hot-swapped, the identical chain then does NOT escalate (uid stays 1001) and the guest stays UP. Supersedes/matches the finding markdown's proposal.
Verdict
REPRODUCED, escalated to uid=0 on the DEFAULT GENERIC kernel (#0, INVARIANTS ON). Root cause confirmed at sys/vfs/ntfs/ntfs_subr.c:867-891: ntfs_ntlookupfile kmalloc's blsize(=ir_size) bytes (:888) then ntfs_readattr copies rdsize(=va_datalen) attacker-controlled bytes (:890) with no rdsize<=blsize check (the sibling ntfs_ntreaddir:1105 does kmalloc(max(...))). This is a groomable, fully attacker-content-controlled heap OOB write: ir_size picks the slab zone and va_datalen picks the extent, overflow lands in the physically-next slab chunk (DragonFly slab has no content canary, zones shared by size across M_* types). Primitive re-confirmed live as a panic (BADFREE2 / chunk_mark_free:1675 under churn) and then ESCALATED. Chain: seat rdbuf in zone 34 (ir_size=704, va_datalen=744) == struct socket zone; 40-byte overflow overwrites the next live socket's so_port(32)=&forged_lwkt_port (and so_proto(16)=&forged_protosw). Trigger getsockopt(level!=SOL_SOCKET) -> so_pr_ctloutput -> lwkt_domsg(forged_port,msg) -> lwkt_beginmsg -> forged_port->mp_putport = shellcode, a SYNCHRONOUS direct call in maxx's context (lwkt_msgport.c:80; lwkt_thread_putport via so_proto/netmsg would instead run in the netisr thread where user shellcode is unmapped, so so_port hijack is the key insight). Shellcode (sc.S, 157 B, no SMAP/SMEP) walks procglob[pid&0xff].allproc, finds our proc, zeroes p_ucred cr_uid/cr_ruid/cr_svuid/groups/caps, returns 0; next syscall re-syncs td_ucred -> uid=0 -> setresuid(0,0,0) -> root shell. Two independent fresh-reset runs printed uid=0(root) (run.chain1.log, run.chain2.log). Threat model: root mounts the attacker-owned NTFS image (SYSCAP_RESTRICTEDROOT, standard FS-image model); every post-mount step (socket spray, ntfs lookup trigger, getsockopt) is unprivileged maxx. No kldload / setuid helper / INVARIANTS-OFF dependency in the chain.
No comments yet.