Документация написана на английском.
Wi-Fi stack above the bring-up: security and robustness review
Read-only audit. Nothing was built, run or modified except this file.
Scope
| File | Lines |
|---|---|
wifi/lx/src/lx_wpa.c (WPA2-PSK arithmetic, 4-way and group handshake) |
523 |
wifi/lx/src/lx_wpa.h |
49 |
wifi/lx/src/lx_80211.c (frames, scan, join, 802.11 <-> Ethernet) |
2097 |
wifi/lx/src/lx_base.c (LinuxKPI stand-ins: threads, work, memory, DMA pool) |
1235 |
wifi/lx/src/lx_bus.c (bus, ring service thread, IRQ glue, driver start/stop) |
863 |
wifi/lx/src/lx.h |
20 |
drivers/wifi/hakios.lx.c (kernel side of the driver object) |
582 |
drivers/wifi/hakios.wlfw.c (WLFW client; also read the codec in hakios.qmi.c 197-575 and the rx/txn code 617-963, only as far as WLFW uses it) |
545 |
drivers/wifi/hakios.sta.c |
371 |
drivers/wifi/hakios.apple80211.c |
739 |
Also read for evidence: tools/wpa-test/test_wpa.c, xnu/files/bsd/sys/hakios.sta.h, hakios_wifi_network() in hakios.wifi.c, the boot seed in xnuloader/loader.c 3050-3080, xnu/files/osfmk/minicrypto/mc_provider.c (PRNG part).
Method: full read of every file above, tracing every length that comes from the air, firmware or userland to its use, checking lx_wpa.c line by line against IEEE 802.11-2020 clause 12.7 (key hierarchy, EAPOL-Key layout, supplicant rules), RFC 8018 (PBKDF2), RFC 2104 and RFC 3394. The ath11k driver sources are not in this tree (xnu-deps/build/ath11k is absent), so driver and firmware behaviour is inferred from comments only. The test program in tools/wpa-test was read but not run (no C compiler on this host).
Notes on the previous review (docs/net-review.md): beacon parsing in heard() and the SSID/element copies in apple80211.c were not redone; I only re-confirmed in passing that element() and the copies are bounded.
Findings
| Id | Severity | Confidence | Where | One line |
|---|---|---|---|---|
| W1 | Medium | CONFIRMED | lx_wpa.c:472-477, 369-384; lx_80211.c:1809-1812 | An unauthenticated message 1 re-derives and overwrites the live KCK/KEK/TK, at any time, from any spoofed sender |
| W2 | Medium | CONFIRMED | lx_80211.c:807-813, 1795-1808 | Handshake result is last-writer-wins: one forged EAPOL frame aborts the join or hides a good message 3 |
| W3 | High if reachable | NEEDS-HARDWARE (code gap CONFIRMED) | lx_80211.c:752-777, 817-826, 913 | 802.11-format data frames reach the IP stack without any "protected / decrypted" check (mac80211's drop-unencrypted rule is missing) |
| W4 | Medium | CONFIRMED (absence in this layer) | lx_80211.c:16-18; lx_wpa.c:429; lx_wpa.h:31 | No packet-number (replay) check for group-addressed frames; the RSC from message 3 is stored and never used |
| W5 | Medium | PLAUSIBLE | lx_base.c:794-834, 759 | DMA ring pool is never freed; every peer or BA session consumes it for good, and the pool is a fixed 12 MB |
| W6 | Low-Medium | PLAUSIBLE | xnuloader/loader.c:3053-3057 -> lx_80211.c:1660 | The supplicant nonce comes from a CSPRNG whose boot seed is a function of the timer counter |
| W7 | Low | CONFIRMED | lx_wpa.c:415-434, 480-515 | Message 3 is not checked for RSNE match, Install/Secure bits, or GTK length/index |
| W8 | Low | CONFIRMED | lx_80211.c:1603, 1626-1628, 565-576 | A requested BSSID is ignored on the scan path, and the chosen target can be replaced by a stronger same-SSID beacon during the join |
| W9 | Low | PLAUSIBLE | lx_80211.c:1474-1503, 850, 872, 804-806 | Teardown races: rx path can queue work or use radio->sta/radio->wpa while station_leave() frees or wipes them |
| W10 | Low | CONFIRMED | lx_80211.c:1417-1428, 1380-1401 | Group rekey: a failed set_key is only logged and the old key is dropped anyway |
| W11 | Low | PLAUSIBLE | lx_base.c:342-357, 278-279; lx_bus.c:410-414 | timer_delete_sync does not wait for a running callback; irq_group can loop without bound under load |
| W12 | Info | CONFIRMED | lx_80211.c:669-672; lx_wpa.c:259-288 | The user's own AP BSSID is logged although the name is hidden; AES intermediates and failed-unwrap plaintext are not wiped |
Counts: 7 CONFIRMED (W1, W2, W4, W7, W8, W10, W12), 4 PLAUSIBLE (W5, W6, W9, W11), 1 NEEDS-HARDWARE (W3). The WPA2 arithmetic itself (PBKDF2, PRF, MIC, key unwrap, replay rule, nonce check) found no defect; see the section "Checked and found fine".
Details
W1 - an unsigned message 1 overwrites the live keys (Medium, CONFIRMED)
lx_wpa.c:472-477:
if ((info & (INFO_PAIRWISE | INFO_ACK | INFO_MIC)) == (INFO_PAIRWISE | INFO_ACK)) {
memcpy(wpa->peer_nonce, message + AT_NONCE, 32);
derive(wpa);
derive() (369-384) writes wpa->kck, wpa->kek, wpa->pairwise and sets have_keys. There is no state check: the branch runs in any state, including after message 3 was accepted and after joined. The only filter is in handshake_message() (lx_80211.c:789): radio->sta != NULL and Ethernet source == the AP's BSSID, both of which an attacker in range can satisfy with a plaintext frame (EAPOL frames are plaintext by design; no MIC protects message 1).
Failure scenarios:
- Window during join. After message 3 is accepted,
station_join()waits (handshake loop) and thenlx_pause_ms(30)(line 1809) beforekey_set(radio, radio->wpa.pairwise, ...)(1810). A forged message 1 arriving in that window replaceswpa.pairwisewith a key derived from the attacker's ANonce. The firmware gets a wrong TK; the AP's frames cannot be deciphered and ours are rejected; the join reports success ("THE NETWORK IS JOINED"), the link is dead. - After join. A forged message 1 replaces KCK/KEK. Every later genuine group-key message (message 1 of the group handshake) then fails the MIC (
lx_wpa_messagereturnsLX_WPA_BAD), so the group key is never updated; once the AP rotates its GTK, broadcast and multicast traffic (ARP requests for our address, IPv6 RA/NS) stops reaching us. A retransmitted genuine message 3 also fails ("nonce is not message 1's"). - Amplification: each forged message 1 costs one PRF (3 HMAC-SHA1) plus a transmitted 121-byte message 2, and one unconditional
printf(lx_80211.c:800).
Standard: IEEE 802.11-2020 12.7.6 treats message 1 as unauthenticated; the supplicant is expected to tolerate extra or forged message 1 frames without changing the keys in use until a message 3 passes MIC verification (the usual solution is a candidate PTK). I could not quote the exact sentence offline, so the rule is marked as my reading of 12.7.6.2-12.7.6.4.
Suggested fix (candidate keys, promoted by a valid message 3):
struct lx_wpa {
+ unsigned char cand_kck[16], cand_kek[16], cand_tk[16]; /* from the latest message 1, not yet proven */
unsigned char kck[16], kek[16];
@@ derive():
- memcpy(wpa->kck, keys, 16);
- memcpy(wpa->kek, keys + 16, 16);
- memcpy(wpa->pairwise, keys + 32, 16);
- wpa->have_keys = 1;
+ memcpy(wpa->cand_kck, keys, 16); memcpy(wpa->cand_kek, keys + 16, 16); memcpy(wpa->cand_tk, keys + 32, 16);
+ wpa->have_cand = 1;
@@ lx_wpa_message(), signed path:
+ /* message 3 (pairwise bit): verify with cand_kck; every other signed message with kck */
+ kck = (info & INFO_PAIRWISE) ? wpa->cand_kck : wpa->kck; /* mic_of takes the key as a parameter */
+ ... after MIC, nonce, replay, unwrap and GTK all pass, for a pairwise message:
+ memcpy(wpa->kck, wpa->cand_kck, 16); memcpy(wpa->kek, wpa->cand_kek, 16); memcpy(wpa->pairwise, wpa->cand_tk, 16);
+ wpa->have_keys = 1;
The unwrap in a pairwise message also has to use cand_kek. Message 2 is answered with cand_kck as it is now.
W2 - the handshake outcome is overwritten by whatever came last (Medium, CONFIRMED)
handshake_message() stores every non-NOTHING result: lines 807-813 (radio->handshake = said;). station_join() waits with while (radio->handshake != LX_WPA_KEYS && radio->handshake != LX_WPA_BAD && ...) (1795-1797), then requires radio->handshake == LX_WPA_KEYS (1800).
Two failures, both from a spoofed plaintext EAPOL frame:
- Any forged signed-looking key frame with
MIC|ACKset and a bad MIC (or no message 1 seen yet) makeslx_wpa_messagereturnLX_WPA_BAD(lx_wpa.c:484, 493). The loop exits at once and the join fails withEAUTH, which iOS shows as "wrong password" (apple80211.c:639mapsEAUTHto result 21, "invalid PMK"). - A genuine message 3 sets
handshake = KEYS; a forged message 1 right behind it setsANSWERbefore the join thread has run. The loop then keeps waiting and times out after 8 s, although the handshake succeeded and message 4 was sent.
Fix: make KEYS sticky, and do not end the wait on BAD (the standard says to silently discard bad frames):
- if (said != LX_WPA_NOTHING) {
+ if (said == LX_WPA_KEYS || (said != LX_WPA_NOTHING && said != LX_WPA_BAD && radio->handshake != LX_WPA_KEYS)) {
...radio->handshake = said;
@@ station_join():
- while (radio->handshake != LX_WPA_KEYS && radio->handshake != LX_WPA_BAD && radio->answer != ANSWER_GONE && ...
+ while (radio->handshake != LX_WPA_KEYS && radio->answer != ANSWER_GONE && ...
Keep a separate radio->handshake_bad counter and handshake_why for the log line.
W3 - no "was it protected" check on the 802.11 data path (NEEDS-HARDWARE; code gap CONFIRMED)
linuxkpi_ieee80211_rx() (lx_80211.c:913) sends any 802.11 data frame through to_ethernet() and ethernet_received(). to_ethernet() reads the Protected bit (0x4000) only to size the header (line 767); it never requires it. ethernet_received() (817-826) hands every non-EAPOL frame to lx_os_net_input() when radio->joined. RX_FLAG_DECRYPTED is not looked at anywhere in the tree (grep), and the module header lists the missing group-frame PN check (see W4) but not this.
Under Linux, mac80211 supplies the rule (drop unencrypted data frames once a key is installed, except EAPOL). This layer replaces mac80211, so the rule has to be here. Failure scenario if the firmware passes a frame up in 802.11 form without having deciphered it: an attacker in range sends a From-DS data frame (TA = BSSID, RA = our MAC, Protected = 0, LLC/SNAP + ARP or IP) after the join; it is accepted and enters the IP stack (ARP poisoning, forged DNS or TCP). The RX_FLAG_8023 path (firmware decapsulates and deciphers) is not affected. Whether the ath11k firmware drops such frames itself I cannot tell from this tree; hence NEEDS-HARDWARE. A test: with a second radio, inject the frame after the join and watch received in the lx_station_say() line.
Suggested fix:
-static bool to_ethernet(struct sk_buff *skb, bool cipher_header_gone)
+static bool to_ethernet(struct sk_buff *skb, bool cipher_header_gone, bool *protected)
...
+ *protected = (control & 0x4000) != 0;
@@ linuxkpi_ieee80211_rx():
- } else if (to_ethernet(skb, (status->flag & RX_FLAG_IV_STRIPPED) != 0)) {
- ethernet_received(radio, skb);
+ } else if (to_ethernet(skb, (status->flag & RX_FLAG_IV_STRIPPED) != 0, &prot)) {
+ ethernet_received(radio, skb, prot && (status->flag & (RX_FLAG_DECRYPTED | RX_FLAG_IV_STRIPPED)));
@@ ethernet_received(radio, skb, bool ciphered):
- } else if (radio->joined && skb->len >= 14) {
+ } else if (radio->joined && (ciphered || hardware_decap) && skb->len >= 14) {
(hardware_decap is true on the RX_FLAG_8023 call.)
W4 - no replay check for group-addressed frames (Medium, CONFIRMED absence)
The module header (lx_80211.c:16-18) says "the check of the packet numbers of the group's frames that the driver leaves to this layer" is not done. lx_wpa.c:429 stores the RSC from message 3 / group messages into wpa->group_counter; nothing reads it (grep: only lx_wpa.c:429 and lx_wpa.h:31). Unicast PN checking is in the firmware; for broadcast and multicast, ath11k's own comment says mac80211 does it.
Failure: an attacker records a genuine broadcast or multicast frame (ARP request, DHCP, mDNS, IPv6 router advertisement) and replays it later; it is accepted. A replayed RA with a short lifetime or an old prefix, or a replayed ARP, is enough to disturb the host. Standard: IEEE 802.11-2020 12.5.3.4.4 (CCMP decapsulation: discard an MPDU whose PN is not greater than the replay counter of the key/TID); the initial counter comes from the RSC in the key message (12.7.6.4). The clause number is from memory, hence the rule is PLAUSIBLE; the absence of any check is CONFIRMED. Whether the firmware performs a group PN check on its own is NEEDS-HARDWARE.
Fix: in the RX_FLAG_8023 path the PN is not visible, so either enable the firmware's group replay detection (driver option) or have the driver pass status->extiv for group frames and compare it with a per-key counter seeded from wpa.group_counter (reset on every group-key install):
+ if (is_multicast_ether_addr(da) && !pn_greater(status->extiv, radio->group_pn)) drop();
+ memcpy(radio->group_pn, status->extiv, 6) ... (little-endian PN, 6 bytes)
W5 - the ring pool is never given back (Medium, PLAUSIBLE)
linux_dma_alloc_coherent() (lx_base.c:794-812) advances pool.rings_used; kmem_free() (814-818) and dma_free_noncoherent() (831-834) are empty. The pool is POOL_RINGS = 12 MB, of which the author notes the data path uses "about 6 MB" (759). The comment at 820-824 states that the data path takes a few KB per traffic class of every station and that "here they stay taken". station_join() creates a new station each attempt (lx_80211.c:1702-1708; a failed attempt included), ba_handle() restarts an RX BA session on every ADDBA from the AP.
Failure: each (re)join, each failed join, and each ADDBA (with wifi_ba=1) permanently consumes pool space. A phone that re-joins often (spoofed deauth frames are accepted because PMF is not implemented, so an attacker can force a re-join as often as wifid retries) eventually reaches "no room for N more bytes of rings" (line 804): the driver cannot create peers and Wi-Fi stays down until reboot. The leak is certain from the code; the per-join size (about 17 TIDs x 1 KB if ath11k sets up every TID) and thus the number of joins to exhaustion (a few hundred) is my estimate, and which ath11k allocations go through these two functions I cannot see.
Fix: replace the bump allocator with a free list of 64-byte-aligned extents (or a bitmap of 4 KB pages) and implement kmem_free() / dma_free_noncoherent(); also rate-limit RX_START per TID in ba_handle().
W6 - the supplicant nonce depends on a weak boot seed (Low-Medium, PLAUSIBLE)
Source of the SNonce: get_random_bytes(radio->wpa.own_nonce, 32) (lx_80211.c:1660) -> arc4random_buf() (lx_base.c:1206) -> read_random() (hakios.lx.c:205) -> the minicrypto HMAC-DRBG (mc_provider.c, seeded from the device-tree random-seed). That seed is built in the loader (xnuloader/loader.c:3053-3057):
seed[i] = (uint8_t)(READ_SYSREG(CNTVCT_EL0) >> (i & 7)) ^ (uint8_t)(i * 37);
All 64 bytes are a function of a few consecutive reads of the virtual counter, so the entropy is that of the counter value at that moment, roughly 25-30 bits if the boot time is known to a few seconds (19.2 MHz counter assumed). Unless the kernel's entropy collector reseeds the DRBG with real entropy before the first join (mc_prng_refresh, not verifiable from here), the nonce, and everything else the kernel draws from read_random, is predictable by enumeration.
Impact on WPA2 itself is limited: the SNonce is sent in the clear in message 2, and the standard needs it to be unique, not secret (IEEE 802.11-2020 12.7.5: nonces to be random with negligible chance of repeating; the section number is from memory). A repeated SNonce combined with a repeated ANonce would repeat the PTK; the AP's ANonce is random. The finding is therefore mainly about the shared kernel PRNG; it is listed here because the task asked where the nonce comes from.
Fix: seed from several independent sources (CNTVCT jitter over many samples, the SoC's TRNG/PRNG registers, the DT rng-seed from the previous boot, UFS/timing jitter) and block the first read_random consumer until a reseed has happened.
W7 - message 3 is not fully validated (Low, CONFIRMED)
lx_wpa.c:480-515 accepts a message 3 on MIC, replay counter, ANonce match, Encrypted bit and a parseable GTK KDE. Not checked: (a) the RSNE in the key data against the RSNE the AP announced (the standard asks for this and for a deauthentication with reason 17 on mismatch; IEEE 802.11-2020 12.7.6.4, clause number PLAUSIBLE), so there is no downgrade protection for anybody holding the PSK; (b) Install and Secure bits; (c) GTK KDE: e[1] - 6 may be anything from 5 to 32 (line 425-427) and the key id e[6] & 3 may be 0, while key_set() always copies 16 bytes (lx_80211.c:1396) and installs with that index. protection() only accepts CCMP, so the right length is 16 and the right index 1-3.
Failure: an AP (or a PSK holder) can make the station install a GTK that is shorter than 16 bytes (the rest is stale wpa->group content, zero at join) or with index 0; this breaks group traffic rather than leaking anything.
Fix:
- e[1] - 6u <= sizeof(wpa->group)) {
+ e[1] - 6u == 16 && (e[6] & 3) != 0) {
and in lx_wpa_message() for a pairwise message: if (!(info & INFO_INSTALL) || !(info & INFO_SECURE)) -> BAD, plus compare the RSNE found in data with the RSNE of the beacon (pass the target's element 48 to lx_wpa_message).
W8 - requested BSSID ignored on the scan path; target replaced during join (Low, CONFIRMED)
station_join() honours what->bssid only when choosing from the already-heard list (lx_80211.c:1612-1613). If nothing matches, station_scan() is called up to three times (1626-1628) and heard() (565-576) fills radio->target with the strongest beacon of that name, with no BSSID filter. So an ASSOCIATE for a given access point (Apple80211 bssid, copied at apple80211.c:540) joins a different one.
heard() keeps overwriting radio->target as long as radio->sta == NULL (567), i.e. through the whole of the join up to line 1702, including the PMK derivation (1651-1665) and the channel set-up. A stronger beacon with the same SSID replaces address, capability and elements; vif->bss_conf.bssid is a pointer to radio->target.address (1668), so it changes under the driver. Also bzero(target, ...) at 1603 is done without lx_os_lock() while heard() writes it under the lock.
Fix: store what->bssid in radio->want_bssid and have heard() test it; stop heard() from updating target once joining is set (do the selection once, under the lock); copy the address into vif->cfg.ap_addr-backed storage instead of pointing at target.
W9 - teardown races (Low, PLAUSIBLE)
station_leave() cancels group_work and ba_work (1474-1475) and later frees radio->sta_memory (1502) and wipes radio->wpa (1480), but rx runs on another thread. In between, management_received() still passes its guards (radio->sta != NULL, radio->reached >= ASSOC, line 850; reached is reset only at 1504) and calls queue_work(radio->work, &radio->ba_work) (872); ba_handle() captures params.sta = radio->sta (1107) before testing it (1122) and calls ampdu_action on a station that the driver has already removed or that is freed. Likewise handshake_message() can call ethernet_send() (798) which passes radio->sta to the driver while it is freed, and queue group_work after joined was cleared by someone else, in which case group_rekey() (1425) installs radio->wpa.group after it was zeroed (an all-zero GTK). The window is the duration of the station_state steps (tens of ms); wifi_ba=1 is needed for the first case.
Fix: take lx_os_lock() around radio->sta/reached tests in the rx path and set a radio->leaving flag first in station_leave(); cancel the works after the flag is set; make ba_handle() read radio->sta once under the lock.
W10 - a failed group-key install is only logged (Low, CONFIRMED)
group_rekey() (1417-1428): radio->group_key = key_set(..., &error), then key_drop(radio, &old, false) whatever error is. key_set() returns the config even when set_key failed, or NULL on ENOMEM. After a failure the old key is removed from the firmware and the new one is not installed: group traffic is lost, rekeys is still incremented, only a lx_tell line says so. Also the replay counter was already advanced for this message, so the AP's retransmission is rejected.
Fix: drop the old key only if error == 0; on failure free the new config, keep the old key, and return the failure to the handshake so that the group message 2 is not sent (the AP will retransmit).
W11 - timers and the IRQ thread (Low, PLAUSIBLE)
timer_delete_sync()(lx_base.c:343-357) returns without waiting for a callback that the ticker thread is running (due->function_415(due)is called after unlocking, lines 278-279); the comment at 342 says so. If the driver frees the structure that contains the timer right after, the callback runs on freed memory. Fix: recordrunningunder the lock and wait for it intimer_delete_sync().irq_group()(lx_bus.c:410-414) loopsdo { ... } while (done >= DATA_BUDGET)while holdingbus.service, on the one thread that serves every vector. Sustained receive load keeps copy-engine interrupts (WMI, handshake) and the ring thread waiting. Fix: bound the loop (for example 4 rounds) and re-arm the vector so the thread yields.
W12 - minor (Info, CONFIRMED)
say_heard()(lx_80211.c:669-672) hides the name of "the network that is wanted" but prints its BSSID, channel and signal; the file header (24) and the code comment at 666 promise only that the name and passphrase are not said. A BSSID is a location identifier.- Not wiped:
t[16]inaes_decrypt()(lx_wpa.c:260-286, holds the last decrypted block, that is part of the GTK) anda[8]inaes_unwrap(); inlx_wpa_message()thedata[]buffer is wiped on success and on "no group key" but not whenaes_unwrap()fails (line 503-505). Kernel stack residue only.
Checked and found fine
Cryptography in lx_wpa.c:
- PBKDF2-HMAC-SHA1 (RFC 8018 5.2 with SHA-1, 4096 iterations, 32-byte output = block 1 whole plus 12 bytes of block 2): salt is SSID || INT(block) with a big-endian 32-bit block number (149-154),
U_1 = HMAC(P, S || INT(i)),U_j = HMAC(P, U_{j-1}), XOR-accumulated, 4095 further rounds (156-164). HMAC handles keys over 64 bytes by hashing. Matches the structure used by IEEE 802.11-2020 Annex J.4.tools/wpa-testchecks the J.4.2 vectors and RFC 2202 (read, not run). - PRF (171-185): label || 0x00 || data || counter byte, counter from 0, 20 bytes per round, 48 bytes for CCMP (KCK 16, KEK 16, TK 16); matches 12.7.1.2 / 12.7.1.3. PTK data (369-378): min(AA,SPA) || max(AA,SPA) || min(ANonce,SNonce) || max(...), 76 bytes with exact offsets. Roles:
ownisvif->addr,peeris the BSSID (lx_80211.c:1658-1659); correct for infrastructure (AA = BSSID). - EAPOL-Key layout (
AT_*offsets): 4+1+2+2+8+32+16+8+8 = 81 for the MIC, 97 for the data length, 99 for the data; matches figure 12-35. - MIC (355-365): HMAC-SHA1 over the EAPOL frame from the version byte through the end of the declared body, with the 16 MIC bytes replaced by zeros, truncated to 16 bytes, key = 16-byte KCK; this is the rule for key descriptor version 2 (HMAC-SHA1-128). Computed over
4 + body(the declared length), not the received length. Comparison is constant time (488-490). Descriptor version is required to be 2 (462) and descriptor type 2 (458). - AES key unwrap (RFC 3394 2.2.2): six rounds j = 5..0, i = n..1,
t = n*j + iXORed big-endian into A (307-320), integrity value A6A6A6A6A6A6A6A6 checked over all 8 bytes with an OR accumulator (321-323, 326); length must be a multiple of 8 and at least 24 (301); output bufferdata[640]vs plaintext at most 4+body-99-8 <= 533. Then = length/8 - 1before the length test wraps for length 0 but is not used before the test. TheENCRYPTEDbit is required (503). Test vectors intools/wpa-testcover RFC 3394 4.1, a spoiled input and a wrong length. - Key data length from the air:
4 + bodyis bounded by the received length and by 640 (451);AT_DATA + data_length > lengthis rejected (466); reads of fixed offsets up to 98 only afterlength >= 99. Truncated or short frames return NOTHING or BAD without reading past the buffer (also covered by the test "a key message cut short"). - Replay counter: 8-byte big-endian comparison, strictly greater (495); updated only after MIC, nonce, unwrap and GTK parse all passed (513). Message 3 must carry message 1's ANonce (499). A retransmitted message 3 (higher counter) is answered again and does not reinstall keys:
handshake_message()only queues work forLX_WPA_GROUP, andstation_join()installs the PTK once, so there is no key-reinstallation (KRACK) exposure through message 3 retransmission. A replayed message 3 or group message 1 is rejected. - GTK KDE walk (415-434): both
at + 2 <= lengthandat + 2 + len <= length;memcpysize at most 32 intogroup[32]; the copy ends at the element end. Group message 1 requires MIC and ACK, the same MIC and replay path. - Reply buffer:
handshake_message()gives 14+256 bytes; replies are 99+22 = 121 (rsn_length is the constant 22, lx_80211.c:1662) or 99. - Nonce source: see W6 (CSPRNG, fresh per join; one per join, reused for message 1 retransmissions, which is allowed).
- Wiping: PBKDF2 temporaries (166-167), HMAC pad and inner (137-138), PRF digest (184), derived
keys[48](382), unwrap round keys and block (324-325),data[]on success;explicit_bzero(&radio->wpa)at leave (lx_80211.c:1480);key_drop()wipes the key config;whatzeroed after the join (1858);apple80211.c:560-565usesmemset_s;hakios.sta.czeroes the queued join request after copying it.explicit_bzeroinlx_base.c:1187is a separate-TUbzeroand is not elided. - Logs: no printf or
lx_tellinlx_80211.c,lx_wpa.c,apple80211.corhakios.sta.cprints a passphrase, PMK, PTK, GTK or nonce; the join prints lengths only;apple80211.clogs types, lengths and kinds only. The Apple80211set_joinvalidates passphrase length 8..63 and PMK length 32 (543-549);hakios_wifi_network()validates 1..32 and 8..63 (4087-4090).station_join()itself does not re-checkpassphrase_length <= 64(it is auint8_t;lx_wpa_pmkwould over-read thewhatstruct for larger values), but every in-tree caller validates.
Frame paths in lx_80211.c:
- Management: every fixed-offset read is behind a length test (
length < 26,>= 30for authentication and association response,>= 28/>= 33for the ADDBA/DELBA action frames);answer_lengthis clamped toELEMENTS_MOST(847);aidmasked to 14 bits; deauthentication/disassociation handling reads only f[24..25]. Thetidindex is masked to 0..7. The sender (TA) and receiver (RA) addresses are compared against the AP and our own address. to_ethernet(): header size at most 24+6+2+4+8 = 44,skb->len >= header + 8checked before the LLC test,skb_pull(header + 8 - 14)is at least 18 and at mostlen; address and ethertype are copied out before the pull. (Minor:f[0],f[1]are read before the firstskb->lentest; two bytes inside the same buffer, not exploitable.)- A-MSDU and fragments: the A-MSDU bit is never interpreted, so flipping it (FragAttacks CVE-2020-24588) has no effect; there is no fragment reassembly, so the mixed-key and fragment-cache attacks do not apply here; fragments are not reassembled (a first fragment would be passed as a frame, but only decrypted data reaches this path, see W3 for the caveat).
- Association response parsing:
element()walkers are bounded; WMM parse requirese[1] >= 24and reads at moste+25; HT (>= 26), VHT (>= 12), HT operation (>= 22) and TIM (>= 2) lengths are tested before use. A concurrent overwrite ofanswer_elementsby a forged association response can change content understation_abilities(), but all reads stay in the 1024-byte array or at worst the next field of the same struct. request_elements()output is at most about 131 bytes into a 300-byte buffer.- Rx serialization: when
wifi_irq=1, copy-engine and data-path service run underbus.service(lx_bus.c:318, 397, 410), otherwise only the ring thread runs them; solx_wpa_message()andmanagement_received()are never run concurrently with themselves. - Scan:
station_scan()request is sized from the channel counts; channel list bounded byfrequency_count <= 64(set inapple80211.c:484); SSID copy bounded (32).
lx_base.c, lx_bus.c, hakios.lx.c:
- DMA:
lkpi_dma_map_phys()refuses anything outside the pool;pmap_kextractanswers only for pool memory; ring allocations are rounded, overflow-safe (size_tarithmetic) and zeroed; skb data slots are bounds-checked againstPOOL_SLOTbefore allocation (879). The pool is one physically contiguous allocation behind the SMMU window; nothing is sized from a hardware register except the register window offsets inwindow()/refused(), which are range-checked against the three windows. - Thread/IRQ model: the hard handler only masks the vector, ORs the pending bit and wakes; handlers run on one thread;
lx_os_irq_disable()anddetach_allwait for a running handler; there is no lost wakeup (assert_waitbefore thexchg). Sleeping lock words panic on self-recursion by design.idr_*andrhashtable_*are under the layer's lock. The transmit queue (lx_queue) keeps its slot reserved untillx_queue_out++; full-queue test isin - out == LX_QUEUE. hakios.sta.c: one worker thread, one lock;hakios_sta_attach(NULL)waits for calls and readers; request structs are copied under the lock and zeroed after use.standing(the passphrase of the file network) is kept for the life of the system by design (to re-join when the radio is switched on).apple80211.c:give()clampscopyoutto the caller'sreq_len;give_network()clamps elements to the room the caller declared and to 1024; scan request lengths are checked againstsizeof.
QMI codec as used by hakios.wlfw.c (WLFW service only):
decode_counted()rejects a count aboveelem_lenbefore storing it (499); arrays (numbers or structures, nested) therefore stay inside the C structure; static arrays useelem_len; strings needlength < elem_lenand are NUL-terminated (476-480), so the%sprints offw_build_idandfw_build_timestamp(wlfw.c:449-454) are safe; each TLV's length is checked against the message and the message against the datagram (qmi_receive, 686-693); unknown TLVs are skipped; a TLV that does not describe a known element is ignored; the destination of a reply is decoded under the lock and the transaction is removed under the lock on timeout, so a late reply cannot write into a dead stack frame. Everyask()caller zeroes its reply structure first, andsend_file()bounds the file (256 KiB) and each piece (QMI_WLANFW_MAX_DATA_SIZE_V01). A reply without the result TLV reads as "success" (the zeroedresultis 0), as it does in Linux's qmi helpers.
Concurrency and lock order: no cycle found among lx_lock, bus.service, wiphy->mtx, sta_lock, apple80211_lock; station_leave() cancels work items without holding wiphy->mtx, so ba_handle()/group_rekey() cannot deadlock against it; the deauth path in the rx thread calls the station events after dropping lx_os_lock.
Not checked
- The ath11k driver itself and the firmware (not in the tree): whether it drops unprotected data frames (W3), checks multicast PN (W4), and how much of its DMA goes through
dma_alloc_coherent/dma_alloc_noncoherent(W5, its rate). tools/wpa-testwas not run (no compiler available on the Windows host), andreference.pywas not read line by line; the AES, PBKDF2 and unwrap checks above are by reading the code against the standards' vectors that the test cites.hakios.wifi.c(bring-up, firmware loading, DSP),hakios.net.c(whathakios_net_inputdoes with a frame), the QRTR transport underhakios.qmi.c, and the generatedhakios.wlfw_ath11k.htables.- The minicrypto DRBG internals and whether the kernel entropy collector reseeds it before the first join (W6).
- Side channels: the AES code is table-driven and
times()has a data-dependent ternary; it runs only for the key unwrap, so only a local cache-timing attacker would matter. - 40/80 MHz width selection and rate/HT/VHT handling were read for bounds only, not for correctness against the standard; power save, roaming, regulatory domain logic and the
wifi_lx=1register-trace path were skimmed. - Behaviour on hardware (everything marked PLAUSIBLE or NEEDS-HARDWARE).
Fix status
- W1: FIXED (partly).
lx_wpa.clx_wpa_message(): an unsigned message 1 is accepted only while no signed message has been accepted yet (!replay_known); afterwards it is ignored (LX_WPA_NOTHING, no reply, keys untouched). This closes the 30 ms window beforekey_set()and the after-join KCK/KEK overwrite. SKIPPED: the candidate-key scheme for a forged message 1 before message 3 (needs new fields instruct lx_wpa, i.e.lx_wpa.h, which was not in the editable set; the forged frame still makes that join fail, as W2 case 1 describes). - W2: FIXED.
lx_80211.chandshake_message():radio->handshakeis no longer overwritten once it isLX_WPA_KEYS;station_join(): the wait no longer ends onLX_WPA_BAD(only KEYS, the AP's deauthentication or the 8 s timeout), the reason is still logged on timeout. A genuinely wrong passphrase still ends through deauthentication or the timeout. - W3: SKIPPED. NEEDS-HARDWARE: whether the driver sets
RX_FLAG_DECRYPTED/IV_STRIPPEDon every legitimate 802.11-form frame is not known; requiring it could drop real group traffic. - W4: SKIPPED. Protocol/design change (per-key PN state, and whether
status->extivcarries the PN depends on the firmware/driver). - W5: SKIPPED. Replacing the bump allocator with a freeing allocator is a design change and needs knowledge of which ath11k allocations go through it.
- W6: SKIPPED. The seed is in
xnuloader/loader.c, outside the editable files. - W7: FIXED (partly).
lx_wpa.cgroup_key(): the GTK KDE must be 16 bytes and have key number 1 to 3. SKIPPED: the Install/Secure bit checks (supplicants such as wpa_supplicant do not require them; rejecting could break real APs) and the RSNE comparison with the beacon (needs a changedlx_wpa_message()signature /lx_wpa.h). - W8: FIXED.
lx_80211.c: the requested BSSID is stored inradio->want_bssidand tested inheard(); a newradio->target_fixedstopsheard()from replacingtargetonce the join has chosen; thebzero(target)is now underlx_os_lock(). SKIPPED:vif->bss_conf.bssidstill points attarget.address(now stable after the choice). - W9: SKIPPED. Locking/teardown redesign (PLAUSIBLE, needs a
leavingprotocol across rx, work items and the driver). - W10: FIXED (partly).
lx_80211.cgroup_rekey(): on a failedset_keythe old key is kept and the new configuration is wiped and freed. SKIPPED: not sending group message 2 and not consuming the replay counter on failure (needs a handshake design change). - W11: SKIPPED.
timer_delete_sync()waiting risks self-deadlock when called from the ticker's own callback, and boundingirq_group()needs knowledge of the vector's (edge/level) re-arm behaviour. - W12: FIXED.
lx_80211.csay_heard(): the BSSID of the wanted network is not printed.lx_wpa.c: the last AES blockt[16]inaes_decrypt(),a[8]inaes_unwrap()anddata[]after a failed unwrap are wiped.
Test: tools/wpa-test (copied to WSL, built with the sanitizers, no output left in the tree) passes, 195 checks 0 failed (seed 1); with seed 7 and one extra ad-hoc check (a forged message 1 after message 3 changes no key and gets no reply) 207 checks, 0 failed. lx_80211.c was only re-read, not compiled.