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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0785 Β· 25 files
FileTypeDescriptionSize
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
README.md readme reproduction instructions
↓ download 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).

VERDICT.md verdict full narrative: mechanism, escalation chain, fix before/after
↓ download raw

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:

  • :867 blsize = vap->va_a_iroot->ir_size; β€” ir_size is an on-disk u_int32_t inside the resident $INDEX_ROOT attribute header.
  • :868 rdsize = vap->va_datalen; β€” a separate on-disk value (resident attribute data length, a_r.a_datalen).
  • :888 rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK); β€” buffer sized by ir_size.
  • :890-891 ntfs_readattr(... rdsize ... rdbuf) β€” copies rdsize bytes. No rdsize <= blsize check. When ir_size < va_datalen, the resident $INDEX_ROOT data (attacker-controlled) overflows the blsize-byte slab object by va_datalen - ir_size bytes 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 passes rsize=rdsize=va_datalen, so the test is va_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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (with-src) + hot-swapped single-fix ntfs.ko (kmalloc(max(blsize,rdsize))); module disasm shows 'cmovae' (the max()) at ntfs_subr.c:888

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.