md(4) mdstrategy_malloc sector-array growth uses 32-bit sector number and 32-bit size arithmetic: wrapped growth kmalloc(0)/(undersized chunk) -> ZERO_LENGTH_PTR bcopy / heap overflow + wild write (LATENT β unreachable behind dscheck)
Summary
bio_offset truncated to u32 secno at :253 and growth sized in 32-bit at :303 - a write with last sector >= 2^32 wraps: kmalloc(0) returns ZERO_LENGTH_PTR ((void*)-8) and bcopy writes to VA -8, or kmalloc(2) yields a 16B chunk and a nsecp*8 heap overflow, followed by an unconditional wild write at secp+secno*8 (>=+16GB). UNREACHABLE today: every bio passes dscheck which bounds offsets to d_media_blocks (verified live: EINVAL at 0x1FFFFFFE00, in-bounds growth at 4MiB works); the raw-md cdev is never devfs-published. Defense-in-depth 64-bit-secno + bound fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4829 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| mdwrap.c | β | 3.2 KB | view raw | |
| build.sh | β | 80 B | view raw | |
| run.sh | β | 139 B | view raw | |
| build.log | β | 48 B | view raw | |
| run.log | β | 163 B | view raw | |
| env.txt | β | 818 B | view raw | |
| README.md | β | 3.6 KB | β raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| fix.diff | β | 2.0 KB | view raw | |
| verdict.json | β | 3.3 KB | view raw |
DF-4829 β md(4) mdstrategy_malloc sector-array growth integer wrap (LATENT β unreachable in current tree)
What the code bug is
sys/dev/disk/md/md.c mdstrategy_malloc():
- md.c:253
secno = (unsigned)(bio->bio_offset >> DEV_BSHIFT);A 64-bit byte offset is truncated to a 32-bit sector number. - md.c:303
secpp = kmalloc((secno + nsec + 1) * sizeof(u_char *), ...);The growth size is computed in 32-bit unsigned arithmetic and wraps for any transfer whose last sector index is β₯ 2^32 (byte offset β₯ 2^41 β 2 TB, e.g.pwrite(fd, buf, 512, 0x1FFFFFFE00)gives secno = 0xFFFFFFFF, sum = 0 βkmalloc(0)). kmalloc(0)does not return NULL βsys/kern/kern_slaballoc.c:884-890returnsZERO_LENGTH_PTR == (void *)-8.- md.c:306
bcopy(sc->secp, secpp, sc->nsecp * sizeof(u_char *));β variant A: bcopy to VA β8 (instant kernel fault); β variant B (1024-byte transfer: sum wraps to 2 β real 16-byte chunk): heap overflow ofsc->nsecp * 8bytes with kernel-pointer content. - md.c:310+315/323
secpp = &sc->secp[secno]; ... *secpp = secp;Every wrapped growth ends in a mandatory wild write atsecp + secno*8(β₯ +16 GB) β unavoidable within the same request.
Reachability verdict (why it is latent)
Every path that can feed a bio to mdstrategy goes through the disk layer:
/dev/mdNis thedisk1_opscovering cdev (_disk_create_named, sys/kern/subr_disk.c:691-727:make_only_devcreates the raw cdev without publishing it in devfs; only the coveringmake_dev_coveringnode appears).- All userland I/O on the covering device β
diskstrategy(subr_disk.c:1222) βdscheck()(sys/kern/subr_diskslice.c:91) which boundssecno + nsec <= endsecno <= d_media_blocksand returns EINVAL/EOF/truncates otherwise. For auto-created md unitsd_media_blocks = MD_NSECT = 20000(10 MiB) β the wrap offset (0x1FFFFFFE00, β2 TB) can never pass. - The only unbounded branch in dscheck β WHOLE_DISK_SLICE with
partition number β₯ 128 ("kernel raw", subr_diskslice.c:166-176, offset
OR'd with
part << 56and passed straight through) β has no userland-openable cdev: partition cdevs are only created from disklabel entries (indices < 128; subr_disk.c:265,448), the raw md cdev is never devfs-published, and mknod cannot resolve to it. - In-kernel writers (mounted FS buffer cache, swap) also route through the dscheck-bounded slice/disk strategy.
Live verification (guest, this run)
- Trigger built and fired (variant A and boundary probes): no crash β
pwrite @ 0x1FFFFFFE00returnsEINVALfrom dscheck; mdstrategy never sees the offset. - In-bounds growth works normally (pwrite @ 4 MiB β nsecp grows, sum 8193, no wrap) β proving the growth code itself executes and stays small.
- Boundary: media end is 0x9C4000 (20000 sectors); everything β₯ that returns EINVAL.
Conclusion: real arithmetic defect, dead code path today. Reported as Info/defense-in-depth. It becomes memory corruption the moment any future path feeds mdstrategy an unclamped bio_offset (e.g. a raw-access node like the partβ₯128 mechanism being exposed, or an ioctl-attached unit added to this driver).
Build / run
cc -O2 -o mdwrap mdwrap.c # on the guest ./mdwrap A # 512B non-uniform pwrite @ 0x1FFFFFFE00 ./mdwrap B # pre-grow + 1024B pwrite (heap-overflow shape) ./probe3 # boundary matrix
Expected on an unmodified kernel: "survived - pwrite errno=22" (EINVAL); guest stays up. A panic here would mean the bounds gate was bypassed.
DF-4829 β VERDICT
Status: not_reproduced / latent (defect certain, reachability dead in current tree). Impact: none today; memory-corruption-class (CWE-190βCWE-787) if ever reached.
The defect (proven by code inspection, certain)
mdstrategy_malloc() truncates the 64-bit bio offset to 32 bits and then
computes the secp-array growth size in 32-bit unsigned arithmetic:
md.c:253 secno = (unsigned)(bio->bio_offset >> DEV_BSHIFT); md.c:303 secpp = kmalloc((secno + nsec + 1) * sizeof(u_char *), M_MD, M_WAITOK|M_ZERO); md.c:306 bcopy(sc->secp, secpp, sc->nsecp * sizeof(u_char *)); md.c:307 kfree(sc->secp, M_MD); sc->secp = secpp; md.c:309 sc->nsecp = secno + nsec + 1; md.c:310 secpp = &sc->secp[secno]; md.c:315/323 *secpp = ... (wild write)
With pwrite(fd, nonuniform_512B, 0x1FFFFFFE00): secno = 0xFFFFFFFF, nsec = 0
(after the while (nsec--) decrement), sum = 0x100000000 β wraps to 0 β
kmalloc(0). Per sys/kern/kern_slaballoc.c:884-890, kmalloc(0) returns
ZERO_LENGTH_PTR = (void *)-8 β so md.c:306 would bcopy nsecp*8 bytes to
VA β8 β fatal fault. With a 1024-byte transfer the sum wraps to 2 β a real
16-byte chunk β nsecp*8-byte heap overflow (content: old secp array β
kernel heap pointers / small ints), followed by a mandatory wild write at
secp + 0xFFFFFFFF*8 (β +32 GB). Every wrapped growth ends in the wild
write inside the same request β there is no post-corruption execution
window, so even if reachable this is panic-with-heap-smash, not a clean
privesc primitive.
Why it did not reproduce (traced, with the gate)
Attempted on the live guest (6 vCPU, kernel `DragonFly dfbsd 6.5-DEVELOPMENT
0: Thu Jul 2 06:02:54 UTC 2026`): trigger returns EINVAL, no crash.
Trace of the gate:
/dev/mdNis thedisk1_opscovering device, not the rawmd_opscdev β_disk_create_named(sys/kern/subr_disk.c:691-727) creates the raw cdev withmake_only_dev, which never callsdevfs_create_dev(compare kern_conf.c:196-220make_devvs kern_conf.c:271-293make_only_dev), so no raw-md node exists in devfs (confirmed on guest: only md0, md0s0 β and after our opens, md1, md1s0).- Userland I/O on the covering device dispatches to
diskstrategy(subr_disk.c:1222-1257) βdscheck(sys/kern/subr_diskslice.c:91-236):secno + nsec > endsecnoβ EINVAL / EOF / truncate;endsecnofor the whole-disk slice =d_media_blocks= MD_NSECT = 20000 (md.c:31-33, 423). Boundary verified live: pwrite @ 0x400000 β 512 OK; @ 0x9C4000Β±, 0x1FFFFFFE00, 0x7FFFFFFE00 β EINVAL. - The single unbounded dscheck branch β WHOLE_DISK_SLICE, part β₯ 128
(subr_diskslice.c:159-176:
nbio->bio_offset = bio->bio_offset | part << 56passed through "with no further interpretation") β requires a cdev whose minor encodes part β₯ 128. Partition cdevs are only manufactured from on-disk disklabel entries (subr_disk.c:255-268, 440-455; label partition indices < 128); devfs cannot resolve mknod'd nodes to the unpublished raw cdev. No in-tree creator (grep: only sys/dev/disk/fd/fd.c:1085 mentions the mechanism, commented out). - In-kernel bios (buffer cache via mounted md, swap) route through the same dscheck-bounded slice strategy.
Therefore mdstrategy_malloc can only ever observe secno < 20000 and nsec β€ 256 (MAXPHYS/512) β max sum 20257 β the wrap is unreachable.
What WAS reproduced
- The growth code path itself executes in-bounds (write @ 4 MiB grows the array to 8193 entries β sum arithmetic working normally).
- The bounds gate behavior matrix (probe3 in run.log).
- (Side observation: opening /dev/md0 auto-created md1 on the guest β live confirmation of known DF-2478.)
Exploit chain
Empty β no reachable primitive. If the gate were ever bypassed the chain would be: open /dev/mdN (root/operator 0640) β pwrite non-uniform 512B at 0x1FFFFFFE00 β bcopy to (void*)-8 β panic; or 1024B variant β 16-byte-chunk heap overflow β wild write at +32 GB β panic. The mandatory wild write in the same request forecloses controlled post-corruption execution.
Fix
fix.diff β 64-bit sector number + explicit growth cap (defense-in-depth;
not build-validated since nothing is reproducible to validate against:
fix_status = not_testable).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testablefix.diff authored (64-bit sector number + MD_MAX_NSECT growth cap + EIO on out-of-range requests). Not kernel-build-validated: the latent path cannot be triggered on the current tree, so there is no reproducible baseline behavior to compare against.
fix.diff in this pack; gate trace in VERDICT.md
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (trigger survived, errno=22), env.txt (boundary matrix: in-bounds OK / >=media-end EINVAL), VERDICT.md (full gate trace), fix.diff (64-bit secno + MD_MAX_NSECT cap)
PoC changes
n/a (first authored version; seed sketch corrected for printf errno-eval order in the boundary probe)
Verified recommended fix
Compute secno in 64-bit ((uint64_t)bio->bio_offset >> DEV_BSHIFT) and reject transfers with secno+nsec above a hard cap before the growth kmalloc (see fix.diff).
Verdict
The 32-bit wrap in mdstrategy_malloc's secp growth is a certain code defect (md.c:253 truncates the 64-bit bio offset; md.c:303 computes the kmalloc size in 32-bit arithmetic, so a write at 0x1FFFFFFE00 gives secno=0xFFFFFFFF and a wrapped size of 0 -> kmalloc(0) returns ZERO_LENGTH_PTR=(void)-8 per kern_slaballoc.c:884, then md.c:306 bcopy's nsecp8 bytes to VA -8; a 1024-byte transfer instead wraps to a real 16-byte chunk = heap overflow, and md.c:310/315/323 always follows with a wild write at secp+32GB). However it is NOT reachable on this tree: /dev/mdN is the disk1_ops covering device whose diskstrategy->dscheck (subr_disk.c:1222, subr_diskslice.c:91-236) bounds all bios to d_media_blocks=20000 (10MiB) and returns EINVAL beyond (verified live: pwrite@0x1FFFFFFE00 -> EINVAL, no panic; in-bounds pwrite@4MiB grows the array normally), the raw md_ops cdev is never devfs-published (make_only_dev, kern_conf.c:271), and the only unbounded dscheck branch (part>=128 kernel-raw, subr_diskslice.c:166-176) has no userland-openable cdev. Reported as Info/latent defense-in-depth; it becomes kernel memory corruption if any future path feeds mdstrategy an unclamped bio_offset.
No comments yet.