TP-Link shipped my access point EOL out of the box. I had to write a kernel exploit to fix it.

TL;DR: My TP-Link EAP770 v1 corrupts downloads over Wi-Fi and has never received a firmware update. Disabling its forwarding offload fixes the problem, but TP-Link's shell cannot change that setting. I spent a weekend with an LLM putting together a kernel exploit so I could write two zeros into sysfs.

Long downloads kept breaking#

In August 2023 I bought a shiny new Wi-Fi 7 access point, a TP-Link EAP770. The connection was a bit janky, a video would occasionally start buffering, but it didn't bother me much.

I mostly worked on a workstation connected over Ethernet. When I needed to go somewhere, I'd rsync my ~ to the laptop over Wi-Fi and take it with me. Those transfers started failing on files larger than a few GiB. SSH was reporting MAC errors, so I spent a while debugging rsync and SSH. Everything 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. I 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. I wrote a wrapper around rsync with retries and moved on.

Later I happened to start using Tailscale for traffic inside my home network, and the transfers stopped needing retries. WireGuard drops a packet if its authentication fails, so the TCP connection inside the tunnel sees a lost packet and retransmits it. Since the transfers worked, I stopped digging.

Zero firmware updates#

The 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. That's the firmware it shipped with, and apparently the last time TP-Link remembered this thing even existed :)

I bought this thing in August 2023, and the Wi-Fi 7 standard was only finalized in September 2024. It has no Wi-Fi Alliance certification, and buying it was stupid of me. PLZ DON'T BUY STUFF UNTIL STANDARDS ARE FINALIZED AND DEVICES ARE CERTIFIED.

In 2025 I noticed that everything else was getting updates while this thing was still on its original firmware. I contacted support in June because the website only had firmware for v2. They couldn't find v1 in their database and asked for photos of the device and box before sending me this:

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.

NICE.

Tracking down the corruption#

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. I still don't know why HTTP/1.1 helped; my guess is that HTTP/2 was simply faster. git clones also failed, and HTTPS connections died with bad record mac. In March, downloading a NixOS ISO with wget died with a TLS error:

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.

I forced HTTP/1.1 and left it alone again.

In September I turned the workstation into a build farm and switched to the laptop, which connects over Wi-Fi. Wi-Fi was also dropping every day around 11:00, right before my daily call. After 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. Spoiler alert: the drops turned out to be a separate bug, I still have them, and there will probably be a second article about why. I suspect the Qualcomm driver on the laptop and have covered it in BPF probes, but I haven't found the cause yet.

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. The new adapter and disabling GRO seemed to help, but none of this stopped the corruption.

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. TcpInCsumErrors stayed at zero. TCP accepted the segment and handed it to TLS, which rejected the record with bad record mac.

The damaged record wouldn't decrypt, so I couldn't inspect the bad bytes against the expected payload. I switched to raw TCP between a wired machine and the Wi-Fi laptop, sending known data that I could compare byte for byte. The corruption only appeared with several TCP streams running at once, in the LAN-to-Wi-Fi direction. The test found three bad 64-byte blocks in 8192 MiB, each starting with zeros.

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. The corruption still showed up after swapping the MediaTek card for the Qualcomm one, so it wasn't the laptop. Wired 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. That left the bridge between Ethernet and Wi-Fi inside the AP. 64 bytes is a cache line on the IPQ9574's cores, which points at a cache or DMA bug rather than a flipped bit. The only thing on that path that both accelerates bridged traffic and recalculates checksums is Qualcomm's SFE/PPE offload.

Two writes that need root#

Reading the driver source suggested disabling SFE/PPE with these two settings:

echo 0 > /sys/sfe/l2_feature
echo 0 > /sys/sfe/ppe_rfs_feature

Both sysfs files are writable only by root, but TP-Link's SSH shell runs as uid 1 with no capabilities. There's no GUI setting for this either.

There's also no v1 firmware image to patch and flash, and the v2 firmware is for different hardware. I tried the sibling EAP773's EAP773(EU)_V1_1.0.14, and the device rejected it. Newer v2 firmware releases are encrypted. Flashing the chip directly needed a flash clip. I didn't want to hunt one down or brick the fucking AP.

I 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. It's sitting in my house, I know which setting to change, and their software won't let me.

Shout-out to TP-Link though: the system is pretty hardened. I found zero vectors in their own binaries, they use whitelists everywhere and load only the modules they need. So no free ; echo 0 > /sys/sfe/ppe_rfs_feature cheese for me. So the way in had to be the kernel.

With Opus 4.8, I put together a chain using CVE-2022-0185 and pipe-buffer manipulation. CVE-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. The pipe-buffer manipulation turns that overflow into an arbitrary write. There were already public write-ups and PoCs for these.

The exploit had to leave the AP running because the broken offload comes back after a reboot. The overflow leaves the 4 KiB slab in a state where a second attempt panics, so there's one shot per boot. I gave Opus guidance on the primitives, then had it iterate until the exploit was stable.

Exploit details

Without 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. I built a QEMU harness with that config, got the chain working there, and then moved to the real device.

The 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. The QEMU build loaded the kernel at physical 0x40080000, but the device loaded it at 0x42080000, putting the calculations off by 0x2000000. The correct address was in a build config variable in the EAP783 source. Even 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.

The exploit:

  1. Unshare into a user namespace so that fsopen is reachable and the underflow can be triggered.
  2. 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.
  3. Overflow into the victim key's metadata and enlarge its length field. Reading the key back now leaks neighbouring objects, including a pipe's buf[0].ops, which contains the address of anon_pipe_buf_ops.
  4. Repoint one pipe's buffer at another pipe's ring page, then forge an entry in that second ring using the leaked ops pointer. 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.
  5. Scan .data to find modprobe_path. 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. But pipe_read only calls ->confirm and ->release, and does not check that the ops table is real. 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. It can then walk .data in batches until it finds the string.
  6. Overwrite modprobe_path and execve a file with magic that matches no binary format. The kernel calls request_module(), which runs the replacement path as global uid 0.
  7. Keep a child alive holding the forged pipe. Letting the process exit would tear down the pipe and panic on the reserved page.

Getting 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. The AP stayed up, and disabling the offload stopped the corruption.

A DMA cache coherency bug#

Qualcomm'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. On this hardware, DMA writes directly to DRAM without keeping the CPU caches in sync. A 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.

SFE/PPE then recalculates the checksums over the corrupted payload, so the client gets a fresh TCP checksum that is valid for the garbage. TCP accepts it; the end-to-end MACs in TLS and SSH catch the damage, reporting bad record mac and corrupted MAC.

On x86, the hardware keeps DMA coherent with the CPU caches, so the same missing cache maintenance wouldn't corrupt anything. While looking through the kernel sources, I found similar DMA cache maintenance problems in several other drivers too. I don't know how to test those on hardware or who to report them to yet.

Driver details

This is Qualcomm's out-of-tree NSS code, available in the openwrt/qca-nss-dp mirror.

SFE's fast transmit path cleans skb_headlen(skb) bytes from the current skb->data, covering the linear part of the outgoing packet.

	buff_addr = (dma_addr_t)virt_to_phys(skb->data);
	EDMA_TXDESC_BUFFER_ADDR_SET(txd, buff_addr);

#ifdef EDMA_40BIT_SUPPORT
	EDMA_TXDESC_BUFFER_ADDR_HI_SET(txd, buff_addr);
#endif

	dmac_clean_range_no_dsb((void *)skb->data, (void *)(skb->data + buf_len));

It then sets skb->fast_recycled = 1 and returns the buffer to a per-CPU recycle list without clearing its contents.

		/*
		 * We set fast_recycled flag if packet has taken the
		 * SFE fast transmit path, so that any Rx DMA driver
		 * that allocates this packet later can avoid
		 * an invalidate operation
		 */
		if (likely(skb->fast_xmit) && likely(skb->is_from_recycler)) {
			skb->fast_recycled = 1;
		}

The recycler zeroes the sk_buff struct up to its tail field, restores fast_recycled, and resets skb->data to skb->head + NET_SKB_PAD.

When that buffer is reused for receive, the refill path sees the flag and skips invalidation:

		/*
		 * Invalidate skb->data
		 * A73 flush operation does an invalidate operation as well.
		 * If the packet is fast transmitted and hence fast recycled,
		 * we can be assured that invalidate was already done at the
		 * time of previous transmit
		 */
		if (unlikely(!skb->fast_recycled)) {
			dmac_inv_range_no_dsb((void *)skb->data,
					      (void *)(skb->data + rx_alloc_size -
					      EDMA_RX_SKB_HEADROOM -
					      NET_IP_ALIGN));

		}
		skb->fast_recycled = 0;

The receive region is 1856 bytes, or 29 cache lines, starting at head + 192. The previous transmit cleaned only two or three lines from a different offset.

Dirty lines outside that cleaned range can be written back after the NIC's DMA, replacing 64 bytes of the received packet at a time. Normal cache eviction can cause the write-back. On arm64, invalidating an unaligned range can also do it: __dma_inv_area uses dc civac, clean and invalidate, on the partial edge lines. Once the dirty data has overwritten the packet in DRAM, invalidating the CPU cache cannot recover the received bytes.

The 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. The 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.

Buffer recycling is enabled by CONFIG_SKB_RECYCLER, a compile-time option with no runtime switch. Disabling 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.

Only 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.

Opus 5.5 found the bug and then stopped talking about it#

When I moved from diagnosing the corruption to applying the fix, Opus 5.5 started cutting sessions off roughly every other message. Mentioning something like "local privilege escalation" is enough. I have the Cyber Verification Program enabled on my account, which hasn't helped. Opus 4.8 worked through the exploitation without that problem.

5.5's guards also kill the session when I just ask it to read this article. GPT-6 Astra reads and edits the same article without any cyber verification.

Before and after#

I tested raw TCP transfers between the same two machines with the offload enabled and disabled.

OffloadDirectionDataSpeedBad 64-byte blocks
onLAN -> Wi-Fi8192 MiB103.4 MiB/s3
offLAN -> Wi-Fiovernight run95.4 MiB/s0

Disabling the offload cost about 8% of the download speed in this test. I have to give up performance to get correct data because TP-Link won't let me properly fix my own fucking device.

With the offload on, I could never download a large file over Wi-Fi in a single stream; only chunked downloads got through. With the offload off, the overnight run came back with zero bad blocks, and large downloads have worked every time since. Uploads from Wi-Fi to the LAN were already clean before the fix.

The NOT_SUPPORT_BRIDGE counter in /sys/sfe/exceptions increases after disabling the offload, as bridged flows take the normal path instead of being accelerated.

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. If 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.

I need to report this to Qualcomm, but the public repository is a mirror and I haven't figured out where to send it.