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)
PoC verification
Evidence pack
findings/poc/DF-2255 Β· 26 filesDF-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
lengthfield, 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) withar9300_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 >= 1024guard is dead code inside#ifdef DONTUSE(ar9300_eeprom.c:3915-3921); ar9300_calibration_data_read_eepromonly range-checks the source address againstAR9300_EEPROM_SIZE16 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-protectorlayout the bytes land on intra-frame slack (adjacent content at+2056..2059unchanged); 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 |
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)
- Byte-identity gate.
extract.shpulled 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(sha25675771111c80e7ebc9f71857efec0e3a861b4a564f2417a17e747469fc4f2f77b, byte-identical to the audit repo tree). Log:logs/extract_baseline.log. The extraction also probed liveness:#ifdef DONTUSEpresent (guard compiled OUT) β the DF-2255 precondition holds in the shipped source. - Kernel-compiled.
df2255_harness.cwas added to the kernel (sys/conf/files:kern/df2255_harness.c standard), built withmake nativekernel KERNCONF=X86_64_GENERIC(gcc 8.3, stock flags incl.-fno-stack-protector -fno-omit-frame-pointer), installed, booted (kernel#2β¦#6line;unameinenv.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. - Attack simulation. The fake EEPROM device holds a crafted block at
cptr=4095(4 KB device, exactly whatar9300_eeprom_restore_internal()passes at attach, ar9300_eeprom.c:4186-4188): headercode=_compress_block(3), ref=0, length=L (2043..2047), payload pattern(i ^ 0x5A), distinctive tail bytes0xE0..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;selftestproved 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 pastword[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 inlogs/run_fix2.logshows(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 wherecptr - 2052 >= 0). - Checksum gate is no obstacle (chk): with the attacker-computed 16-bit
additive checksum stored in the image, the parse reaches
_compress_blockand 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..2059adjacent 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
athdevice 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_DIAGAPIbuilds). No setuid/caps/ioctl from unprivileged context touchesword[]. - 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
ovflengths: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:
chkstill restores the 16/16 marker bytes; - differential: DF-2256 (
arrfailAH_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_sizepassed issizeofof the 2048-byte rig image stand-in; the realsizeof(ar9300_eeprom_t)is 1088 (packed; measured on the guest compiler)._compress_nonerequireslength == mdata_size, so with the real struct the uncompressed path is reachable atlength=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_dirtyrecorded 0 β guest reverted to thewith-srcsnapshot 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_testableConfirmed kernel references
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:3892
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:3848
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:3915
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:3922
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:3616
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300_eeprom.c:4186
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300eep.h:41
- sys/contrib/dev/ath/ath_hal/ar9300/ar9300eep.h:99
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).
No comments yet.