DragonFlyBSD Kernel Audit
← triage · dashboard
DF-2435

Heap buffer overflow in status_str via ksprintf of negative strtouq offsets

Summary

dm_target_crypt_init sizes status_str buffer from sum of input argv string lengths then formats iv_offset and block_offset with %ju after parsing via strtouq. strtouq("-1") returns UQUAD_MAX=18446744073709551615 which %ju renders as 20 characters 18 more than 2-character input "-1". ksprintf performs no bounds checking (sprintf not snprintf) so writes 18 bytes (up to 36 if both offsets negative) past end of kmallocd status_str buffer corrupting adjacent kernel heap. Overflow content includes attacker-controlled dev path string. Reachable by any user with write access to /dev/mapper/control (0640 root:operator) no further privilege check. Impact: controlled kernel heap corruption up to 36 bytes partial attacker control. With slab grooming victim object overwrite -> kernel-control-flow hijack -> local privilege escalation.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2435 · 15 files
FileTypeDescriptionSize
dm_crypt_overflow.c trigger-source single-shot overflow + status_str readback proof 10.9 KB view raw
dm_crypt_loop.c exploit-chain loop: 200 iters of overflow+remove to panic slab 4.1 KB view raw
build.sh build-script cc -O2 -o ... -lprop for both PoCs 275 B view raw
run.sh run-script ./run.sh [overflow|loop] 581 B view raw
run.log run-log single-shot readback (baseline): overflow confirmed 1.1 KB view raw
run_loop.log run-log loop (baseline): BADFREE2 panic after ~100 iters 707 B view raw
baseline_readback.log run-log baseline readback on fresh #0 guest 1.1 KB view raw
fix_run.log run-log patched loop (200 iters): clean completion, no panic 276 B view raw
fix_run_readback.log run-log patched single-shot readback 1.1 KB view raw
fix_build.log build-log patched dm_target_crypt.ko build (-Werror clean) 7.3 KB view raw
panic.txt panic-signature BADFREE2 panic: _kfree <- dm_table_load_ioctl 575 B view raw
fix.diff suggested-fix kmalloc(len) -> kmalloc(DM_MAX_PARAMS_SIZE) 1.2 KB view raw
env.txt environment uname, kern.version, cc, dm_target_crypt.ko hash 603 B view raw
VERDICT.md verdict full narrative: mechanism, primitive, fix validation 11.3 KB ↓ raw
README.md readme summary + reproduce instructions 2.7 KB ↓ raw
README.md readme summary + reproduce instructions
↓ download raw

DF-2435 — dm_target_crypt_init status_str heap overflow via %ju of negative strtouq

Summary

dm_target_crypt_init() (sys/dev/disk/dm/crypt/dm_target_crypt.c) sizes the status_str buffer from the sum of input argv string lengths (line 462-466), then formats iv_offset and block_offset with %ju (line 573) after parsing via strtouq (lines 475, 477). strtouq("-1") returns UQUAD_MAX = 18446744073709551615 (20 digits), but the buffer was sized from the original "-1" string (2 chars). ksprintf() (sprintf, no bounds check) writes 36 bytes past the kmalloc'd buffer → kernel heap overflow (CWE-787).

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 gets Permission denied. This is a root→kernel memory-corruption / local-DoS / hardening gap, not an unprivileged→root escalation.

Reproduce

./build.sh && ./run.sh overflow      # single-shot: readback proof of overflow
./build.sh && ./run.sh loop          # loop (200 iters): baseline panics ~100
  • Build: cc -O2 -o dm_crypt_overflow dm_crypt_overflow.c -lprop
  • Run as root (must be root or operator-group to open the control dev).
  • Expected on the BUGGY (unpatched) kernel:
  • overflow mode: prints OVERFLOW CONFIRMED: status_str is 129 bytes but buffer was only kmalloc(94).
  • loop mode: panics after ~100-125 iterations with panic: BADFREE2 at _kfreedm_table_load_ioctl, or chunk_mark_allocated assertion failure at _kmallocdm_target_crypt_init.
  • Expected on the FIXED kernel: both modes complete cleanly (RUN_EXIT=0), guest stays up, no panic.

How the PoCs work

  1. dm_crypt_overflow opens /dev/mapper/control, creates a dm device, reloads a crypt table with params "aes-xts-plain <key> -1 /dev/md0 -1". The negative offsets trigger strtouq("-1")UQUAD_MAX, and ksprintf("%ju") overflows the undersized status_str. Then reads back status_str via command="table" (using prop_dictionary_sendrecv_ioctl) to show 129 bytes from a 94-byte allocation — definitive proof of the overflow.
  2. dm_crypt_loop repeats create+overflow-reload+remove 200× to accumulate slab-zone corruption until the INVARIANTS slab bitmap/redzone checks catch it as a panic.

Fix

See fix.diff: replace kmalloc(len, ...) with kmalloc(DM_MAX_PARAMS_SIZE, ...) (1024 bytes, same size used by dm_target_crypt_table when copying status_str). Validated by rebuilding the dm_target_crypt module and re-running both PoCs — overflow confirmed → clean completion, panic → no panic.

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

DF-2435 — dm_target_crypt_init status_str heap overflow via %ju of negative strtouq

Verdict

REPRODUCED (confirmed heap overflow + deterministic panic) + FIX VALIDATED. dm_target_crypt_init() sizes the status_str buffer from the sum of input argv string lengths, then formats iv_offset and block_offset with %ju after parsing via strtouq. strtouq("-1") returns UQUAD_MAX = 18446744073709551615 (20 digits) but the buffer was sized from the original "-1" string (2 chars). ksprintf() (sprintf, no bounds check) writes 36 bytes past the kmalloc'd buffer into adjacent kernel heap. Confirmed two ways: (1) status_str readback showing 129 bytes from a 94-byte kmalloc, and (2) deterministic BADFREE2 / chunk_mark_allocated slab assertion panic from accumulated corruption. The authored fix.diff (kmalloc → DM_MAX_PARAMS_SIZE) is built, installed as dm_target_crypt.ko, and confirmed to close the bug (panic → clean 200-iteration completion).

Escalation to uid=0 is blocked by a valid hard blocker (the whole dm ioctl surface is root/operator-only), see below.

Mechanism (trigger → primitive → effect)

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

 462: len = 0;
 463: for (i = 0; i < argc; i++) {
 464:     len += strlen(argv[i]);       // sizes from INPUT string lengths
 465:     len++;
 466: }
 468: status_str = kmalloc(len, M_DMCRYPT, M_WAITOK);  // <-- too small
 ...
 475: iv_offset = strtouq(argv[2], NULL, 0);    // "-1" → UQUAD_MAX
 477: block_offset = strtouq(argv[4], NULL, 0);  // "-1" → UQUAD_MAX
 ...
 567: memset(hex_key, '0', strlen(hex_key));
 568: if (iv_opt) {
 569:     ksprintf(status_str, "%s-%s-%s:%s %s %ju %s %ju",
 570:         crypto_alg, crypto_mode, iv_mode, iv_opt,
 571:         hex_key, iv_offset, dev, block_offset);
 572: } else {
 573:     ksprintf(status_str, "%s-%s-%s %s %ju %s %ju",     // <-- OVERFLOW
 574:         crypto_alg, crypto_mode, iv_mode,
 575:         hex_key, iv_offset, dev, block_offset);
 576: }

For the input params = "aes-xts-plain <64hexkey> -1 /dev/md0 -1":

component input length formatted length delta
argv[0] "aes-xts-plain" 13 + 1 = 14 13 + 1 = 14 0
argv[1] hexkey (64) 64 + 1 = 65 64 + 1 = 65 0
argv[2] "-1" → %ju 2 + 1 = 3 20 + 1 = 21 +18
argv[3] "/dev/md0" 8 + 1 = 9 8 + 1 = 9 0
argv[4] "-1" → %ju 2 + 1 = 3 20 + 1 = 21 +18
total 94 bytes 130 bytes +36

ksprintf writes 130 bytes (129 chars + NUL) into a 94-byte kmalloc'd buffer. The slab allocator (zoneindex() in kern_slaballoc.c:638) rounds 94 up to a 96-byte chunk, so 36 - 2 = 34 bytes overflow into the next slab chunk, corrupting whatever object is allocated there.

Confirmed effects (unpatched #0 kernel)

1. Readback proof (single-shot): Reading back priv->status_str via command="table" shows the full 129-byte formatted string — proof that ksprintf wrote 36 bytes past the 94-byte kmalloc boundary into adjacent slab memory:

status_str read back = 129 bytes:
  "aes-xts-plain 0000...0000 18446744073709551615 /dev/md0 18446744073709551615"

The 18446744073709551615 digits (UQUAD_MAX) are the %ju over-expansion of strtouq("-1") that the buffer was NOT sized for.

2. Slab panic (loop, ~100-125 iterations): Each overflow corrupts the adjacent slab chunk's free-list c_Next pointer. After ~100-125 create+reload+remove cycles, a subsequent kfree or kmalloc in the same slab zone hits the corrupted pointer:

panic: BADFREE2
cpuid = 2
Trace:
  _kfree() at _kfree+0x593
  dm_table_load_ioctl() at dm_table_load_ioctl+0x394
  dmioctl() at dmioctl+0x2eb

(BADFREE2 is the INVARIANTS check at kern_slaballoc.c:1591: a free-chunk's c_Next pointer fell below KvaStart — garbage from the overflow.)

An earlier run also hit the allocation-side assertion:

panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed
  in chunk_mark_allocated at kern_slaballoc.c:1659
Trace:
  chunk_mark_allocated() ← _kmalloc() ← dm_target_crypt_init() ← dm_table_load_ioctl()

Primitive characterization

  • Class: heap buffer overflow (CWE-787 OOB write).
  • kmalloc: kmalloc(94, M_DMCRYPT, M_WAITOK) → 96-byte slab chunk (zoneindex zone for sizes 89-96).
  • Write size: 36 bytes past the buffer end (34 bytes into the adjacent 96-byte chunk, 2 bytes within the current chunk's slack).
  • Content control: The overflow content is the tail of the formatted status string: digits of iv_offset (UQUAD_MAX = 20 digits), a space, the dev path string (attacker-controlled), a space, block_offset digits (UQUAD_MAX = 20 digits), and a NUL. The dev path is fully attacker-controlled (any resolvable block device path of any length), and its position in the overflow region shifts with its length. So a large fraction of the 36 overflow bytes are attacker-shaped.
  • Observed outcome (INVARIANTS ON): deterministic panic from slab corruption after heap grooming (loop). On a noinv kernel, the corruption would be silent.

Why no uid=0 chain (valid hard blocker — privilege gate)

Per the Phase-6 hard-blocker rules, escalation is blocked because the vulnerable path is reachable only from an already-root context:

  1. Module load: dm is a KLD module (DECLARE_MODULE(dm, …), device-mapper.c); it is not in the GENERIC kernel and not auto-loaded. Reaching any dm ioctl requires kldload dm, a root-only op. The dm_target_crypt target module auto-loads from there, also gated by the root-loaded dm module.
  2. Device node permission: the control device is created as make_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control") (device-mapper.c:181) — crw-r----- root operator.
  3. Unprivileged user cannot reach it — verified on the guest: maxx (uid 1001, gid 1001, not in operator or wheel) gets open /dev/mapper/control: Permission denied. There is no devfs rule relaxing this, and dmsetup/lvm/cryptsetup are not setuid.

Root→kernel is game-over by definition (root can already set uid=0). So this is a root/operator → kernel memory-corruption / local-DoS / hardening gap, not an unprivileged→root escalation. The realistic impact ceiling: a root operator (or any operator-group member) can deterministically panic the kernel (DoS) and — with slab grooming on a noinv kernel — potentially corrupt the kernel heap toward code execution. Worth fixing as defense-in-depth.

PoC

Two complementary PoCs:

  1. dm_crypt_overflow.c — single-shot: creates a dm device, reloads a crypt table with "-1" "-1" offsets (triggering the overflow), then reads back status_str via command="table" to show the 129-byte string from a 94-byte allocation (readback proof of the overflow). Build: cc -O2 -o dm_crypt_overflow dm_crypt_overflow.c -lprop. Run (as root): ./dm_crypt_overflow.

  2. dm_crypt_loop.c — loop: repeats create+overflow-reload+remove 200× to accumulate slab corruption until INVARIANTS catches it as BADFREE2 / chunk_mark_allocated panic. Build: cc -O2 -o dm_crypt_loop dm_crypt_loop.c -lprop. Run (as root): ./dm_crypt_loop 200.

The crypt target module auto-loads on first reload via dm_target_autoload() (dm_target.c:67).

Fix (fix.diff)

Minimal and targeted at the root cause — size the buffer for the worst-case formatted output, not the input argv string lengths:

Replace:

/* len is strlen() of input string +1 */
status_str = kmalloc(len, M_DMCRYPT, M_WAITOK);

With:

status_str = kmalloc(DM_MAX_PARAMS_SIZE, M_DMCRYPT, M_WAITOK);

DM_MAX_PARAMS_SIZE (1024, defined in dm.h:63) is the same size used by dm_target_crypt_table() (dm_target_crypt.c:599) when copying status_str into the response buffer, so it is guaranteed sufficient for any formatted status string. This eliminates the integer-overflow-prone dynamic sizing entirely. The len computation is retained for priv->params_len (a dead field, currently never read).

git apply --check passes against the read-only sys/ tree.

Fix validation (Phase 8)

step result
baseline #0 kernel, unpatched single-shot readback: status_str = 129 bytes from kmalloc(94) → overflow confirmed. loop (200 iters): panics after ~100-125 iters with BADFREE2 at _kfreedm_table_load_ioctl
apply fix.diff to /usr/src 1 hunk applied cleanly at line 464
rebuild dm_target_crypt module make in sys/dev/disk/dm/cryptdm_target_crypt.ko OK, -Werror, no warnings/errors
install dm_target_crypt.ko /boot/kernel/dm_target_crypt.ko replaced (sha256 304039df…); kernel image stays #0 (dm_target_crypt is purely loadable)
re-run loop PoC (200 iters) clean completion, LOOP_EXIT=0, "exhausted 200 iterations without panic", guest UP, boot.log empty

The same loop PoC that deterministically panicked the unpatched module after ~100 iterations now completes cleanly under 200 iterations on the patched dm_target_crypt.ko. fix_status = fixed.

(dm_target_crypt is a purely loadable KLD module, so the fix lives entirely in dm_target_crypt.ko — the kernel image is left at the #0 baseline and only dm_target_crypt.ko is swapped.)

Files

file purpose
dm_crypt_overflow.c trigger PoC (single-shot: overflow + status_str readback proof)
dm_crypt_loop.c loop PoC (200 iters: accumulates corruption → slab panic)
build.sh cc -O2 -o ... -lprop for both PoCs
run.sh ./run.sh [overflow|loop] (as root, after kldload dm)
run.log single-shot readback run (baseline, overflow confirmed)
run_loop.log loop run (baseline, BADFREE2 panic)
baseline_readback.log baseline readback on fresh #0 guest
panic.txt BADFREE2 panic from boot.log
fix.diff git-apply-able fix (kmalloc → DM_MAX_PARAMS_SIZE)
fix_build.log patched dm_target_crypt.ko build log (-Werror clean)
fix_run.log patched loop run → clean completion, guest up, no panic
fix_run_readback.log patched single-shot readback (status_str same content, no overflow)
env.txt guest uname / kern.version / cc / dm_target_crypt.ko hash
VERDICT.md this file
manifest.json machine-readable artifact catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline dm_target_crypt overflows status_str (readback 129 bytes from kmalloc(94)) and panics after ~100-125 loop iterations (BADFREE2 at _kfree <- dm_table_load_ioctl). Patched dm_target_crypt.ko (kmalloc(DM_MAX_PARAMS_SIZE=1024)) completes 200 loop iterations cleanly (LOOP_EXIT=0), guest up, no panic. Same PoC that panicked unpatched now completes without error => fix closes the bug.

BASELINE (unpatched, loop 200 iters): panic BADFREE2 / _kfree() at _kfree+0x593 / dm_table_load_ioctl() at dm_table_load_ioctl+0x394, guest DDB after ~100-125 iters. PATCHED (dm_target_crypt.ko 304039df..., loop 200 iters): exhausted 200 iterations without panic, LOOP_EXIT=0, guest UP, boot.log empty.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel image unchanged; dm_target_crypt.ko rebuilt with fix sha256 304039df...)

Confirmed kernel references

Detail

Exploit chain

Blocked by valid hard blocker (privilege gate): /dev/mapper/control is 0640 root:operator, dm module requires root-only kldload, dm_target_crypt auto-loads from there. maxx gets Permission denied. Root->kernel game-over by definition. The primitive IS a real heap overflow (36 bytes, ~34 into adjacent 96-byte slab chunk, content includes attacker-controlled dev path + UQUAD_MAX digits). With slab grooming on noinv kernel could corrupt victim toward code execution — but only from root/operator context. Chain file is dm_crypt_loop.c (grooming loop).

Evidence (decisive lines)

BASELINE readback: status_str = 129 bytes 'aes-xts-plain 0000...0000 18446744073709551615 /dev/md0 18446744073709551615' — 129 bytes from kmalloc(94). LOOP panic: panic: BADFREE2 / _kfree() at _kfree+0x593 / dm_table_load_ioctl() at dm_table_load_ioctl+0x394 / dmioctl. PATCHED: loop exhausted 200 iterations without panic, LOOP_EXIT=0, guest UP.

PoC changes

Authored both PoCs from scratch (dir empty). dm_crypt_overflow.c: libprop NETBSD_DM_IOCTL create+reload(table type=crypt, params='aes-xts-plain -1 /dev/md0 -1') to trigger overflow, then command=table readback to prove status_str (129 bytes) exceeds kmalloc(94). dm_crypt_loop.c: same trigger in a 200-iter loop to accumulate slab corruption until INVARIANTS panic. Key fixes during iteration: dev path /dev/md0 works; readback needs prop_dictionary_sendrecv_ioctl; DM_QUERY_INACTIVE_TABLE_FLAG(0x1000)+DM_STATUS_TABLE_FLAG(0x10) needed.

Verified recommended fix

Replace kmalloc(len, M_DMCRYPT, M_WAITOK) at dm_target_crypt.c:468 with kmalloc(DM_MAX_PARAMS_SIZE, M_DMCRYPT, M_WAITOK). DM_MAX_PARAMS_SIZE (1024, dm.h:63) is the same size used by dm_target_crypt_table() when copying status_str, guaranteed sufficient for worst-case %ju expansion. The len computation retained for priv->params_len (dead field).

Verdict

REPRODUCED. dm_target_crypt_init (sys/dev/disk/dm/crypt/dm_target_crypt.c:462-468) sizes status_str from sum of input argv string lengths (94 bytes), then ksprintf (line 573) formats iv_offset and block_offset with %ju after strtouq('-1') returns UQUAD_MAX=18446744073709551615 (20 digits). ksprintf writes 130 bytes into the 94-byte buffer = 36-byte heap overflow (CWE-787). Confirmed two ways: (1) status_str readback via command=table shows 129 bytes from a kmalloc(94) allocation — the extra 35 bytes are the %ju over-expansion of strtouq('-1'); (2) loop of 200 create+overflow-reload+remove cycles panics after ~100-125 iterations with 'panic: BADFREE2' at _kfree <- dm_table_load_ioctl (overflow corrupted the slab free-chunk c_Next pointer).