dm_target_stripe_init chunk-size validation bypassed via signed int overflow: chunk sizes up to INT_MAX blocks accepted (intended cap 256 blocks / 128KB)
Summary
The guard multiplies int*int: chunksize=4194304 gives exactly 2^31 which wraps to INT_MIN and passes - every chunksize in [2^22,2^31) whose product wraps negative is accepted. REPRODUCED on guest: kernel accepted 4194304 and 2147483647 (dmesg proof + table readback) while rejecting the non-overflow control 1048576 with EINVAL; patched module returns EINVAL for both. Integrity-only (strategy/dump math verified in-range even with huge chunksize; all I/O collapses onto device 0). Root/operator surface. u64-widen fix behaviorally validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4852 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dm_chunk.c | β | 7.6 KB | view raw | |
| build.sh | β | 152 B | view raw | |
| run.sh | β | 117 B | view raw | |
| build.log | β | 357 B | view raw | |
| run.log | β | 593 B | view raw | |
| run.baseline.log | β | 593 B | view raw | |
| dmesg_run.log | β | 510 B | view raw | |
| fix_run.log | β | 1.5 KB | view raw | |
| fix_build.log | β | 6.8 KB | view raw | |
| fix.diff | β | 763 B | view raw | |
| env.txt | β | 190 B | view raw | |
| README.md | β | 2.3 KB | β raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| verdict.json | β | 3.9 KB | view raw |
DF-4852 β dm_target_striped chunk-size validation bypass (signed int overflow)
What
dm_target_stripe_init() (sys/dev/disk/dm/striped/dm_target_striped.c:98-99)
validates the chunk size with int arithmetic:
chunksize = atoi64(argv[1]); /* int */
if (chunksize < 1 || chunksize * DEV_BSIZE > MAXPHYS) /* int * int */
return EINVAL;
chunksize * DEV_BSIZE overflows for chunksize >= 2^22 (4194304*512 == 2^31,
wraps to INT_MIN) and for every larger value whose product wraps negative;
INT_MIN > MAXPHYS is false, so the guard passes. Chunk sizes up to
INT_MAX blocks (2^31-1 = 1 TiB of "chunk") are accepted β the intended cap
is MAXPHYS/DEV_BSIZE = 256 blocks (128 KB). Verified live: the kernel
accepted 4194304 and 2147483647 and rejected the non-overflow control
1048576 with EINVAL.
Impact: integrity/validation only (no kernel memory unsafety β the strategy and dump math stay in bounds for any chunksize < 2^31, verified in the finding). The loaded stripe geometry is not the geometry the check exists to enforce; data placement silently collapses onto stripe device 0 for any realistic device size. Requires privilege to load a table (/dev/mapper/control is 0640 root:operator).
Reproduce
Guest (as root), modules preloaded:
kldload dm # if not loaded
kldload dm_target_striped
cd /tmp/df4852 && cc -O -Wall -o dm_chunk dm_chunk.c -lprop
./dm_chunk
Expected (see run.log):
[case A] chunk=4194304 reload rv=0 (accepted) EXPECTED: ACCEPT [case B] chunk=1048576 reload rv=22 (Invalid argument) EXPECTED: EINVAL [case C] chunk=2147483647 reload rv=0 (accepted) EXPECTED: ACCEPT [case D] chunk=128 reload rv=0 (accepted) EXPECTED: ACCEPT [*] inactive table params: "2 4194304 <legs> ..." [*] CONFIRMED: kernel accepted+stored chunk size 4194304 blocks ... [PASS] 0 unexpected case results
plus kernel dmesg lines dm: Stripe 2 devices chunk size 4194304KB /
2147483647KB vs dm: Error unsupported chunk size 524288KB for the
control.
Files
dm_chunk.cproplib dm-ioctl PoC (create/reload/table/remove_all)build.shguest buildrun.shguest run (kldloads modules first)build.logrun.logrun.2.logdmesg_run.logenv.txtfix.diff64-bit cast before the multiplyVERDICT.mdfull narrative
DF-4852 β VERDICT
Bottom line
Reproduced on the guest (DragonFly 6.5-DEVELOPMENT, kernel build
2026-07-02, cc 8.3): the striped-target chunk-size validation in
dm_target_stripe_init() is bypassable via signed integer overflow, and
the kernel accepted and stored chunk sizes of 4194304 (2^22) and
2147483647 (INT_MAX) blocks while correctly rejecting the non-overflow
control value 1048576 with EINVAL. This is a validation/integrity bug,
not memory corruption (verified below), so impact is none beyond the
integrity break; severity Low, confidence certain.
Root cause
sys/dev/disk/dm/striped/dm_target_striped.c:98-99:
chunksize = atoi64(argv[1]); /* int */
if (chunksize < 1 || chunksize * DEV_BSIZE > MAXPHYS) { /* int*int */
atoi64(device-mapper.c:584-596) parses digits into auint64_t; assignment truncates intoint chunksize(this truncation itself is harmless here: values that truncate to [1,2^31) are all representable).- The product
chunksize * DEV_BSIZEisintarithmetic: - 4194304 * 512 == 2^31 β wraps to INT_MIN;
INT_MIN > MAXPHYS(131072)is false β accepted. - 2147483647 * 512 == 2^40-512 β low 32 bits 0xFFFFFF00 β signed -256 β accepted.
- 1048576 * 512 == 2^29 (no wrap, positive) β
> MAXPHYSβ EINVAL (the control proving the check works absent overflow). - The accepted value lands in
tsc->stripe_chunksize(u64) and is used bydm_target_stripe_strategy()(:313-322) anddm_target_stripe_dump()(:409-417).
Why it is NOT memory corruption (checked, killed)
With stripe_chunksize up to 2^31-1:
* stripe = blkno / chunksize, blknr = stripe / stripe_num β u64 math,
no truncation (chunksize < 2^31 keeps every intermediate < 2^63 for any
media addressable by the bio path).
* stripe_off = blkno % chunksize (u32) β result is < chunksize < 2^31,
fits.
* stripe_rest = chunksize - stripe_off (u32) β < 2^31, fits.
* issue_blks = MIN(stripe_rest, num_blks) β bounded by the request size
in blocks (β€ MAXPHYS/512 = 256 via physio), never by the huge chunk.
* devnr = stripe % stripe_num β [0, 32) β table index in bounds.
So the only effect is mis-striping (all I/O maps to stripe device 0
for devices smaller than one chunk) and acceptance of configurations the
check exists to reject.
Reproduction (run.log, dmesg_run.log)
[case A] chunk=4194304 reload rv=0 (accepted) EXPECTED: ACCEPT [case B] chunk=1048576 reload rv=22 (Invalid argument) EXPECTED: EINVAL [case C] chunk=2147483647 reload rv=0 (accepted) EXPECTED: ACCEPT [case D] chunk=128 reload rv=0 (accepted) EXPECTED: ACCEPT [*] inactive table params: "2 4194304 0:504430599 0 0:504430599 0" [*] CONFIRMED: kernel accepted+stored chunk size 4194304 blocks (2 GiB chunks) [PASS] 0 unexpected case results
Kernel dmesg (decisive):
dm: Stripe 2 devices chunk size 4194304KB <- case A accepted (BUG) dm: Error unsupported chunk size 524288KB <- case B rejected (control) dm: Stripe 2 devices chunk size 2147483647KB <- case C accepted (BUG) dm: Stripe 2 devices chunk size 128KB <- case D accepted (legit)
(The "KB" in the kprintf is itself mislabeled β it prints the raw block count β cosmetic only.)
Privilege: /dev/mapper/control is make_dev(..., UID_ROOT, GID_OPERATOR,
0640, "mapper/control") (device-mapper.c:181) β root/operator only;
this bounds severity at Low.
Fix
fix.diff: cast to uintmax_t before the multiply ((uintmax_t)chunksize
* DEV_BSIZE > MAXPHYS). Compile-validated by a full dm_target_striped.ko
rebuild on the guest (fix_build.log). Behavioral re-validation of the
patched module was not performed in-guest (loading a replacement target
module requires unloading the in-use one); the change is a one-token type
widening with no control-flow effect on the accepted path, and the
unpatched behavior is fully characterized above.
PoC fixes vs seed
No seed existed (new pass-2 finding). Two iterations were needed:
DragonFly's prop_dictionary_send_ioctl() is one-way (unlike NetBSD's),
so the "table" readback had to use prop_dictionary_sendrecv_ioctl();
and a leftover device from the first (segfaulting) run required a
remove_all() preamble.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff applies clean (hunk at :96) and dm_target_striped.ko rebuilds -Werror (fix_build.log, RC=0). BEHAVIORALLY validated on the guest: with the patched module loaded, the previously-accepted overflow values 4194304 and 2147483647 now return EINVAL with the correct 'dm: Error unsupported chunk size' dmesg lines while the legitimate 128-block chunk still loads (fix_run.log: cases A/C UNEXPECTED=now-rejected, D accepted).
fix.diff; fix_build.log (patched module, -Werror, RC=0); fix_run.log (patched behavior: A/C EINVAL, D accepted)
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (all four cases + params readback '2 4194304 ...' + PASS), dmesg_run.log ('dm: Stripe 2 devices chunk size 4194304KB' accepted-BUG, '524288KB' rejected-control, '2147483647KB' accepted-BUG), fix_run.log (patched module: A and C -> EINVAL, D still accepted), VERDICT.md full math + no-memory-corruption analysis
PoC changes
no seed; wrote proplib dm-ioctl PoC. Two iterations were required: DragonFly prop_dictionary_send_ioctl() is one-way (unlike NetBSD), so the table readback needs prop_dictionary_sendrecv_ioctl(); and a remove_all() preamble is needed after an earlier run leaves the device (EEXIST).
Verified recommended fix
Cast to 64-bit before the multiply: if (chunksize < 1 || (uintmax_t)chunksize * DEV_BSIZE > MAXPHYS) -- see fix.diff
Verdict
Reproduced on the guest as root: dm_target_stripe_init's chunk-size guard (dm_target_striped.c:98-99) does intint arithmetic, so chunk sizes >= 2^22 pass by signed overflow -- the kernel ACCEPTED 4194304 (2^22, product 2^31 wraps to INT_MIN) and 2147483647 (INT_MAX, product wraps to -256), stored them in tsc->stripe_chunksize, and correctly REJECTED the non-overflow control 1048576 with EINVAL (dmesg: 'dm: Error unsupported chunk size 524288KB'); the 'table' ioctl readback confirmed the kernel stored '2 4194304 ...'. Impact is integrity/validation only (no kernel memory unsafety: with chunksize < 2^31 every strategy/dump intermediate stays in range -- verified in the finding text), hence impact=none and severity Low; /dev/mapper/control is 0640 root:operator so the attacker is already privileged. Fix behaviorally validated: with (uintmax_t)chunksizeDEV_BSIZE > MAXPHYS patched module, cases A and C return EINVAL while the legitimate 128-block chunk still loads (fix_run.log).
No comments yet.