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

ath_hal_getregdump(): signed/unsigned comparison break + unchecked do-while first write => unbounded attacker-length kernel-heap overflow with attacker-known register values

Summary

getregdump keeps the remaining size in a signed int but compares against sizeof() (size_t) in both loops: once space goes negative the comparisons convert unsigned and can never fail again. space is forced negative by the do-while's unconditionally-executed first register write whenever an outer iteration begins with space==8. The write length is then bounded only by the attacker's ioctl payload - a controlled-length linear heap overflow whose 4-byte words are register reads of attacker-chosen addresses (MAC/BSSID - known to a local attacker). PROVEN deterministic by a bug-faithful replica harness on the guest: 4064 bytes past a 32-byte buffer before a guard page, -O0 and -O2. Combined with DF-4320's missing gate: no privilege needed on ATH_DIAGAPI kernels. Signed-compare + pre-checked-while fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4321 Β· 11 files
FileTypeDescriptionSize
oob_sim.c β€” 5.0 KB view raw
build.sh β€” 126 B view raw
run.sh β€” 175 B view raw
build.log β€” 38 B view raw
run.log β€” 1.5 KB view raw
run.2.log β€” 515 B view raw
trace.log β€” 783 B view raw
env.txt β€” 1003 B view raw
VERDICT.md β€” 4.9 KB ↓ raw
README.md β€” 2.8 KB ↓ raw
fix.diff β€” 1.0 KB view raw

DF-4321 β€” ath_hal_getregdump(): signed/unsigned comparison break β†’ unbounded, attacker-length kernel-heap overflow

Build

cc -O2 -Wall -o oob_sim oob_sim.c    # also reproducible at -O0 (exact byte accounting)

Run

./oob_sim                            # or: cc -O0 ... && ./oob_sim0

Expected

DF-4321: ath_hal_getregdump() signed/unsigned bounds bug
buffer: 32 bytes at ..., guard page at ...
payload: outsize=32; regs[0]={0x8000,0x800c}, regs[1..4095]={0x9000,0x9000}
legitimately writable: 32 bytes (8 header + 6 values)

=> WROTE PAST THE GUARD PAGE (fault at +4KB)
   bytes written before fault: 4096 (buffer was 32) = 4064-byte overflow
first 16 bytes past the 32-byte buffer (offset 32..48): a5a5a5a50090000000900000a5a5a5a5

The harness is a bug-faithful replica of the kernel loop (sys/dev/netif/ath/ath_hal/ah.c:848-862): a 32-byte buffer at the start of a page followed by a PROT_NONE guard page stands in for the kmalloc(32) DYN output buffer; the mock register read stands in for OS_REG_READ. It demonstrates that the loop's space accounting never stops the writes once space goes negative β€” the int operand is converted to size_t in space >= sizeof(uint32_t) β€” so the write length is bounded only by the attacker's regs[] array (ioctl input) and kernel address space, not by the buffer. Verified deterministic across runs; the smear pattern 00900000 a5a5a5a5 ... is the range header + "register value" being written far past the allocation.

Kernel-level reproduction additionally requires an Atheros NIC and a kernel built with options ATH_DIAGAPI (the ioctl that reaches this code, SIOCGATHDIAG, is not compiled into stock X86_64_GENERIC; see DF-4320). The audit guest has neither (virtio-only PCI bus), so the kernel path could not be exercised there.

Trigger mapping (harness β†’ kernel ioctl)

harness kernel (struct ath_diag ioctl)
buffer 32 bytes @ page start ad_out_size = 32 β†’ kmalloc(32) DYN buf
regs[0]={0x8000,0x800c} indata HAL_REGRANGE #0 (4 regs β†’ 24 bytes)
regs[1..N]={0x9000,0x9000} indata ranges #1..N (12 bytes each)
mock 0xA5A5A5A5 OS_REG_READ(ah, 0x9000) β€” attacker-chosen register (e.g. AR_STA_ID0 = NIC MAC, known)

Files

  • oob_sim.c β€” the replica harness.
  • run.log (-O2), run.2.log (-O0, exact accounting), run.3.log (first accidental discovery run at -O0 with a 2-range stack array β€” showed the runaway walking random memory), trace.log (annotated -O0 trace showing space going negative and the loop continuing).
  • fix.diff β€” the corrected loop (signed comparisons + checked first write).
VERDICT.md
↓ download raw

DF-4321 β€” VERDICT

Status: inconclusive at kernel level (no Atheros NIC and no ATH_DIAGAPI in the guest kernel); the bug itself is certain β€” proven by exact code analysis and a bug-faithful replica harness that deterministically overflows 4064 bytes past a 32-byte buffer (guard-page instrumented, repeatable).

Root cause

sys/dev/netif/ath/ath_hal/ah.c:848-862 (ath_hal_getregdump(), reached from HAL_DIAG_REGS β€” ath_hal_getdiagstate(), ah.c:882-884):

static u_int
ath_hal_getregdump(struct ath_hal *ah, const HAL_REGRANGE *regs,
    void *dstbuf, int space)                       /* space: SIGNED int */
{
    uint32_t *dp = dstbuf;
    int i;

    for (i = 0; space >= 2*sizeof(uint32_t); i++) {   /* (A) int vs size_t */
        uint32_t r = regs[i].start;
        uint32_t e = regs[i].end;
        *dp++ = r;
        *dp++ = e;
        space -= 2*sizeof(uint32_t);
        do {                                           /* (B) body runs first */
            *dp++ = OS_REG_READ(ah, r);                /*     unchecked write */
            r += sizeof(uint32_t);
            space -= sizeof(uint32_t);
        } while (r <= e && space >= sizeof(uint32_t)); /* (C) int vs size_t */
    }
    return (char *) dp - (char *) dstbuf;
}

Two compounding defects:

  1. (C)/(A) signed/unsigned: sizeof() is size_t; comparing int space >= sizeof(uint32_t) converts space to unsigned. Once space is negative it becomes ~2^63 and every space check is permanently true. The loop is then bounded only by the attacker's data: the inner by the user-supplied register-range end e, the outer by the end of the user-supplied regs[] array β€” never by the destination buffer.
  2. (B): the do-while executes its first *dp++ write before any space check. An outer iteration that starts with space == 8 consumes the remaining 8 bytes as header and then performs one unconditional register-value write, taking space to βˆ’4 β€” the entry ticket into defect 1.

Trigger (exact arithmetic, verified by the harness)

SIOCGATHDIAG with ad_id = HAL_DIAG_REGS(13) | ATH_DIAG_IN | ATH_DIAG_DYN, ad_out_size = 32 (kernel kmalloc(32) output buffer), indata HAL_REGRANGE[]: * range 0: {0x8000, 0x800c} β†’ header(8) + 4 values(16) = 24 bytes, space = 8; * ranges 1..N: {0x9000, 0x9000} β†’ each writes header(8)+1 value(4) = 12 bytes; range 1 leaves space = βˆ’4; every subsequent range is pure overflow.

Total written = 24 + 12Β·N bytes into a 32-byte allocation. N is bounded only by the ioctl input buffer (ad_in_size), so the overflow length is attacker-chosen; each 4-byte word is OS_REG_READ(ah, r) with r an attacker-chosen register β€” e.g. AR_STA_ID0 (the NIC MAC address) or AR_BSS_ID0 (the associated AP's BSSID), both fully known to a local attacker β†’ controlled-length linear kernel-heap overflow with attacker-known contents on the no-SMEP/no-SMAP audit-guest class of machines.

Evidence

  • run.log / run.2.log: replica harness (exact loop expressions) on the DragonFly guest as an unprivileged user β€” => WROTE PAST THE GUARD PAGE, 4064-byte overflow past the 32-byte buffer, deterministic across runs; heap smear a5a5a5a5 00900000 00900000 a5a5a5a5 = interleaved range headers and "register values".
  • trace.log / run.3.log: the discovery runs β€” the same replica with a 2-entry regs array walked off into random memory (segfault), which is how the unsigned-comparison defect was first caught; the annotated trace shows space going negative while the loop keeps writing (space=-63688 ... dpoff=63808 ... until fault).
  • Guest: DragonFly 6.5-DEVELOPMENT #0 ... X86_64_GENERIC, cc (clang) β€” build/run commands in the logs.

Why not kernel-reproduced here

The kernel path needs SIOCGATHDIAG, which is compiled only with options ATH_DIAGAPI (absent from stock X86_64_GENERIC β€” nm shows no ath_ioctl_diag in if_ath.ko), plus an Atheros NIC (guest is virtio-only). With that config (and DF-4320's missing gate) this becomes an unprivileged kernel heap overflow. On such a system the escalation path is: groom the kmalloc zone of ad_out_size β†’ overflow into an adjacent object with known MAC/BSSID-derived words β†’ target a refcounted object / function-pointer-bearing structure β†’ code exec β†’ uid=0. Not developed further here for lack of any ath device to drive the ioctl.

Fix

fix.diff β€” make both space comparisons signed and check space before the first value write (while instead of do-while semantics). The dump simply returns fewer bytes than requested when the buffer is short β€” callers already clamp (ad->ad_out_size = outsize in ath_ioctl_diag, if_ath_ioctl.c:215-219).

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

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff converts both space comparisons to signed and moves the value-write check before the first write (while instead of do-while); git-apply clean against sys/; runtime validation impossible on this guest (kernel path needs ath NIC + ATH_DIAGAPI). Replica harness with the fixed loop stops cleanly inside the buffer.

findings/poc/DF-4321/fix.diff; git apply --check passes
↓ fix.diffper-fix-DF-4321

Confirmed kernel references

Detail

Exploit chain

on ATH_DIAGAPI + ath NIC: SIOCGATHDIAG{ad_id=HAL_DIAG_REGS(13)|IN|DYN, ad_out_size=32, indata=[{0x8000,0x800c},{X,X}xN]} -> 24+12*N bytes written into kmalloc(32) -> groom the 32-byte kmalloc zone -> overwrite adjacent kernel object with known MAC/BSSID-derived words -> victim object/function pointer -> uid=0 (chain sketched only; no ath hardware to develop it against)

Evidence (decisive lines)

run.log / run.2.log (guard-page instrumented replica: 4064-byte overflow, deterministic); trace.log (discovery trace: space negative while loop keeps writing); oob_sim.c (verbatim kernel loop expressions); VERDICT.md (line-by-line arithmetic); fix.diff (signed comparisons + checked first write, git-apply clean)

PoC changes

initial harness under-modeled the bug (predicted a bounded 4-byte OOB); first guest run segfaulted, exposing the real signed/unsigned conversion; harness rewritten bug-faithful (exact kernel expressions) with guard-page instrumentation and an attacker-style multi-range payload

Verified recommended fix

Signed comparisons for space plus a pre-check before the first register-value write in ath_hal_getregdump()

Verdict

The overflow logic is certain and proven by a bug-faithful replica harness on the DragonFly guest (deterministic across runs and at -O0/-O2): ath_hal_getregdump() compares the signed int space against sizeof(uint32_t) (size_t), so once space goes negative (via the do-while's unconditional first register write when an outer iteration begins with space==8) the comparison converts to ~2^63 and no space check can ever fail again. The write length is then bounded only by the attacker's ioctl payload (register-range ends and the regs[] array length), not by the kmalloc'd output buffer: measured 4064 bytes past a 32-byte buffer before a guard page, smear pattern a5a5a5a5/00900000 = range headers + 'register values'. Every overflowed 4-byte word is OS_REG_READ() of an attacker-chosen register, e.g. AR_STA_ID0 (NIC MAC) or AR_BSS_ID0 (BSSID) - attacker-known contents. In-kernel reproduction requires SIOCGATHDIAG (options ATH_DIAGAPI, absent from stock X86_64_GENERIC) plus an Atheros NIC (guest is virtio-only), so the kernel path itself could not be exercised; on such a config this is an unprivileged-to-root-class heap overflow primitive (see DF-4320 for the gate). Impact recorded 'none' strictly for this guest.