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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4852 Β· 14 files
FileTypeDescriptionSize
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.c proplib dm-ioctl PoC (create/reload/table/remove_all)
  • build.sh guest build
  • run.sh guest run (kldloads modules first)
  • build.log run.log run.2.log dmesg_run.log env.txt
  • fix.diff 64-bit cast before the multiply
  • VERDICT.md full narrative
VERDICT.md
↓ download raw

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 a uint64_t; assignment truncates into int chunksize (this truncation itself is harmless here: values that truncate to [1,2^31) are all representable).
  • The product chunksize * DEV_BSIZE is int arithmetic:
  • 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 by dm_target_stripe_strategy() (:313-322) and dm_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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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)
↓ fix.diffper-fix-DF-4852

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).