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)
PoC verification
Evidence pack
findings/poc/DF-2435 · 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
overflowmode: printsOVERFLOW CONFIRMED: status_str is 129 bytes but buffer was only kmalloc(94).loopmode: panics after ~100-125 iterations withpanic: BADFREE2at_kfree←dm_table_load_ioctl, orchunk_mark_allocatedassertion failure at_kmalloc←dm_target_crypt_init.- Expected on the FIXED kernel: both modes complete cleanly (RUN_EXIT=0), guest stays up, no panic.
How the PoCs work
dm_crypt_overflowopens/dev/mapper/control, creates a dm device, reloads acrypttable with params"aes-xts-plain <key> -1 /dev/md0 -1". The negative offsets triggerstrtouq("-1")→UQUAD_MAX, andksprintf("%ju")overflows the undersizedstatus_str. Then reads backstatus_strviacommand="table"(usingprop_dictionary_sendrecv_ioctl) to show 129 bytes from a 94-byte allocation — definitive proof of the overflow.dm_crypt_looprepeats 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.
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 (zoneindexzone 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, thedevpath string (attacker-controlled), a space,block_offsetdigits (UQUAD_MAX = 20 digits), and a NUL. Thedevpath 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:
- Module load:
dmis a KLD module (DECLARE_MODULE(dm, …),device-mapper.c); it is not in the GENERIC kernel and not auto-loaded. Reaching any dm ioctl requireskldload dm, a root-only op. Thedm_target_crypttarget module auto-loads from there, also gated by the root-loadeddmmodule. - 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. - Unprivileged user cannot reach it — verified on the guest:
maxx(uid 1001, gid 1001, not inoperatororwheel) getsopen /dev/mapper/control: Permission denied. There is no devfs rule relaxing this, anddmsetup/lvm/cryptsetupare 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:
-
dm_crypt_overflow.c— single-shot: creates a dm device, reloads a crypt table with"-1" "-1"offsets (triggering the overflow), then reads backstatus_strviacommand="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. -
dm_crypt_loop.c— loop: repeats create+overflow-reload+remove 200× to accumulate slab corruption until INVARIANTS catches it asBADFREE2/chunk_mark_allocatedpanic. 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 _kfree ← dm_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/crypt → dm_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
fixedVALIDATED: 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.
Confirmed kernel references
- sys/dev/disk/dm/crypt/dm_target_crypt.c:462
- sys/dev/disk/dm/crypt/dm_target_crypt.c:464
- sys/dev/disk/dm/crypt/dm_target_crypt.c:468
- sys/dev/disk/dm/crypt/dm_target_crypt.c:475
- sys/dev/disk/dm/crypt/dm_target_crypt.c:477
- sys/dev/disk/dm/crypt/dm_target_crypt.c:573
- sys/kern/kern_slaballoc.c:1591
- sys/kern/kern_slaballoc.c:1659
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
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).
No comments yet.