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)
PoC verification
Evidence pack
findings/poc/DF-2436 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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_doneduringdm_target_crypt_destroyβ ssh dies,boot.logshowspanic: assertion "count & TOK_COUNTMASK" failed ... mpipe_done() ... dm_target_crypt_destroy(). (panic.txtis 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.
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:
-
Line 489 β
priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK);allocates the crypt config WITHOUTM_ZERO. The struct contains raw heap residue in:status_str,ivgen,ivgen_priv,crypto_session,read_mpipe,write_mpipe(struct fields atdm_target_crypt.c:80-99). -
Line 513 β
dm_table_init_target(table_en, priv);(which setstable_en->target_config = cfgatdm_table.c:266-268) publishes the garbage-filledprivto the table entry. -
Lines 521 / 533 / 543 / 552 / 564 β five
goto notsuperror paths that are reachable AFTERprivis published (513) but BEFORE the fields are initialized: -priv->ivgenis set at 547 -priv->crypto_sessionis set at 549 -priv->status_stris set at 577 -dmtc_init_mpipe(priv)(initializesread_mpipe/write_mpipe) is at 580 -
Lines 584-587 (the
notsupblock) only frees the localstatus_strandreturn ENOTSUP;β it does NOT zeropriv's fields, does NOT NULLtable_en->target_config, and does NOT freepriv. -
The caller
dm_table_load_ioctl()atsys/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); // <-- destroydm_table_destroy(dm_table.c:147-152) iterates the table entries and callstable_en->target->destroy(table_en). -
dm_target_crypt_destroy()atdm_target_crypt.c:607reads the uninitialized fields: - Line 620dmtc_destroy_mpipe(priv);βmpipe_done(&priv->read_mpipe)(dm_target_crypt.c:205) βkern_mpipe.c:133lwkt_gettoken(&mpipe->token)on the uninitialized garbagelwkt_tokenβ assertioncount & TOK_COUNTMASKfails β panic atlwkt_token.c:458. - (If that had survived) line 628strlen(priv->status_str)on a garbage pointer, line 633priv->ivgen->dtoron a garbage function pointer, line 637cryptoapi_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
fixedVALIDATED: 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).
Confirmed kernel references
- sys/dev/disk/dm/crypt/dm_target_crypt.c:489
- sys/dev/disk/dm/crypt/dm_target_crypt.c:513
- sys/dev/disk/dm/crypt/dm_target_crypt.c:533
- sys/dev/disk/dm/crypt/dm_target_crypt.c:584
- sys/dev/disk/dm/crypt/dm_target_crypt.c:620
- sys/kern/kern_mpipe.c:117
- sys/kern/kern_mpipe.c:128
- sys/kern/kern_mpipe.c:133
- sys/crypto/cryptoapi/cryptoapi.c:1093
- sys/dev/disk/dm/dm_ioctl.c:783
- sys/dev/disk/dm/dm_ioctl.c:785
- sys/dev/disk/dm/dm_table.c:266
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.
No comments yet.