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

ath_ioctl_phyerr() copies out the UNINITIALIZED M_TEMP result buffer on its error paths β€” attacker-sized kernel heap disclosure to unprivileged users

Summary

The DYN path kmallocs ad_out_size M_TEMP bytes never initialized, and BOTH error branches (unknown id / short SET_THRESH) still execute the unconditional clamp+copyout - the sane sibling ath_ioctl_diag only copies out on HAL-accept. Attacker-chosen size, unlimited repetitions, no priv check on SIOCGATHPHYERR. Sink-level REPRODUCED on guest: EINVAL returned while 2048B of marker-sprayed heap (255/256 slots) landed in the user buffer, 3 stable runs; error==0 gate A/B-validated. Hardware/config-gated entry (ath NIC + option ATH_DIAGAPI) caps it at Medium.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5024 Β· 16 files
FileTypeDescriptionSize
df5024.c β€” 5.8 KB view raw
df5024_fixed.c β€” 5.8 KB view raw
df5024_trigger.c β€” 3.0 KB view raw
Makefile β€” 52 B ↓ download
build.sh β€” 115 B view raw
run.sh β€” 159 B view raw
build.log β€” 954 B view raw
run.log β€” 2.2 KB view raw
run_fixed_ab.log β€” 1.5 KB view raw
leak_sample.txt β€” 1.9 KB view raw
env.txt β€” 813 B view raw
fix.diff β€” 1.1 KB view raw
fix_validation.log β€” 1.8 KB view raw
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
verdict.json β€” 4.3 KB view raw

DF-5024 β€” ath_ioctl_phyerr() uninitialized-heap disclosure on error paths (dfs_null.c)

File: sys/dev/netif/ath/ath_dfs/null/dfs_null.c (pass 2) Lines: 253-281 (allocation at :253, unconditional copyout at :280) Class: kernel heap info leak (CWE-908 / CWE-200) β€” NEW in pass 2; not a re-report of DF-2051 (GET_THRESH overflow) or DF-2052 (SET_THRESH NULL deref), which live on the success paths of the two known command ids.

Build (on the DragonFly guest, as root)

sh build.sh          # builds df5024.ko (vulnerable-pattern sink) + df5024_trigger

Run

sh run.sh            # sprays marker through M_TEMP, then exercises the leak 3x
sh run_ab-style:     # see run_fixed_ab.log β€” A/B against df5024_fixed.c (fix applied)

Expected output (decisive lines from run.log)

A:unknown-id: ioctl rc=-1 errno=22(Invalid argument) nonzero=1 marker8=255/256
  first64: ffffffffffffffff 4446353032342121 4446353032342121 ...
B:short-set:  ioctl rc=-1 errno=22(Invalid argument) nonzero=1 marker8=255/256
  • The ioctl FAILS with EINVAL β€” yet the user buffer contains 2048 bytes of kernel heap: 4446353032342121 is the ASCII marker DF5024!! that the sysctl spray wrote into M_TEMP and freed; the leading ffffffffffffffff qword is malloc-zone chunk metadata.
  • 255/256 marker slots β€” the entire kmalloc(outsize, M_TEMP, M_INTWAIT) block from dfs_null.c:253 crossed the kernel/user boundary despite the command being rejected.
  • Stable across 3 runs (run.log); with the fix pattern applied (df5024_fixed.c / run_fixed_ab.log) the same ioctls return EINVAL and the buffer stays all-zero (marker8=0/256) β€” leak eliminated.

Why a sink module

The guest has no Atheros NIC and the stock kernel is built without ATH_DIAGAPI, so SIOCGATHPHYERR cannot be entered at runtime (env.txt). Following the DF-4986/DF-5012 house precedent, df5024.c carries the verbatim ioctl body of ath_ioctl_phyerr() (dfs_null.c:221-288) with only the two HAL calls elided β€” they are unreachable on the leaking paths, which break/default before any sc->sc_ah dereference. The kmalloc/copyout behavior being demonstrated is entirely kernel-generic (M_TEMP + copyout).

Artifacts

  • df5024.c β€” sink, vulnerable pattern (verbatim structure)
  • df5024_fixed.c β€” same sink with the fix's error == 0 gate
  • df5024_trigger.c β€” user trigger (unknown-id + short-SET variants)
  • run.log, run_fixed_ab.log, leak_sample.txt, build.log, env.txt
  • fix.diff β€” the real fix against sys/ (error-gated copyout)
VERDICT.md
↓ download raw

DF-5024 Verification Verdict

File: sys/dev/netif/ath/ath_dfs/null/dfs_null.c:253,275-281 Severity: Medium (unprivileged kernel-heap disclosure; hardware/config-gated entry) Method: sink-level reproduction on the stock guest kernel (DF-4986/DF-5012 house precedent) + line-level source trace + behavioral fix A/B + real-module compile validation of the fix.

Reproduced: YES (leak, sink-level; entry path hardware-gated)

The defect (source trace)

ath_ioctl_phyerr() (dfs_null.c:220-288):

  1. dfs_null.c:245-258 β€” when the user sets ATH_DIAG_DYN (0x8000) in ad_id, outdata = kmalloc(outsize, M_TEMP, M_INTWAIT) is allocated with the attacker-chosen ad_out_size and never initialized.
  2. dfs_null.c:259-277 β€” the command dispatch. Two error paths leave outdata untouched: * default: for any id βˆ‰ {2,3} (DFS_RADARDETECTS=6 from if_athioctl.h:404, or any garbage id) β†’ error = EINVAL (:275-276); * DFS_SET_THRESH with ad_in_size < sizeof(HAL_PHYERR_PARAM) (60) β†’ error = EINVAL (:261-264) β€” this path also works with ATH_DIAG_IN|ATH_DIAG_DYN and a 0/short input buffer.
  3. dfs_null.c:278-281 β€” unconditionally: clamp ad_out_size, then if (outdata && copyout(outdata, ad->ad_out_data, ad->ad_out_size)) β€” the full uninitialized M_TEMP block is published to userspace even though the command was rejected (EINVAL is then returned; the data is already out).

Contrast with the sane sibling ath_ioctl_diag() (if_ath_ioctl.c:214-222): there the copyout happens only when ath_hal_getdiagstate() accepts the id β€” unknown ids never copy out. The null-DFS copy dropped that guard.

Reachability: SIOCGATHPHYERR β†’ ath_ioctl() (if_ath_ioctl.c:301-302, #ifdef ATH_DIAGAPI) β€” no privilege check anywhere on the path (SIOCZATHSTATS two cases above shows what a privileged variant looks like: caps_priv_check_self, :288). Entry needs an ath interface (hardware) and a kernel built with option ATH_DIAGAPI. On such a system any local user holds this leak.

Runtime proof (stock guest kernel, uname in env.txt)

run.log / leak_sample.txt (sink = verbatim ioctl body):

  • ioctl returns EINVAL while 2048 bytes of recycled kernel M_TEMP land in the user buffer: 255/256 slots carry the sysctl-sprayed marker DF5024!!; the leading qword (ff..ff) is malloc-chunk metadata β€” kernel provenance proven, not zeros/stale user data.
  • Both error paths leak (unknown id AND short SET input).
  • Stable across 3 runs.

Fix validation

  • Behavioral A/B (run_fixed_ab.log): with the fix's error == 0 gate transplanted into the sink, the same ioctls return EINVAL and the buffer stays all-zero (marker8=0/256, nonzero=0) β€” the disclosure is gone.
  • Compile validation: fix.diff applied to the guest's /usr/src copy; sys/dev/netif/ath/ath_dfs/null module rebuilt clean with -Werror (ath_dfs.ko). Sources restored afterward.
  • Also batch-compiled clean together with the three ips fixes (ips.ko, -Werror).

Fix

fix.diff β€” gate the clamp+copyout on error == 0 (mirrors ath_ioctl_diag). Independent git apply --check clean; no interaction with the DF-2051/DF-2052 fixes (different lines).

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 (error-gated copyout) behaviorally validated at sink level (run_fixed_ab.log: EINVAL with all-zero buffer, marker8=0/256 vs 255/256 baseline) and compile-validated against the guest's /usr/src copy (ath_dfs.ko module rebuilt -Werror clean, fix_validation.log; sources restored). The real ath entry path is hardware-gated on this guest, so the stock-kernel A/B is against the verbatim-pattern sink, not the driver itself.

run_fixed_ab.log (A/B output); fix.diff; fix_validation.log (patched ath_dfs.ko build)
↓ fix.diffper-fix-DF-5024

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log lines 'A:unknown-id: ioctl rc=-1 errno=22 ... marker8=255/256' and 'B:short-set ...' (leak on both error paths, EINVAL returned); leak_sample.txt hexdump showing ffffffffffffffff allocator qword + 'DF5024!!' (4446353032342121) fill; run_fixed_ab.log '-- FIXED pattern:' showing nonzero=0 marker8=0/256; fix_validation.log patched ath_dfs.ko build; env.txt hardware/config gating probe

PoC changes

authored fresh from source analysis (no seed): sink module replicates the ioctl body line-for-line with the two HAL calls elided (unreachable on the leaking paths); marker-spray sysctl added to prove kernel-heap provenance of the disclosed bytes

Verified recommended fix

Gate the clamp+copyout at dfs_null.c:278-281 on error == 0, mirroring ath_ioctl_diag() (if_ath_ioctl.c:214-222).

Verdict

Sink-level reproduction on the stock guest kernel (DF-4986/DF-5012 house precedent): a kld module carrying the verbatim ioctl body of dfs_null.c ath_ioctl_phyerr() (lines 221-288, HAL calls elided -- they are unreachable on the leaking paths) demonstrates that the unconditional copyout at dfs_null.c:280 publishes the attacker-sized UNINITIALIZED kmalloc(outsize, M_TEMP, M_INTWAIT) block to userspace on BOTH error paths (unknown ioctl id -> default EINVAL; DFS_SET_THRESH with ad_in_size < 60 -> EINVAL). The ioctl returns EINVAL while 2048 bytes of recycled kernel M_TEMP land in the user buffer: 255/256 8-byte slots carry a marker previously sprayed into M_TEMP via sysctl, and the leading qword is malloc-chunk metadata -- kernel-heap provenance proven, stable across 3 runs. The real entry (SIOCGATHPHYERR, no privilege check on the path, if_ath_ioctl.c:301-302) is hardware-gated on this guest (no Atheros NIC, stock kernel lacks ATH_DIAGAPI; env.txt) but is unprivileged on any system with an ath interface and a DIAGAPI kernel -- arbitrary-size kernel heap disclosure (heap contents/pointers, KASLR defeat material). A/B with the fix pattern transplanted into the sink: EINVAL returned, buffer stays all-zero (marker8=0/256) -- leak eliminated; fix.diff also compile-validated against the real sys/ tree (ath_dfs.ko, -Werror clean).