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

dm pdev dedup key silently truncated to 31 chars: a linear table load binds to the wrong physical device (or to a device whose path was never opened)

Summary

The 32-byte name field IS the dedup key, strlcpy truncates silently - a later path matching the truncated prefix strcmp-matches and reuses the earlier pdev, binding the table to the WRONG disk without opening the requested path. VERIFIED on guest: a table intending /dev/md0 came back bound to /dev/md0s0 (deps dev 0x1e100007 vs 0x1e110007), deterministic across two boots; serno-style DFly paths routinely exceed 31 chars and same-model disks share prefixes. Reject-over-length fix validated on rebuilt dm.ko (loud failure + correct binding).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5151 Β· 12 files
FileTypeDescriptionSize
poc_dm_pdev_trunc.c β€” 8.6 KB view raw
build.sh β€” 127 B view raw
run.sh β€” 179 B view raw
run.log β€” 6.8 KB view raw
run.2.log β€” 6.8 KB view raw
fix.diff β€” 989 B view raw
fix_build.log β€” 48.3 KB view raw
fix_run.log β€” 5.0 KB view raw
env.txt β€” 288 B view raw
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
verdict.json β€” 5.0 KB view raw

DF-5151 β€” dm pdev dedup key silently truncated to 31 chars β†’ wrong-device table binding

  • File audited (pass 2): sys/dev/disk/dm/linear/dm_target_linear.c (root cause in sys/dev/disk/dm/dm_pdev.c, reached through the linear target's unvalidated argv[0] device path).
  • Severity: Low (privileged local β€” table loads go through NETBSD_DM_IOCTL on /dev/mapper/control, 0640 root:operator; the group cannot usefully drive it because the pdev vn_open(FREAD|FWRITE) runs under the caller's credentials).
  • Verdict: reproduced on the stock 6.5-DEVELOPMENT guest (two independent boots, incl. a fresh vm.sh reset with-src). Fix validated on a rebuilt dm.ko.

Build

./build.sh        # cc -O2 -Wall -o poc_dm_pdev_trunc poc_dm_pdev_trunc.c

(no libprop needed β€” the plist XML is hand-built; only <sys/ioctl.h>)

Run (as root on the guest)

sh run.sh                       # baseline: kldload dm; kldload dm_target_linear

Setup created by run.sh:

T = /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaa    (exactly 31 chars) -> symlink to /dev/md0   (Y)
L = /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaabb  (33 chars)        -> symlink to /dev/md0s0 (X)

What the PoC does:

  1. create dm devices dfa/dfb/dfc
  2. reload dfa with linear params "<L> 0" β€” kernel opens X (md0s0) and stores the pdev under strlcpy(name, L, 32) = T (truncated)
  3. reload dfb with linear params "<T> 0" β€” T resolves to Y (/dev/md0), but dm_pdev_lookup_name(T) strcmp-matches the truncated stored key β†’ dfb silently reuses dfa's pdev without ever opening T
  4. control: reload dfc with a nonexistent path β†’ must FAIL (normal behavior)
  5. resume dfa/dfb (tables load into the inactive slot), then deps both

Expected output (stock kernel β€” the bug)

expected deps(dfb) if correct = 0x1e110007     <- dev_t of /dev/md0
reload dfc (...): ioctl FAILED with errno=2    <- control behaves normally
==== RESULT ====
deps(dfa [path L -> /dev/md0s0]) = 0x1e100007  <- md0s0, correct for dfa
deps(dfb [path T -> /dev/md0   ]) = 0x1e100007  <- WRONG: dfb got md0s0's pdev!
MISDIRECTION PROVEN (BUG PRESENT): ...
poc exit=0

/dev/md0 is 0x1e110007, /dev/md0s0 is 0x1e100007 (see ls -l in run.log). Every IO issued to dm device dfb lands on /dev/md0s0 even though its table was loaded with a path that resolves to /dev/md0.

Expected output (patched dm.ko β€” fix validated)

reload dfa (/tmp/dmtr/aaaaaaaaaaaaaaaaaaaaabb): ioctl FAILED with errno=2
dfa reload with 33-char path was REJECTED by kernel
deps(dfb [path T -> /dev/md0   ]) = 0x1e110007   <- correct device now
FIX CONFIRMED: long path rejected loudly, short path bound to its own device

Console/dmesg evidence of the loud rejection: dm_pdev_insert: device path too long (max 31): /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaabb

Fix rebuild + run:

cd /usr/src && patch -p1 < /tmp/poc5151/fix.diff
cd /usr/src/sys/dev/disk/dm && make obj && make          # -> dm.ko (-Werror clean)
cd /tmp/poc5151 && DM_KO=/usr/obj/usr/src/sys/dev/disk/dm/dm.ko sh run.sh

Artifacts

  • poc_dm_pdev_trunc.c β€” PoC source (hand-built proplist XML ioctls)
  • build.sh, run.sh β€” exact commands
  • run.log β€” decisive baseline run (dirty-boot guest)
  • run.2.log β€” baseline run on a FRESH vm.sh reset with-src boot (deterministic)
  • fix.diff β€” git-apply-able fix (bounds check in dm_pdev_insert)
  • fix_build.log, fix_run.log β€” patched-module build + verification run
  • env.txt β€” guest uname + device list
VERDICT.md
↓ download raw

DF-5151 VERDICT β€” dm_pdev dedup key truncation β†’ wrong-device table binding

Status: reproduced (deterministic, two boots). Fix validated on rebuilt dm.ko. Impact: silent cross-device data misdirection for dm-linear (and any target that passes device paths to dm_pdev_insert). No memory corruption.

Root cause (line-accurate)

  • sys/dev/disk/dm/dm.h:62 β€” #define DM_MAX_DEV_NAME 32; the pdev is keyed by char name[DM_MAX_DEV_NAME] (sys/dev/disk/dm/dm.h:114).
  • sys/dev/disk/dm/linear/dm_target_linear.c:67 β€” dm_target_linear_init passes the user-supplied argv[0] (from DM_TABLE_PARAMS, sys/dev/disk/dm/dm_ioctl.c:771-772 β€” no length validation anywhere on the path; dm_table_init's strsep at dm_ioctl.c:826-830 imposes no bound) into dm_pdev_insert(argv[0]).
  • sys/dev/disk/dm/dm_pdev.c:203-204 β€” dm_pdev_alloc does strlcpy(dmp->name, name, DM_MAX_DEV_NAME): a path longer than 31 chars is silently truncated.
  • sys/dev/disk/dm/dm_pdev.c:60-63 + 128-136 β€” dedup is strcmp of the caller's FULL path against the STORED (possibly truncated) name. Consequences: 1. A later dm_pdev_insert(T) where T equals the 31-char truncated prefix of an earlier long path L strcmp-matches β†’ the new table entry silently reuses L's pdev (L's physical device). vn_open(T) never happens β€” T doesn't even have to exist. 2. Two distinct long paths sharing a 31-char prefix both miss each other (full β‰  truncated) β†’ duplicate pdev entries under the same truncated key (refcount/vnode churn, unbounded pdev duplication under table reloads).
  • sys/dev/disk/dm/dm_target_linear.c:114-116 β€” strategy then routes every bio for the mis-bound table to tlc->pdev's vnode at tlc->offset: reads/writes land on the wrong physical device.

Reachability: NETBSD_DM_IOCTL on /dev/mapper/control (sys/dev/disk/dm/device-mapper.c:181, 0640 root:operator). ioctls are not flag-gated, but the pdev vn_open(FREAD|FWRITE) in dm_dk_lookup (dm_pdev.c:74-78) runs under the caller's credentials, so an operator-group user cannot open real disk nodes RW β€” effectively root-only to reach a meaningful device. Severity Low accordingly.

Reproduction (see run.log / run.2.log)

  • T = /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaa (31 chars) β†’ symlink β†’ /dev/md0 (0x1e110007)
  • L = /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaabb (33 chars) β†’ symlink β†’ /dev/md0s0 (0x1e100007)
  • reload dfa with L 0 (linear): stored pdev name := first 31 chars of L == T.
  • reload dfb with T 0 (linear): user intends /dev/md0; kernel strcmp-matches the truncated key and hands back dfa's pdev.
  • deps: deps(dfa) == deps(dfb) == 0x1e100007 (md0s0) although dfb's path resolves to md0 (0x1e110007). Control reload with a non-colliding nonexistent path fails ENOENT, i.e. the kernel normally rejects unknown paths β€” the collision is what bypasses the open entirely.
  • Deterministic: identical result on a fresh vm.sh reset with-src boot.

Real-world shape: DragonFly-style long device paths (/dev/serno/<long serial>-part*.s1d easily exceed 31 chars) are exactly the class that truncates; any two same-model disks whose serials agree in the first ~20 characters collapse onto one truncated key.

Fix validation (fix.diff, fix_build.log, fix_run.log)

dm_pdev_insert now rejects strlen(dev_name) >= DM_MAX_DEV_NAME with a loud kprintf (dm.ko rebuilt in-dir with -Werror, clean). Re-running the exact PoC against the patched module:

  • reload dfa (33-char path) β†’ rejected (ENOENT to userland; console: dm_pdev_insert: device path too long (max 31): /tmp/dmtr/...)
  • reload dfb (31-char T) β†’ accepted, deps(dfb) = 0x1e110007 = /dev/md0 β€” the correct device.
  • No misdirection possible: the truncating insert can no longer happen.

Alternative (bigger) upstream fix: enlarge name[]/key pdevs by dev_t instead of by name string; the bounds check is the minimal safe change and turns silent misconfiguration into a loud failure.

Not-this-bug (checked and killed)

  • table/strategy/destroy lifetime: readers hold table_mtx SHARED across the whole dispatch (dm_table.c:65-74, device-mapper.c:408-465); switch/destroy take EXCLUSIVE (dm_table.c:118,136) β€” no linear UAF window; module unload is refused while tables hold target refs (dm_target.c:181-184).
  • dm_target_linear_table NULL-config deref: unreachable β€” entries only enter a table after init success, and failed loads destroy the partial table (dm_ioctl.c:783-790).
  • kmalloc(M_WAITOK)==NULL branch (dm_target_linear.c:70-72) is dead code; the pdev-ref leak it would cause mirrors known DF-4922 (flakey).
  • dump-offset div-by-zero when DIOCGPART fails: known DF-2058/DF-2451.
  • non-device vnode backing (v_rdev NULL): known DF-2057/DF-2450.
  • offset/length bounds + atoi64 wrap (feeds tlc->offset * DEV_BSIZE at dm_target_linear.c:114): known DF-2150, not re-reported.
  • upcall: no such member in DragonFly's dm_target_t (dm.h:159-175) β€” dead NetBSD-ism; nothing to audit.
  • dm_table_add_deps return ignored (dm_target_linear.c:77): linear uses exactly one pdev per entry, the -1 duplicate case cannot arise for this target.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

dm.ko rebuilt in-dir from patched /usr/src (-Werror clean, fix_build.log). Baseline on stock module: misdirection reproduced (deps(dfb)=0x1e100007, wrong device). Patched module: 33-char path load rejected loudly (console: 'dm_pdev_insert: device path too long (max 31)'), 31-char path binds correctly (deps(dfb)=0x1e110007=/dev/md0), 'FIX CONFIRMED'. Bad behavior gone.

fix_run.log (patched-module run output), fix_build.log (patch -p1 + make obj + make, dm.ko), dmesg line 'dm_pdev_insert: device path too long (max 31): /tmp/dmtr/aaaaaaaaaaaaaaaaaaaaabb'
↓ fix.diffper-fix-DF-5151

Confirmed kernel references

Detail

Exploit chain

root (or any credential able to open the backing devices RW) sends NETBSD_DM_IOCTL reload to /dev/mapper/control with linear params ' 0' where L is a >=32-char path to device X (kernel stores truncated 31-char key T), then reloads another device with params ' 0' where T resolves to device Y (or is nonexistent) -> second table silently binds to X; all IO intended for Y lands on X. No unprivileged route: pdev vn_open runs under caller creds.

Evidence (decisive lines)

run.log / run.2.log: 'deps(dfb [path T -> /dev/md0]) = 0x1e100007' vs 'expected ... = 0x1e110007' + 'MISDIRECTION PROVEN'; control reload of a non-colliding nonexistent path fails ENOENT (normal rejection bypassed only via the truncated-key collision). fix_run.log: 'FIX CONFIRMED ... deps(dfb) = 0x1e110007' and console 'dm_pdev_insert: device path too long (max 31)' in dmesg.

PoC changes

Seed sketch had to be written from scratch (no libdevmapper on guest, no proplib headers): hand-built proplist XML over NETBSD_DM_IOCTL with a locally-defined struct plistref {void*,size_t}. Two iterations to correct behavior: (1) proplib internalizer requires the document wrapped in ... - without it copyin fails EIO; (2) tables load into the INACTIVE slot - a 'resume' ioctl is required before 'deps' shows anything (first comparison compared two empty arrays); deps parser tightened to only read integers inside the cmd_data array.

Verified recommended fix

Reject device paths >= DM_MAX_DEV_NAME in dm_pdev_insert (loud EINVAL path) or enlarge/dynamically allocate the pdev name and key pdevs by dev_t instead of by name string.

Verdict

Deterministically reproduced on the stock 6.5-DEVELOPMENT guest (two boots, incl. fresh vm.sh reset with-src): dm_pdev_alloc strlcpy's the table-load device path into the fixed 32-byte pdev name key (dm_pdev.c:204) and dm_pdev_insert dedups by strcmp of the caller's full path against that stored, silently-truncated key (dm_pdev.c:129,60-63). A table loaded with a >=32-char path L stores key T=first-31-chars(L); a subsequent linear table loaded with path T (which may resolve to a completely different device, or not exist at all) strcmp-matches T and silently reuses L's pdev without ever opening T. deps() proves the misbinding: a table whose path resolves to /dev/md0 (0x1e110007) came back bound to /dev/md0s0 (0x1e100007). Every bio for the mis-bound device is routed to the wrong physical disk (dm_target_linear.c:114-116). No kernel memory corruption: impact class is silent privileged data misdirection (cross-device IO routing), so impact=none in the corruption-oriented enum; long real-world paths (/dev/serno/-part*.s1d) exceed 31 chars and same-model disks share prefixes. Fix validated: with the strlen bounds check in dm_pdev_insert, the 33-char path load is loudly rejected and the 31-char path binds to its own correct device.