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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2449 Β· 13 files
FileTypeDescriptionSize
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
README.md readme summary + reproduce instructions
↓ download 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 prints start=0x0000000000000000 length=0x0000000000000000; trials 1-4 print start=0xffffffffffffffff length=0xffffffffffffffff (with the use_malloc_pattern sysctl 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 prints FIX 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).

VERDICT.md verdict full narrative: mechanism, privilege analysis, fix validation
↓ download raw

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] β€” passes dm_check_version(),
  • "command" = "create" β€” makes the named dm device,
  • "command" = "reload" β€” routes through dm_cmd_to_fun() in device-mapper.c:286 to dm_table_load_ioctl (cmd_fn table line 131),
  • per-table-entry dictionary with "type" = "zero", "params" = "0" (so dm_table_init does not return EINVAL from if (params == NULL)), "start" and "length" OMITTED β€” the trigger,
  • "command" = "table" with DM_STATUS_TABLE_FLAG | DM_QUERY_INACTIVE_TABLE_FLAG to read back the stale start/length values.

Privilege analysis β€” why this is info-leak, not privesc

  1. Module load: the dm driver is a KLD module; reaching the ioctl requires kldload dm, which is a root-only operation (PRIV_KLD_LOAD).
  2. Device node: /dev/mapper/control is created as make_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control") (device-mapper.c:181) β€” crw-r----- root operator. The maxx user (uid 1001, not in operator/wheel) gets EACCES on open().
  3. 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:

  1. Add M_ZERO to the dm_table_entry_t kmalloc β€” guarantees the entire struct starts zeroed (so any future field whose initialization is missed cannot leak stale bytes either).
  2. Check the return value of both prop_dictionary_get_uint64 calls β€” if either "start" or "length" is missing, kfree the freshly- allocated entry, release the table reference, unbusy the device and target, and return EINVAL. 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)

  1. Baseline (with-src snapshot, kernel 6.5-DEVELOPMENT #0, unpatched dm.ko 0xa2000 bytes, debug.use_malloc_pattern=1): PoC reproduces β€” trial 0 = 0/0, trials 1-4 = 0xffffffffffffffff, variance 4/4. Leak confirmed.
  2. Patched: applied fix.diff to /usr/src/sys/dev/disk/dm/dm_ioctl.c, rebuilt the dm module alone (make in sys/dev/disk/dm, ~30 s, no full kernel rebuild needed β€” fix_build.log), installed dm.ko (now 0x6000 bytes, sha256 1b4397b64a0f5005064aac90853c8e684080c6b7de0c1a77fed01552f0609c0b) β†’ /boot/kernel/dm.ko, kldunload dm; kldload dm.
  3. 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)
  4. Sanity: a VALID reload that DOES supply start / length continues to succeed on the patched module (read-back returns exactly the supplied values 0 / 12345) β€” fix does not break legitimate use.
  5. Verdict: fix closes the bug (16-byte stale-heap leak β†’ clean EINVAL).

PoC

dm_uninit_startlength.c β€” libprop NETBSD_DM_IOCTL:

  1. command="create", name="df2449_<i>" for i in 0..N_TRIALS-1.
  2. command="reload", per-table-entry dict with "type"="zero", "params"="0", "start" / "length" OMITTED.
  3. command="table" with DM_STATUS_TABLE_FLAG | DM_QUERY_INACTIVE_TABLE_FLAG, read back start / length.
  4. Variance check across trials: if any two trials differ, the bytes are uninitialized slab residue (deterministic init would be constant).
  5. 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 baseline kernel + single-fix dm.ko (rebuilt from patched /usr/src/sys/dev/disk/dm)

Confirmed kernel references

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.