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

Stack buffer overflow in ar9300_eeprom_restore_internal_address via unchecked EEPROM compression length field

Summary

EEPROM calibration-data parser unpacks 11-bit length field (max 2047) from on-card EEPROM/OTP compression header and uses it to size read into fixed 2048-byte stack buffer word[MOUTPUT]. When header advertises length>=2043 read of compression_header_length(4)+length+compression_checksum_length(2)=length+6 bytes overflows buffer by 1-5 bytes. Original length>=1024 guard disabled behind #ifdef DONTUSE (line 3915). ar9300_compression_header_unpack extracts length=11-bit max 2047 (line 3848). No bounds check follows. ar9300_calibration_data_read_eeprom only checks (address+many)>AR9300_EEPROM_SIZE(16384) no knowledge of caller destination buffer. On 4KB-EEPROM cards all 2053 byte-reads succeed (cptr starts 4095 cptr-2052=2043>=0) EEPROM-controlled data written 1-5 bytes past word[2048] into adjacent kernel-stack locals or stack canary. Attacker: malicious AR9300-series adapter (PCI/PCIe/AHB) with crafted EEPROM or attacker who can program EEPROM (root via HAL_DIAG_EEWRITE). Overflow triggers automatically during driver attach no userspace action needed beyond device presence/hotplug. With stack protector: immediate panic local DoS. Without: adjacent-local corruption could influence switch(code) dispatch or dptr potentially kernel code execution from crafted peripheral. 16-bit additive checksum trivially computable by attacker. CONTRIBUTED vendored code upstream owns fix same bug exists in FreeBSD. [VERIFIED pass2: reproduced in kernel rig around byte-identical extracted code - length 2043-2047 writes crafted e0-e4 bytes at word[2048..2052] with sentinel intact, two-run diff tracks exactly the OOB bytes; guard is dead inside ifdef DONTUSE; checksum gate attacker-computable; hardware/root-gated input so privesc false, ceiling local DoS with precise 1-5 byte controlled stack write; fix-validated 5x sentinel intact]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2255 Β· 26 files
FileTypeDescriptionSize
README.md β€” 6.7 KB ↓ raw
VERDICT.md β€” 8.4 KB ↓ raw
df2255_harness.c β€” 34.1 KB view raw
extract.sh β€” 3.8 KB view raw
df2255_verbatim_fix2256.inc β€” 10.5 KB ↓ download
build.sh β€” 179 B view raw
run.sh β€” 210 B view raw
build.log β€” 615.9 KB ↓ download
run.log β€” 6.4 KB view raw
fix.diff β€” 1.2 KB view raw
fix_build.log β€” 619.0 KB ↓ download
fix_run.log β€” 8.4 KB view raw
env.txt β€” 2.8 KB view raw
verdict.json β€” 5.1 KB view raw
logs/extract_baseline.log β€” 1.8 KB view raw
logs/build_baseline.log β€” 615.9 KB ↓ download
logs/fix1_build.log β€” 620.0 KB ↓ download
logs/fix1b_build.log β€” 618.5 KB ↓ download
logs/fix2_build.log β€” 619.0 KB ↓ download
logs/fix2255_extract.log β€” 1.7 KB view raw
logs/fix2256_extract.log β€” 1.7 KB view raw
logs/run_baseline.log β€” 6.4 KB view raw
logs/run_fix1.log β€” 4.3 KB view raw
logs/run_fix1_fullbattery.log β€” 21.3 KB view raw
logs/run_fix2.log β€” 6.3 KB view raw
logs/freebsd_head_ar9300_eeprom.c β€” 163.1 KB view raw

DF-2255 β€” Stack OOB write in ar9300_eeprom_restore_internal_address (verbatim-code kernel rig)

File: sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c (CONTRIB, vendored QCA HAL) Severity: Medium Β· Verdict: REPRODUCED (controlled 1–5 byte kernel-stack OOB write, attacker-known content, through the byte-identical real parse code) Β· Fix: VALIDATED

The bug

ar9300_eeprom_restore_internal_address() parses the compressed calibration blocks that sit at the top of an AR9300 card EEPROM/OTP at driver attach (ar9300_eeprom.c:4186-4188 calls it with cptr = ar9300_eeprom_base_address() = 4095 on a 4 KB EEPROM, blank=0xff):

  • each block header carries an 11-bit length field, max 2047 (ar9300_compression_header_unpack, ar9300_eeprom.c:3848);
  • the whole block is read into u_int8_t word[MOUTPUT] = word[2048] (ar9300_eeprom.c:3892, ar9300eep.h:41) with ar9300_calibration_data_read_array(ah, cptr, word, compression_header_length + osize + compression_checksum_length) = 4 + length + 2 = up to 2053 bytes (ar9300_eeprom.c:3922-3925);
  • the original length >= 1024 guard is dead code inside #ifdef DONTUSE (ar9300_eeprom.c:3915-3921);
  • ar9300_calibration_data_read_eeprom only range-checks the source address against AR9300_EEPROM_SIZE 16 KB (ar9300_eeprom.c:3616) and has no knowledge of the destination size.

length >= 2043 β‡’ 1–5 bytes past word[2047] are written with EEPROM-controlled data. On a 4 KB EEPROM every byte read succeeds (cptr - 2052 = 2043 >= 0), so the overflow always fires; the 16-bit additive checksum gate is trivially computable by whoever crafts the image (chk case proves the gate passes and _compress_block applies the payload). On 1/2 KB devices the highest 5 slots read at negative addresses and fail β€” those become the DF-2256 uninitialized-slots case instead.

Rig (rig class: verbatim-code kernel-compiled harness)

ath attach is hardware-gated (no Atheros NIC in the QEMU guest), so per the established contrib rig the eight target functions are extracted byte identical from the live /usr/src copy by extract.sh (sha256-gated: source sha 75771111c80e7ebc…, extraction logged in extract.log / logs/extract_baseline.log) into df2255_verbatim.inc, #included by df2255_harness.c, compiled into the kernel (sys/conf/files: kern/df2255_harness.c standard; runtime kldload GPFs on this guest). The public symbols are aliased df2255_* via preprocessor #defines because the stock kernel already links the contrib ath_hal β€” the extracted text itself is untouched. The harness fabricates the HAL state exactly as the driver presents it at attach (calibration_data_source = calibration_data_eeprom, ah_eepromRead method β†’ fake 4 KB/2 KB serial EEPROM device) and calls the real ar9300_eeprom_restore_internal_address() through a volatile function pointer with the kernel stack below the frame pre-filled with an 0xA5 sentinel. Trigger (root): sysctl kern.df2255.rig=… (info|arrfail|uninit|otp2k|chk|ovf <len>|ovfall|diff), evidence on console/dmesg with the DFH: prefix.

Reproduce

# guest, root:  build the rig kernel (see build.sh)
cd /root/df2255 && sh build.sh          # extract (sha256 gate) + nativekernel + installkernel
reboot
sh run.sh                                # or: sysctl -w kern.df2255.rig=ovfall / diff / …

Expected (baseline kernel): for ovfall, five lines *** OOB WRITE CONFIRMED: eeprom-controlled kernel-stack bytes past word[2047] *** β€” word[2048..2052] = e0 e1 e2 e3 e4 (the crafted EEPROM tail) with the 0xA5 sentinel intact at word[2053..2055]; diff shows exactly +2048(e0->d0) … +2052(e4->d4) when only the five tail EEPROM bytes change. See run.log (authentic serial-console capture).

Fix validation

fix.diff replaces the dead #ifdef DONTUSE guard with a live bound check length > MOUTPUT - compression_header_length - compression_checksum_length (> 2042 β‡’ skip block). Applied to the in-guest /usr/src, rebuilt (logs/fix1_build.log), rebooted: all five ovf lengths report word[2048..2052] = full sentinel β€” no eeprom bytes landed past word[2047] (sentinel intact -- FIX EFFECTIVE) β€” and diff shows 0 tracked OOB bytes (logs/run_fix1.log, fix_run.log). The legitimate path still works (chk: 16/16 marker bytes restored), and DF-2256 is still live under this single fix (differential proof, logs/run_fix1_fullbattery.log).

Threat model (honest reachability)

  • Input is the on-card EEPROM/OTP content: set at manufacture, or rewritable only by a privileged HAL_DIAG_EEWRITE on ATH_DIAGAPI builds. The attacker is therefore a malicious/defective AR9300-series card (PCI/PCIe) or an attacker who already has root β€” NOT an unprivileged local user and NOT a remote peer. No unprivilegedβ†’root path exists on this guest (no ath hardware at all) or on any stock deployment.
  • Demonstrated primitive: 1–5 contiguous, fully attacker-controlled bytes written past a 2048-byte kernel-stack buffer, repeatable up to MSTATE(100) times per attach at chosen contents. On this gcc8 -fno-stack-protector layout the bytes land on intra-frame slack (adjacent content at +2056..2059 unchanged); on SSP kernels gcc places arrays against the canary, so the same write smashes canary bytes β‡’ reliable panic on attach/hotplug.
  • Realistic impact ceiling: local DoS from crafted peripheral; theoretical code execution needs a layout where a saved register/pointer sits exactly at word+2048..2052. Severity Medium is correct.

Upstream status (contrib β€” fix owner is upstream)

Current FreeBSD head sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c (fetched 2026-10-17, sha256 ae1c326d…, copy in logs/) still has both the dead #ifdef DONTUSE guard (its line 3971) and the (void) read pattern β€” no divergent upstream fix exists; fix.diff is a novel, upstream-contributable patch (QCA license header preserved).

Files

file purpose
df2255_harness.c kernel-compiled rig (verbatim include + fake HAL/EEPROM + sysctl trigger)
extract.sh byte-identical extraction + sha256 gate + liveness probes
df2255_verbatim_fix2256.inc sample generated include (fixed tree)
build.sh / run.sh exact build & run
build.log / run.log baseline kernel build + decisive baseline run (serial console)
fix.diff the validated fix (git-apply-able vs sys/)
fix_build.log / fix_run.log fix kernel build + before/after run
logs/ all raw logs: extractions, builds, per-kernel runs, FreeBSD head copy
env.txt guest/uptime/kernel mapping
VERDICT.md, manifest.json, verdict.json verdict + machine records
VERDICT.md
↓ download raw

DF-2255 β€” VERDICT

Status: reproduced (impact: dos β€” controlled stack-corruption primitive, hardware-gated) Β· Fix: validated Β· Contributor: df-bsd-reviewer, 2026-10-17.

What was claimed

ar9300_eeprom_restore_internal_address() reads a compressed calibration block of 4 + length + 2 bytes (length = 11-bit header field, max 2047) into the fixed 2048-byte stack buffer u_int8_t word[MOUTPUT] (ar9300_eeprom.c:3892, ar9300eep.h:41). The length >= 1024 guard is dead inside #ifdef DONTUSE (ar9300_eeprom.c:3915-3921). For length >= 2043 the read runs 1–5 bytes past the buffer with EEPROM-controlled data.

How it was verified (rig class: verbatim kernel-compiled harness)

  1. Byte-identity gate. extract.sh pulled the eight functions (ar9300_eeprom_restore_internal_address, ar9300_uncompress_block, ar9300_compression_header_unpack, ar9300_compression_checksum, ar9300_calibration_data_read{,_eeprom,_array}, ar9300_eeprom_struct_default_find_by_id) from the live /usr/src/sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c (sha256 75771111c80e7ebc9f71857efec0e3a861b4a564f2417a17e747469fc4f2f77b, byte-identical to the audit repo tree). Log: logs/extract_baseline.log. The extraction also probed liveness: #ifdef DONTUSE present (guard compiled OUT) β€” the DF-2255 precondition holds in the shipped source.
  2. Kernel-compiled. df2255_harness.c was added to the kernel (sys/conf/files: kern/df2255_harness.c standard), built with make nativekernel KERNCONF=X86_64_GENERIC (gcc 8.3, stock flags incl. -fno-stack-protector -fno-omit-frame-pointer), installed, booted (kernel #2…#6 line; uname in env.txt). Runtime kldload GPFs on this guest, so kernel-compilation is the established rig path. Link-collision with the in-kernel contrib ath_hal is avoided by preprocessor symbol aliasing only β€” the extracted text is untouched.
  3. Attack simulation. The fake EEPROM device holds a crafted block at cptr=4095 (4 KB device, exactly what ar9300_eeprom_restore_internal() passes at attach, ar9300_eeprom.c:4186-4188): header code=_compress_block(3), ref=0, length=L (2043..2047), payload pattern (i ^ 0x5A), distinctive tail bytes 0xE0.. at stream positions >= 2048. The kernel stack below the caller frame is pre-filled with 0xA5 sentinel; on return the dead callee frame is snapshotted with a volatile copy loop (no call, no frame disturbance; selftest proved 8192/8192 sentinel fidelity).

Decisive observations (authentic serial console, run.log)

  • ovfall (kernel #6, baseline): for every L in 2043..2047: word[2048..2052] after the call contain exactly the crafted EEPROM tail β€” DFH: ovf word[0] at window+6080 word[2040..2063]: 00 00 00 ad a2 a3 a0 a1 e0 e1 e2 e3 e4 a5 a5 a5 ... DFH: ovf len=2047 -> read_array of 2053 bytes into word[2048]: e0 e1 e2 e3 e4 *** OOB WRITE CONFIRMED: eeprom-controlled kernel-stack bytes past word[2047] *** The last in-bounds payload bytes (a2 a3 a0 a1 = pattern at 2044..2047) are followed by the five attacker bytes and then untouched sentinel β€” the write is precisely bounded to 1–5 bytes past word[2047], contents fully chosen in the EEPROM image. Monotone across L: 2043β†’1 byte, 2044β†’2, … 2047β†’5.
  • diff (same kernel): two identical calls differing only in the five EEPROM bytes destined for word[2048..2052] (E0..E4 vs D0..D4) move exactly +2048(e0->d0) +2049(e1->d1) +2050(e2->d2) +2051(e3->d3) +2052(e4->d4) β€” a pure write primitive tracking attacker data. (Two additional bytes at +2057/+2058 moved run-to-run; they are adjacent-frame residue beyond the write, labeled noise by the final harness build β€” kernel #9 output in logs/run_fix2.log shows (noise) and counts 0 OOB bytes under the fix.)
  • Scope (otp2k): on a simulated 2 KB EEPROM the five highest-slot reads hit negative addresses and are rejected, so word[2048..2052] keep the pre-call sentinel β€” no OOB write on 1/2 KB devices; the overflow precondition is a 4 KB EEPROM (or any device where cptr - 2052 >= 0).
  • Checksum gate is no obstacle (chk): with the attacker-computed 16-bit additive checksum stored in the image, the parse reaches _compress_block and applies a marker payload to the in-RAM eeprom image (DF2255-PWNED-OK!, 16/16 bytes). Whoever crafts the EEPROM passes the gate by construction.
  • No panic occurred during any run: on this gcc8 no-SSP layout the five bytes land on intra-frame slack (+2056..2059 adjacent content unchanged across runs). On SSP builds gcc places arrays against the stack canary, so the same write smashes canary bytes and panics at function return β€” the finding text is correct that a stack-protector kernel turns this into an immediate panic.

Reachability / privesc assessment (explicit)

  • The parsed input is card EEPROM/OTP content. An unprivileged local user cannot reach this code path at all: no ath device exists in the QEMU guest (rig drives the verbatim function directly), and on real hardware the input is written at manufacture or via the privileged HAL_DIAG_EEWRITE path (ATH_DIAGAPI builds). No setuid/caps/ioctl from unprivileged context touches word[].
  • Escalation chain: none developed, none plausible from this class β€” attacker model is a malicious peripheral (or already-root). Best case for the attacker is 5 controlled bytes adjacent to a 2048-byte stack array at attach time; realistic outcome is DoS (panic on SSP kernels, garbage config/DoS otherwise). Impact recorded as dos; the memory-corruption primitive itself is proven with byte-level precision.
  • DF-2256 is the amplifier for this bug (stale word[0..3] can synthesize a length even when the card data is unreadable) β€” see the DF-2256 pack.

Fix validation (mandatory for memory-corruption)

fix.diff replaces the dead guard with a live bound check:

if (length > MOUTPUT - compression_header_length -
        compression_checksum_length) {   /* > 2042 */
    HALDEBUG(... "%s: Skipping bad header\n", __func__);
    cptr -= compression_header_length;
    continue;
}

Applied to in-guest /usr/src (patch hunk 1/1 clean), re-extracted (logs/fix2255_extract.log, new source sha d348bf37…, NOTE shows the guard now live), rebuilt (logs/fix1_build.log, logs/fix1b_build.log), rebooted, re-ran the exact same triggers:

  • all five ovf lengths: word[2048..2052] = a5 a5 a5 a5 a5 β€” no eeprom bytes landed past word[2047] (sentinel intact -- FIX EFFECTIVE) (5x, logs/run_fix1.log);
  • diff: 0 tracked bytes in the OOB slots (fix_run.log, kernel #9);
  • legitimate path unbroken: chk still restores the 16/16 marker bytes;
  • differential: DF-2256 (arrfail AH_TRUE + stale slots) is still live under fix-2255-only β€” the two findings are independent (logs/run_fix1_fullbattery.log).

Fix status: fixed.

Upstream divergence

FreeBSD head (fetched 2026-10-17, logs/freebsd_head_ar9300_eeprom.c, sha256 ae1c326d8141201c2a1c7d2a27ebc306d5adc57b73134404d8d2e84c6dcc5b00) still ships the dead #ifdef DONTUSE guard (its line 3971) and the (void) read calls β€” i.e. upstream QCA/FreeBSD has NOT fixed this differently (or at all). Our fix.diff is the novel upstream-contributable patch; being contrib code, the DragonFly position should be to carry it locally and offer it to FreeBSD/ath upstream.

Rig notes / deviations

  • The mdata_size passed is sizeof of the 2048-byte rig image stand-in; the real sizeof(ar9300_eeprom_t) is 1088 (packed; measured on the guest compiler). _compress_none requires length == mdata_size, so with the real struct the uncompressed path is reachable at length=1088 <= 2042 β€” the fix does not disturb it (bound is 2042).
  • word[0] sits at a deterministic window offset 6080 in the verbatim frame on these builds; when the fixed parse walk overwrites the crafted signature the harness falls back to that offset (labeled in the logs).
  • No panic file: no panic occurred (see assessment above); guest_dirty recorded 0 β€” guest reverted to the with-src snapshot after the run.

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
↓ fix.diffper-fix-DF-2255

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (baseline kernel serial console): five *** OOB WRITE CONFIRMED *** lines for len 2043..2047 with word[2048..2052] = e0 e1 e2 e3 e4 preceded by in-bounds payload and followed by untouched 0xA5 sentinel; diff case moves exactly +2048..+2052 when only those EEPROM bytes change; chk case shows attacker-computed checksum passes and payload applied (DF2255-PWNED-OK!, 16/16). logs/run_fix1.log + fix_run.log: same triggers after fix.diff -> sentinel intact -- FIX EFFECTIVE x5, diff 0 tracked OOB bytes, chk still 16/16. logs/extract_baseline.log: sha256 gate 75771111... byte-identical extraction + ifdef DONTUSE liveness probe. env.txt: kernel/boot mapping.

PoC changes

Authored the whole rig for this verify run (no runnable seed existed): marker-based sha256-gated extractor, kernel-compiled harness with fake 4KB/2KB EEPROM device and sentinel stack window, sysctl trigger, three-kernel validation matrix. Iterations needed: kernel include fixes (string.h -> sys/libkern.h), -Wmissing-prototypes prototypes, symbol aliasing to avoid link collision with in-kernel ath_hal, PROBE_WIN 8192 with dump-safe scan limit, iteration-1 header replant so the crafted signature survives the parse walk, payload-pattern signature fallback plus last-known-offset fallback (6080) for fixed-kernel runs.

Verified recommended fix

Replace the ifdef DONTUSE-disabled length >= 1024 guard with a live check skipping blocks whose length > MOUTPUT - compression_header_length - compression_checksum_length (2042) before the read into word[MOUTPUT].

Verdict

Reproduced through the byte-identical real parse code compiled into the guest kernel: an EEPROM block header with length 2043..2047 (11-bit field, guard dead under ifdef DONTUSE) drives ar9300_calibration_data_read_array to write 4+length+2 = 2049..2053 bytes into u_int8_t word[2048] on the kernel stack, placing 1..5 fully attacker-controlled bytes past word[2047] (serial log shows e0 e1 e2 e3 e4 there with sentinel intact at 2053+, and a two-run diff tracking exactly +2048..+2052). On 4KB EEPROM cards every read succeeds so the overflow always fires; the 16-bit additive checksum gate is attacker-computable (chk case passes it and applies a payload). On this gcc8 -fno-stack-protector layout the bytes land on intra-frame slack (no panic observed); on SSP kernels the write smashes canary bytes = reliable attach-time panic. Input is on-card EEPROM/OTP content: attacker is a malicious/defective AR9300 peripheral or already-root (HAL_DIAG_EEWRITE on ATH_DIAGAPI builds) - no unprivileged local path exists, so realistic ceiling is hardware-triggered local DoS with a precise 1-5 byte stack write primitive. fix.diff (live bound check length > 2042 replacing the ifdef DONTUSE guard) validated on a rebuilt kernel: all five lengths leave word[2048..2052] sentinel-intact, diff tracks 0 OOB bytes, legitimate _compress_block path still restores 16/16 marker bytes, and DF-2256 remains live under the single fix (differential).