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

I2CRDWR ioctl leaks usrbufs array on copyin failure (kernel memory exhaustion DoS)

Summary

In I2CRDWR handler usrbufs allocated at :352 before copyin of msgs array at :353. If copyin fails code breaks to cleanup at :385 which only frees buf - usrbufs never freed. Each failed call leaks sizeof(void*)*nmsgs bytes. Attacker can repeat indefinitely to exhaust kernel memory. For nmsgs=100000: each call allocates ~1.6MB buf + ~800KB usrbufs copyin fails buf freed but usrbufs (~800KB) leaked. After ~1000 iterations ~800MB leaked.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2309 Β· 15 files
FileTypeDescriptionSize
usrbufleak.c β€” 3.9 KB view raw
fix.diff β€” 521 B view raw
build.sh β€” 132 B view raw
run.sh β€” 109 B view raw
calib.log β€” 730 B view raw
run.log β€” 936 B view raw
fix_run.log β€” 910 B view raw
exhaust.log β€” 1.7 KB view raw
panic.txt β€” 541 B view raw
env.txt β€” 809 B view raw
master2309.log β€” 3.8 KB view raw
iic.fixbuild.log β€” 876 B view raw
build.log β€” 0 B ↓ download
VERDICT.md β€” 4.3 KB ↓ raw
verdict.json β€” 5.8 KB view raw
VERDICT.md
↓ download raw

DF-2309 β€” VERDICT: reproduced (linear, permanent kernel-heap leak β†’ deterministic panic at the malloc-type limit; fix validated)

Bottom line

iicioctl(I2CRDWR) at sys/bus/iicbus/iic.c allocates usrbufs (sizeof(void) * d->nmsgs, zeroed, M_TEMP) at :352 before copyining the user msgs array at :353; when that copyin fails the break at :355 reaches an epilogue that frees only buf (:385-386) β€” usrbufs is freed only on the success path (:372). Each failing call permanently leaks 8nmsgs bytes of wired kernel heap. Verified live on the DragonFly 6.5-DEVELOPMENT guest with the production i2c stack (DF-5539 harness rig; setup patches DF-5559/DF-5560 disclosed, I2CRDWR logic under test stock):

  • 20-call calibration: 16.8 MB consumed; vmstat -m type temp Count +20, MemUse 332K β†’ 7.98M β€” precisely one live 800 KB object per failed call (buf freed, usrbufs retained). Requests counter +2/call.
  • 300-call baseline: 230.5 MB consumed vs 228.9 MB predicted β€” linear at 8*nmsgs per call, no plateau, temp Count +320.
  • Module unload/reload does not return the memory (global M_TEMP leak, not pool retention).
  • Exhaustion endpoint: DragonFly enforces a per-malloc-type limit (temp Limit = 390M on this guest, seen in vmstat -m). With the leaked total pushed past it (~490 calls), the next in-ioctl kmalloc panics: panic: temp: malloc limit exceeded with backtrace _kmalloc() <- iicioctl() <- dev_dioctl() <- devfs_fo_ioctl() <- mapped_ioctl() (panic.txt, serial capture). The DoS is a guaranteed kernel panic, reachable purely by repeating a failing ioctl.
  • Fix validated: one-line kfree(usrbufs, M_TEMP); before the error break (fix.diff), only iic.ko rebuilt/reloaded. Identical 300-call workload: 2.1 MB consumed (measurement noise; temp Count flat, no new live objects), 300/300 calls still EFAULT, and a follow-up 200-call run on the patched module also ~0. Leak eliminated, error semantics preserved, no double-free (the success path frees at :372 and is not reached after the break).

Threat model / impact ceiling

/dev/iicN is 0600 root:wheel (iic.c:128-130): on a default configuration the trigger requires root (impact of the leak itself is then limited to self-inflicted starvation/panic). On embedded or devfs-ruleset configurations that expose the node to unprivileged users, this is an unauthenticated-per-open repeatable kernel-memory-exhaustion + deterministic-panic DoS: ~390 MB / ~490 calls at nmsgs=100000 (the per-call leak is attacker-chosen up to 8*2^32 bytes in principle via nmsgs, bounded in practice by kmalloc limits and the type limit).

nmsgs bounds worth noting (adjacent, out of scope here): the kmalloc multiplications at :351/:352 happen before any sanity clamp of nmsgs β€” huge nmsgs mainly produce giant transient buf allocations plus the matching usrbufs leak; DF-2312 tracks nmsgs bounding separately.

Reproduction detail

usrbufleak.c issues ioctl(fd, I2CRDWR, &(struct iic_rdwr_data){ .msgs = (void *)0x10, .nmsgs = 100000 }) in a loop; every call returns -1/EFAULT (any other result aborts). It samples vm.stats.vm.v_free_count every K calls; run.sh snapshots vmstat -m type temp around the phases. Numbers above are from the canonical re-run (guest instance 2, after vm.sh reset with-src); the exhaustion panic was executed on guest instance 1 (serial capture kept β€” panics are authoritatively read from the serial console; the run's calib/phase-A/B logs from that instance were superseded by the canonical re-run with identical results: 229.8 MB / Count +320 on instance 1 vs 230.5 MB / Count +320 on instance 2).

Fix

fix.diff β€” free usrbufs on the copyin-failure path before break:

        error = copyin(d->msgs, buf, sizeof(*d->msgs) * d->nmsgs);
        if (error)
+           kfree(usrbufs, M_TEMP);
            break;

No double-free: the success path's kfree(usrbufs) at :372 is skipped by the break, and M_WAITOK kmallocs in this case cannot fail between :352 and :353. Validated fixed on the guest (see above).

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 applied to the guest's /usr/src copy of sys/bus/iicbus/iic.c (on top of the two disclosed setup patches), only iic.ko rebuilt and reloaded. Baseline stock I2CRDWR logic: 300 failing-copyin calls leaked 230.5 MB (temp Count +320, one live 800 KB object per call). Patched: identical workload consumed 2.1 MB of sampling noise with Count flat at 789 and 300/300 calls still returning EFAULT; an additional 200-call run on the patched module also showed ~0 growth. Leak eliminated, error semantics preserved, no double-free (success-path kfree at :372 is skipped by the error break).

fix.diff; iic.fixbuild.log; run.log (baseline 230.5 MB / Count +320); fix_run.log (patched 2.1 MB / Count flat); exhaust.log (extra 200 patched calls ~0); panic.txt (stock exhaustion endpoint).
↓ fix.diffper-fix-DF-2309

Confirmed kernel references

Detail

Exploit chain

open /dev/iicN (root on default configs; unprivileged where devfs rules expose the node) -> loop ioctl(I2CRDWR, {msgs = unmapped pointer, nmsgs = 100000}) -> each call leaks 800 KB of M_TEMP permanently -> after ~490 calls (~390 MB) the per-type malloc limit is exhausted and the next in-ioctl kmalloc panics the kernel (or, short of the limit, the system runs with hundreds of MB of kernel heap permanently gone). No info disclosure or code execution; pure resource-exhaustion DoS with a deterministic panic endpoint.

Evidence (decisive lines)

run.log (phase A): 300/300 EFAULT, 230.5 MB consumed vs 228.9 predicted, vmstat -m temp 332K->245M Count 469->789. calib.log: 20 calls -> 16.8 MB, Count +20 (one live object per call). fix_run.log (phase B): same workload on patched iic.ko, 2.1 MB noise, Count flat. exhaust.log: extra 200 patched calls ~0. panic.txt: serial capture 'panic: temp: malloc limit exceeded' _kmalloc<-iicioctl<-dev_dioctl (executed on a stock reload after phase B on guest instance 1). env.txt: uname/kldstat/post-phase allocator state. VERDICT.md: full narrative incl. module-reload attribution and fix double-free analysis.

PoC changes

No seed PoC existed. Authored usrbufleak.c from scratch: failing-copyin I2CRDWR loop with sysctl vm.stats.vm.v_free_count sampling every K calls plus run.sh vmstat -m type-temp snapshots and calibration/attribution/fix-validation phases. Iterations: (1) initial exhaustion probe was accidentally run against the patched module (still loaded from phase B) -- repeated on an explicitly reverted+rebuilt+reloaded stock module, which panicked as expected; (2) that panic killed the guest in DDB before evidence scp, so phases A/B were re-run canonically on a fresh reset with identical results (230.5 MB / Count +320 vs 229.8 MB / Count +320) and the panic kept as a serial-console capture.

Verified recommended fix

Free the usrbufs array on the copyin-failure path: add 'kfree(usrbufs, M_TEMP);' before the 'break' at iic.c:355 (see fix.diff; validated: leak gone, EFAULT semantics unchanged, no double-free).

Verdict

REPRODUCED on the DragonFly 6.5-DEVELOPMENT guest with production modules: iicioctl(I2CRDWR) (sys/bus/iicbus/iic.c:352,353-355,385-386) allocates usrbufs (8nmsgs, M_ZERO, M_TEMP) before copyin()ing the user msgs array and frees only buf on the copyin-failure path -- every failing call permanently leaks exactly one 8nmsgs-byte wired kernel heap object of malloc type temp. Measured with the DF-5539 harness rig (real iicbus.ko/iicbb.ko/iic.ko; setup patches DF-5559/DF-5560 disclosed, I2CRDWR logic stock): 20-call calibration 16.8 MB with vmstat -m temp Count +20 (one live 800 KB object per call at nmsgs=100000); 300-call baseline 230.5 MB consumed vs 228.9 MB predicted, Count +320, perfectly linear; kldunload/kldload of iic.ko does not return the memory (global M_TEMP). Exhaustion endpoint executed on a stock reload: pushing type temp past its per-type Limit (390M) panics the kernel -- 'panic: temp: malloc limit exceeded', _kmalloc() <- iicioctl() <- dev_dioctl() <- devfs_fo_ioctl() <- mapped_ioctl() (panic.txt, serial capture) -- so the DoS is a deterministic panic, not just gradual starvation. fix.diff (one-line kfree(usrbufs, M_TEMP) before the error break) validated by rebuilding only iic.ko: identical 300-call workload consumes 2.1 MB of noise with Count flat and every call still failing EFAULT; an extra 200-call patched run likewise ~0. No double-free (success-path free at :372 is skipped by the break). Reachability: /dev/iicN is 0600 root:wheel (iic.c:128-130), so default-config impact requires root; devfs-exposed embedded configs give unprivileged users a repeatable kernel-memory-exhaustion + guaranteed-panic DoS.