{"article":{"slug":"wanted-to-download-an-iso-ended-up-writing-a-kernel-exploit","title":"Wanted to download an ISO, ended up writing a kernel exploit","subtitle":null,"summary":"deafbeef bought a TP-Link EAP770 Wi-Fi 7 AP that corrupts large downloads; with the device already EOL, TP-Link's shell blocked the sysfs fix, so he wrote a kernel exploit against his own access point to flip two zeros and make transfers reliable.","content_type":"blog_post","language":"en","canonical_url":"https://blog.dfb.sh/blog/eap770-root-for-two-zeros/","author":{"name":"deafbeef","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"deafbeef blog","url":"https://blog.dfb.sh/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Networking","slug":"networking","url":"https://listedarticles.com/topics/networking"},{"name":"Linux","slug":"linux","url":"https://listedarticles.com/topics/linux"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":3102,"reading_minutes":13,"published_at":"2026-10-10T00:00:00.000Z","added_at":"2026-10-15T08:14:51.682Z","updated_at":"2026-10-15T08:14:51.682Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/wanted-to-download-an-iso-ended-up-writing-a-kernel-exploit","markdown_url":"https://listedarticles.com/articles/wanted-to-download-an-iso-ended-up-writing-a-kernel-exploit.md","example":false,"citation":"deafbeef, deafbeef blog. \"Wanted to download an ISO, ended up writing a kernel exploit.\" 10 Oct 2026. https://blog.dfb.sh/blog/eap770-root-for-two-zeros/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://blog.dfb.sh/blog/eap770-root-for-two-zeros/"},"body_markdown":"TL;DR:**\nMy TP-Link EAP770 v1 corrupts downloads over Wi-Fi and TP-Link EOL'd it before I unboxed it. The fix is two zeros in sysfs. TP-Link's shell won't let me write them, so I wrote a kernel exploit for my own fucking access point.\n\n## Long downloads kept breaking#\n\nIn August 2023 I bought a shiny new Wi-Fi 7 access point, a TP-Link EAP770.\nThe connection was a bit janky, a video would occasionally start buffering, but it didn't bother me much.\n\nI mostly worked on a workstation connected over Ethernet.\nWhen I needed to go somewhere, I'd rsync my `~` to the laptop over Wi-Fi and take it with me.\nThose transfers started failing on files larger than a few GiB.\nSSH was reporting MAC errors, so I spent a while debugging rsync and SSH.\nEverything else worked, changing rsync options made it better or worse, Wireshark showed zero bad checksums and zero retransmissions, and a single UDP stream of random bytes came through clean.\nI trusted TCP to deliver the right bytes because that's what the fucking thing is for, so I was sure the problem was in rsync or SSH.\nI wrote a wrapper around rsync with retries and moved on.\n\nLater I happened to start using Tailscale for traffic inside my home network, and the transfers stopped needing retries.\nWireGuard drops a packet if its authentication fails, so the TCP connection inside the tunnel sees a lost packet and retransmits it.\nSince the transfers worked, I stopped digging.\n\n## Zero firmware updates#\n\nThe device is an EAP770(EU) v1.0 with a Qualcomm IPQ9574 running Linux 5.4.164 and firmware `1.0.0 Build 20230605 Rel. 71641`.\nThat's the firmware it shipped with, and apparently the last time TP-Link remembered this thing even existed :)\n\nI bought this thing in August 2023, and the Wi-Fi 7 standard was only finalized in September 2024.\nIt has [no Wi-Fi Alliance certification](https://www.omadanetworks.com/us/business-networking/omada-wifi-ceiling-mount/eap770/), and buying it was stupid of me.\nPLZ DON'T BUY STUFF UNTIL STANDARDS ARE FINALIZED AND DEVICES ARE CERTIFIED.\n\nIn 2025 I noticed that everything else was getting updates while this thing was still on its original firmware.\nI contacted support in June because the website only had firmware for v2.\nThey couldn't find v1 in their database and asked for photos of the device and box before sending me this:\n\n> The EAP770 Version 1 is an End-of-Life product. While it may still be available through some retailers, it is no longer supported with firmware updates.\n\nNICE.\n\n## Tracking down the corruption#\n\nIn 2026 I decided to switch to NixOS and found that a 128 MiB file from the Nix binary cache on Fastly would fail over HTTP/2 but downloaded fine over HTTP/1.1.\nI still don't know why HTTP/1.1 helped; my guess is that HTTP/2 was simply faster.\n`git` clones also failed, and HTTPS connections died with `bad record mac`.\nIn March, downloading a NixOS ISO with `wget` died with a TLS error:\n\n```\n2026-03-05 12:37:18 (31.6 MB/s) - Read error at byte 2722477636/3683778560 (error:0A000119:SSL routines::decryption failed or bad record mac; error:0A000139:SSL routines::record layer failure). Retrying.\n```\n\nI forced HTTP/1.1 and left it alone again.\n\nIn September I turned the workstation into a build farm and switched to the laptop, which connects over Wi-Fi.\nWi-Fi was also dropping every day around 11:00, right before my daily call.\nAfter it had died right before the call several days in a row, I decided to open this Pandora's box again and sat down with Opus 5.5 to trace what was happening.\nSpoiler alert: the drops turned out to be a separate bug.\nI covered the laptop's Qualcomm driver in BPF probes and ran `apstats` on the AP as root.\nThe bug is in the closed-source firmware on the laptop's Wi-Fi card; see the update at the bottom.\n\nI had tried enabling and disabling Wi-Fi 7, turning off GRO on the client, and replacing the MediaTek adapter in my Framework 16 with a Qualcomm one.\nThe new adapter and disabling GRO seemed to help, but none of this stopped the corruption.\n\nI captured a failing HTTPS download with tcpdump and found a 1440-byte TCP segment with 64 wrong bytes in a row and a valid TCP checksum.\n`TcpInCsumErrors` stayed at zero.\nTCP accepted the segment and handed it to TLS, which rejected the record with `bad record mac`.\n\nThe damaged record wouldn't decrypt, so I couldn't inspect the bad bytes against the expected payload.\nI switched to raw TCP between a wired machine and the Wi-Fi laptop, sending known data that I could compare byte for byte.\nThe corruption only appeared with several TCP streams running at once, in the LAN-to-Wi-Fi direction.\nThe test found three bad 64-byte blocks in 8192 MiB, each starting with zeros.\n\nA valid checksum on a damaged segment meant something had recalculated it after the corruption, so the question became what on the path rewrites TCP headers.\nThe corruption still showed up after swapping the MediaTek card for the Qualcomm one, so it wasn't the laptop.\nWired clients were clean and the CCMP MIC on the bad frames was intact, so it wasn't the cable and it wasn't the air.\nThat left the bridge between Ethernet and Wi-Fi inside the AP.\n64 bytes is a cache line on the IPQ9574's cores, which points at a cache or DMA bug rather than a flipped bit.\nThe only thing on that path that both accelerates bridged traffic and recalculates checksums is Qualcomm's SFE/PPE offload.\n\n## Two writes that need root#\n\nReading the driver source suggested disabling SFE/PPE with these two settings:\n\n```\necho 0 > /sys/sfe/l2_feature\necho 0 > /sys/sfe/ppe_rfs_feature\n```\n\nBoth sysfs files are writable only by root, but TP-Link's SSH shell runs as `uid 1` with no capabilities.\nThere's no GUI setting for this either.\n\nThere's also no v1 firmware image to patch and flash, and the v2 firmware is for different hardware.\nI tried the sibling EAP773's `EAP773(EU)_V1_1.0.14`, and the device rejected it.\nNewer v2 firmware releases are encrypted.\nFlashing the chip directly needed a flash clip.\nI didn't want to hunt one down or brick the fucking AP.\n\nI wasn't going to spend another $200 replacing an AP because TP-Link couldn't be bothered to maintain the one they had already sold me.\nIt's sitting in my house, I know which setting to change, and their software won't let me.\n\nShout-out to TP-Link though: the system is pretty hardened.\nI found zero vectors in their own binaries, they use whitelists everywhere and load only the modules they need.\nSo no free `; echo 0 > /sys/sfe/ppe_rfs_feature` cheese for me.\nSo the way in had to be the kernel.\n\nWith Opus 4.8, I put together a chain using CVE-2022-0185 and pipe-buffer manipulation.\nCVE-2022-0185 is an integer underflow in `legacy_parse_param()`, reachable from an unprivileged user namespace, that lets a write overflow a 4 KiB slab object.\nThe pipe-buffer manipulation turns that overflow into an arbitrary write.\nThere were already public write-ups and PoCs for these.\n\nThe exploit had to leave the AP running because the broken offload comes back after a reboot.\nThe overflow leaves the 4 KiB slab in a state where a second attempt panics, so there's one shot per boot.\nI gave Opus guidance on the primitives, then had it iterate until the exploit was stable.\n\nExploit details\n\nWithout a v1 GPL source drop, I used the nearest published source, for the EAP783, whose kernel config differs from the AP's `/proc/config.gz` by three cosmetic lines.\nI built a QEMU harness with that config, got the chain working there, and then moved to the real device.\n\nThe kernel has no KASLR, so addresses stay the same between boots, but `kallsyms` is zeroed and I still had to find the addresses at runtime.\nThe QEMU build loaded the kernel at physical `0x40080000`, but the device loaded it at `0x42080000`, putting the calculations off by `0x2000000`.\nThe correct address was in a build config variable in the EAP783 source.\nEven after correcting the base, the device's `.text`, `.rodata` and `.data` had each moved by a different amount relative to the QEMU build, so adding one offset to everything didn't work.\n\nThe exploit:\n\n1. Unshare into a user namespace so that `fsopen` is reachable and the underflow can be triggered.\n2. Defragment the 4 KiB slab with a few hundred same-size allocations, then groom it to put the overflow source, a victim key, a SysV message and pipe rings next to each other.\n3. Overflow into the victim key's metadata and enlarge its length field.\n   Reading the key back now leaks neighbouring objects, including a pipe's `buf[0].ops`, which contains the address of `anon_pipe_buf_ops`.\n4. Repoint one pipe's buffer at another pipe's ring page, then forge an entry in that second ring using the leaked ops pointer.\n   `pipe_buf_can_merge()` checks that the ops pointer matches, so a merge write through the forged entry can reach an arbitrary physical page, including a page of the kernel image.\n5. Scan `.data` to find `modprobe_path`.\n   Reading kernel-image pages through a pipe normally calls `put_page()` on the reserved page when the buffer is consumed, causing `Bad page map` and a panic.\n   But `pipe_read` only calls `->confirm` and `->release`, and does not check that the ops table is real.\n   A forged `pipe_buf_operations` with both entries pointing to `generic_pipe_buf_confirm`, which returns zero without doing anything else, lets the scan read the pages without releasing them.\n   It can then walk `.data` in batches until it finds the string.\n6. Overwrite `modprobe_path` and `execve` a file with magic that matches no binary format.\n   The kernel calls `request_module()`, which runs the replacement path as global `uid 0`.\n7. Keep a child alive holding the forged pipe.\n   Letting the process exit would tear down the pipe and panic on the reserved page.\n\nGetting this working took about seventeen hours of token gambling with Opus 4.8, including reading the device's binaries in radare2, checking the enabled protections, and working through the crashes.\nThe AP stayed up, and disabling the offload stopped the corruption.\n\n## A DMA cache coherency bug#\n\nQualcomm's `qca-nss-dp` driver reuses packet buffers without synchronizing the CPU cache for the whole receive buffer before handing it back to the NIC.\nOn this hardware, DMA writes directly to DRAM without keeping the CPU caches in sync.\nA dirty cache line left over from the previous use of a buffer can be written back after the NIC has received a new packet, overwriting part of that packet in memory.\n\nSFE/PPE then recalculates the checksums over the corrupted payload, so the client gets a fresh TCP checksum that is valid for the garbage.\nTCP accepts it; the end-to-end MACs in TLS and SSH catch the damage, reporting `bad record mac` and `corrupted MAC`.\n\nOn x86, the hardware keeps DMA coherent with the CPU caches, so the same missing cache maintenance wouldn't corrupt anything.\nWhile looking through the kernel sources, I found similar DMA cache maintenance problems in several other drivers too.\nI don't know how to test those on hardware or who to report them to yet.\n\nDriver details\n\nThis is Qualcomm's out-of-tree NSS code, available in the [`openwrt/qca-nss-dp`](https://github.com/openwrt/qca-nss-dp/tree/6a5c4716ca258d67202fc7964c9294dfefa3ccfa) mirror.\n\nSFE's fast transmit path cleans `skb_headlen(skb)` bytes from the current `skb->data`, covering the linear part of the outgoing packet.\n\n[edma\\_tx.c#L483-L490](https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_tx.c#L483-L490)\n\n```\n483\tbuff_addr = (dma_addr_t)virt_to_phys(skb->data);\n484\tEDMA_TXDESC_BUFFER_ADDR_SET(txd, buff_addr);\n485\n486#ifdef EDMA_40BIT_SUPPORT\n487\tEDMA_TXDESC_BUFFER_ADDR_HI_SET(txd, buff_addr);\n488#endif\n489\n490\tdmac_clean_range_no_dsb((void *)skb->data, (void *)(skb->data + buf_len));\n```\n\nIt then sets `skb->fast_recycled = 1` and returns the buffer to a per-CPU recycle list without clearing its contents.\n\n[edma\\_tx.c#L735-L743](https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_tx.c#L735-L743)\n\n```\n735\t\t/*\n736\t\t * We set fast_recycled flag if packet has taken the\n737\t\t * SFE fast transmit path, so that any Rx DMA driver\n738\t\t * that allocates this packet later can avoid\n739\t\t * an invalidate operation\n740\t\t */\n741\t\tif (likely(skb->fast_xmit) && likely(skb->is_from_recycler)) {\n742\t\t\tskb->fast_recycled = 1;\n743\t\t}\n```\n\nThe recycler zeroes the `sk_buff` struct up to its `tail` field, restores `fast_recycled`, and resets `skb->data` to `skb->head + NET_SKB_PAD`.\n\nWhen that buffer is reused for receive, the refill path sees the flag and skips invalidation:\n\n[edma\\_rx.c#L477-L491](https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_rx.c#L477-L491)\n\n```\n477\t\t/*\n478\t\t * Invalidate skb->data\n479\t\t * A73 flush operation does an invalidate operation as well.\n480\t\t * If the packet is fast transmitted and hence fast recycled,\n481\t\t * we can be assured that invalidate was already done at the\n482\t\t * time of previous transmit\n483\t\t */\n484\t\tif (unlikely(!skb->fast_recycled)) {\n485\t\t\tdmac_inv_range_no_dsb((void *)skb->data,\n486\t\t\t\t\t      (void *)(skb->data + rx_alloc_size -\n487\t\t\t\t\t      EDMA_RX_SKB_HEADROOM -\n488\t\t\t\t\t      NET_IP_ALIGN));\n489\n490\t\t}\n491\t\tskb->fast_recycled = 0;\n```\n\nThe receive region is 1856 bytes, or 29 cache lines, starting at `head + 192`.\nThe previous transmit cleaned only two or three lines from a different offset.\n\nDirty lines outside that cleaned range can be written back after the NIC's DMA, replacing 64 bytes of the received packet at a time.\nNormal cache eviction can cause the write-back.\nOn arm64, invalidating an unaligned range can also do it: `__dma_inv_area` uses `dc civac`, clean and invalidate, on the partial edge lines.\nOnce the dirty data has overwritten the packet in DRAM, invalidating the CPU cache cannot recover the received bytes.\n\nThe NIC verifies the checksum of the original packet before that overwrite, and the AP's receive path marks it `CHECKSUM_UNNECESSARY` using the hardware status bits.\nThe exact sequence inside PPE is hidden in proprietary hardware and driver code, but the packet capture shows that the checksum was calculated after the corruption.\n\nBuffer recycling is enabled by `CONFIG_SKB_RECYCLER`, a compile-time option with no runtime switch.\nDisabling the offload stops buffers taking the fast transmit path, so the driver no longer sets `fast_recycled` and receive invalidates the full buffer before handing it to the NIC.\n\nOnly downloads to a Wi-Fi client go through the AP's wired receive path; uploads don't, and wired-to-wired traffic stays on the switch.\n\n## Opus 5.5 found the bug and then stopped talking about it#\n\nWhen I moved from diagnosing the corruption to applying the fix, Opus 5.5 started cutting sessions off roughly every other message.\nMentioning something like \"local privilege escalation\" is enough.\nI have the Cyber Verification Program enabled on my account, which hasn't helped.\nOpus 4.8 worked through the exploitation without that problem.\n\n5.5's guards also kill the session when I just ask it to read this article.\nGPT-6 Astra reads and edits the same article without any cyber verification.\n\n## Before and after#\n\nI tested raw TCP transfers between the same two machines with the offload enabled and disabled.\n\n| Offload | Direction | Data | Speed | Bad 64-byte blocks |\n| --- | --- | --- | --- | --- |\n| on | LAN -> Wi-Fi | 8192 MiB | 103.4 MiB/s | 3 |\n| off | LAN -> Wi-Fi | overnight run | 95.4 MiB/s | 0 |\n\nDisabling the offload cost about 8% of the download speed in this test.\nI have to give up performance to get correct data because TP-Link won't let me properly fix my own fucking device.\n\nWith the offload on, I could never download a large file over Wi-Fi in a single stream; only chunked downloads got through.\nWith the offload off, the overnight run came back with zero bad blocks, and large downloads have worked every time since.\nUploads from Wi-Fi to the LAN were already clean before the fix.\n\nThe `NOT_SUPPORT_BRIDGE` counter in `/sys/sfe/exceptions` increases after disabling the offload, as bridged flows take the normal path instead of being accelerated.\n\nThe settings reset after a reboot, so a watcher on my NAS polls the AP and reruns the exploit when the offload is back on.\nIf the attempt fails, it reboots the AP through TP-Link's management API and tries again on the next boot, with a hard cap on the number of reboots.\n\nQualcomm can fix their driver if they want, or host it somewhere more open.\nOpenWrt doesn't have this bug, and TP-Link won't release new firmware for my AP anyway.\nThe similar DMA bugs I found in Linux deserve a closer look, though.\nI'll get back to those over the weekend.\n\n## Update: tracking down the drops#\n\nThe Qualcomm card I put in the Framework 16 is a WCN785x, and every few hours it stopped sending data for 1 to 4 minutes.\nSince I already had root on the AP, I ran `apstats` on the EAP770 alongside the BPF probes on the laptop and caught three drops with logs from both sides.\n\nEverything the AP sent to the laptop arrived and got ACKed, so the laptop's radio was transmitting and the AP's receiver worked.\nOnly data frames from the laptop stopped going out.\n\nOn the laptop, packets kept going into `ath12k` and the queue grew to thousands of frames.\nThe firmware confirmed a few frames at a time and eventually returned the rest to the driver with `CMD_REMOVE_AGED_FRAMES`, 10630 out of 26388 in one drop.\nThe driver was otherwise quiet.\nOn the AP, `rx_data` stayed at `+0` for most ticks apart from an occasional burst, with zero RX errors and zero retries, so the frames never left the card.\nThen the card unblocked on its own and sent the whole backlog in two seconds.\nThe AP saw `rx_data+3273` and then `+12498`, and the laptop saw 7751 ACKed frames.\n\nThe signal was -31 to -42 dBm, the drops happened with no scan running, and the IP layer on the laptop was fine during them.\nI'm already on the latest upstream firmware, `WLAN.HMT.1.1.c7-00108 UPSTREAM-3`.\n`ath12k` only gets completion statuses back, and everything about when a frame goes on the air is decided inside Qualcomm's closed-source `amss.bin`.\nThat leaves the firmware.\n\nI still don't know why it stops transmitting.\nThe bursts that got through during a drop look like 802.11ax/be uplink scheduling, trigger-based uplink or MU EDCA, but I didn't find those settings on the AP to check.\n\nI switched the AP's 5 GHz radio from Wi-Fi 7 to Wi-Fi 6 (802.11ax) and left a watcher running for 24 hours.\nIf the drops stop, it's a Wi-Fi 7 bug, so I stay on Wi-Fi 6 and send a bug report to `ath12k`.\nIf they don't, the firmware is broken regardless of the mode, and it's either a bug report or an Intel card.\n","body_html":"<p>TL;DR:**\nMy TP-Link EAP770 v1 corrupts downloads over Wi-Fi and TP-Link EOL&#39;d it before I unboxed it. The fix is two zeros in sysfs. TP-Link&#39;s shell won&#39;t let me write them, so I wrote a kernel exploit for my own fucking access point.</p>\n<h2 id=\"long-downloads-kept-breaking\">Long downloads kept breaking</h2>\n<p>In August 2023 I bought a shiny new Wi-Fi 7 access point, a TP-Link EAP770.\nThe connection was a bit janky, a video would occasionally start buffering, but it didn&#39;t bother me much.</p>\n<p>I mostly worked on a workstation connected over Ethernet.\nWhen I needed to go somewhere, I&#39;d rsync my <code>~</code> to the laptop over Wi-Fi and take it with me.\nThose transfers started failing on files larger than a few GiB.\nSSH was reporting MAC errors, so I spent a while debugging rsync and SSH.\nEverything else worked, changing rsync options made it better or worse, Wireshark showed zero bad checksums and zero retransmissions, and a single UDP stream of random bytes came through clean.\nI trusted TCP to deliver the right bytes because that&#39;s what the fucking thing is for, so I was sure the problem was in rsync or SSH.\nI wrote a wrapper around rsync with retries and moved on.</p>\n<p>Later I happened to start using Tailscale for traffic inside my home network, and the transfers stopped needing retries.\nWireGuard drops a packet if its authentication fails, so the TCP connection inside the tunnel sees a lost packet and retransmits it.\nSince the transfers worked, I stopped digging.</p>\n<h2 id=\"zero-firmware-updates\">Zero firmware updates</h2>\n<p>The device is an EAP770(EU) v1.0 with a Qualcomm IPQ9574 running Linux 5.4.164 and firmware <code>1.0.0 Build 20230605 Rel. 71641</code>.\nThat&#39;s the firmware it shipped with, and apparently the last time TP-Link remembered this thing even existed :)</p>\n<p>I bought this thing in August 2023, and the Wi-Fi 7 standard was only finalized in September 2024.\nIt has <a href=\"https://www.omadanetworks.com/us/business-networking/omada-wifi-ceiling-mount/eap770/\" rel=\"nofollow ugc noopener\">no Wi-Fi Alliance certification</a>, and buying it was stupid of me.\nPLZ DON&#39;T BUY STUFF UNTIL STANDARDS ARE FINALIZED AND DEVICES ARE CERTIFIED.</p>\n<p>In 2025 I noticed that everything else was getting updates while this thing was still on its original firmware.\nI contacted support in June because the website only had firmware for v2.\nThey couldn&#39;t find v1 in their database and asked for photos of the device and box before sending me this:</p>\n<blockquote><p>The EAP770 Version 1 is an End-of-Life product. While it may still be available through some retailers, it is no longer supported with firmware updates.</p></blockquote>\n<p>NICE.</p>\n<h2 id=\"tracking-down-the-corruption\">Tracking down the corruption</h2>\n<p>In 2026 I decided to switch to NixOS and found that a 128 MiB file from the Nix binary cache on Fastly would fail over HTTP/2 but downloaded fine over HTTP/1.1.\nI still don&#39;t know why HTTP/1.1 helped; my guess is that HTTP/2 was simply faster.\n<code>git</code> clones also failed, and HTTPS connections died with <code>bad record mac</code>.\nIn March, downloading a NixOS ISO with <code>wget</code> died with a TLS error:</p>\n<pre><code>2026-03-05 12:37:18 (31.6 MB/s) - Read error at byte 2722477636/3683778560 (error:0A000119:SSL routines::decryption failed or bad record mac; error:0A000139:SSL routines::record layer failure). Retrying.</code></pre>\n<p>I forced HTTP/1.1 and left it alone again.</p>\n<p>In September I turned the workstation into a build farm and switched to the laptop, which connects over Wi-Fi.\nWi-Fi was also dropping every day around 11:00, right before my daily call.\nAfter it had died right before the call several days in a row, I decided to open this Pandora&#39;s box again and sat down with Opus 5.5 to trace what was happening.\nSpoiler alert: the drops turned out to be a separate bug.\nI covered the laptop&#39;s Qualcomm driver in BPF probes and ran <code>apstats</code> on the AP as root.\nThe bug is in the closed-source firmware on the laptop&#39;s Wi-Fi card; see the update at the bottom.</p>\n<p>I had tried enabling and disabling Wi-Fi 7, turning off GRO on the client, and replacing the MediaTek adapter in my Framework 16 with a Qualcomm one.\nThe new adapter and disabling GRO seemed to help, but none of this stopped the corruption.</p>\n<p>I captured a failing HTTPS download with tcpdump and found a 1440-byte TCP segment with 64 wrong bytes in a row and a valid TCP checksum.\n<code>TcpInCsumErrors</code> stayed at zero.\nTCP accepted the segment and handed it to TLS, which rejected the record with <code>bad record mac</code>.</p>\n<p>The damaged record wouldn&#39;t decrypt, so I couldn&#39;t inspect the bad bytes against the expected payload.\nI switched to raw TCP between a wired machine and the Wi-Fi laptop, sending known data that I could compare byte for byte.\nThe corruption only appeared with several TCP streams running at once, in the LAN-to-Wi-Fi direction.\nThe test found three bad 64-byte blocks in 8192 MiB, each starting with zeros.</p>\n<p>A valid checksum on a damaged segment meant something had recalculated it after the corruption, so the question became what on the path rewrites TCP headers.\nThe corruption still showed up after swapping the MediaTek card for the Qualcomm one, so it wasn&#39;t the laptop.\nWired clients were clean and the CCMP MIC on the bad frames was intact, so it wasn&#39;t the cable and it wasn&#39;t the air.\nThat left the bridge between Ethernet and Wi-Fi inside the AP.\n64 bytes is a cache line on the IPQ9574&#39;s cores, which points at a cache or DMA bug rather than a flipped bit.\nThe only thing on that path that both accelerates bridged traffic and recalculates checksums is Qualcomm&#39;s SFE/PPE offload.</p>\n<h2 id=\"two-writes-that-need-root\">Two writes that need root</h2>\n<p>Reading the driver source suggested disabling SFE/PPE with these two settings:</p>\n<pre><code>echo 0 &gt; /sys/sfe/l2_feature\necho 0 &gt; /sys/sfe/ppe_rfs_feature</code></pre>\n<p>Both sysfs files are writable only by root, but TP-Link&#39;s SSH shell runs as <code>uid 1</code> with no capabilities.\nThere&#39;s no GUI setting for this either.</p>\n<p>There&#39;s also no v1 firmware image to patch and flash, and the v2 firmware is for different hardware.\nI tried the sibling EAP773&#39;s <code>EAP773(EU)_V1_1.0.14</code>, and the device rejected it.\nNewer v2 firmware releases are encrypted.\nFlashing the chip directly needed a flash clip.\nI didn&#39;t want to hunt one down or brick the fucking AP.</p>\n<p>I wasn&#39;t going to spend another $200 replacing an AP because TP-Link couldn&#39;t be bothered to maintain the one they had already sold me.\nIt&#39;s sitting in my house, I know which setting to change, and their software won&#39;t let me.</p>\n<p>Shout-out to TP-Link though: the system is pretty hardened.\nI found zero vectors in their own binaries, they use whitelists everywhere and load only the modules they need.\nSo no free <code>; echo 0 &gt; /sys/sfe/ppe_rfs_feature</code> cheese for me.\nSo the way in had to be the kernel.</p>\n<p>With Opus 4.8, I put together a chain using CVE-2022-0185 and pipe-buffer manipulation.\nCVE-2022-0185 is an integer underflow in <code>legacy_parse_param()</code>, reachable from an unprivileged user namespace, that lets a write overflow a 4 KiB slab object.\nThe pipe-buffer manipulation turns that overflow into an arbitrary write.\nThere were already public write-ups and PoCs for these.</p>\n<p>The exploit had to leave the AP running because the broken offload comes back after a reboot.\nThe overflow leaves the 4 KiB slab in a state where a second attempt panics, so there&#39;s one shot per boot.\nI gave Opus guidance on the primitives, then had it iterate until the exploit was stable.</p>\n<p>Exploit details</p>\n<p>Without a v1 GPL source drop, I used the nearest published source, for the EAP783, whose kernel config differs from the AP&#39;s <code>/proc/config.gz</code> by three cosmetic lines.\nI built a QEMU harness with that config, got the chain working there, and then moved to the real device.</p>\n<p>The kernel has no KASLR, so addresses stay the same between boots, but <code>kallsyms</code> is zeroed and I still had to find the addresses at runtime.\nThe QEMU build loaded the kernel at physical <code>0x40080000</code>, but the device loaded it at <code>0x42080000</code>, putting the calculations off by <code>0x2000000</code>.\nThe correct address was in a build config variable in the EAP783 source.\nEven after correcting the base, the device&#39;s <code>.text</code>, <code>.rodata</code> and <code>.data</code> had each moved by a different amount relative to the QEMU build, so adding one offset to everything didn&#39;t work.</p>\n<p>The exploit:</p>\n<ol><li>Unshare into a user namespace so that <code>fsopen</code> is reachable and the underflow can be triggered.</li><li>Defragment the 4 KiB slab with a few hundred same-size allocations, then groom it to put the overflow source, a victim key, a SysV message and pipe rings next to each other.</li><li><p>Overflow into the victim key&#39;s metadata and enlarge its length field.</p><p> Reading the key back now leaks neighbouring objects, including a pipe&#39;s <code>buf[0].ops</code>, which contains the address of <code>anon_pipe_buf_ops</code>.</p></li><li><p>Repoint one pipe&#39;s buffer at another pipe&#39;s ring page, then forge an entry in that second ring using the leaked ops pointer.</p><p> <code>pipe_buf_can_merge()</code> checks that the ops pointer matches, so a merge write through the forged entry can reach an arbitrary physical page, including a page of the kernel image.</p></li><li><p>Scan <code>.data</code> to find <code>modprobe_path</code>.</p><p> Reading kernel-image pages through a pipe normally calls <code>put_page()</code> on the reserved page when the buffer is consumed, causing <code>Bad page map</code> and a panic.\n But <code>pipe_read</code> only calls <code>-&gt;confirm</code> and <code>-&gt;release</code>, and does not check that the ops table is real.\n A forged <code>pipe_buf_operations</code> with both entries pointing to <code>generic_pipe_buf_confirm</code>, which returns zero without doing anything else, lets the scan read the pages without releasing them.\n It can then walk <code>.data</code> in batches until it finds the string.</p></li><li><p>Overwrite <code>modprobe_path</code> and <code>execve</code> a file with magic that matches no binary format.</p><p> The kernel calls <code>request_module()</code>, which runs the replacement path as global <code>uid 0</code>.</p></li><li><p>Keep a child alive holding the forged pipe.</p><p> Letting the process exit would tear down the pipe and panic on the reserved page.</p></li></ol>\n<p>Getting this working took about seventeen hours of token gambling with Opus 4.8, including reading the device&#39;s binaries in radare2, checking the enabled protections, and working through the crashes.\nThe AP stayed up, and disabling the offload stopped the corruption.</p>\n<h2 id=\"a-dma-cache-coherency-bug\">A DMA cache coherency bug</h2>\n<p>Qualcomm&#39;s <code>qca-nss-dp</code> driver reuses packet buffers without synchronizing the CPU cache for the whole receive buffer before handing it back to the NIC.\nOn this hardware, DMA writes directly to DRAM without keeping the CPU caches in sync.\nA dirty cache line left over from the previous use of a buffer can be written back after the NIC has received a new packet, overwriting part of that packet in memory.</p>\n<p>SFE/PPE then recalculates the checksums over the corrupted payload, so the client gets a fresh TCP checksum that is valid for the garbage.\nTCP accepts it; the end-to-end MACs in TLS and SSH catch the damage, reporting <code>bad record mac</code> and <code>corrupted MAC</code>.</p>\n<p>On x86, the hardware keeps DMA coherent with the CPU caches, so the same missing cache maintenance wouldn&#39;t corrupt anything.\nWhile looking through the kernel sources, I found similar DMA cache maintenance problems in several other drivers too.\nI don&#39;t know how to test those on hardware or who to report them to yet.</p>\n<p>Driver details</p>\n<p>This is Qualcomm&#39;s out-of-tree NSS code, available in the <a href=\"https://github.com/openwrt/qca-nss-dp/tree/6a5c4716ca258d67202fc7964c9294dfefa3ccfa\" rel=\"nofollow ugc noopener\"><code>openwrt/qca-nss-dp</code></a> mirror.</p>\n<p>SFE&#39;s fast transmit path cleans <code>skb_headlen(skb)</code> bytes from the current <code>skb-&gt;data</code>, covering the linear part of the outgoing packet.</p>\n<p><a href=\"https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_tx.c#L483-L490\" rel=\"nofollow ugc noopener\">edma_tx.c#L483-L490</a></p>\n<pre><code>483    buff_addr = (dma_addr_t)virt_to_phys(skb-&gt;data);\n484    EDMA_TXDESC_BUFFER_ADDR_SET(txd, buff_addr);\n485\n486#ifdef EDMA_40BIT_SUPPORT\n487    EDMA_TXDESC_BUFFER_ADDR_HI_SET(txd, buff_addr);\n488#endif\n489\n490    dmac_clean_range_no_dsb((void *)skb-&gt;data, (void *)(skb-&gt;data + buf_len));</code></pre>\n<p>It then sets <code>skb-&gt;fast_recycled = 1</code> and returns the buffer to a per-CPU recycle list without clearing its contents.</p>\n<p><a href=\"https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_tx.c#L735-L743\" rel=\"nofollow ugc noopener\">edma_tx.c#L735-L743</a></p>\n<pre><code>735        /*\n736         * We set fast_recycled flag if packet has taken the\n737         * SFE fast transmit path, so that any Rx DMA driver\n738         * that allocates this packet later can avoid\n739         * an invalidate operation\n740         */\n741        if (likely(skb-&gt;fast_xmit) &amp;&amp; likely(skb-&gt;is_from_recycler)) {\n742            skb-&gt;fast_recycled = 1;\n743        }</code></pre>\n<p>The recycler zeroes the <code>sk_buff</code> struct up to its <code>tail</code> field, restores <code>fast_recycled</code>, and resets <code>skb-&gt;data</code> to <code>skb-&gt;head + NET_SKB_PAD</code>.</p>\n<p>When that buffer is reused for receive, the refill path sees the flag and skips invalidation:</p>\n<p><a href=\"https://github.com/openwrt/qca-nss-dp/blob/6a5c4716ca258d67202fc7964c9294dfefa3ccfa/hal/dp_ops/edma_dp/edma_v2/edma_rx.c#L477-L491\" rel=\"nofollow ugc noopener\">edma_rx.c#L477-L491</a></p>\n<pre><code>477        /*\n478         * Invalidate skb-&gt;data\n479         * A73 flush operation does an invalidate operation as well.\n480         * If the packet is fast transmitted and hence fast recycled,\n481         * we can be assured that invalidate was already done at the\n482         * time of previous transmit\n483         */\n484        if (unlikely(!skb-&gt;fast_recycled)) {\n485            dmac_inv_range_no_dsb((void *)skb-&gt;data,\n486                          (void *)(skb-&gt;data + rx_alloc_size -\n487                          EDMA_RX_SKB_HEADROOM -\n488                          NET_IP_ALIGN));\n489\n490        }\n491        skb-&gt;fast_recycled = 0;</code></pre>\n<p>The receive region is 1856 bytes, or 29 cache lines, starting at <code>head + 192</code>.\nThe previous transmit cleaned only two or three lines from a different offset.</p>\n<p>Dirty lines outside that cleaned range can be written back after the NIC&#39;s DMA, replacing 64 bytes of the received packet at a time.\nNormal cache eviction can cause the write-back.\nOn arm64, invalidating an unaligned range can also do it: <code>__dma_inv_area</code> uses <code>dc civac</code>, clean and invalidate, on the partial edge lines.\nOnce the dirty data has overwritten the packet in DRAM, invalidating the CPU cache cannot recover the received bytes.</p>\n<p>The NIC verifies the checksum of the original packet before that overwrite, and the AP&#39;s receive path marks it <code>CHECKSUM_UNNECESSARY</code> using the hardware status bits.\nThe exact sequence inside PPE is hidden in proprietary hardware and driver code, but the packet capture shows that the checksum was calculated after the corruption.</p>\n<p>Buffer recycling is enabled by <code>CONFIG_SKB_RECYCLER</code>, a compile-time option with no runtime switch.\nDisabling the offload stops buffers taking the fast transmit path, so the driver no longer sets <code>fast_recycled</code> and receive invalidates the full buffer before handing it to the NIC.</p>\n<p>Only downloads to a Wi-Fi client go through the AP&#39;s wired receive path; uploads don&#39;t, and wired-to-wired traffic stays on the switch.</p>\n<h2 id=\"opus-5-5-found-the-bug-and-then-stopped-talking-about-it\">Opus 5.5 found the bug and then stopped talking about it</h2>\n<p>When I moved from diagnosing the corruption to applying the fix, Opus 5.5 started cutting sessions off roughly every other message.\nMentioning something like &quot;local privilege escalation&quot; is enough.\nI have the Cyber Verification Program enabled on my account, which hasn&#39;t helped.\nOpus 4.8 worked through the exploitation without that problem.</p>\n<p>5.5&#39;s guards also kill the session when I just ask it to read this article.\nGPT-6 Astra reads and edits the same article without any cyber verification.</p>\n<h2 id=\"before-and-after\">Before and after</h2>\n<p>I tested raw TCP transfers between the same two machines with the offload enabled and disabled.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Offload</th><th>Direction</th><th>Data</th><th>Speed</th><th>Bad 64-byte blocks</th></tr></thead><tbody><tr><td>on</td><td>LAN -&gt; Wi-Fi</td><td>8192 MiB</td><td>103.4 MiB/s</td><td>3</td></tr><tr><td>off</td><td>LAN -&gt; Wi-Fi</td><td>overnight run</td><td>95.4 MiB/s</td><td>0</td></tr></tbody></table></div>\n<p>Disabling the offload cost about 8% of the download speed in this test.\nI have to give up performance to get correct data because TP-Link won&#39;t let me properly fix my own fucking device.</p>\n<p>With the offload on, I could never download a large file over Wi-Fi in a single stream; only chunked downloads got through.\nWith the offload off, the overnight run came back with zero bad blocks, and large downloads have worked every time since.\nUploads from Wi-Fi to the LAN were already clean before the fix.</p>\n<p>The <code>NOT_SUPPORT_BRIDGE</code> counter in <code>/sys/sfe/exceptions</code> increases after disabling the offload, as bridged flows take the normal path instead of being accelerated.</p>\n<p>The settings reset after a reboot, so a watcher on my NAS polls the AP and reruns the exploit when the offload is back on.\nIf the attempt fails, it reboots the AP through TP-Link&#39;s management API and tries again on the next boot, with a hard cap on the number of reboots.</p>\n<p>Qualcomm can fix their driver if they want, or host it somewhere more open.\nOpenWrt doesn&#39;t have this bug, and TP-Link won&#39;t release new firmware for my AP anyway.\nThe similar DMA bugs I found in Linux deserve a closer look, though.\nI&#39;ll get back to those over the weekend.</p>\n<h2 id=\"update-tracking-down-the-drops\">Update: tracking down the drops</h2>\n<p>The Qualcomm card I put in the Framework 16 is a WCN785x, and every few hours it stopped sending data for 1 to 4 minutes.\nSince I already had root on the AP, I ran <code>apstats</code> on the EAP770 alongside the BPF probes on the laptop and caught three drops with logs from both sides.</p>\n<p>Everything the AP sent to the laptop arrived and got ACKed, so the laptop&#39;s radio was transmitting and the AP&#39;s receiver worked.\nOnly data frames from the laptop stopped going out.</p>\n<p>On the laptop, packets kept going into <code>ath12k</code> and the queue grew to thousands of frames.\nThe firmware confirmed a few frames at a time and eventually returned the rest to the driver with <code>CMD_REMOVE_AGED_FRAMES</code>, 10630 out of 26388 in one drop.\nThe driver was otherwise quiet.\nOn the AP, <code>rx_data</code> stayed at <code>+0</code> for most ticks apart from an occasional burst, with zero RX errors and zero retries, so the frames never left the card.\nThen the card unblocked on its own and sent the whole backlog in two seconds.\nThe AP saw <code>rx_data+3273</code> and then <code>+12498</code>, and the laptop saw 7751 ACKed frames.</p>\n<p>The signal was -31 to -42 dBm, the drops happened with no scan running, and the IP layer on the laptop was fine during them.\nI&#39;m already on the latest upstream firmware, <code>WLAN.HMT.1.1.c7-00108 UPSTREAM-3</code>.\n<code>ath12k</code> only gets completion statuses back, and everything about when a frame goes on the air is decided inside Qualcomm&#39;s closed-source <code>amss.bin</code>.\nThat leaves the firmware.</p>\n<p>I still don&#39;t know why it stops transmitting.\nThe bursts that got through during a drop look like 802.11ax/be uplink scheduling, trigger-based uplink or MU EDCA, but I didn&#39;t find those settings on the AP to check.</p>\n<p>I switched the AP&#39;s 5 GHz radio from Wi-Fi 7 to Wi-Fi 6 (802.11ax) and left a watcher running for 24 hours.\nIf the drops stop, it&#39;s a Wi-Fi 7 bug, so I stay on Wi-Fi 6 and send a bug report to <code>ath12k</code>.\nIf they don&#39;t, the firmware is broken regardless of the mode, and it&#39;s either a bug report or an Intel card.</p>","headings":[{"level":2,"text":"Long downloads kept breaking","id":"long-downloads-kept-breaking"},{"level":2,"text":"Zero firmware updates","id":"zero-firmware-updates"},{"level":2,"text":"Tracking down the corruption","id":"tracking-down-the-corruption"},{"level":2,"text":"Two writes that need root","id":"two-writes-that-need-root"},{"level":2,"text":"A DMA cache coherency bug","id":"a-dma-cache-coherency-bug"},{"level":2,"text":"Opus 5.5 found the bug and then stopped talking about it","id":"opus-5-5-found-the-bug-and-then-stopped-talking-about-it"},{"level":2,"text":"Before and after","id":"before-and-after"},{"level":2,"text":"Update: tracking down the drops","id":"update-tracking-down-the-drops"}]}}