Infinite loop / negative b_bcount in DAIOCTRIM from negative user-supplied length
Summary
daioctl(DAIOCTRIM) reads bytes_left bytes_start as signed off_t only enforces 512-byte alignment not sign. Passing del_num[1]=LLONG_MIN satisfies LLONG_MIN%512==0 then byte_count=MIN(bytes_left 0x7FFF8000) yields LLONG_MIN which truncates to int byte_count=0 (low 32 bits of 0x8000000000000000 are 0). Loop body issues zero-block TRIM and bytes_left-=0 leaves bytes_left unchanged looping forever. Each iteration allocates kernel buffer -> reliable kernel-thread hang + memory pressure. Smaller negative values truncate to negative bp->b_bcount passed into device strategy. [VERIFIED pass2: reproduced and aggravated beyond claim - LLONG_MIN spins at 2.2M iter/s issuing zero-range TRIM CCBs with one buffer alloc per iteration 200001 bios watchdog-terminated only; -4096 dispatches negative b_bcount that dastart unsigned division converts to a 129-range ~4GiB misdirected TRIM returning SUCCESS; no memory corruption - range writes bounded at 129 of 512 slots and b_bcount never a copy size on FREEBLKS path; fix-validated combined EINVAL hunk]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2276 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.7 KB | β raw | |
| VERDICT.md | β | 7.5 KB | β raw | |
| run.log | β | 3.7 KB | view raw | |
| build.log | β | 5.2 MB | β download | |
| fix.diff | β | 1.0 KB | view raw | |
| fix_build.log | β | 612.9 KB | β download | |
| fix_run.log | β | 2.6 KB | view raw | |
| da_user.c | β | 2.5 KB | view raw | |
| getnewbuf_before.txt | β | 25 B | view raw | |
| getnewbuf_after.txt | β | 27 B | view raw | |
| env.txt | β | 272 B | view raw | |
| verdict.json | β | 5.3 KB | view raw |
DF-2276 verification pack β DAIOCTRIM infinite loop / negative b_bcount
Bug: sys/bus/cam/scsi/scsi_da.c:436-481 β the DAIOCTRIM handler reads the
user off_t pair SIGNED, checks only 512-alignment, then computes
int byte_count; /* scsi_da.c:421 */ ... byte_count = MIN(bytes_left, 0x7FFF8000); /* off_t result -> int */ bp->b_bcount = byte_count; /* struct buf b_bcount is int */ ... bytes_left -= byte_count; /* loop variable */
del_num[1] = LLONG_MINis a multiple of 512 (passes both asserts);MIN(LLONG_MIN, 0x7FFF8000) = LLONG_MINtruncates to int 0 -> the loop dispatches zero-block TRIMs forever,bytes_leftnever changes, onegetnewbufallocation per iteration, the ioctl never returns.- Smaller negatives (e.g. -0x1000) truncate to a NEGATIVE int
bp->b_bcount = 0xFFFFF000handed todev_dstrategy().
Verdict: REPRODUCED exactly as claimed β plus one aggravating discovery (see VERDICT.md): through the REAL dastrategy/dastart the negative b_bcount is reinterpreted by an unsigned division into a ~4 GiB TRIM.
Rig
Same harness kernel as DF-2275 (see ../DF-2275/rig): verbatim scsi_da.c (sha256-gated) compiled in as the only da driver, fabricated second disk da99, fake transport. All measurements below came from the REAL code path (userland ioctl -> mapped_ioctl copyin -> dev_dioctl -> daioctl -> dev_dstrategy -> dastrategy -> xpt_schedule -> dastart -> xpt_action -> dadone -> biodone -> biowait). A harness watchdog (debug.dfda.watchdog) forces the EIO error exit after N bios so the infinite loop terminates with counters.
Build
cd ../DF-2275/rig && ./build.sh
Run (baseline = vulnerable)
./run.log sections correspond to:
sysctl debug.dfda.setup=1; sysctl debug.dfda.mode=1
/root/da_user neg # {0, -4096} full real strategy path
sysctl debug.dfda.mode=0
/root/da_user neg # capture: what the dispatch itself sends
sysctl debug.dfda.watchdog=200000; /root/da_user llmin # full path
sysctl debug.dfda.watchdog=1000000; /root/da_user llmin # capture rate
sysctl debug.dfda.stats; sysctl vfs.getnewbufcalls
Expected (vulnerable kernel) β all observed
- neg {0,-4096}: ioctl returns SUCCESS; the dispatched buf carries
b_bcount = -4096 (0xfffff000)(cap[0] line in run.log); the real dastart turns it into a 129-range (~4 GiB) XPT_TRIM (dxfer=1536 = ((129+63)/64)*512) β decoded in VERDICT.md. - llmin {0,LLONG_MIN}: every bio has b_bcount=0 and bio_offset=0 (bytes_start never advances); 200001 bios in 0.092 s through the FULL real path (200000 zero-range XPT_TRIM CCBs, dxfer=0, ranges=0) and 1000001 bios in 0.105 s in capture mode (~2.2M / ~9.5M iterations per second); vfs.getnewbufcalls grew by 200006 (one buffer allocation per iteration). Without the watchdog this runs forever: the syscall never returns, the thread never checks signals (unkillable), one CPU pinned.
Fixed kernel (A/B)
(same kernel as DF-2275 fix) ./run_fixed.sh neg -> r=-1 errno=22 (EINVAL), nothing dispatched (strat_count=0) llmin -> r=-1 errno=22 (EINVAL), instant, nothing dispatched
Impact ceiling: privileged-reach (SYSCAP_RESTRICTEDROOT at daopen, scsi_da.c:505) kernel DoS β an unkillable in-kernel loop from one ioctl β plus, on real da hardware, up to ~4 GiB of misdirected TRIM (data destruction) for mid-range negative lengths. No kernel memory corruption (dastart structurally bounds the range writes; full analysis in VERDICT.md). No unprivileged route.
Artifacts: run.log, build.log, fix.diff, fix_build.log, fix_run.log, da_user.c, getnewbuf_before.txt / getnewbuf_after.txt, env.txt; rig shared with DF-2275 (../DF-2275/rig).
DF-2276 VERDICT β REPRODUCED (privileged-reach DoS + misdirected TRIM), fix validated
What was claimed
sys/bus/cam/scsi/scsi_da.c:436-481: bytes_left/bytes_start are read as
signed off_t from the user payload, only 512-alignment is enforced, and
the loop arithmetic int byte_count = MIN(bytes_left, 0x7FFF8000) /
bp->b_bcount = byte_count / bytes_left -= byte_count both (a) spins
forever for LLONG_MIN (truncates to 0) and (b) hands a negative b_bcount
to the device strategy for mid-range negatives.
Mechanical verification (REAL dispatch, fabricated da99)
Same rig as DF-2275 (verbatim scsi_da.c, sha256-gated; real make_dev da99;
real daopen/caps gate; userland trigger da_user.c). Everything from
ioctl(2) to the buffer handoff is real kernel code; only the transport
wire (xpt_action) is faked per-object, completing TRIMs through the REAL
dadone.
(a) LLONG_MIN β infinite zero-block TRIM loop: CONFIRMED
mode=llmin start=0 len=-9223372036854775808 -> ioctl... FULL real path, watchdog=200000: strat_count=200001 forced=1 trim_ccbs=200000 trim_zero_ranges=200000 trim_ranges_total=0 bcount_nonzero=0 bcount_negative=0 offset_nonzero=0 cap[0..7]: bcmd=3(LOCK) bcount=0 (0x0) offset=0 ioctl returned r=-1 errno=5 in 0.092 sec <- watchdog EIO exit capture mode, watchdog=1000000: strat_count=1000001 ... in 0.105 sec vfs.getnewbufcalls: 1125 -> 201131 (delta 200006)
Interpretation, line by line:
bytes_left = LLONG_MINpasses both KKASSERTs (2^63 is a multiple of 512) β scsi_da.c:443-444.byte_count = MIN(bytes_left, 0x7FFF8000)keeps the off_t LLONG_MIN; assignment toint byte_counttruncates to 0 (scsi_da.c:462).bp->b_bcount = 0β observed on every one of 1.2M bios.bytes_left -= byte_countsubtracts 0 β loop conditionwhile (bytes_left)(scsi_da.c:449) never falsifies.bytes_start += 0β every bio_offset is 0 (offset_nonzero=0 across 1.2M bios).- Every iteration issues a REAL XPT_TRIM through dastrategy/dastart with ranges=0 and dxfer_len=0 (200000 consecutive zero-range TRIM CCBs observed; trim_ranges_total=0).
- One buffer allocation per iteration: getnewbufcalls delta 200006 for 200001 bios (+opens). Buffers are brelse()d each iteration, so the memory pressure is churn, not unbounded growth; the dominant effect is the never-returning, signal-blind (no PCATCH anywhere in the loop) syscall thread pinning one CPU: ~2.2M iterations/s through the full real strategy path, ~9.5M/s without it. Without the harness watchdog this is an unkillable forever-loop = privileged local DoS.
(b) mid-range negative (-0x1000): CONFIRMED + AGGRAVATED
mode=neg start=0 len=-4096 (0xfffffffffffff000) -> ioctl... cap[0]: bcmd=3 bcount=-4096 (0xfffff000) offset=0 (full real path) trim_ccbs=1 dxfer=1536 ranges=64+ bio_bcount=-4096 data16=000000000000ffffffff00000000ffff ioctl returned r=0 errno=0 (success)
- The dispatched struct buf carries
b_bcount = -4096(0xfffff000) into the REALdev_dstrategy -> dastrategyβ the claimed negative b_bcount primitive, captured at the dispatch boundary. - NEW (aggravating):
dastartcomputescount = bp->b_bcount / softc->params.secsize(scsi_da.c:1341). b_bcount isint, secsize isu_int32_t, so C converts the int to UNSIGNED: (-4096)/512 becomes 4294963200/512 = 8388599 sectors (positive). The range-builder loop (scsi_da.c:1345-1360) then emits 129 LBA-range descriptors (128 x 65535 + 119 sectors ~ 4.29 GiB) andcam_fill_csiogets dxfer_len = ((129+63)/64)512 = 1536 β exactly the observed capture. For a "-4 KiB" request the REAL driver builds a ~4 GiB TRIM covering [bytes_start, bytes_start + ~4GiB). On real da hardware this is mass unintended data destruction; on the fake transport it completed successfully and the ioctl returned SUCCESS* (bytes_left -= -4096 -> 0 -> clean exit). Worst case is b_bcount = -512 -> 8388607 sectors -> still 129 ranges.
TEAM DIRECTIVE: is the negative b_bcount memory corruption?
NO β closed by source analysis + the live run:
- dastrategy (scsi_da.c:687-754) does no b_bcount validation at all; it sorts the bio into bio_queue_trim (bioqdisksort uses bio_offset, not b_bcount) β so the negative length DOES reach dastart.
- In dastart the unsigned division maps every negative int b_bcount to count in [0, 8388607] (2^32/512 upper bound). The chunking loop writes ceil(count/65535) <= 129 descriptors into trim_req.data (capacity 512 descriptors / 4096 bytes, scsi_da.c:132-137) β always IN BOUNDS.
- The merge-limit check (scsi_da.c:1365-1368) bounds total ranges
across queued bios to <= 512;
req->bios[bps++]indexes are likewise bounded by the same loop. - The FREEBLKS path never uses b_bcount as a copy size: no bcopy/
uiomove consumes it; the KKASSERT at scsi_da.c:1490 guards only the
READ/WRITE path, and
scsi_read_write(which does use b_bcount for dxfer_len) is not reachable with b_cmd == BUF_CMD_FREEBLKS. - dadone TRIM completion (scsi_da.c:1777-1795) only propagates resid/error into the bios.
So the corruption-adjacent primitive (negative struct-buf length handed to a block driver strategy) is real but is structurally defused kernel- side; the residual impact is the infinite-loop DoS (LLONG_MIN) and misdirected multi-GiB TRIM (mid-range negatives) β both gated behind daopen SYSCAP_RESTRICTEDROOT (scsi_da.c:505). Classification stays privileged-reach DoS/data-destruction; no escalation to memory corruption or uid 0 exists on this path.
Fix validation (A/B, same single-fix kernel as DF-2275)
fix.diff adds, before the loop:
if (bytes_start < 0 || bytes_left < 0)
return(EINVAL);
if ((bytes_left % 512) != 0)
return(EINVAL);
if ((bytes_start % 512) != 0)
return(EINVAL);
| case | baseline (uname #1 01:07) | fixed (uname #2 01:35) |
|---|---|---|
| neg | SUCCESS + negative b_bcount + 129-range TRIM | errno 22 EINVAL, strat_count=0 |
| llmin | 200001/1000001 bios then watchdog EIO | errno 22 EINVAL, instant, strat_count=0 |
| aligned | SUCCESS, CDB 1 range lba0 len8 dxfer512 | SUCCESS, byte-identical CDB |
fix_status: fixed.
Upstream comparison
FreeBSD DIOCGDELETE (same off_t[2] shape) is validated up front in
sys/geom/geom_dev.c:609-618: (offset % sectorsize) != 0 ||
(length % sectorsize) != 0 || length <= 0 -> EINVAL, and the loop is
while (length > 0) β sign-safe by construction. The validated fix
matches the canonical rejection set (non-positive length + misalign ->
EINVAL). Divergence: DragonFly keeps while (bytes_left); with negatives
rejected up front the loop is equivalent, but flipping the condition to
while (bytes_left > 0) would be defense-in-depth. FreeBSD also lets
the GEOM provider bound offset+length; the DragonFly handler does no
bounds check against disk size (pre-existing, out of scope, worth a
follow-up).
Files
run.log (decisive runs + fixed A/B + getnewbuf delta), build.log, fix.diff, fix_build.log, fix_run.log, da_user.c (trigger), getnewbuf_before.txt, getnewbuf_after.txt, env.txt; shared rig: ../DF-2275/rig (sources, full logs, disasm).
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
fixedSingle-fix kernel (fix.diff shared with DF-2275) A/B tested: llmin returns errno 22 instantly with strat_count=0 (baseline: 200001+ bios and only the watchdog stopped it), neg returns errno 22 with nothing dispatched (baseline: negative b_bcount and a 129-range TRIM), aligned control {0,4096} still succeeds through the real path with a byte-identical CDB.
fix_run.log and run.log fixed sections (neg/llmin EINVAL with strat_count=0, aligned control CDB); rig/run_fixed_outer.log (full suite output).
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log holds all four decisive runs (neg full path with cap[0] bcount=-4096 and the 129-range TRIM capture; neg capture mode; both llmin watchdog runs with all-zero counters) plus the fixed-kernel EINVAL results and the getnewbuf delta; getnewbuf_before.txt/getnewbuf_after.txt; VERDICT.md decodes the unsigned-division arithmetic; shared rig in ../DF-2275/rig (run_baseline_outer.log is the raw suite output).
PoC changes
No seed PoC existed; the trigger da_user.c and the shared CAM verbatim-interposition rig (see DF-2275 poc_changes) were written from scratch; the watchdog sysctl (force EIO after N bios) was added so the infinite loop terminates with decisive iteration and allocation counters instead of hanging the guest.
Verified recommended fix
Reject negative del_num[0]/del_num[1] up front and return EINVAL for misaligned ranges in daioctl (fix.diff, validated A/B; shared with DF-2275).
Verdict
REPRODUCED exactly as claimed, mechanically through the real dispatch on the shared harness kernel, plus one aggravating discovery. (1) del_num[1]=LLONG_MIN is 512-aligned and passes both KKASSERTs; byte_count = MIN(bytes_left, 0x7FFF8000) keeps LLONG_MIN and the assignment to int byte_count truncates to 0, so every iteration dispatches a zero-block TRIM while bytes_left is unchanged: 200001 bios observed through the FULL real path (200000 consecutive zero-range XPT_TRIM CCBs, all b_bcount=0 and bio_offset=0) at ~2.2M iterations/s, and 1000001 bios at ~9.5M/s in capture mode, each iteration doing one getnewbuf (vfs.getnewbufcalls delta 200006); only the harness watchdog (EIO exit) ended the runs - unmodified the ioctl never returns, never checks signals, and pins a CPU: privileged-reach unkillable DoS. (2) del_num[1]=-4096 (also 512-aligned) reaches dev_dstrategy with bp->b_bcount = -4096 (0xfffff000), captured at the dispatch boundary; the ioctl then returns SUCCESS. Aggravation: dastart computes count = bp->b_bcount / softc->params.secsize as int divided by u_int32_t, i.e. UNSIGNED, turning -4096 into 8388599 positive sectors, so the real driver builds a 129-range ~4GiB TRIM (dxfer_len 1536 = ((129+63)/64)*512) for a minus-4KB request - on real hardware a misdirected multi-GiB TRIM (privileged data destruction). Team directive closed: NO kernel memory corruption - the negative b_bcount never becomes a copy size; the dastart range builder writes at most 129 descriptors into the 512-descriptor trim_req.data, the merge-limit check bounds totals to 512, and the FREEBLKS path never feeds b_bcount to scsi_read_write; residual impact is DoS + wrong-range TRIM, both gated by the daopen SYSCAP_RESTRICTEDROOT check. Fix validated A/B: negatives and LLONG_MIN now return EINVAL instantly with nothing dispatched, aligned control unchanged.
No comments yet.