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

kern.ncallout fetched after the only clamp β†’ int-overflowed callwheel sizing in swi_softclock_setup: zero-byte per-CPU allocations panic vm_map (latent 2^30-iteration OOB init loop; infinite boot loop on ≀2-CPU)

Field Value
ID DF-2948
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-190 / CWE-787 (latent) / CWE-835
File sys/kern/subr_param.c
Lines 327-330 (sinks: kern_timeout.c:386-413)
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

init_param2() clamps the COMPUTED ncallout to 560hz and only then executes TUNABLE_INT_FETCH("kern.ncallout", &ncallout) β€” so the direct boot tunable enters the kernel with no bounds at all. The consumer swi_softclock_setup() does all sizing in int: target = ncallout/ncpus + 16, cwheelsize doubles to the next power of two, and 'int wheel_sz = sizeof(struct wheel) * cwheelsize' β€” sizeof(struct wheel)=24 on x86_64, so cwheelsize=2^30 gives wheel_sz = 24*2^30 ≑ 0 (mod 2^32). VERIFIED on the 6-vCPU guest with kern.ncallout=2147483647: target= 357913966 β†’ cwheelsize=2^30 β†’ wheel_sz=0 β†’ the six per-CPU zero-length callwheel allocations collide β†’ 'panic: vm_map_entry_link: dup addr' during SI_SUB_SOFTCLOCKS, dead in DDB. Behind that map panic the sink code shows the latent outcome: the init loop runs 2^30 iterations of spin_init/TAILQ_INIT over a zero-byte allocation (~25 GB OOB write); on ≀2-vCPU boxes target can be driven into (2^30, 2^31) where the doubling loop executes 1<<31 (signed overflow β†’ negative β†’ 0) and hangs boot forever. Distinct from DF-0174 (ncallout overflow via unbounded kern.maxfiles): the direct tunable bypasses the sum entirely. Fix: clamp the tunable after the fetch + size_t wheel_sz + target sanity cap + KKASSERT on allocation.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_param.c (GLM 5.3); boot panic reproduced twice on stock guest (DF-2947 unused).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2948 Β· 10 files
FileTypeDescriptionSize
README.md file 3.0 KB ↓ raw
VERDICT.md file 3.7 KB ↓ raw
env.txt file 218 B view raw
fix.diff file 1.9 KB view raw
manifest.json file 1.3 KB view raw
panic.txt file 5.2 KB view raw
plant.sh file 2.5 KB view raw
run.sh file 650 B view raw
sha256.txt file 148 B view raw
verdict.json file 5.2 KB view raw
README.md file
↓ download raw

DF-2948 β€” kern.ncallout fetched after the only clamp β†’ int-truncated callwheel sizing β†’ boot panic / latent OOB init

sys/kern/subr_param.c:327-330 clamps the computed ncallout to 5*60*hz and only then fetches the kern.ncallout tunable β€” which therefore enters the kernel with no bounds at all. The consumer swi_softclock_setup() (sys/kern/kern_timeout.c:374-413) does all its math in int:

target    = ncallout / ncpus + 16;                 /* :386 */
cwheelsize = 1;
while (cwheelsize < target) cwheelsize <<= 1;      /* :389-390 signed shift */
cwheelmask = cwheelsize - 1;
...
int wheel_sz;                                       /* :398 */
wheel_sz      = sizeof(*sc->callwheel) * cwheelsize;  /* :406  24 * 2^30 β†’ 0 (mod 2^32) */
sc->callwheel = kmem_alloc3(kernel_map, wheel_sz, ...);  /* :407 zero-length alloc per CPU */
memset(sc->callwheel, 0, wheel_sz);
for (i = 0; i < cwheelsize; ++i)                   /* :410 2^30 OOB init writes */
        spin_init(&sc->callwheel[i].spin, "wheel");

sizeof(struct wheel) = 8 (spinlock) + 16 (TAILQ_HEAD) = 24 on x86_64, so wheel_sz = 24 * 2^30 = 25769803776 ≑ 0 (mod 2^32) truncates to 0.

  • 6-vCPU guest (kern.ncallout=2147483647): target = 357913966 β†’ cwheelsize = 2^30 β†’ wheel_sz = 0 β†’ six per-CPU zero-length kmem_alloc3(kernel_map, 0) calls collide β†’ panic: vm_map_entry_link: dup addr during boot (demonstrated). Had the map not panicked first, the 2^30-iteration init loop would spin_init/TAILQ_INIT ~25 GB past a zero-byte allocation β€” an out-of-bounds kernel write.
  • ≀2-vCPU boxes: target can be pushed into (2^30, 2^31) where the doubling loop executes 1 << 31 (signed overflow β†’ negative β†’ 0) and spins forever: infinite boot loop.
  • Degenerate corner (target wrapped negative via ncallout + 16 overflow at INT_MAX on 1 CPU): cwheelsize stays 1 β†’ every callout in one bucket β†’ pathological softclock behavior.

Distinct from DF-0174 (ncallout overflow via unbounded kern.maxfiles): this is the direct tunable path, which no maxfiles clamp can reach, and the sink is the cwheelsize/wheel_sz int math in kern_timeout.c.

Reproduce (host side, guest up & clean)

dfbsd-qemu/vm.sh run_root 'cp /boot/loader.conf /boot/loader.conf.df2948bak && printf "kern.ncallout=\"2147483647\"\n" >> /boot/loader.conf && sync'
dfbsd-qemu/vm.sh run_root 'shutdown -r now'
sleep 80; dfbsd-qemu/vm.sh status     # -> down
grep -n "panic" dfbsd-qemu/boot.log
dfbsd-qemu/vm.sh reset with-src

Expected output

Guest never reaches ssh; serial console (panic.txt) shows, right after "Initialize MI interrupts for 6 cpus":

panic: vm_map_entry_link: dup addr map 0xffffffff8159aea0 ent 0xffffffff81709b18
cpuid = 0
Trace beginning at frame 0xffffffff817a9e30 ...
Debugger("panic")
CPU0 stopping CPUs: 0x0000003e
Stopped at      -0x7f433c74:    movb   $0,0xbd77f9(%rip)
db>

Impact: unbootable system. Trust boundary: /boot/loader.conf (root/loader prompt), same class as DF-0173/DF-0174.

VERDICT.md file
↓ download raw

DF-2948 β€” VERDICT

Status: reproduced (impact: panic β€” boot-time kernel panic via integer truncation in callwheel sizing; pristine single-variable run)

What was run

Stock guest, kernel DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, 6 vCPU, 4 GB). Single change: one line appended to /boot/loader.conf:

kern.ncallout="2147483647"

then shutdown -r now.

What happened (vs. what was predicted)

Predicted chain: TUNABLE_INT_FETCH("kern.ncallout", &ncallout) at subr_param.c:330 runs after the only clamp (5*60*hz, lines 328-329), so INT_MAX enters untouched β†’ target = ncallout/ncpus + 16 = 357913966 β†’ cwheelsize doubles to 2^30 β†’ int wheel_sz = 24 * 2^30 truncates to 0 (mod 2^32) β†’ six per-CPU kmem_alloc3(kernel_map, 0).

Observed (panic.txt): boot dies right after "Initialize MI interrupts for 6 cpus" β€” the SI_SUB_SOFTCLOCKS phase β€” with:

panic: vm_map_entry_link: dup addr map 0xffffffff8159aea0 ent 0xffffffff81709b18
Trace beginning at frame 0xffffffff817a9e30
Debugger("panic")
Stopped at      -0x7f433c74:    movb   $0,0xbd77f9(%rip)
db>

i.e. the second per-CPU zero-length callwheel allocation collided with the first in kernel_map and the map code panicked. The guest sat in DDB; ssh never came up; vm.sh status β†’ down.

The panic fired before the predicted OOB stomp (for (i = 0; i < cwheelsize; ++i) spin_init(&sc->callwheel[i].spin, ...) β€” 2^30 Γ— 24 bytes β‰ˆ 25 GB of init writes past a zero-byte allocation, kern_timeout.c:410-413), so the demonstrated impact is boot DoS, while the code path clearly shows the latent memory-corruption consequence whenever the zero-length allocation does not trip the map assertion first (e.g. differing vm_map behavior, or values of ncallout that yield a small positive truncated wheel_sz with a huge cwheelsize, such as ncallout giving cwheelsize = 2^30 with sizeof 24 β†’ 0; ncpus=1..2 with target in (2^30, 2^31) instead overflows the doubling loop 1 << 31 into an infinite boot loop).

Two runs performed: (1) with a stale kern.nbuf="-1048576" line still present β€” same panic (and that line had been separately proven harmless, see DF-2946 VERDICT.md negative results); (2) pristine single-variable re-run after vm.sh reset β€” identical panic, identical addresses.

Why it is a finding and not a duplicate of DF-0174

DF-0174 covers ncallout = 16 + maxproc + maxfiles overflow via unbounded kern.maxfiles. This finding is the direct tunable input: kern.ncallout is fetched after the only clamp in the file (subr_param.c:330 vs 328-329), so it bypasses every bound regardless of maxfiles, and the defective sink is the int target / int cwheelsize / int wheel_sz arithmetic in swi_softclock_setup (kern_timeout.c:386-407) β€” wheel_sz truncation to 0 and the signed-shift overflow are not reachable through the maxfiles path on multi-CPU boxes (maxproc is capped at limsize*40 β‰ˆ 327k and even INT_MAX maxfiles only wraps the sum, which the 5*60*hz clamp then re-bounds when reached through line 327). Fixing DF-0174 by clamping maxfiles would leave kern.ncallout fully unclamped.

Trust boundary / severity

Boot tunable β€” requires root/loader-prompt/console. No runtime sysctl exists for ncallout (kern_mib.c has none; it is boot-only). Ceiling: persistent unbootable system. Rated Low (privileged boot DoS), consistent with DF-0173/DF-0174 ratings.

Fix validation

Not performed in-guest (Low boot-DoS class; the demonstrated manifestation is a panic, not corruption β€” the corruption path is latent behind the vm_map panic). fix.diff clamps kern.ncallout to the same 5*60*hz bound after the fetch (subr_param.c) and fixes the int math in swi_softclock_setup (size_t wheel sizing with overflow guard).

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

Fix not built in-guest: demonstrated manifestation is a boot panic (DoS), not memory corruption β€” the OOB-init-loop outcome is latent behind the vm_map_entry_link panic, so per contract the kernel-rebuild validation cycle is reserved for findings with demonstrated corruption; fix.diff is a minimal line-accurate clamp + sink hardening authored against the read-only sys/ tree.

['fix.diff']
↓ fix.diffper-fix-DF-2948

Confirmed kernel references

Detail

Exploit chain

1) privileged/console plant: kern.ncallout="2147483647" in /boot/loader.conf; 2) init_param2 fetches it AFTER the 560hz clamp -> unbounded int ncallout; 3) swi_softclock_setup: target=ncallout/ncpus+16, cwheelsize doubles to 2^30, int wheel_sz truncates 24*2^30 to 0; 4) per-CPU kmem_alloc3(kernel_map, 0) -> second allocation collides -> panic vm_map_entry_link: dup addr; machine will not boot until loader.conf repaired. Latent variants: zero/small truncated wheel_sz with intact 2^30 init loop -> massive kernel OOB write; 1-2 vCPU -> 1<<31 signed-shift overflow -> infinite boot loop. No post-boot or unprivileged trigger (kern.ncallout has no runtime sysctl).

Evidence (decisive lines)

['panic.txt β€” \'panic: vm_map_entry_link: dup addr map 0xffffffff8159aea0 ent 0xffffffff81709b18\' + trace + Debugger("panic") + db> right after \'Initialize MI interrupts for 6 cpus\' (SI_SUB_SOFTCLOCKS)', 'env.txt β€” stock kernel identity (6.5-DEVELOPMENT #0, X86_64_GENERIC, hw.ncpu=6)', 'run.sh β€” exact host-side sequence (pristine single-variable plant after a clean reset)', 'VERDICT.md β€” arithmetic derivation (24*2^30 mod 2^32 == 0), two runs (one with stale harmless kern.nbuf line, one pristine, identical panic), duplicate-separation vs DF-0174', 'fix.diff β€” clamp kern.ncallout after the fetch in init_param2 + size_t wheel sizing with sanity cap and KKASSERT in swi_softclock_setup']

PoC changes

no compilation needed: trigger is a single loader.conf line; value 2147483647 (INT_MAX) chosen so target=INT_MAX/6+16 lands below 2^31 (avoiding only the unreachable-on-6-cpu infinite-loop variant) while forcing cwheelsize=2^30 and the wheel_sz int truncation to exactly 0

Verified recommended fix

Move the 560hz (and a lower-bound) clamp to AFTER the TUNABLE_INT_FETCH("kern.ncallout") in init_param2; harden swi_softclock_setup with a target sanity cap and size_t wheel_sz.

Verdict

init_param2() clamps the computed ncallout to 560hz (subr_param.c:328-329) and only THEN fetches the kern.ncallout tunable (line 330), so the tunable enters the kernel with no bounds at all. The consumer swi_softclock_setup() (kern_timeout.c:374-413) does all sizing in int: with kern.ncallout=2147483647 on the 6-vCPU guest, target=357913966 doubles cwheelsize to 2^30 and 'int wheel_sz = sizeof(struct wheel)cwheelsize' = 242^30 = 25769803776 truncates to 0 (mod 2^32); the six per-CPU kmem_alloc3(kernel_map, 0) zero-length callwheel allocations collide and the kernel panics 'vm_map_entry_link: dup addr' during SI_SUB_SOFTCLOCKS, dead in DDB, ssh never up β€” unbootable until loader.conf is repaired. Behind that map panic lies the latent outcome spelled out in the sink code: a 2^30-iteration spin_init/TAILQ_INIT loop writing ~25 GB past a zero-byte allocation (kern_timeout.c:410-413), and on <=2-vCPU boxes target can be driven into (2^30,2^31) where the cwheelsize doubling loop itself executes 1<<31 (signed overflow -> negative -> 0) and hangs boot forever. Distinct from DF-0174 (ncallout overflow via unbounded kern.maxfiles): the direct tunable bypasses the sum entirely, and no maxfiles clamp can bound it.