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

Use of uninitialized heap memory in dm_target_crypt_destroy on partial init failure

Summary

dm_target_crypt_init allocates priv WITHOUT M_ZERO publishes to table_en->target_config at :513 then has 5 goto notsup error paths before priv->ivgen ivgen_priv crypto_session status_str mpipes initialized. Caller dm_table_load_ioctl reacts to error by calling dm_table_destroy which invokes dm_target_crypt_destroy on partially-initialized priv. destroy calls mpipe_done on uninitialized mpipes dereferences uninitialized priv->ivgen pointer passes uninitialized priv->status_str to kfree passes uninitialized priv->crypto_session to cryptoapi_cipher_freesession. Concrete trigger: argv[0]=aes-cbc-essiv:sha1 with valid 256-bit AES key -> essiv_ivgen_ctor hashes SHA1 (20 bytes) tries setkey with 20-byte key AES only accepts 16/24/32 EINVAL goto notsup with ivgen/crypto_session/status_str/mpipes all raw heap. With heap grooming attacker-shaped fake iv_generator ->dtor chosen function pointer kernel control-flow hijack.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2436 Β· 12 files
FileTypeDescriptionSize
dm_crypt_uninit.c trigger-source libprop NETBSD_DM_IOCTL PoC: reload crypt table with invalid iv_mode -> notsup after priv publish -> destroy reads uninitialized fields -> panic 9.3 KB view raw
build.sh build-script cc -O2 -o dm_crypt_uninit dm_crypt_uninit.c -lprop 172 B view raw
run.sh run-script kldload dm + ./dm_crypt_uninit as root 437 B view raw
run.log run-log unpatched baseline run: ssh timed out, guest panicked 583 B view raw
panic.txt panic-signature fatal assertion in lwkt_gettoken <- mpipe_done <- dm_target_crypt_destroy 762 B view raw
dmesg.txt dmesg patched-module dmesg: clean iv_mode unsupported + ENOTSUP, no panic 496 B view raw
fix.diff suggested-fix git-apply-able: add M_ZERO to priv kmalloc at dm_target_crypt.c:489 489 B view raw
fix_build.log build-log single-fix dm_target_crypt.ko module build output (rc=0) 7.3 KB view raw
fix_run.log run-log patched-module PoC re-run x3: rv=45 ENOTSUP, RUN_EXIT=0, guest survives 1.1 KB view raw
env.txt environment uname, cc, control dev perms, maxx id, module sha256 733 B view raw
VERDICT.md verdict full narrative: mechanism, privilege analysis, fix, validation 9.1 KB ↓ raw
README.md readme human-facing summary + reproduce instructions 3.2 KB ↓ raw
README.md readme human-facing summary + reproduce instructions
↓ download raw

DF-2436 β€” dm_target_crypt_destroy use of uninitialized heap memory on partial-init failure

Summary

dm_target_crypt_init() (sys/dev/disk/dm/crypt/dm_target_crypt.c) allocates the crypt config priv WITHOUT M_ZERO (line 489), publishes it to table_en->target_config at line 513 (dm_table_init_target), then has 5 goto notsup error paths (lines 521/533/543/552/564) that are reachable AFTER the publish but BEFORE the fields ivgen, ivgen_priv, crypto_session, status_str, read_mpipe, write_mpipe are initialized (lines 547/549/577/580). The caller dm_table_load_ioctl (dm_ioctl.c:783-785) reacts to the init error by calling dm_table_destroy β†’ dm_target_crypt_destroy, which reads those uninitialized fields β€” dmtc_destroy_mpipe β†’ mpipe_done β†’ lwkt_gettoken on the garbage mpipe->token β†’ panic (INVARIANTS). CWE-908.

Privilege

Root/operator-only. /dev/mapper/control is 0640 root:operator (device-mapper.c:181), the dm module is demand-loaded via root-only kldload, and dm_target_crypt auto-loads from there. Verified: unprivileged maxx (uid 1001, not in wheel/operator) gets Permission denied. This is a root→kernel hardening gap / local DoS, not an unprivileged→root escalation. There is no unprivileged path, so uid0 is a valid-hard-blocked non-goal.

Reproduce

./build.sh && ./run.sh         # as root, after `kldload dm` (run.sh does it)
  • Build: cc -O2 -o dm_crypt_uninit dm_crypt_uninit.c -lprop
  • Run as root (must be root or operator-group to open the control dev).
  • Expected on the BUGGY (unpatched) kernel: guest PANICS in mpipe_done during dm_target_crypt_destroy β€” ssh dies, boot.log shows panic: assertion "count & TOK_COUNTMASK" failed ... mpipe_done() ... dm_target_crypt_destroy(). (panic.txt is the captured signature.)
  • Expected on the FIXED kernel: reload returns rv=45 (ENOTSUP) cleanly, guest stays up, no panic. (fix_run.log.)

How the PoC works

dm_crypt_uninit opens /dev/mapper/control, creates a dm device, then reloads a crypt table with params "aes-xts-bogusiv <hexkey> 0 /dev/md0 0". dm_target_crypt_init then: 1. allocates priv without M_ZERO (raw heap) [line 489] 2. dm_pdev_insert("/dev/md0") succeeds [line 492] 3. dmtc_find_crypto_cipher("aes","xts",256) succeeds [line 498] 4. dm_table_init_target PUBLISHes priv (garbage) [line 513] 5. hex2key succeeds [line 515] 6. iv_mode="bogusiv" NOT in ivgens β†’ goto notsup [line 533] The caller sees ENOTSUP, calls dm_table_destroy β†’ destroy reads the uninitialized read_mpipe.write_mpipe.status_str.ivgen.crypto_session fields β†’ mpipe_done β†’ lwkt_gettoken on the garbage token β†’ panic.

Fix

See fix.diff: add M_ZERO to the kmalloc at line 489 so all fields start NULL/0; then dm_target_crypt_destroy is a safe no-op on any partial-init path (mpipe_done is guaranteed safe on a zero'd malloc_pipe per kern_mpipe.c:117-121; status_str/ivgen/crypto_session are NULL so their guards/frees short-circuit). Validated by rebuilding the dm_target_crypt module and re-running the PoC β€” panic β†’ clean ENOTSUP return, guest survives.

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

DF-2436 β€” VERDICT

Verdict: REPRODUCED (panic) + FIX VALIDATED (fixed)

Bug: Use of uninitialized heap memory (CWE-908) in dm_target_crypt_destroy() on partial-init failure of dm_target_crypt_init().

Status: REPRODUCED as a kernel panic on the unpatched #0 GENERIC kernel (INVARIANTS ON). Fix authored (M_ZERO on the priv allocation) and VALIDATED by building a single-fix dm_target_crypt.ko module, installing it, and re-running the SAME PoC β€” the panic is gone; the init-error path returns ENOTSUP cleanly and the guest stays up.

Impact: panic (local DoS / hardening gap). NOT uid0 β€” see the privilege analysis below (root/operator-only trigger is a valid hard blocker).


Mechanism (trigger β†’ primitive β†’ effect), path:line at each hop

dm_target_crypt_init() in sys/dev/disk/dm/crypt/dm_target_crypt.c:

  1. Line 489 β€” priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK); allocates the crypt config WITHOUT M_ZERO. The struct contains raw heap residue in: status_str, ivgen, ivgen_priv, crypto_session, read_mpipe, write_mpipe (struct fields at dm_target_crypt.c:80-99).

  2. Line 513 β€” dm_table_init_target(table_en, priv); (which sets table_en->target_config = cfg at dm_table.c:266-268) publishes the garbage-filled priv to the table entry.

  3. Lines 521 / 533 / 543 / 552 / 564 β€” five goto notsup error paths that are reachable AFTER priv is published (513) but BEFORE the fields are initialized: - priv->ivgen is set at 547 - priv->crypto_session is set at 549 - priv->status_str is set at 577 - dmtc_init_mpipe(priv) (initializes read_mpipe/write_mpipe) is at 580

  4. Lines 584-587 (the notsup block) only frees the local status_str and return ENOTSUP; β€” it does NOT zero priv's fields, does NOT NULL table_en->target_config, and does NOT free priv.

  5. The caller dm_table_load_ioctl() at sys/dev/disk/dm/dm_ioctl.c:783-785: c if ((ret = dm_table_init(target, table_en, str)) != 0) { dm_table_release(&dmv->table_head, DM_TABLE_INACTIVE); dm_table_destroy(&dmv->table_head, DM_TABLE_INACTIVE); // <-- destroy dm_table_destroy (dm_table.c:147-152) iterates the table entries and calls table_en->target->destroy(table_en).

  6. dm_target_crypt_destroy() at dm_target_crypt.c:607 reads the uninitialized fields: - Line 620 dmtc_destroy_mpipe(priv); β†’ mpipe_done(&priv->read_mpipe) (dm_target_crypt.c:205) β†’ kern_mpipe.c:133 lwkt_gettoken(&mpipe->token) on the uninitialized garbage lwkt_token β†’ assertion count & TOK_COUNTMASK fails β†’ panic at lwkt_token.c:458. - (If that had survived) line 628 strlen(priv->status_str) on a garbage pointer, line 633 priv->ivgen->dtor on a garbage function pointer, line 637 cryptoapi_cipher_freesession(priv->crypto_session) on a garbage pointer.

Trigger chosen

Invalid iv_mode β†’ the goto notsup at line 533. Params: aes-xts-bogusiv <64-hex-key> 0 /dev/md0 0: - crypto_alg="aes", crypto_mode="xts" β†’ dmtc_find_crypto_cipher("aes","xts",256) returns non-NULL (dm_target_crypt.c:425-426) βœ“ - dm_pdev_insert("/dev/md0") succeeds (md0 present) βœ“ - hex2key() succeeds (valid hex) βœ“ β€” so we pass the publish at 513 - iv_mode="bogusiv" is not in ivgens[] (dm_target_crypt.c:150-156, valid: essiv/plain/plain64) β†’ loop falls through β†’ goto notsup at 533

At line 533: ivgen, ivgen_priv, crypto_session, status_str, read_mpipe, write_mpipe are ALL still raw heap residue. Destroy reads them β†’ panic.

Panic signature (from dfbsd-qemu/boot.log, full text in panic.txt)

dm_target_crypt: iv_mode='bogusiv' unsupported
dm_target_crypt: ENOTSUP
panic: assertion "count & TOK_COUNTMASK" failed in _lwkt_reltokref at /usr/src/sys/kern/lwkt_token.c:458
lwkt_relalltokens() at lwkt_relalltokens+0x80
lwkt_gettoken() at lwkt_gettoken+0x299
mpipe_done() at mpipe_done+0x37                       <-- uninitialized mpipe->token
dm_target_crypt_destroy() at dm_target_crypt_destroy+0x29
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>

The trace nails the bug to dm_target_crypt_destroy β†’ mpipe_done β†’ lwkt_gettoken on the uninitialized lwkt_token inside priv->read_mpipe.


Privilege analysis β€” why this is NOT uid0 (valid hard blocker)

Gate Status Citation
/dev/mapper/control perms 0640 root:operator device-mapper.c:181; ls -l in env.txt
kldload dm root only standard DragonFly kld load privilege
unprivileged maxx (uid 1001) not in wheel/operator id in env.txt

The whole dm ioctl surface (create/reload/table) is reachable only from root or the operator group. Verified: an unprivileged maxx cannot open the control dev (Permission denied). There is no unprivileged path to this bug. Per the bright-line rule, root→kernel is game-over by definition, so a uid0 escalation claim is invalid here — this is a root/operator local DoS (panic) and a hardening gap (defense-in-depth), not an unpriv→root escalation. This is a VALID hard blocker for the uid0 chain; the bug's honest impact is panic.

(The uninitialized fields would, on a non-INVARIANTS build with slab grooming, constitute a kernel function-pointer-hijack primitive β€” e.g. forging priv->ivgen to point at a fake iv_generator whose dtor is an attacker-chosen pointer β€” and SMEP/SMAP are OFF on this guest, so such a pointer could jump to userspace shellcode. But that primitive is reachable only from root/operator, so it does not cross a privilege boundary.)


Exploit chain

none (memory-corruption class, but root/operator-only trigger = valid hard blocker for uid0). The primitive is characterized above (uninitialized heap read/use of 6 fields, culminating in a function-pointer-shaped field priv->ivgen->dtor); the realistic impact ceiling is a root/operator local DoS (panic) and, with slab grooming on a noinv build, a kernel code-exec primitive from an already-privileged credential. No unprivileged escalation is possible because the ioctl surface is gated at 0640 root:operator.


Fix

fix.diff β€” a one-line, root-cause fix: add M_ZERO to the priv allocation at dm_target_crypt.c:489:

-   priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK);
+   priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT,
+       M_WAITOK | M_ZERO);

With M_ZERO, all fields start NULL/0, so dm_target_crypt_destroy() becomes a safe no-op on any partial-init error path: - mpipe_done() on a zero'd malloc_pipe is explicitly safe β€” the comment at kern_mpipe.c:117-121 guarantees it ("This routine can also safely be called on an uninitialized mpipe structure if it was zero'd ..."). free_count == total_count == 0 passes the KKASSERT; thread==NULL/array==NULL skip the cleanup loops. - priv->status_str == NULL β†’ the if (priv->status_str) guard at 628 skips. - priv->ivgen == NULL β†’ the if ((priv->ivgen) && ...) guard at 633 skips. - priv->crypto_session == NULL β†’ cryptoapi_cipher_freesession(NULL) returns early (cryptoapi.c:1093-1094).

This is minimal and targeted at the root cause; it is preferable to the alternatives (moving the publish to after all init, or adding NULL-inits in the notsup block) because it covers ALL error paths uniformly with a single change.


Fix validation (Phase 8)

Before (unpatched #0 kernel, original dm_target_crypt.ko): Same PoC β†’ panic assertion "count & TOK_COUNTMASK" failed ... mpipe_done ... dm_target_crypt_destroy, guest down, ssh dies. (run.log + panic.txt.)

After (single-fix dm_target_crypt.ko module rebuilt from patched source, kernel still #0): Same PoC β†’ reload returned rv=45 (Operation not supported), RUN_EXIT=0, guest stays up. Confirmed deterministic over 3 runs (fix_run.log). dmesg shows the clean iv_mode='bogusiv' unsupported + ENOTSUP sequence with no panic (dmesg.txt).

The fix module is a standalone dm_target_crypt.ko built from the patched /usr/src/sys/dev/disk/dm/crypt/dm_target_crypt.c via make in that directory (single .c file, ~10 s build). The base kernel was not rebuilt because dm_target_crypt is a loadable module (not compiled into X86_64_GENERIC); replacing /boot/kernel/dm_target_crypt.ko and reloading is sufficient and equivalent. sha256 of the module changed (bfadc5... β†’ 766a71...), confirming the patched code is loaded.

fix_status: fixed.


PoC changes

Authored from scratch (the PoC directory did not exist). dm_crypt_uninit.c is a libprop NETBSD_DM_IOCTL PoC modeled on the DF-2435 sibling: it creates a dm device, reloads a crypt table with params aes-xts-bogusiv <key> 0 /dev/md0 0 to drive dm_target_crypt_init past the priv publish (line 513) into the invalid-iv_mode goto notsup (line 533), which leaves priv published with uninitialized fields; the caller's error path then invokes dm_target_crypt_destroy, which reads them and panics.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: same dm_crypt_uninit PoC triggered the mpipe_done panic (assertion count & TOK_COUNTMASK failed) on unpatched #0 baseline (guest DOWN), and does NOT panic on single-fix dm_target_crypt.ko built from patched source (reload returns rv=45 ENOTSUP cleanly, RUN_EXIT=0, guest stays UP, deterministic over 3 runs). M_ZERO fix closes the bug.

baseline (unpatched #0 + orig dm_target_crypt.ko): panic 'assertion count & TOK_COUNTMASK failed in _lwkt_reltokref at lwkt_token.c:458' via mpipe_done+0x37 <- dm_target_crypt_destroy+0x29; guest DOWN. patched (single-fix dm_target_crypt.ko, M_ZERO at :489): reload returned rv=45 (ENOTSUP); RUN_EXIT=0; guest UP; clean 'iv_mode=bogusiv unsupported' + 'ENOTSUP' NO panic (3/3 runs).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (base kernel unchanged; dm_target_crypt loadable module rebuilt from patched source and installed to /boot/kernel/, sha256 bfadc5...->766a71...)

Confirmed kernel references

Detail

Exploit chain

none (valid hard blocker for uid0): the entire dm ioctl surface is gated root/operator-only. /dev/mapper/control is 0640 root:operator and kldload dm requires root; maxx gets Permission denied. root->kernel game-over by definition. Primitive characterized: uninitialized-heap-use of 6 priv fields culminating in a function-pointer-shaped field (priv->ivgen->dtor) that on a noinv build with slab grooming would be a kernel code-exec/hijack primitive from an already-privileged credential (SMEP/SMAP OFF would let it jump to userspace). Reachable only from root/operator, so realistic impact ceiling = root/operator local DoS (panic) + defense-in-depth hardening gap.

Evidence (decisive lines)

UNPATCHED #0 baseline (original dm_target_crypt.ko): dm_target_crypt: iv_mode='bogusiv' unsupported / ENOTSUP / panic: assertion 'count & TOK_COUNTMASK' failed in _lwkt_reltokref at lwkt_token.c:458 / lwkt_gettoken() at lwkt_gettoken+0x299 / mpipe_done() at mpipe_done+0x37 / dm_target_crypt_destroy() at dm_target_crypt_destroy+0x29 / Stopped at Debugger+0x7c / db> (guest DOWN). PATCHED dm_target_crypt.ko (M_ZERO at :489): reload returned rv=45 (Operation not supported), RUN_EXIT=0, guest UP (3/3 runs).

PoC changes

Authored PoC from scratch (dir did not exist). dm_crypt_uninit.c libprop NETBSD_DM_IOCTL (modeled on DF-2435 sibling): creates dm device and reloads crypt table with params 'aes-xts-bogusiv <64hexkey> 0 /dev/md0 0' to drive dm_target_crypt_init past priv publish :513 and hex2key :515 into the invalid-iv_mode goto notsup :533, leaving priv published with uninitialized ivgen/crypto_session/status_str/read_mpipe/write_mpipe; caller error path invokes dm_target_crypt_destroy which reads them and panics in mpipe_done. build.sh, run.sh, VERDICT.md, README.md, fix.diff, manifest.json.

Verified recommended fix

Add M_ZERO to the priv kmalloc at sys/dev/disk/dm/crypt/dm_target_crypt.c:489 (kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK | M_ZERO)). With M_ZERO all fields start NULL/0, so dm_target_crypt_destroy is a safe no-op on any partial-init error path: mpipe_done safe on zero'd malloc_pipe (kern_mpipe.c:117-121), status_str/ivgen/crypto_session NULL so guards/frees short-circuit (cryptoapi.c:1093 NULL-returns). Covers all 5 notsup paths uniformly. Full git-apply-able diff in findings/poc/DF-2436/fix.diff.

Verdict

REPRODUCED as kernel panic on unpatched #0 GENERIC (INVARIANTS ON), FIX VALIDATED. dm_target_crypt_init (dm_target_crypt.c:489) allocates priv WITHOUT M_ZERO, publishes it to table_en->target_config at :513, then has 5 goto notsup error paths (:521/:533/:543/:552/:564) reachable AFTER publish but BEFORE ivgen(:547)/crypto_session(:549)/status_str(:577)/mpipes(:580) initialized. The notsup block (:584) does not zero the fields or un-publish priv. The caller dm_table_load_ioctl calls dm_table_destroy on init error, invoking dm_target_crypt_destroy which reads uninitialized fields: dmtc_destroy_mpipe(priv)->mpipe_done(&priv->read_mpipe)->lwkt_gettoken on the GARBAGE lwkt_token -> panic 'assertion count & TOK_COUNTMASK failed in _lwkt_reltokref at lwkt_token.c:458'. PoC triggers via a crypt table reload with invalid iv_mode 'bogusiv' that passes publish :513 and hex2key :515, then takes goto notsup at :533 with all six fields raw heap. Confirmed: boot.log panic trace mpipe_done+0x37 <- dm_target_crypt_destroy+0x29.