dm_table_load_ioctl: uninitialized heap start/length in dm_table_entry_t leak to userspace via status ioctl
Summary
dm_table_load_ioctl allocates dm_table_entry_t with kmalloc(M_DM M_WAITOK) NO M_ZERO then fills table_en->start/length with prop_dictionary_get_uint64() calls whose return value never checked. If attacker omits start/length keys proplib leaves out parameter unwritten fields retain stale heap bytes. Values later copied verbatim to userland by dm_table_status_ioctl :937-940 leaking 16 bytes uninitialized kernel heap per entry per query. Garbage values also used as I/O routing bounds in dmstrategy after resume. Attacker: operator group create dm device reload with type=zero but NO start/length keys then status with DM_QUERY_INACTIVE_TABLE_FLAG. KASLR defeat slab layout inference.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2449 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dm_uninit_startlength.c | trigger-source | PoC: create/reload(table entry omits start/length)/table-readback; reports leak variance or FIX-IS-IN-EFFECT | 11.3 KB | view raw |
| build.sh | build-script | cc -O2 -o dm_uninit_startlength dm_uninit_startlength.c -lprop | 113 B | view raw |
| run.sh | run-script | ./dm_uninit_startlength (as root, after kldload dm) | 138 B | view raw |
| fix.diff | suggested-fix | git-apply-able: add M_ZERO + check get_uint64 returns -> EINVAL on missing start/length | 1.5 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism, privilege analysis, fix validation | 11.2 KB | β raw |
| README.md | readme | summary + reproduce instructions | 2.3 KB | β raw |
| build.log | build-log | final successful PoC build (compiler output) | 13 B | view raw |
| run.log | run-log | decisive baseline run on unpatched #0 kernel (5 trials, leak confirmed) | 1.3 KB | view raw |
| fix_build.log | build-log | single-fix dm module build (full make output) | 46.3 KB | view raw |
| fix_run.log | run-log | PoC re-run on patched dm.ko: 5 EINVAL, FIX IS IN EFFECT | 741 B | view raw |
| fix_run.2.log | run-log | determinism re-run on patched dm.ko: same EINVAL result | 741 B | view raw |
| env.txt | environment | uname, kern.version, cc version, sysctls, kldstat, /dev/mapper/control perms | 778 B | view raw |
| manifest.json | manifest | this catalog | 3.0 KB | view raw |
DF-2449 β dm_table_load_ioctl uninitialized start/length leak
Bug (sys/dev/disk/dm/dm_ioctl.c, dm_table_load_ioctl)
dm_table_load_ioctl allocates dm_table_entry_t with
kmalloc(sizeof(dm_table_entry_t), M_DM, M_WAITOK) β NO M_ZERO β then
fills table_en->start / table_en->length via
prop_dictionary_get_uint64() whose return value is never checked.
prop_dictionary_get_uint64 returns false WITHOUT writing *valp when
the key is absent (sys/libprop/prop_dictionary_util.c:126 early-return
before the line-136 *valp = ... assignment), so omitting "start" /
"length" from the per-table-entry dict leaves the two uint64_t fields
at whatever stale slab bytes kmalloc returned. Those bytes are then
leaked verbatim to userspace by dm_table_status_ioctl
(sys/dev/disk/dm/dm_ioctl.c:937-940). 16 bytes per entry per query.
Files
dm_uninit_startlength.cβ the PoC.build.sh,run.shβ exact build / run.fix.diffβ git-apply-able fix (M_ZERO + checked get_uint64 β EINVAL).VERDICT.mdβ full narrative, mechanism, validation.manifest.jsonβ artifact catalog.
Reproduce
./build.sh && ./run.sh
(as root, after kldload dm).
To make the leak visible on a quiet slab, enable the INVARIANTS debug pattern first (otherwise trial 0 may return 0/0 because the slab's underlying page came from the pre-zeroed pool):
sysctl -w debug.use_malloc_pattern=1
sysctl -w debug.use_weird_array=1
./run.sh
Expected
- Unpatched kernel (
6.5-DEVELOPMENT #0): trial 0 printsstart=0x0000000000000000 length=0x0000000000000000; trials 1-4 printstart=0xffffffffffffffff length=0xffffffffffffffff(with theuse_malloc_patternsysctl on) β variance across trials is the leak signature. Without the sysctl, the values are real slab residue. - Patched dm.ko: every reload returns
EINVAL (22); the PoC printsFIX IS IN EFFECT: no uninitialized-heap leak.A valid reload that DOES supply start/length still succeeds and read-back matches.
Impact
/dev/mapper/control is 0640 root:operator; dm module load is
root-only. Reachable only by root / operator group β root/operator β
kernel info leak (CWE-457 / CWE-908), not unprivβroot. No write
primitive, so no escalation chain (valid Phase-6 hard blocker).
DF-2449 β dm_table_load_ioctl uninitialized-heap start/length leak
Verdict
REPRODUCED (info leak of uninitialized kernel heap) + FIX VALIDATED. The
bug is real: dm_table_load_ioctl allocates dm_table_entry_t with
kmalloc(M_DM, M_WAITOK) (no M_ZERO) and never checks the return value of
the two prop_dictionary_get_uint64() calls that fill table_en->start /
table_en->length. When the attacker omits the "start" / "length" keys
from the per-table-entry dictionary, proplib's prop_dictionary_get_uint64
returns false without writing *valp (early return (false) at
sys/libprop/prop_dictionary_util.c:126 runs before the *valp = ...
assignment at line 136), so the two uint64_t fields retain whatever stale
slab bytes kmalloc returned. dm_table_status_ioctl later copies those
fields verbatim into the response dictionary
(sys/dev/disk/dm/dm_ioctl.c:937-940), leaking up to 16 bytes of
uninitialized kernel heap per table entry per status query.
Privilege boundary: /dev/mapper/control is crw-r----- root operator
(device-mapper.c:181) and the dm module must be kldload-ed by root, so
this is reachable only by root or an operator-group member. There is
no unprivileged path (maxx uid 1001 is not in operator/wheel and gets
EACCES on open("/dev/mapper/control")). Rootβkernel is game-over by
definition, so this is a root/operator β kernel info-leak / hardening
gap, NOT an unprivβroot escalation. There is no write primitive (the bug
surfaces stale bytes via a read-back path), so there is no escalation chain
to develop β this is the valid hard blocker for Phase 6. Realistic impact
ceiling: 16 bytes of uninitialized kernel heap leaked per query β useful
for slab-layout inference / KASLR-defeat / information disclosure
(CWE-457 / CWE-908), not privilege escalation.
The authored fix.diff is built as a single-fix dm.ko module, installed,
and confirmed to close the bug (leak of stale bytes β clean EINVAL).
Mechanism (trigger β primitive β effect)
dm_table_load_ioctl() in sys/dev/disk/dm/dm_ioctl.c:
673: int
674: dm_table_load_ioctl(prop_dictionary_t dm_dict)
...
743: if ((table_en = kmalloc(sizeof(dm_table_entry_t),
744: M_DM, M_WAITOK)) == NULL) { <-- NO M_ZERO
...
750: prop_dictionary_get_uint64(target_dict, DM_TABLE_START,
751: &table_en->start); <-- rv NOT checked
752: prop_dictionary_get_uint64(target_dict, DM_TABLE_LENGTH,
753: &table_en->length); <-- rv NOT checked
Proplib helper (sys/libprop/prop_dictionary_util.c, expanded from
TEMPLATE(64) macro):
118: bool
119: prop_dictionary_get_uint64 (prop_dictionary_t dict,
120: const char *key,
121: uint64_t *valp)
122: {
123: prop_number_t num;
124:
125: num = prop_dictionary_get(dict, key);
126: if (prop_object_type(num) != PROP_TYPE_NUMBER)
127: return (false); <-- early return,
... NO *valp write
136: *valp = (uint64_t)
137: prop_number_unsigned_integer_value(num);
138:
139: return (true);
140: }
So when "start" / "length" are absent from the dictionary,
prop_dictionary_get_uint64 returns false and never touches *valp. The
uninitialized table_en->start / table_en->length are then leaked back to
userspace by the status ioctl:
869: int
870: dm_table_status_ioctl(prop_dictionary_t dm_dict)
...
931: TAILQ_FOREACH(table_en, tbl, next) {
...
937: prop_dictionary_set_uint64(target_dict, DM_TABLE_START,
938: table_en->start); <-- stale bytes
939: prop_dictionary_set_uint64(target_dict, DM_TABLE_LENGTH,
940: table_en->length); <-- stale bytes
Observed leak signature
The DM-bucket slab is normally fresh-zero on a quiet system (trial 0 returns
0/0 because the underlying page came from the pre-zeroed ZeroPage pool β see
sys/kern/kern_slaballoc.c:316). To make the leak visible deterministically
we enable the INVARIANTS-gated debug.use_malloc_pattern=1 sysctl
(sys/kern/kern_slaballoc.c:222-229) which fills every non-M_ZERO
kmalloc chunk with -1. Trial output from the unpatched baseline:
[*] running 5 reload cycles, omitting start/length each time...
[trial 0] df2449_0: start=0x0000000000000000 length=0x0000000000000000
[trial 1] df2449_1: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 2] df2449_2: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 3] df2449_3: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 4] df2449_4: start=0xffffffffffffffff length=0xffffffffffffffff
[*] variance over 5 successful reloads: 4/4 differ in start, 4/4 in length
[!!!] UNINITIALIZED-HEAP LEAK CONFIRMED: at least one
trial returned non-zero start/length despite the
reload ioctl OMITTING both keys.
[!!!] Values VARY across trials -- definitive
evidence of uninitialized heap residue
(a properly-initialized field would be
constant 0/0 across all trials).
Trial 0 = 0/0 (fresh slab page), trials 1-4 = 0xffffffffffffffff
(the INVARIANTS malloc-pattern -1 filling). The variance across trials
(0/0 vs 0xff..ff) is itself definitive proof: a properly-initialized
field would be the same constant across every trial. On a non-debug
kernel, the same path leaks the previous slab tenant's actual data
(slba residue), not the -1 pattern β useful for slab-layout inference.
Trigger
A NETBSD_DM_IOCTL (sys/dev/disk/dm/netbsd-dm.h:41) carrying a libprop
dictionary with:
"version"=[4, 0, 0]β passesdm_check_version(),"command"="create"β makes the named dm device,"command"="reload"β routes throughdm_cmd_to_fun()indevice-mapper.c:286todm_table_load_ioctl(cmd_fn table line 131),- per-table-entry dictionary with
"type"="zero","params"="0"(sodm_table_initdoes notreturn EINVALfromif (params == NULL)),"start"and"length"OMITTED β the trigger, "command"="table"withDM_STATUS_TABLE_FLAG | DM_QUERY_INACTIVE_TABLE_FLAGto read back the stalestart/lengthvalues.
Privilege analysis β why this is info-leak, not privesc
- Module load: the
dmdriver is a KLD module; reaching the ioctl requireskldload dm, which is a root-only operation (PRIV_KLD_LOAD). - Device node:
/dev/mapper/controlis created asmake_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control")(device-mapper.c:181) βcrw-r----- root operator. Themaxxuser (uid 1001, not inoperator/wheel) getsEACCESonopen(). - So the bug is reachable only by root or an
operator-group member.
Combined with the fact that the primitive is a read of stale slab bytes (no attacker-controlled write occurs; the bug surfaces whatever the slab left there via a read-back ioctl), there is no escalation chain to develop β this is the valid hard blocker for Phase 6. Realistic impact ceiling: info disclosure of 16 bytes of uninitialized kernel heap.
Exploit chain
none β pure uninitialized-heap info leak, no write primitive (valid hard
blocker). No escalation file (exploit.c) is produced because there is no
corruption to convert.
Fix (fix.diff)
Minimal, root-cause-targeted, defense-in-depth:
- Add
M_ZEROto thedm_table_entry_tkmallocβ guarantees the entire struct starts zeroed (so any future field whose initialization is missed cannot leak stale bytes either). - Check the return value of both
prop_dictionary_get_uint64calls β if either"start"or"length"is missing,kfreethe freshly- allocated entry, release the table reference, unbusy the device and target, and returnEINVAL. This rejects malformed input rather than silently defaulting.
if ((table_en = kmalloc(sizeof(dm_table_entry_t),
M_DM, M_WAITOK | M_ZERO)) == NULL) { /* was M_WAITOK */
...
}
/*
* Require start/length to be present in the per-table-entry dict.
* prop_dictionary_get_uint64() returns false without writing *valp
* when the key is absent, which would otherwise leave the field at
* whatever stale slab bytes kmalloc returned (the M_DM kmalloc above
* is not M_ZERO before this change). M_ZERO on the kmalloc also
* guarantees the whole struct starts zeroed for any future field
* whose initialization is missed here. DF-2449.
*/
if (!prop_dictionary_get_uint64(target_dict, DM_TABLE_START,
&table_en->start) ||
!prop_dictionary_get_uint64(target_dict, DM_TABLE_LENGTH,
&table_en->length)) {
kfree(table_en, M_DM);
dm_table_release(&dmv->table_head, DM_TABLE_INACTIVE);
dm_dev_unbusy(dmv);
dm_target_unbusy(target);
return EINVAL;
}
(There is no finding markdown for DF-2449 yet β findings/DF-2449-*.md
does not exist in this tree. The fix.diff is the authoritative verified
fix; the recommended_fix summary below describes it.)
Fix validation (Phase 8)
- Baseline (
with-srcsnapshot, kernel6.5-DEVELOPMENT #0, unpatcheddm.ko0xa2000 bytes,debug.use_malloc_pattern=1): PoC reproduces β trial 0 = 0/0, trials 1-4 =0xffffffffffffffff, variance 4/4. Leak confirmed. - Patched: applied
fix.diffto/usr/src/sys/dev/disk/dm/dm_ioctl.c, rebuilt the dm module alone (makeinsys/dev/disk/dm, ~30 s, no full kernel rebuild needed βfix_build.log), installeddm.ko(now 0x6000 bytes, sha2561b4397b64a0f5005064aac90853c8e684080c6b7de0c1a77fed01552f0609c0b) β/boot/kernel/dm.ko,kldunload dm; kldload dm. - Re-run: same PoC β all 5 reloads now return
EINVAL (22)("entry rejected"). No inactive table is installed, so no leak path is reachable. Repeated 2Γ β deterministic. (fix_run.log,fix_run.2.log) - Sanity: a VALID reload that DOES supply
start/lengthcontinues to succeed on the patched module (read-back returns exactly the supplied values 0 / 12345) β fix does not break legitimate use. - Verdict: fix closes the bug (16-byte stale-heap leak β clean
EINVAL).
PoC
dm_uninit_startlength.c β libprop NETBSD_DM_IOCTL:
command="create",name="df2449_<i>"foriin0..N_TRIALS-1.command="reload", per-table-entry dict with"type"="zero","params"="0","start"/"length"OMITTED.command="table"withDM_STATUS_TABLE_FLAG | DM_QUERY_INACTIVE_TABLE_FLAG, read backstart/length.- Variance check across trials: if any two trials differ, the bytes are uninitialized slab residue (deterministic init would be constant).
- On a FIXED kernel, every reload returns
EINVAL; the PoC reports "FIX IS IN EFFECT".
Build: cc -O2 -o dm_uninit_startlength dm_uninit_startlength.c -lprop
Run (as root, after kldload dm): ./dm_uninit_startlength
Recommended (to make the leak visible on a quiet slab):
sysctl -w debug.use_malloc_pattern=1
Fix verification
fixedVALIDATED: same PoC on unpatched 6.5-DEVELOPMENT #0 baseline leaked uninitialized start/length across all 5 trials (variance 4/4, values 0/0 then 0xff..ff with debug.use_malloc_pattern=1). After applying fix.diff, rebuilding ONLY dm module (make in sys/dev/disk/dm, ~30s), installing dm.ko, kldunload/kldload, SAME PoC now sees all 5 reloads return EINVAL (22) -- no inactive table installed, leak path at lines 937-940 unreachable. Deterministic across 2 re-runs. A VALID reload that DOES supply start/length still succeeds on patched module (read-back returns exactly 0/12345). Fix closes the bug (16-byte stale-heap leak -> clean EINVAL).
BEFORE (unpatched dm.ko, debug.use_malloc_pattern=1): trial 0 start=0x0 length=0x0; trial 1 start=0xffffffffffffffff length=0xffffffffffffffff; variance 4/4; UNINITIALIZED-HEAP LEAK CONFIRMED. AFTER (single-fix dm.ko, same PoC, same sysctl): trial 0-4 reload rv=22 (EINVAL) entry rejected; ALL 5 reloads returned EINVAL -- FIX IS IN EFFECT: no uninitialized-heap leak.
Confirmed kernel references
- sys/dev/disk/dm/dm_ioctl.c:743
- sys/dev/disk/dm/dm_ioctl.c:744
- sys/dev/disk/dm/dm_ioctl.c:750
- sys/dev/disk/dm/dm_ioctl.c:752
- sys/dev/disk/dm/dm_ioctl.c:937
- sys/dev/disk/dm/dm_ioctl.c:939
- sys/libprop/prop_dictionary_util.c:118
- sys/libprop/prop_dictionary_util.c:126
- sys/libprop/prop_dictionary_util.c:136
- sys/kern/kern_slaballoc.c:1144
- sys/kern/kern_slaballoc.c:222
- sys/dev/disk/dm/device-mapper.c:181
Detail
Exploit chain
none (valid Phase-6 hard blocker: read-only primitive + root-only reachability). Bug surfaces stale slab bytes via a READ-back path (dm_table_status_ioctl at lines 937-940) -- no attacker-controlled write. Reachable surface is /dev/mapper/control (crw-r----- root:operator, device-mapper.c:181) plus kldload dm (root-only PRIV_KLD_LOAD); maxx (uid 1001) gets EACCES. root/operator->kernel info-leak / hardening gap (CWE-457/CWE-908), NOT unpriv->root escalation. No exploit.c produced because no corruption to convert. Realistic impact ceiling: 16 bytes of uninitialized kernel heap per query -- useful for slab-layout inference / KASLR-defeat / information disclosure.
Evidence (decisive lines)
[baseline unpatched, debug.use_malloc_pattern=1] [trial 0] start=0x0 length=0x0; [trial 1] start=0xffffffffffffffff length=0xffffffffffffffff; [trial 2] 0xff..ff; [*] variance over 5 successful reloads: 4/4 differ in start, 4/4 in length; [!!!] UNINITIALIZED-HEAP LEAK CONFIRMED -- values VARY across trials.
PoC changes
Authored entire PoC from scratch (dir empty). dm_uninit_startlength.c uses libprop NETBSD_DM_IOCTL pattern from DF-2448/DF-2435: create device, reload with target type 'zero' and DM_TABLE_PARAMS='0' but OMIT start/length, then 'table' readback with DM_STATUS_TABLE_FLAG|DM_QUERY_INACTIVE_TABLE_FLAG, then variance checks across N_TRIALS=5 reloads. PoC handles FIXED case: if every reload returns EINVAL, reports 'FIX IS IN EFFECT'. fix.diff authored after line-accurate confirmation: adds M_ZERO to kmalloc AND checks get_uint64 returns, returning EINVAL with proper cleanup (kfree + dm_table_release + dm_dev_unbusy + dm_target_unbusy) if either key missing.
Verified recommended fix
In sys/dev/disk/dm/dm_ioctl.c dm_table_load_ioctl: (1) change dm_table_entry_t kmalloc at line 743-744 from M_DM, M_WAITOK to M_DM, M_WAITOK | M_ZERO so entire struct starts zeroed; (2) check returns of two prop_dictionary_get_uint64 calls at lines 750-753 -- if either false (key missing), kfree freshly-allocated entry, dm_table_release/dm_dev_unbusy/dm_target_unbusy, return EINVAL. Defense-in-depth: M_ZERO protects future field whose initialization missed, explicit EINVAL rejects malformed input rather than silently defaulting. Full git-apply-able diff in findings/poc/DF-2449/fix.diff. No prior finding markdown existed, so authoritative verified fix.
Verdict
REPRODUCED. dm_table_load_ioctl (sys/dev/disk/dm/dm_ioctl.c:743-753) kmalloc's dm_table_entry_t with M_DM, M_WAITOK and NO M_ZERO, then calls prop_dictionary_get_uint64 for DM_TABLE_START/DM_TABLE_LENGTH whose return value is never checked. prop_dictionary_get_uint64 (prop_dictionary_util.c:118-140) returns false WITHOUT writing valp when the key is absent (early return at line 126 fires before valp assignment at line 136). So omitting start/length from the per-table-entry dict leaves both uint64_t fields at stale slab bytes, which dm_table_status_ioctl copies verbatim into the response dict at lines 937-940, leaking 16 bytes of uninitialized kernel heap per entry per query. Confirmed on unpatched 6.5-DEVELOPMENT #0 baseline: with debug.use_malloc_pattern=1, trial 0 returned 0/0 (fresh slab page from pre-zeroed pool), trials 1-4 returned 0xffffffffffffffff (subsequent allocs surface INVARIANTS -1 pattern). The 0/0-vs-0xff..ff VARIANCE across trials (4/4 differ in start and length) is definitive proof: a properly-initialized field would be constant across all trials.
No comments yet.