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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4829 Β· 10 files
FileTypeDescriptionSize
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-890 returns ZERO_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 of sc->nsecp * 8 bytes with kernel-pointer content.
  • md.c:310+315/323 secpp = &sc->secp[secno]; ... *secpp = secp; Every wrapped growth ends in a mandatory wild write at secp + 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:

  1. /dev/mdN is the disk1_ops covering cdev (_disk_create_named, sys/kern/subr_disk.c:691-727: make_only_dev creates the raw cdev without publishing it in devfs; only the covering make_dev_covering node appears).
  2. All userland I/O on the covering device β†’ diskstrategy (subr_disk.c:1222) β†’ dscheck() (sys/kern/subr_diskslice.c:91) which bounds secno + nsec <= endsecno <= d_media_blocks and returns EINVAL/EOF/truncates otherwise. For auto-created md units d_media_blocks = MD_NSECT = 20000 (10 MiB) β€” the wrap offset (0x1FFFFFFE00, β‰ˆ2 TB) can never pass.
  3. 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 << 56 and 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.
  4. 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 @ 0x1FFFFFFE00 returns EINVAL from 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.

VERDICT.md
↓ download raw

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:

  1. /dev/mdN is the disk1_ops covering device, not the raw md_ops cdev β€” _disk_create_named (sys/kern/subr_disk.c:691-727) creates the raw cdev with make_only_dev, which never calls devfs_create_dev (compare kern_conf.c:196-220 make_dev vs kern_conf.c:271-293 make_only_dev), so no raw-md node exists in devfs (confirmed on guest: only md0, md0s0 β€” and after our opens, md1, md1s0).
  2. 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; endsecno for 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.
  3. The single unbounded dscheck branch β€” WHOLE_DISK_SLICE, part β‰₯ 128 (subr_diskslice.c:159-176: nbio->bio_offset = bio->bio_offset | part << 56 passed 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).
  4. 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_testable
baseline no→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-4829

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.