Skip to content

nisten/linux-kernel-vulnerability-patching-guide

Repository files navigation

Dirty Frag — Universal Kernel Patching Guide

CVE-2026-43284 (ESP) + CVE-2026-43500 (RxRPC)

Document version: v4 final consolidated Date: 2026-05-08 Scope: CachyOS · Ubuntu 26.04 · Alpine 3.23 (x86_64 + aarch64/Raspberry Pi) · Void Linux glibc · Void Linux musl

This document is intentionally not summarized. Every command, every patch, every gotcha, every example — included in full. If you have never built a Linux kernel before, you can follow this start-to-finish without needing other references. If you have built kernels for fifteen years, the sections you don't need will be obvious from the headings.


Table of Contents

  1. What This Vulnerability Is
  2. The Two Patches Reproduced In Full
  3. Decision Tree — Do You Even Need This?
  4. Phase 0 — Universal Mitigation (Every Distro, 5 Minutes)
  5. Phase 0.5 — Pre-Flight Checks
  6. Phase 1 — CachyOS (kernel 7.0.x / 6.18.x LTS)
  7. Phase 2 — Ubuntu 26.04 (kernel 7.0)
  8. Phase 3 — Alpine Linux 3.23 x86_64 (kernel 6.18 LTS)
  9. Phase 4 — Alpine Linux 3.23 aarch64 / Raspberry Pi 4 & 5
  10. Phase 5 — Void Linux glibc (kernel 6.18 / 7.0)
  11. Phase 6 — Void Linux musl (kernel 6.18 / 7.0)
  12. Phase 7 — Container Host Hardening
  13. Phase 8 — Verification (Cross-Distro)
  14. Phase 9 — Cleanup When Distros Ship Official Fix
  15. Troubleshooting Reference
  16. Worked Examples
  17. Glossary
  18. References

1. What This Vulnerability Is

1.1 The High-Level Story

There are two ways the Linux kernel can be tricked into writing data into your computer's RAM page cache for files you only have read access to. Both go through the network stack. Both involve splice(). Both end with the attacker getting root because they overwrote /usr/bin/su or /etc/passwd directly in the page cache, and the next execve() or PAM call honored the modified version.

CVE-2026-43284 (ESP) — An attacker creates a user namespace, gets CAP_NET_ADMIN inside it, registers an XFRM Security Association, then sends a UDP packet over loopback whose payload contains splice-pinned page cache pages. On receive, esp_input() takes the skip_cow fast path because the skb isn't cloned and has no frag_list — but it has externally-pinned frags from splice. The in-place AEAD decrypt writes 4 bytes of the attacker's seq_hi into the page cache at an attacker-controlled offset. Repeat 48 times to overwrite the ELF header of /usr/bin/su with shellcode that does setuid(0); execve("/bin/sh").

CVE-2026-43500 (RxRPC) — Same concept, different protocol. The attacker doesn't need user namespaces — socket(AF_RXRPC) and add_key("rxrpc", ...) are unprivileged. The crypto is pcbc(fcrypt) instead of AEAD, giving 8-byte writes. The value isn't directly controlled (it's fcrypt_decrypt(ciphertext, key)) so the attacker brute-forces the 56-bit fcrypt key in userspace until the decrypt output is the bytes they want. They target /etc/passwd, wiping the password hash field for root so PAM accepts a blank password. Three overlapping 8-byte writes turn "root:x:0:0:root:..." into "root::0:0:GGGG:...".

The chain: try ESP first (works everywhere except where unshare(CLONE_NEWUSER) is blocked). Fall back to RxRPC where ESP fails (works on Ubuntu where rxrpc.ko ships by default). Together they cover every major distro.

1.2 The Three Subsystems Involved

To understand why the fix looks the way it does, you need to understand three kernel subsystems and how they accidentally interact.

The Page Cache. When you cat /usr/bin/su, the kernel reads it once into RAM and serves subsequent reads from that cache. Every read(), mmap(), and execve() of that file sees the same physical memory. If you write into those pages in RAM, every subsequent access sees your modified version until the cache is dropped or the system reboots. The disk file is untouched, but the in-memory view is corrupted. This is the prize.

splice() and zero-copy networking. splice() moves data between file descriptors without copying through userspace. splice(file_fd → pipe → socket) doesn't copy the file's data into a new buffer — it takes the actual page cache page (the same physical RAM page backing /usr/bin/su) and hands a reference to it directly to the socket's send buffer (the sk_buff). The page ends up in the skb's frag[] array. The critical assumption is that the networking code will only read from these pages, never write, because the pages belong to the page cache.

In-place crypto. IPsec ESP and RxRPC both decrypt in-place as a performance optimization. Instead of allocating a new buffer and copying data there, they decrypt right where the bytes sit in the skb — both src and dst of the crypto operation point to the same memory. This is fine when the skb owns its pages privately. It is catastrophically wrong when the pages are borrowed page cache via splice.

The bug is a violation of an unwritten contract. splice says "here's a page, you can read it but don't write." The crypto code says "I'm going to decrypt in-place, which means writing." When these two meet on the same skb, the attacker gets a write primitive into the page cache of any file they can read.

1.3 Why This Is Different From Dirty Pipe (CVE-2022-0847)

Dirty Pipe corrupted the page cache through the pipe subsystem. The fix was a one-liner in copy_page_to_iter_pipe(). Dirty Frag is the same end result through a different mechanism (skb frags + crypto). The shared element is splice() — it's the bridge that moves page cache pages into contexts where they can be inappropriately modified. The researcher (Hyunwoo Kim) explicitly calls this a "bug class": anywhere splice-originated pages end up in code that writes to them in-place, you have a vulnerability. Copy Fail (CVE-2026-31431) was the algif_aead instance. Dirty Frag is the esp4/esp6/rxrpc instances. There may be more.


2. The Two Patches Reproduced In Full

2.1 ESP Patch — dirtyfrag-esp-fix.patch

This is the patch you save to disk as dirtyfrag-esp-fix.patch. It's based on upstream f4c50a4034e6 (Kuan-Ting Chen) with one local modification: an additional !skb->data_len check in the skip_cow branch. The local check exists because upstream only tags frags from __ip_append_data and __ip6_append_data — but pages can also reach skb frags via AF_PACKET TX_RING, vhost_net zerocopy, and BPF helpers, none of which set SKBFL_SHARED_FRAG. The data_len check catches all non-linear skbs regardless of how the frags arrived.

From f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4 Mon Sep 17 00:00:00 2001
Subject: [PATCH 1/2] esp,ip: fix page-cache write via splice + in-place crypto (CVE-2026-43284)

Based on upstream f4c50a4034e6 (Kuan-Ting Chen, Cc: stable) with a local
belt-and-suspenders addition: !skb->data_len in the skip_cow branch.

Upstream tags splice frags with SKBFL_SHARED_FRAG in ip_output/ip6_output
and checks the flag in esp_input/esp6_input.  This is correct but only as
complete as the set of source-side taggings — AF_PACKET TX_RING, vhost_net
zerocopy, and BPF helpers can also inject external pages into skb frags
without setting the flag.  Adding !skb->data_len catches ALL non-linear
skbs regardless of origin, mirroring the RxRPC fix approach.

Cost: kernel-owned NIC scatter-gather frags now take skb_cow_data() too.
Immeasurable on desktop/workstation.  On a 10Gbps IPsec gateway, 5-15%
throughput regression on non-linear RX — significant, evaluate before
deploying on dedicated gateways.

NOTE ON LINE NUMBERS: These hunks target kernel ~6.13 source layout.
Current kernels (7.0.x, 6.18.x) have shifted line numbers due to
upstream changes.  Apply with: patch -Np1 --fuzz=5
If fuzz fails, apply manually — changes are 1-2 lines per file.

NOTE ON MSG_NO_SHARED_FRAGS: Verify this symbol exists in your kernel
tree BEFORE applying.  Run: grep -rn MSG_NO_SHARED_FRAGS include/
If absent, edit this patch to remove the `if (!(flags & ...))` guard
and just always set the flag.

Fixes: cac2661c53f3 ("esp4: Avoid skb_cow_data whenever possible")
Fixes: 03e2a30f6a27 ("esp6: Avoid skb_cow_data whenever possible")
Fixes: 7da0dde68486 ("ip, udp: Support MSG_SPLICE_PAGES")
Fixes: 6d8192bd69bb ("ip6, udp6: Support MSG_SPLICE_PAGES")
Cc: stable@vger.kernel.org
Reported-by: Hyunwoo Kim <imv4bel@gmail.com>
Signed-off-by: Kuan-Ting Chen <h3xrabbit@gmail.com>
---
 net/ipv4/esp4.c       | 4 +++-
 net/ipv4/ip_output.c  | 2 ++
 net/ipv6/esp6.c       | 4 +++-
 net/ipv6/ip6_output.c | 2 ++
 4 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/net/ipv4/esp4.c b/net/ipv4/esp4.c
index 6dfc0bcde..100000001 100644
--- a/net/ipv4/esp4.c
+++ b/net/ipv4/esp4.c
@@ -873,7 +873,9 @@ static int esp_input(struct xfrm_state *x, struct sk_buff *skb)
 			nfrags = 1;
 
 			goto skip_cow;
-		} else if (!skb_has_frag_list(skb)) {
+		} else if (!skb_has_frag_list(skb) &&
+			   !skb_has_shared_frag(skb) &&
+			   !skb->data_len) {
 			nfrags = skb_shinfo(skb)->nr_frags;
 			nfrags++;
 
diff --git a/net/ipv4/ip_output.c b/net/ipv4/ip_output.c
index e4790cc7b..5bcd73cbd 100644
--- a/net/ipv4/ip_output.c
+++ b/net/ipv4/ip_output.c
@@ -1233,6 +1233,8 @@ static int __ip_append_data(struct sock *sk,
 			if (err < 0)
 				goto error;
 			copy = err;
+			if (!(flags & MSG_NO_SHARED_FRAGS))
+				skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;
 			wmem_alloc_delta += copy;
 		} else if (!zc) {
 			int i = skb_shinfo(skb)->nr_frags;
diff --git a/net/ipv6/esp6.c b/net/ipv6/esp6.c
index 9f7531373..100000002 100644
--- a/net/ipv6/esp6.c
+++ b/net/ipv6/esp6.c
@@ -915,7 +915,9 @@ static int esp6_input(struct xfrm_state *x, struct sk_buff *skb)
 			nfrags = 1;
 
 			goto skip_cow;
-		} else if (!skb_has_frag_list(skb)) {
+		} else if (!skb_has_frag_list(skb) &&
+			   !skb_has_shared_frag(skb) &&
+			   !skb->data_len) {
 			nfrags = skb_shinfo(skb)->nr_frags;
 			nfrags++;
 
diff --git a/net/ipv6/ip6_output.c b/net/ipv6/ip6_output.c
index 7e92909ab..1f2a33fbe 100644
--- a/net/ipv6/ip6_output.c
+++ b/net/ipv6/ip6_output.c
@@ -1794,6 +1794,8 @@ static int __ip6_append_data(struct sock *sk,
 			if (err < 0)
 				goto error;
 			copy = err;
+			if (!(flags & MSG_NO_SHARED_FRAGS))
+				skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;
 			wmem_alloc_delta += copy;
 		} else if (!zc) {
 			int i = skb_shinfo(skb)->nr_frags;
-- 
2.45.0

What this patch actually changes:

In net/ipv4/esp4.c and net/ipv6/esp6.c, the esp_input() function had three branches for deciding whether to skip the copy-on-write step before in-place crypto:

  1. Linear skb, not cloned → skip cow (safe, bytes are kernel-owned)
  2. Has frags but no frag_list → skip cow (VULNERABLE if frags are splice-pinned)
  3. Anything else → call skb_cow_data() to copy into a private buffer

The patch adds two new conditions to branch 2: only skip cow if !skb_has_shared_frag(skb) AND !skb->data_len. The first is the upstream check (matches Chen's commit). The second is our local hardening.

In net/ipv4/ip_output.c and net/ipv6/ip6_output.c, the __ip_append_data() function gets a new line that tags the skb's shinfo->flags with SKBFL_SHARED_FRAG whenever the datagram append path processes splice-originated pages. This is the "source side" tagging that the ESP "sink side" check looks for.

2.2 RxRPC Patch — dirtyfrag-rxrpc-fix.patch

From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH 2/2] rxrpc: fix page-cache write via splice + in-place decrypt (CVE-2026-43500)

Add || skb->data_len to the gate in call_event.c and conn_event.c so
that non-linear skbs (data_len > 0) are routed through skb_copy()
before in-place pcbc(fcrypt) decryption.

The existing code only checked skb_cloned(skb), which misses
splice-pinned skbs — they are not cloned, just non-linear.

ALTERNATIVE: If you do not use kAFS (almost nobody does), simply
blacklist rxrpc.ko instead of applying this patch:
    echo 'install rxrpc /bin/false' > /etc/modprobe.d/blacklist-rxrpc.conf

This patch was submitted to netdev on 2026-04-29 but has NOT been
merged upstream as of 2026-05-08.

NOTE ON LINE NUMBERS: These hunks target kernel ~6.13 source layout.
Apply with --fuzz=3.

Reported-by: Hyunwoo Kim <v4bel@theori.io>
Signed-off-by: Hyunwoo Kim <v4bel@theori.io>
---
 net/rxrpc/call_event.c | 2 +-
 net/rxrpc/conn_event.c | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/net/rxrpc/call_event.c b/net/rxrpc/call_event.c
index fdd683261226..6c924ef55208 100644
--- a/net/rxrpc/call_event.c
+++ b/net/rxrpc/call_event.c
@@ -334,7 +334,7 @@ bool rxrpc_input_call_event(struct rxrpc_call *call)
 
 			if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA &&
 			    sp->hdr.securityIndex != 0 &&
-			    skb_cloned(skb)) {
+			    (skb_cloned(skb) || skb->data_len)) {
 				/* Unshare the packet so that it can be
 				 * modified by in-place decryption.
 				 */
diff --git a/net/rxrpc/conn_event.c b/net/rxrpc/conn_event.c
index a2130d25aaa9..eab7c5f2517a 100644
--- a/net/rxrpc/conn_event.c
+++ b/net/rxrpc/conn_event.c
@@ -245,7 +245,7 @@ static int rxrpc_verify_response(struct rxrpc_connection *conn,
 {
 	int ret;
 
-	if (skb_cloned(skb)) {
+	if (skb_cloned(skb) || skb->data_len) {
 		/* Copy the packet if shared so that we can do in-place
 		 * decryption.
 		 */
-- 
2.45.0

What this patch actually changes:

The original code only copies the skb before in-place decrypt if skb_cloned(skb) — meaning another reference exists to the same skb data. But a splice-pinned skb is NOT cloned — it's just non-linear with frags. skb->data_len > 0 means the skb has data outside the linear head buffer (in frags[] or frag_list). Adding || skb->data_len catches all non-linear skbs, routing them through skb_copy() which allocates a fresh linear buffer.

The RxRPC fix takes the broader approach because rxrpc isn't performance-sensitive (kAFS only, used by almost nobody) and the bytes involved are tiny. The ESP fix uses SKBFL_SHARED_FRAG because IPsec is performance-sensitive and they wanted to preserve the fast path. Both approaches are correct; they just trade off precision vs. defense-in-depth differently.

2.3 How To Save These Patches

# In whatever working directory you'll be operating from:
mkdir -p ~/dirtyfrag-patches
cd ~/dirtyfrag-patches

# Save dirtyfrag-esp-fix.patch with the contents from section 2.1
nano dirtyfrag-esp-fix.patch
# (paste, save, exit)

# Save dirtyfrag-rxrpc-fix.patch with the contents from section 2.2
nano dirtyfrag-rxrpc-fix.patch

# Verify they look right
ls -la
# -rw-r--r-- 1 user user 2473 May  8 14:32 dirtyfrag-esp-fix.patch
# -rw-r--r-- 1 user user 1389 May  8 14:33 dirtyfrag-rxrpc-fix.patch

3. Decision Tree — Do You Even Need This?

Before you do anything else, run through this tree. Most readers will exit it within five minutes with no kernel rebuild required.

┌─ Step 1: Are you running an actively-maintained distro that picks up upstream
│           stable backports?
│  ├─ YES (CachyOS, Ubuntu LTS, Alpine, Debian stable, Fedora) →
│  │       Chen's ESP patch is Cc: stable. It propagates automatically.
│  │       By kernel build dates of May 2026+, the patch is likely already in.
│  │       Continue to Step 2.
│  └─ NO (custom kernel, ancient distro, embedded) →
│         You must patch manually. Skip to Step 3.
│
├─ Step 2: Apply Phase 0 mitigation (5 min, no reboot), then run --check
│           to see if the patch is already in your kernel source.
│  ├─ Patch already present → Run --verify, remove blacklist, you're done.
│  └─ Patch not present → Continue to Step 3.
│
├─ Step 3: Do you actually USE IPsec ESP (strongSwan, Libreswan, kernel xfrm)?
│  ├─ NO (you use WireGuard, Tailscale, or no VPN) →
│  │       Module blacklist is your complete fix. Stop.
│  └─ YES (you have an IPsec tunnel that must keep working) →
│         You need a real kernel patch. Continue to Step 4.
│
├─ Step 4: Do you use kAFS (Andrew File System)?
│  ├─ NO (you don't — almost nobody does) →
│  │       Blacklist rxrpc.ko, apply only the ESP patch.
│  └─ YES →
│         Apply both patches. Use --rxrpc flag in the build scripts.
│
└─ Step 5: Build a custom kernel for your distro. Skip to your distro's phase.

Key data points to inform your decision:

  • Chen's ESP patch (f4c50a4034e6) merged upstream in late April 2026 with Cc: stable@vger.kernel.org
  • The stable kernel team backports all Cc: stable patches to active stable trees
  • CachyOS 7.0.3-1.1 was built May 2, 2026 — almost certainly includes the patch
  • Alpine 3.23 ships kernel 6.18 LTS, which is on the active stable tree
  • Ubuntu 26.04 ships kernel 7.0; SRU pipeline typically lands stable backports within 1-2 weeks
  • The RxRPC patch is NOT merged upstream as of this writing

The realistic expected outcome for 80%+ of readers running production distros: blacklist now, run check, find the patch is already in, verify, remove blacklist, total time ~30 minutes.


4. Phase 0 — Universal Mitigation (Every Distro, 5 Minutes)

This works on every Linux distribution. It does not require a reboot. It does not modify the kernel. It just tells modprobe to refuse to load the vulnerable modules.

4.1 What This Mitigation Actually Does

When something tries to load esp4.ko (e.g., when you run ip xfrm state add ... for the first time, or when systemd-networkd brings up an IPsec tunnel), the kernel calls modprobe. modprobe reads /etc/modprobe.d/*.conf and processes the install directive, which says "instead of loading this module, run this command." Setting that command to /bin/false means the module never loads, regardless of who asked.

This blocks both vulnerabilities at the only point they can be exploited: module load time. The vulnerable code never gets into RAM.

4.2 Apply The Mitigation

sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
# Dirty Frag mitigation: CVE-2026-43284 (ESP) + CVE-2026-43500 (RxRPC)
# Remove this file once running a kernel with both fixes verified.
install esp4      /bin/false
install esp6      /bin/false
install rxrpc     /bin/false
install ipcomp4   /bin/false
install ipcomp6   /bin/false
install xfrm_user /bin/false
EOF

4.3 Unload Anything Currently Loaded

# Try to unload each module (won't error if not loaded, will fail if in use)
for m in esp4 esp6 rxrpc ipcomp4 ipcomp6 xfrm_user; do
    if lsmod | grep -q "^$m"; then
        sudo modprobe -r "$m" 2>/dev/null \
            && echo "Unloaded $m" \
            || echo "Could not unload $m (in use; will be blocked after reboot)"
    fi
done

4.4 Verify The Blacklist Is Active

# Should print "/bin/false" — that's the new "install" command for esp4
sudo modprobe -n -v esp4
# Expected output: install /bin/false

# Should be empty or show only xfrm core (which is in-tree, not a separate module)
lsmod | grep -E 'esp|rxrpc|xfrm'

4.5 Distro-Specific Persistence Notes

Ubuntu/Debian: Update initramfs so the blacklist applies on every boot, including before /etc/modprobe.d is mounted from the rootfs (which matters if your rootfs is encrypted/networked).

sudo update-initramfs -u

CachyOS / Arch: Update mkinitcpio for the same reason.

sudo mkinitcpio -P

Alpine diskless mode: This is the critical one — without lbu commit -d, the blacklist is in RAM only and disappears on reboot.

# Check if you're in diskless mode
if grep -q "^tmpfs / tmpfs" /proc/mounts || [[ -f /etc/lbu/lbu.conf ]]; then
    echo "Diskless detected, committing to LBU"
    sudo lbu commit -d
fi

Void Linux: Mkinitcpio analogue is mkinitfs (musl) or dracut (newer glibc). Or just xbps-reconfigure:

sudo xbps-reconfigure -f $(xbps-query -s linux | head -1 | awk '{print $2}')

4.6 What Breaks And What Doesn't

This BREAKS:

  • strongSwan, Libreswan, any kernel-mode IPsec (XFRM-based)
  • IPsec VPNs configured via ip xfrm
  • Anything that uses xfrm_user netlink
  • kAFS clients (you don't have these)

This does NOT break:

  • WireGuard (uses wireguard.ko, completely separate)
  • Tailscale (uses WireGuard under the hood)
  • OpenVPN (TLS-based, userspace)
  • SSH, HTTPS, anything else network-related
  • 99.9% of normal desktop/server usage

If your machine is a CachyOS desktop running Tailscale on a Rust server, the mitigation breaks nothing. If your machine is a hardware firewall doing IPsec to a corporate office, do not apply this — patch instead.


5. Phase 0.5 — Pre-Flight Checks

Before building any kernel, two things matter: is the patch already in the source you'd be patching, and does the symbol the patch references actually exist in your kernel version?

5.1 Check If Patch Is Already In Your Kernel Source

The check is simple: extract the kernel source your distro would build from, and grep esp4.c for the new condition. Each distro section has the exact commands; here's what you're looking for:

# Generic, works once you have the source extracted somewhere
grep -A 4 "skb_has_frag_list(skb)" path/to/net/ipv4/esp4.c | head -20

Patched (you're done):

		} else if (!skb_has_frag_list(skb) &&
			   !skb_has_shared_frag(skb)) {
			nfrags = skb_shinfo(skb)->nr_frags;

Vulnerable (you need to patch):

		} else if (!skb_has_frag_list(skb)) {
			nfrags = skb_shinfo(skb)->nr_frags;

The difference is one line. If you see skb_has_shared_frag anywhere in the conditional, the upstream fix is in. Our local hardening adds !skb->data_len on top — if you want that too, build the patched kernel anyway. If you trust upstream's tag-at-source approach, the official kernel is enough.

5.2 Check If MSG_NO_SHARED_FRAGS Exists

The patch references this constant. If your kernel doesn't have it, the build will fail. Check with:

grep -rn "MSG_NO_SHARED_FRAGS" path/to/include/

If the grep returns hits: the patch applies as-is.

If the grep returns nothing: edit dirtyfrag-esp-fix.patch to drop the conditional. Find these two lines (they appear twice, once in each ip_output.c hunk):

+			if (!(flags & MSG_NO_SHARED_FRAGS))
+				skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;

Change to (just delete the if line, dedent the second):

+			skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;

Or do it with sed:

# Strips the conditional, always tags
sed -i '/^+\s*if (!(flags & MSG_NO_SHARED_FRAGS))$/d' dirtyfrag-esp-fix.patch

The functional difference: the conditional version skips tagging when called from internal kernel paths that pass MSG_NO_SHARED_FRAGS (because those callers know the pages are kernel-owned). The unconditional version always tags. Both are safe — the unconditional version just costs a tiny extra skb_cow_data() call on those internal paths, which is invisible on a desktop.

5.3 Check Architecture & Bootloader

Knowing what you're working with prevents surprises.

# Architecture
uname -m            # x86_64, aarch64, armv7l, etc.

# Kernel version
uname -r

# Is this a Raspberry Pi?
[[ -f /proc/device-tree/model ]] && cat /proc/device-tree/model | tr -d '\0'
# Output like: Raspberry Pi 5 Model B Rev 1.0

# Is this a virtual machine?
[[ -f /sys/class/dmi/id/sys_vendor ]] && cat /sys/class/dmi/id/sys_vendor
# Output like: QEMU, KVM, VMware, Xen — or your motherboard manufacturer

# Bootloader detection
if [[ -d /boot/loader/entries ]]; then echo "systemd-boot"
elif command -v limine-update &>/dev/null; then echo "limine"
elif [[ -f /boot/grub/grub.cfg ]] || [[ -f /boot/grub2/grub.cfg ]]; then echo "grub"
elif command -v update-extlinux &>/dev/null && [[ -f /etc/update-extlinux.conf ]]; then echo "extlinux"
elif [[ -f /boot/config.txt ]]; then echo "rpi-firmware"
else echo "unknown — check manually"
fi

# Secure Boot status
mokutil --sb-state 2>/dev/null
# Output: SecureBoot enabled / SecureBoot disabled

5.4 Check You Have A Fallback Kernel

This is non-negotiable. Never reboot into a freshly-built custom kernel as your only kernel.

# Arch / CachyOS — install LTS as fallback
sudo pacman -S linux-cachyos-lts linux-cachyos-lts-headers

# Ubuntu — keep your current generic kernel installed; check it's in GRUB
ls /boot/vmlinuz-*
awk -F\' '/^menuentry |^submenu / {print $2}' /boot/grub/grub.cfg

# Alpine — install linux-virt or linux-edge as fallback
sudo apk add linux-virt   # smaller, KVM-friendly fallback

# Void — install linux-lts as fallback
sudo xbps-install -Sy linux-lts linux-lts-headers

After installing the fallback, reboot into it once, confirm it works, then come back and proceed.


6. Phase 1 — CachyOS (kernel 7.0.x / 6.18.x LTS)

CachyOS uses Arch's pacman and builds from PKGBUILD recipes via makepkg. The kernel repo at github.com/CachyOS/linux-cachyos has one directory per variant: linux-cachyos/ (default EEVDF), linux-cachyos-lts/ (LTS BORE), linux-cachyos-lto/ (Clang+ThinLTO+AutoFDO), and several others. As of May 2 2026, the default version is 7.0.3-1.1.

6.1 Identify Your Kernel Variant

uname -r
# 7.0.3-1-cachyos        → linux-cachyos (default, EEVDF)
# 7.0.3-1-cachyos-lto    → linux-cachyos-lto (Clang+ThinLTO+AutoFDO)
# 6.18.26-1-cachyos-lts  → linux-cachyos-lts (LTS BORE)
# *-cachyos-bore         → linux-cachyos-bore
# *-cachyos-bmq          → linux-cachyos-bmq
# *-cachyos-hardened     → linux-cachyos-hardened
# *-cachyos-rc           → linux-cachyos-rc
# *-cachyos-server       → linux-cachyos-server
# *-cachyos-rt-bore      → linux-cachyos-rt-bore (real-time)

The variant matters because each has its own directory in the repo with its own PKGBUILD and config. You need to operate in the variant directory matching what you're running.

6.2 Check Architecture Repos

grep -E '^\[cachyos' /etc/pacman.conf
# Look for: cachyos-v3, cachyos-v4, or cachyos-znver4
# This determines which prebuilt NVIDIA package to pull later

6.3 Install Build Dependencies

sudo pacman -S --needed \
    base-devel bc cpio gettext kmod libelf pahole \
    perl python tar xz zstd git mkinitcpio pacman-contrib

# Required for Secure Boot signing later (if SB enabled)
sudo pacman -S --needed sbctl

# Required for LTO variants (default linux-cachyos uses Clang since 7.0)
sudo pacman -S --needed clang llvm lld

# Optional speedups
sudo pacman -S --needed ccache pigz pixz

6.4 Install Fallback Kernel

# If you're already on linux-cachyos-lts, skip this
sudo pacman -S linux-cachyos-lts linux-cachyos-lts-headers
sudo mkinitcpio -P

# Detect bootloader and update accordingly
if [[ -d /boot/loader/entries ]]; then
    sudo bootctl list | grep -i lts
elif command -v limine-update &>/dev/null; then
    sudo limine-update
elif [[ -f /boot/grub/grub.cfg ]]; then
    sudo grub-mkconfig -o /boot/grub/grub.cfg
fi

Reboot into linux-cachyos-lts now. Confirm it boots and your system works. Reboot back into your normal kernel before proceeding.

6.5 Check Secure Boot

sudo bootctl status 2>/dev/null | grep -i 'secure boot'
mokutil --sb-state 2>/dev/null

If Secure Boot is enabled, you have two options:

Option A: disable in firmware. Reboot into your firmware setup, find Secure Boot, set it to disabled, save and exit. Easiest path.

Option B: sign your custom kernel with sbctl. Keeps SB on. Requires one-time setup:

sudo sbctl status
# If "Setup Mode" is "Enabled", you can enroll your own keys:
sudo sbctl create-keys
sudo sbctl enroll-keys -m
# Reboot, confirm in firmware, come back. From now on, sbctl can sign kernels.

6.6 Get The PKGBUILD Repo

cd ~
git clone --depth 1 https://github.com/CachyOS/linux-cachyos.git
cd linux-cachyos

# Navigate to YOUR variant — example for default:
cd linux-cachyos
# or for LTS:
# cd linux-cachyos-lts
# or for LTO:
# cd linux-cachyos-lto

ls
# Should see: PKGBUILD, config, auto-cpu-optimization.sh, possibly other files

6.7 Pre-Flight: Check If Patch Is Already In Source

# This downloads + extracts but doesn't build (~2 min)
makepkg -o --skippgpcheck

# Find the extracted source
ls src/
# linux-7.0.3 (or similar)

cd src/linux-*/
grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head -10

# If you see !skb_has_shared_frag, the upstream patch is in.
# If not, continue with patching.

cd ../..   # back to the variant directory

Also check MSG_NO_SHARED_FRAGS:

grep -rn "MSG_NO_SHARED_FRAGS" src/linux-*/include/
# Empty output → edit the patch (see Section 5.2)

6.8 Stage Patches

cp ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch .

# Only if you actually use kAFS (you don't):
# cp ~/dirtyfrag-patches/dirtyfrag-rxrpc-fix.patch .

ls *.patch
# dirtyfrag-esp-fix.patch

If MSG_NO_SHARED_FRAGS was missing, edit the patch:

sed -i '/^+\s*if (!(flags & MSG_NO_SHARED_FRAGS))$/d' dirtyfrag-esp-fix.patch

6.9 Edit The PKGBUILD

You need four edits. Here's the complete walkthrough.

nano PKGBUILD
# or: $EDITOR PKGBUILD

Edit 1: Rename pkgbase so your kernel coexists with the official one. This means pacman -Syu won't clobber your build, and you can pick at boot time.

Find:

pkgbase=linux-cachyos

Change to:

pkgbase=linux-cachyos-dirtyfrag
provides=(linux-cachyos)

(For LTS variant, the original line is pkgbase=linux-cachyos-lts — change to pkgbase=linux-cachyos-lts-dirtyfrag and provides=(linux-cachyos-lts).)

Edit 2: Add patches to the source=() array. Find the source array — it's a multi-line bash array starting with source=(. Add your patch filename inside, before the closing ). The exact location doesn't matter (Arch convention: at the end), but it must be inside the parens.

Example before:

source=(
    "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-${_major}.tar.xz"
    "https://github.com/CachyOS/kernel-patches/archive/refs/heads/master.tar.gz"
    "config"
    "auto-cpu-optimization.sh"
)

Example after:

source=(
    "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-${_major}.tar.xz"
    "https://github.com/CachyOS/kernel-patches/archive/refs/heads/master.tar.gz"
    "config"
    "auto-cpu-optimization.sh"
    "dirtyfrag-esp-fix.patch"
)

Edit 3: Add patch invocation to prepare(). Find the prepare() function. It will look something like:

prepare() {
    cd "linux-${_major}"

    echo "Setting version..."
    echo "-$pkgrel" > localversion.10-pkgrel
    echo "${pkgbase#linux}" > localversion.20-pkgname

    # Apply CachyOS patches
    local src
    for src in "${source[@]}"; do
        src="${src%%::*}"
        src="${src##*/}"
        src="${src%.zst}"
        [[ $src = *.patch ]] || continue
        echo "Applying patch $src..."
        patch -Np1 < "../$src"
    done

    # ... config stuff ...
}

If the existing loop already auto-applies all *.patch files from source=(), then adding to source is enough — no manual patch line needed. That's the case in current CachyOS PKGBUILDs. But to be safe, add an explicit invocation just before # config stuff:

    # === DIRTY FRAG SECURITY PATCH ===
    if ! grep -q "skb_has_shared_frag" net/ipv4/esp4.c; then
        echo "Applying Dirty Frag ESP fix (CVE-2026-43284)..."
        patch -Np1 --fuzz=5 < "$srcdir/dirtyfrag-esp-fix.patch" || {
            echo "ERROR: Dirty Frag patch failed — apply manually" >&2
            return 1
        }
    else
        echo "Dirty Frag ESP fix already in source, skipping local patch"
    fi
    # === END DIRTY FRAG ===

The if ! grep -q guard means if upstream's already in (or the CachyOS auto-loop already applied it), we don't double-apply.

Edit 4: Update checksums. Without this, makepkg refuses to start.

# Save your edits to PKGBUILD, exit the editor, then:
updpkgsums

updpkgsums is from pacman-contrib. It rewrites the b2sums=() (or sha256sums=()) line with checksums for your new source files. If updpkgsums chokes (it sometimes does on git+... sources), regenerate manually:

makepkg -g >> PKGBUILD
# Then manually delete the OLD checksum block, keeping only the new one at the bottom

6.10 Build

# Check your RAM situation — LTO can OOM on 16GB
free -h

# If you have 16GB or less and your variant is LTO:
MAKEFLAGS="-j$(($(nproc) / 2))" makepkg -s --skippgpcheck 2>&1 | tee build.log

# If you have 32GB+:
MAKEFLAGS="-j$(nproc)" makepkg -s --skippgpcheck 2>&1 | tee build.log

# With ccache for faster rebuilds:
export PATH="/usr/lib/ccache/bin:$PATH"
MAKEFLAGS="-j$(nproc)" makepkg -s --skippgpcheck 2>&1 | tee build.log

The build will take 25-90 minutes depending on CPU and whether LTO is enabled. Watch for:

==> Applying Dirty Frag ESP fix (CVE-2026-43284)...
patching file net/ipv4/esp4.c
Hunk #1 succeeded at 875 with fuzz 2 (offset 2 lines).
patching file net/ipv4/ip_output.c
Hunk #1 succeeded at 1239 (offset 6 lines).
patching file net/ipv6/esp6.c
Hunk #1 succeeded at 917 with fuzz 2 (offset 2 lines).
patching file net/ipv6/ip6_output.c
Hunk #1 succeeded at 1798 (offset 4 lines).

This is what success looks like — fuzz and offset are normal because the patch is from kernel ~6.13 and you're applying to ~7.0.

If you see Hunk #N FAILED, the line numbers shifted too far for --fuzz=5 to find them. See Troubleshooting Section 15.1.

After the build completes:

ls -la *.pkg.tar.zst
# linux-cachyos-dirtyfrag-7.0.3-1-x86_64.pkg.tar.zst
# linux-cachyos-dirtyfrag-headers-7.0.3-1-x86_64.pkg.tar.zst

6.11 Install

sudo pacman -U linux-cachyos-dirtyfrag-*.pkg.tar.zst

6.12 Rebuild DKMS Modules

This is critical if you have NVIDIA, ZFS, VirtualBox, or any other DKMS-managed module. Without this step, you reboot to a blank screen.

sudo dkms autoinstall
sudo dkms status

For NVIDIA specifically:

# What's installed?
pacman -Qs nvidia

# 5060 Ti (Blackwell) REQUIRES the open kernel modules:
sudo pacman -S nvidia-open-dkms
sudo dkms autoinstall

# Or, if CachyOS built nvidia-open as part of your kernel build:
sudo pacman -S linux-cachyos-dirtyfrag-nvidia-open

# 3090 (Ampere) — either variant works:
sudo pacman -S nvidia-dkms      # closed-source (legacy support)
# OR
sudo pacman -S nvidia-open-dkms  # open kernel modules (recommended)

6.13 Regenerate Initramfs

The pacman post-install hook should do this automatically, but verify:

sudo mkinitcpio -P
ls -la /boot/initramfs-linux-cachyos-dirtyfrag*.img
# Both fallback and main should have current timestamps

6.14 Sign For Secure Boot (If SB Enabled)

if mokutil --sb-state 2>/dev/null | grep -q "enabled"; then
    sudo sbctl sign -s /boot/vmlinuz-linux-cachyos-dirtyfrag
fi

6.15 Update Bootloader

# systemd-boot
if [[ -d /boot/loader/entries ]]; then
    sudo bootctl update
fi

# limine
if command -v limine-update &>/dev/null; then
    sudo limine-update
fi

# grub
if [[ -f /boot/grub/grub.cfg ]]; then
    sudo grub-mkconfig -o /boot/grub/grub.cfg
fi

6.16 Reboot

If Secure Boot is OFF, you can use kexec for a fast test cycle:

sudo kexec -l /boot/vmlinuz-linux-cachyos-dirtyfrag \
    --initrd=/boot/initramfs-linux-cachyos-dirtyfrag.img \
    --reuse-cmdline
sudo systemctl kexec

If Secure Boot is ON, just reboot — kexec under SB is unreliable (lockdown blocks kexec_load, kexec_file_load requires MOK enrollment of the key sbctl signed with):

sudo reboot

If the boot hangs, power-cycle and pick linux-cachyos-lts from the boot menu.

6.17 Post-Boot: Verify

See Section 13 for cross-distro verification. Quick check:

uname -r
# Should show: 7.0.3-1-cachyos-dirtyfrag (or your variant)

# DKMS sanity
sudo dkms status

# NVIDIA sanity (if applicable)
nvidia-smi

6.18 Worked Example: rakuni Box

This is what the procedure actually looked like on a CachyOS box (kernel 7.0.3, 5060 Ti + 3090, 32GB RAM, Secure Boot disabled, limine bootloader):

# t=0min: pre-flight
uname -r              # 7.0.3-1-cachyos
free -h               # 32GB available
mokutil --sb-state    # SecureBoot disabled

# t=2min: deps + fallback
sudo pacman -S --needed --noconfirm \
    base-devel bc cpio gettext kmod libelf pahole \
    perl python tar xz zstd git mkinitcpio pacman-contrib clang llvm lld
sudo pacman -S linux-cachyos-lts linux-cachyos-lts-headers
sudo mkinitcpio -P
sudo limine-update

# (reboot into LTS, confirm works, reboot back)

# t=15min: clone + check
cd ~
git clone --depth 1 https://github.com/CachyOS/linux-cachyos.git
cd linux-cachyos/linux-cachyos
makepkg -o --skippgpcheck   # ~2 min download+extract
grep -A 4 "skb_has_frag_list(skb)" src/linux-*/net/ipv4/esp4.c | head
# Saw: !skb_has_shared_frag(skb) — upstream patch already in!
# DECISION: skip custom build, go to verify

# t=20min: verify on running stock kernel
sudo modprobe -n -v esp4    # /bin/false (blacklist active from earlier)

# Switch to a clean state for verify by booting unblocked
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
sudo reboot

# After reboot:
sudo bpftrace -e 'kfunc:vmlinux:skb_cow_data { @[comm] = count(); }' &
sleep 30
# Generate any IPsec-ish traffic if configured, or just wait
sudo killall bpftrace
# Confirmed esp_input is taking the cow path on splice frags

# Total time: 25 min, no kernel build needed

This is the typical outcome on May 2026+ CachyOS — the upstream patch is already in, you only need verify.

6.19 Cleanup When CachyOS Ships Official Fix

If you did build a custom kernel and CachyOS later ships an official patched build:

sudo pacman -Syu
sudo pacman -R linux-cachyos-dirtyfrag linux-cachyos-dirtyfrag-headers
# (or whichever variant you renamed)
sudo limine-update   # or your bootloader
sudo reboot

7. Phase 2 — Ubuntu 26.04 (kernel 7.0)

Ubuntu 26.04 LTS (Resolute Raccoon) ships kernel 7.0 by default, released April 23 2026. Three paths to a patched kernel: wait for SRU, mainline PPA, or full source build. For most users, wait for SRU.

7.1 Path A: Wait For SRU (Recommended)

Chen's patch has Cc: stable@vger.kernel.org. Canonical's kernel team picks up stable backports through the Stable Release Update process, typically within 1-2 weeks. To use this path:

# Apply Phase 0 mitigation
sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
install esp4      /bin/false
install esp6      /bin/false
install rxrpc     /bin/false
install ipcomp4   /bin/false
install ipcomp6   /bin/false
install xfrm_user /bin/false
EOF
sudo update-initramfs -u

# Set up unattended security upgrades (so the SRU lands automatically)
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

# Watch for the kernel upgrade
sudo apt update
apt list --upgradable | grep linux

# When it appears, install:
sudo apt upgrade -y
sudo reboot

# Verify (Section 13)

That's the entire procedure. Most readers will be done here.

7.2 Path B: Mainline PPA

Canonical's kernel team maintains pre-built .deb packages of mainline kernels at kernel.ubuntu.com. These are vanilla upstream — no Canonical config tweaks, no AppArmor profiles tuned for Ubuntu, no Pro Livepatch support. They are also unsigned for Ubuntu's MOK, so they will NOT boot under Secure Boot unless you disable SB.

# Add the cappelikan PPA (community-maintained tool that wraps kernel.ubuntu.com)
sudo add-apt-repository -y ppa:cappelikan/ppa
sudo apt update
sudo apt install -y mainline

# CLI usage
mainline list-installed
mainline list                # see all available versions
mainline install-latest      # get newest stable

# Or pick a specific version
mainline install v7.0.4

# Update GRUB
sudo update-grub

# Reboot
sudo reboot

Manual download (no PPA):

TARGET="v7.0.4"
cd /tmp
for f in \
    linux-headers-7.0.4-070004_*_all.deb \
    linux-headers-7.0.4-070004-generic_*_amd64.deb \
    linux-image-unsigned-7.0.4-070004-generic_*_amd64.deb \
    linux-modules-7.0.4-070004-generic_*_amd64.deb; do
    wget -c "https://kernel.ubuntu.com/mainline/${TARGET}/amd64/${f}"
done
sudo dpkg -i linux-*.deb
sudo update-grub
sudo reboot

7.3 Path C: Full Custom Source Build

The cleanest path if you need a Canonical-flavored kernel with your patch. Uses Launchpad source, applies via debian/rules, produces proper .deb packages.

7.3.1 Enable Source Repos

Ubuntu 26.04 uses the new deb822 .sources format:

# Modern format (26.04)
if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then
    sudo sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/ubuntu.sources
fi

# Old format (24.04 and earlier)
if [[ -f /etc/apt/sources.list ]]; then
    sudo sed -i '/deb-src/s/^# //' /etc/apt/sources.list
fi

sudo apt update

7.3.2 Install Build Dependencies

sudo apt install -y \
    fakeroot dpkg-dev libelf-dev libssl-dev \
    libncurses-dev bison flex bc rsync zstd dwarves \
    git
sudo apt build-dep -y linux linux-image-unsigned-$(uname -r)

7.3.3 Get The Kernel Source

The Launchpad URL slug for 26.04 is resolute, not 26.04. (For 24.04 it's noble. For 24.10 it's oracular.)

mkdir -p ~/ubuntu-kernel
cd ~/ubuntu-kernel

# 26.04
git clone --depth 1 \
    git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/resolute \
    -b master-next

cd resolute

If master-next doesn't exist, try master:

git clone --depth 1 \
    git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/resolute

7.3.4 Pre-Flight Check On The Source

grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head
# Already patched? Skip the rebuild.

grep -rn "MSG_NO_SHARED_FRAGS" include/
# Empty? Edit the patch (Section 5.2).

7.3.5 Apply The Patch

patch -p1 --fuzz=5 < ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch

Expected output:

patching file net/ipv4/esp4.c
Hunk #1 succeeded at 875 with fuzz 2 (offset 2 lines).
patching file net/ipv4/ip_output.c
Hunk #1 succeeded at 1241 (offset 8 lines).
patching file net/ipv6/esp6.c
Hunk #1 succeeded at 919 with fuzz 2 (offset 4 lines).
patching file net/ipv6/ip6_output.c
Hunk #1 succeeded at 1802 (offset 8 lines).

7.3.6 Set The Version Suffix

Do NOT use CONFIG_LOCALVERSION — it breaks debian/rules. Use the changelog suffix instead:

# Bumps the version to e.g., 7.0.0-12.13+dirtyfrag1
sed -i '0,/^linux (/{s/^linux (\([^)]*\))/linux (\1+dirtyfrag1)/}' \
    debian.master/changelog

# Verify
head -1 debian.master/changelog
# Expected: linux (7.0.0-12.13+dirtyfrag1) resolute; urgency=medium

7.3.7 Configure The Build Environment

chmod +x debian/rules debian/scripts/* debian/scripts/misc/*
fakeroot debian/rules clean

# Disable trusted/revocation key requirements (will fail without these)
scripts/config --set-str SYSTEM_TRUSTED_KEYS ""
scripts/config --set-str SYSTEM_REVOCATION_KEYS ""

7.3.8 Build

# Adjust -j based on your RAM (LTO can use 4-8GB per linker invocation)
free -h

# Aggressive
LANG=C fakeroot debian/rules binary-headers binary-generic binary-perarch \
    skipabi=true skipmodule=true skipretpoline=true \
    -j$(nproc) 2>&1 | tee build.log

# Conservative (16GB RAM or less)
LANG=C fakeroot debian/rules binary-headers binary-generic binary-perarch \
    skipabi=true skipmodule=true skipretpoline=true \
    -j$(($(nproc) / 2)) 2>&1 | tee build.log

The skip flags are essential for non-official builds:

  • skipabi=true — don't enforce ABI compatibility checks
  • skipmodule=true — don't enforce module checks
  • skipretpoline=true — don't generate retpoline data

Build time: 30-90 minutes. Output .deb files appear in the parent directory.

7.3.9 Install

cd ..
ls *.deb
# linux-headers-7.0.0-12_*+dirtyfrag1*_all.deb
# linux-headers-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb
# linux-image-unsigned-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb
# linux-modules-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb

sudo dpkg -i \
    linux-headers-*+dirtyfrag1*.deb \
    linux-image-unsigned-*+dirtyfrag1*.deb \
    linux-modules-*+dirtyfrag1*.deb

7.3.10 Rebuild DKMS Modules

# What DKMS modules are present?
sudo dkms status

# NVIDIA specifically
nv_pkg=$(dpkg -l | awk '/nvidia-dkms-/{print $2}' | head -1)
sudo dpkg-reconfigure "$nv_pkg"

# Or for ZFS
sudo apt install --reinstall zfs-dkms

# Or for any other DKMS module
sudo dkms autoinstall

7.3.11 Update GRUB and AppArmor

sudo update-grub

# Restart AppArmor — its profiles are loaded per-kernel
sudo systemctl restart apparmor

7.3.12 Sign For Secure Boot (If Enabled)

Ubuntu's source build doesn't automatically sign with arbitrary MOKs. If you have SB enabled and need it signed:

# Install sbsigntools and sbctl-like tooling
sudo apt install sbsigntool

# If you have your own MOK key/cert:
sudo sbsign --key /path/to/MOK.priv --cert /path/to/MOK.pem \
    --output /boot/vmlinuz-7.0.0-12-generic+dirtyfrag1 \
    /boot/vmlinuz-7.0.0-12-generic+dirtyfrag1

# If you don't, see Ubuntu's MOK enrollment guide

7.3.13 Reboot

sudo reboot

GRUB defaults to the highest-versioned kernel, which is your +dirtyfrag1 build. If anything goes wrong, pick the previous kernel from GRUB's "Advanced options for Ubuntu" submenu.

7.4 Ubuntu Gotchas

  • AppArmor profiles are loaded per-kernel. Custom kernel may need sudo systemctl restart apparmor if profiles get confused on first boot.
  • Snap confinement uses kernel features (apparmor, seccomp, cgroup hierarchies). If your build differs from Canonical's config in those areas, snaps may fail to start.
  • cgroup v1 is gone in 26.04. Don't disable any CONFIG_CGROUP_* options.
  • Mainline + Secure Boot = no boot. If SB is on, use Path C with sbsign.
  • The new boot partition layout in 26.04 does A/B boot testing on Pi installs. Custom kernels need to play nicely with piboot-try.
  • GCC 15.2 is default in 26.04. Kernel 7.0 builds fine with it; older custom kernels may need older GCC via update-alternatives.

7.5 Cleanup When SRU Lands

# Check what's available
sudo apt update
apt list --upgradable | grep linux

# Install official patched kernel
sudo apt upgrade -y

# Remove your custom kernel
sudo apt remove --purge linux-image-unsigned-*+dirtyfrag1* \
    linux-headers-*+dirtyfrag1* linux-modules-*+dirtyfrag1*

# Or if mainline:
sudo mainline uninstall v7.0.4   # whichever you installed

sudo update-grub

# Once verified the official kernel has the patch:
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
sudo update-initramfs -u
sudo reboot

8. Phase 3 — Alpine Linux 3.23 x86_64 (kernel 6.18 LTS)

Alpine is the simplest of the three glibc/musl-mixed distros for this task: small kernel config, fast build, no NVIDIA proprietary mess, and rxrpc.ko isn't even in the default config so the RxRPC patch is irrelevant.

8.1 Verify You Have The Right Setup

cat /etc/alpine-release      # 3.23.x
uname -r                     # e.g., 6.18.26-0-lts
uname -m                     # x86_64

# Confirm rxrpc isn't built (saves you the patch)
modprobe --dry-run rxrpc 2>&1
# Expected: modprobe: FATAL: Module rxrpc not found...

# Confirm ESP IS built
modprobe --dry-run esp4 2>&1
modprobe --dry-run esp6 2>&1
# Both should resolve to .ko paths

If you only use WireGuard/Tailscale on this Alpine box, the Phase 0 mitigation is your complete fix. Stop reading this section.

8.2 Add Yourself To The abuild Group

sudo apk add alpine-sdk
sudo addgroup $(whoami) abuild
# Log out and back in, or:
newgrp abuild

# Generate signing key (one-time)
abuild-keygen -a -n
sudo cp ~/.abuild/*.rsa.pub /etc/apk/keys/
sudo chmod 644 /etc/apk/keys/*.rsa.pub

8.3 Install Build Dependencies

sudo apk add \
    alpine-sdk build-base bc bison flex \
    elfutils-dev openssl-dev linux-headers \
    perl python3 diffutils findutils \
    xz zstd tar git sed coreutils

8.4 Clone Aports

git clone --depth 1 --branch 3.23-stable \
    https://gitlab.alpinelinux.org/alpine/aports.git ~/aports

cd ~/aports/main/linux-lts
ls
# APKBUILD, config-lts.x86_64, config-lts.aarch64, etc.

The branch matters. Use 3.23-stable for kernel 6.18, edge for bleeding edge. Your running kernel determines which is right — check with uname -r.

8.5 Pre-Flight Check

abuild fetch
abuild unpack

# Find extracted source
ls src/
# linux-6.18.26 (or whatever)

cd src/linux-*/
grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head
# Patched? Skip rebuild.

grep -rn "MSG_NO_SHARED_FRAGS" include/
# Empty? Edit patch.

cd ../..   # back to aports/main/linux-lts

8.6 Stage The Patch

cp ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch .

# If MSG_NO_SHARED_FRAGS was missing
# sed -i '/^+\s*if (!(flags & MSG_NO_SHARED_FRAGS))$/d' dirtyfrag-esp-fix.patch

8.7 Edit The APKBUILD

Three changes. Open with your editor:

nano APKBUILD
# or: vi APKBUILD

Change 1: Use a custom flavor. This makes your kernel install side-by-side with the stock one. Find:

_flavor="lts"

Or, if no _flavor exists, the variable might be inferred from pkgname. Add at the top of the file:

_flavor="lts-dirtyfrag"

If _flavor already exists, change its value to "lts-dirtyfrag".

Change 2: Add patch to source. Find the source= block (it's typically a multi-line variable):

source="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$_kernver.tar.xz
    config-lts.x86_64
    config-lts.aarch64
    "

Add your patch:

source="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$_kernver.tar.xz
    config-lts.x86_64
    config-lts.aarch64
    dirtyfrag-esp-fix.patch
    "

Change 3: Inject patch application into prepare(). Find the prepare() function. It often calls default_prepare:

prepare() {
    default_prepare

    # ... existing stuff ...
}

Add the patch line after default_prepare:

prepare() {
    default_prepare

    # === DIRTY FRAG SECURITY PATCH ===
    if ! grep -q "skb_has_shared_frag" "$builddir/net/ipv4/esp4.c"; then
        msg "Applying Dirty Frag ESP fix (CVE-2026-43284)..."
        patch -p1 --fuzz=5 -d "$builddir" \
            < "$srcdir/dirtyfrag-esp-fix.patch" \
            || die "Dirty Frag patch failed"
    else
        msg "Dirty Frag fix already in source, skipping"
    fi
    # === END DIRTY FRAG ===
}

If prepare() doesn't exist at all, add the entire function near the top of the file, after the variable declarations.

8.8 Update Checksums

abuild checksum

This regenerates the sha512sums= line at the bottom of the APKBUILD with your new patch's checksum included.

8.9 Build

abuild -r 2>&1 | tee build.log

The -r flag installs missing dependencies automatically. Build time is typically 10-20 minutes on modern hardware (Alpine's kernel config is much smaller than CachyOS or Ubuntu).

Expected output during patch step:

>>> linux-lts-dirtyfrag: Applying Dirty Frag ESP fix (CVE-2026-43284)...
patching file net/ipv4/esp4.c
Hunk #1 succeeded at 875 with fuzz 2 (offset 2 lines).
patching file net/ipv4/ip_output.c
patching file net/ipv6/esp6.c
patching file net/ipv6/ip6_output.c

After the build:

ls ~/packages/main/x86_64/linux-lts-dirtyfrag*.apk
# linux-lts-dirtyfrag-6.18.26-r0.apk
# linux-lts-dirtyfrag-dev-6.18.26-r0.apk
# linux-lts-dirtyfrag-doc-6.18.26-r0.apk

8.10 Install

sudo apk add --allow-untrusted ~/packages/main/x86_64/linux-lts-dirtyfrag*.apk

8.11 Update Bootloader

# extlinux is the default on most Alpine x86_64 installs
if command -v update-extlinux &>/dev/null; then
    sudo update-extlinux
fi

# grub fallback
if [[ -f /boot/grub/grub.cfg ]]; then
    sudo grub-mkconfig -o /boot/grub/grub.cfg
fi

# syslinux variant
if [[ -f /etc/update-extlinux.conf ]]; then
    sudo update-extlinux
fi

Verify your new kernel appears in the boot menu:

ls /boot/
# vmlinuz-lts                       (stock)
# vmlinuz-lts-dirtyfrag             (yours)
# initramfs-lts.img
# initramfs-lts-dirtyfrag.img
# extlinux.conf

cat /boot/extlinux.conf | grep LABEL -A 4

8.12 Diskless Mode: Persist!

This is the Alpine-specific gotcha that bites everyone the first time. If you boot from RAM disc (LBU mode), nothing in /etc or /boot is persistent unless you commit:

if grep -q "^tmpfs / tmpfs" /proc/mounts || [[ -f /etc/lbu/lbu.conf ]]; then
    echo "Diskless mode detected — committing"
    sudo lbu commit -d
fi

Do this BEFORE rebooting. If you skip it, the next boot loads the stock kernel from your USB and your custom kernel disappears.

8.13 Reboot And Verify

sudo reboot

# After reboot:
uname -r
# Expected: 6.18.26-0-lts-dirtyfrag

# Verify
sudo apk verify linux-lts-dirtyfrag

See Section 13 for cross-distro verification.

8.14 Cleanup

# When Alpine ships the official patched kernel
sudo apk update
sudo apk upgrade

# Switch back to stock
sudo apk del linux-lts-dirtyfrag linux-lts-dirtyfrag-dev linux-lts-dirtyfrag-doc
sudo apk add linux-lts linux-lts-dev linux-lts-doc

# Bootloader
sudo update-extlinux

# Diskless persist
[[ -f /etc/lbu/lbu.conf ]] && sudo lbu commit -d

sudo reboot

9. Phase 4 — Alpine Linux 3.23 aarch64 / Raspberry Pi 4 & 5

The aarch64 path is mostly the same as x86_64 but with three significant differences: the kernel package is linux-rpi (not linux-lts), the bootloader is the Pi firmware (not extlinux), and you might be cross-compiling from x86 instead of building on the Pi itself.

9.1 Detect The Pi

uname -m
# aarch64

cat /proc/device-tree/model | tr -d '\0'
# Raspberry Pi 5 Model B Rev 1.0
# (or "Raspberry Pi 4 Model B Rev 1.5", etc.)

If you're on aarch64 in a VM (KVM guest, AWS Graviton, etc.), use linux-virt or linux-lts instead of linux-rpi — same as Phase 3 but with -r aarch64 arch.

9.2 Build On The Pi vs Cross-Compile

Build on the Pi (simplest): A Pi 5 with 8GB RAM builds the linux-rpi kernel in ~30-60 minutes. A Pi 4 takes ~60-90 minutes. Just follow this section's commands directly on the Pi.

Cross-compile from x86_64 (faster): Set up an Alpine x86_64 build host with QEMU binfmt for aarch64. Saves ~70% wall-clock time but adds setup complexity.

# On x86_64 Alpine host
sudo apk add qemu-aarch64 qemu-openrc binfmt-support
sudo rc-service qemu-binfmt start
sudo rc-update add qemu-binfmt default

# Verify binfmt registration
ls /proc/sys/fs/binfmt_misc/
# Expected: qemu-aarch64 entry

# When running abuild later, target aarch64:
abuild -r -A aarch64

For this guide, I'll assume you're building on the Pi.

9.3 Install Build Deps

Same as x86_64:

sudo apk add \
    alpine-sdk build-base bc bison flex \
    elfutils-dev openssl-dev linux-headers \
    perl python3 diffutils findutils \
    xz zstd tar git sed coreutils

sudo addgroup $(whoami) abuild
abuild-keygen -a -n
sudo cp ~/.abuild/*.rsa.pub /etc/apk/keys/
sudo chmod 644 /etc/apk/keys/*.rsa.pub

9.4 Clone Aports

git clone --depth 1 --branch 3.23-stable \
    https://gitlab.alpinelinux.org/alpine/aports.git ~/aports

cd ~/aports/main/linux-rpi
ls
# APKBUILD, config-rpi.aarch64, possibly config-rpi.armv7

9.5 Pre-Flight Check

abuild fetch
abuild unpack
ls src/
# linux-6.18.26-rpi (or similar)

cd src/linux-*/
grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head
grep -rn "MSG_NO_SHARED_FRAGS" include/
cd ../..

9.6 Stage Patch And Edit APKBUILD

cp ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch .
nano APKBUILD

The edits are identical to x86_64 (Section 8.7), but with _flavor="rpi-dirtyfrag":

_flavor="rpi-dirtyfrag"

# ... source= line gets dirtyfrag-esp-fix.patch ...

prepare() {
    default_prepare

    if ! grep -q "skb_has_shared_frag" "$builddir/net/ipv4/esp4.c"; then
        msg "Applying Dirty Frag ESP fix (CVE-2026-43284)..."
        patch -p1 --fuzz=5 -d "$builddir" \
            < "$srcdir/dirtyfrag-esp-fix.patch" \
            || die "Dirty Frag patch failed"
    fi
}

9.7 Build

abuild checksum
abuild -r 2>&1 | tee build.log

Build time on a Pi 5 with 8GB RAM and SD card storage: ~45 minutes. With NVMe storage on a Pi 5: ~30 minutes. With cross-compile from x86: ~10 minutes.

Output:

ls ~/packages/main/aarch64/linux-rpi-dirtyfrag*.apk

9.8 Install

sudo apk add --allow-untrusted ~/packages/main/aarch64/linux-rpi-dirtyfrag*.apk

9.9 Update /boot/config.txt — THIS IS THE PI-SPECIFIC STEP

The Pi firmware ignores your bootloader entirely. It reads /boot/config.txt at boot time and loads whatever kernel image is named there. After installing your new kernel, you must update this file.

# Find the new kernel image
ls /boot/
# vmlinuz-rpi                        (stock)
# vmlinuz-rpi-dirtyfrag              (yours)
# initramfs-rpi
# initramfs-rpi-dirtyfrag
# config.txt
# cmdline.txt
# bootcode.bin
# kernel8.img                        (stock 64-bit kernel — actually a copy/symlink)

# View current config.txt
cat /boot/config.txt

You'll typically see something like:

[all]
arm_64bit=1
kernel=vmlinuz-rpi
initramfs initramfs-rpi
include usercfg.txt

Change kernel= to your new kernel and add a matching initramfs line:

sudo nano /boot/config.txt

Modify to:

[all]
arm_64bit=1
kernel=vmlinuz-rpi-dirtyfrag
initramfs initramfs-rpi-dirtyfrag
include usercfg.txt

If you want to keep the option to boot stock, you can use Pi's conditional sections:

[all]
arm_64bit=1

[default]
kernel=vmlinuz-rpi
initramfs initramfs-rpi

[dirtyfrag]
kernel=vmlinuz-rpi-dirtyfrag
initramfs initramfs-rpi-dirtyfrag

include usercfg.txt

Then activate the [dirtyfrag] section by adding to /boot/cmdline.txt (or by setting os_prefix= in config.txt).

9.10 Verify cmdline.txt Still Works

The Pi's kernel command line is in /boot/cmdline.txt. Don't change it unless you have a reason — your custom kernel still wants the same root device, etc.:

cat /boot/cmdline.txt
# Example:
# console=tty1 root=PARTUUID=xxxx-xx rootfstype=ext4 fsck.repair=yes rootwait

9.11 Reboot

sudo reboot

If the Pi doesn't boot:

  • Plug the SD card into another computer
  • Edit /boot/config.txt to revert kernel= back to vmlinuz-rpi
  • Reinsert and boot
  • Diagnose

9.12 RPi-Specific Gotchas

/boot is FAT32. Case-sensitivity issues with kernel filenames. Don't use camelCase or spaces.

rpi-update is dangerous. It pulls bleeding-edge firmware that may break compatibility with your custom kernel. Avoid it after you've built a custom kernel.

No extlinux on RPi. The Pi firmware reads config.txt directly. Don't run update-extlinux on a Pi — there's nothing for it to update.

GPU memory split is set in config.txt via gpu_mem=. Don't lose your VC4 acceleration after a kernel rebuild.

32-bit ARM is dropped in Alpine 3.23. RPi 2/3/Zero W on Alpine 3.23 is 64-bit only. RPi 1 and Zero (ARMv6) are not supported.

Pi 5 vs Pi 4. Pi 5 (BCM2712) and Pi 4 (BCM2711) have different kernel module sets. Both are in linux-rpi config but the right one is selected at runtime via device tree.

SD card wear. Building kernels on an SD card writes a lot. Use NVMe-attached storage on Pi 5 if possible, or build in tmpfs:

# Build in RAM (Pi 5 8GB only, kernel build needs ~6GB)
sudo mount -t tmpfs -o size=7G tmpfs /tmp/build
cd /tmp/build
git clone --depth 1 --branch 3.23-stable \
    https://gitlab.alpinelinux.org/alpine/aports.git
cd aports/main/linux-rpi
# ... rest of procedure

10. Phase 5 — Void Linux glibc (kernel 6.18 / 7.0)

Void Linux uses xbps for package management and xbps-src for building from void-packages. Init is runit, not systemd. Out-of-tree modules (NVIDIA, ZFS) are packaged as separate xbps packages, not DKMS — this changes how kernel updates flow.

10.1 Verify You're On Void Glibc

cat /etc/os-release
# NAME="Void"
# ID=void

xbps-query -p version libc.so.6 2>/dev/null
# Should show glibc version (e.g., 2.42)

# musl Void shows musl-1.2.5 instead — see Phase 6 for that

10.2 Identify Your Kernel Package

Unlike Arch/Alpine which use generic names like linux-lts, Void uses per-version packages:

xbps-query -l | grep '^ii linux'
# ii linux6.18-6.18.26_1
# ii linux6.18-headers-6.18.26_1
# ii linux-headers-6.18.26_1
# ii linux-lts-6.18.26_1            <-- meta-package

You'll be modifying the template at srcpkgs/linux6.18/template (or whichever version matches). For 7.0: srcpkgs/linux7.0/template.

10.3 Install Build Dependencies

sudo xbps-install -Sy \
    base-devel bc cpio kmod libelf-devel pahole \
    perl python3 tar xz zstd git

10.4 Set Up xbps-src

# Clone void-packages
cd ~
git clone --depth 1 https://github.com/void-linux/void-packages.git
cd void-packages

# Bootstrap the build environment (this takes a few minutes)
./xbps-src binary-bootstrap

10.5 Pre-Flight Check

# Trigger source download but don't build
./xbps-src fetch linux6.18

# Find the source
ls hostdir/sources/linux-6.18.26/
# Should have linux-6.18.26.tar.xz and possibly extracted dir

# Extract for inspection
cd hostdir/sources/linux-6.18.26/
tar xf linux-6.18.26.tar.xz
cd linux-6.18.26
grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head
grep -rn "MSG_NO_SHARED_FRAGS" include/

cd ~/void-packages

10.6 Stage The Patch

Void patches go in srcpkgs/<pkg>/patches/, applied in alphabetical order. Use a high prefix to ensure ours runs last.

mkdir -p srcpkgs/linux6.18/patches
cp ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch \
    srcpkgs/linux6.18/patches/9999-dirtyfrag-esp-fix.patch

# If MSG_NO_SHARED_FRAGS was missing
# sed -i '/^+\s*if (!(flags & MSG_NO_SHARED_FRAGS))$/d' \
#     srcpkgs/linux6.18/patches/9999-dirtyfrag-esp-fix.patch

xbps-src auto-applies patches in patches/, so you don't need to edit the template to invoke patch. But you do need to bump the revision number so xbps treats your build as newer.

10.7 Edit The Template

nano srcpkgs/linux6.18/template

Find:

revision=1

Change to:

revision=99

(High revision = clearly your custom build. When Void ships the official fix, it'll be revision 2 or 3, and pacman/xbps will see your 99 as newer for now.)

Optionally, change the package name to coexist with the stock kernel:

pkgname=linux6.18-dirtyfrag

This requires also updating the replaces= and provides= fields:

# At the top of the template, after pkgname=:
replaces="linux6.18>=0"
provides="linux6.18-${version}_${revision}"

10.8 Update Patch Checksums

# This adds the patch's checksum to the template
./xbps-src update-check linux6.18
# Or manually:
sha256sum srcpkgs/linux6.18/patches/9999-dirtyfrag-esp-fix.patch
# Add to checksum= block at bottom of template if needed

10.9 Build

./xbps-src pkg linux6.18 2>&1 | tee build.log

xbps-src builds in a chroot at masterdir/, so it doesn't pollute your host. Build time: 20-40 minutes on modern hardware.

Watch for:

=> Applying patches/9999-dirtyfrag-esp-fix.patch
patching file net/ipv4/esp4.c
Hunk #1 succeeded at 875 with fuzz 2.
patching file net/ipv4/ip_output.c
patching file net/ipv6/esp6.c
patching file net/ipv6/ip6_output.c

After build:

ls hostdir/binpkgs/
# linux6.18-6.18.26_99.x86_64.xbps
# linux6.18-headers-6.18.26_99.x86_64.xbps

10.10 Install

# Add the local binpkgs as a repo
sudo xbps-install --repository=$(pwd)/hostdir/binpkgs \
    -S linux6.18 linux6.18-headers

10.11 Rebuild Out-Of-Tree Modules

Void doesn't use DKMS the way Arch/Ubuntu do. Out-of-tree modules are packaged per-kernel-version:

# NVIDIA
sudo xbps-install -Sy nvidia
# (this rebuilds against the kernel headers you just installed)

# ZFS
sudo xbps-install -Sy zfs

10.12 Update Bootloader

Void uses xbps hooks to update the bootloader on kernel install. Verify they ran:

ls -la /boot/
# vmlinuz-6.18.26_1                  (stock)
# vmlinuz-6.18.26_99                 (yours, if same pkgname)
# initramfs-6.18.26_99.img

# GRUB
sudo grub-mkconfig -o /boot/grub/grub.cfg

# OR efibootmgr-based EFI boot
sudo efibootmgr -v

# OR syslinux
sudo /usr/libexec/xbps-kernel-hook.sh post-install linux6.18 6.18.26_99

10.13 Reboot And Verify

sudo reboot

uname -r
# Expected: 6.18.26_99 (or your variant)

xbps-query linux6.18
# Should show your custom revision

10.14 Void Glibc Gotchas

No DKMS by default. When kernel rebuilds, you must also reinstall NVIDIA/ZFS as separate xbps packages. They don't auto-rebuild like DKMS would.

Per-version package names. linux6.18, linux6.19, linux7.0 are entirely separate packages. Switching kernel versions means installing a new package, not just upgrading.

runit boot order. After kernel install, Void uses xbps-trigger-update-grub (and others) to refresh the bootloader. If something goes wrong: sudo xbps-reconfigure -f linux6.18-dirtyfrag to manually re-run the hooks.

xbps-src needs space. The masterdir/ chroot grows to ~10GB. The kernel build adds another ~10GB. Make sure /var/cache/xbps and your void-packages checkout location have enough room.

Hostdir vs masterdir. Sources go to hostdir/sources/, builds happen in masterdir/, output goes to hostdir/binpkgs/. Don't confuse them.


11. Phase 6 — Void Linux musl (kernel 6.18 / 7.0)

Same as Phase 5 (Void glibc) with one critical difference: builds happen in a musl chroot. The kernel binary itself is libc-independent (it doesn't link against any libc), but the userspace tooling around the kernel build differs.

11.1 Verify You're On Void Musl

cat /etc/os-release
# NAME="Void"
# ID=void

ldd --version 2>&1 | head -1
# musl libc

xbps-query -p version musl 2>/dev/null
# musl-1.2.5_1 (or similar)

11.2 The Only Difference: -A x86_64-musl Flag

Everything in Phase 5 applies, except the xbps-src invocation needs -A x86_64-musl:

# Bootstrap a musl masterdir
./xbps-src -A x86_64-musl binary-bootstrap

# Fetch
./xbps-src -A x86_64-musl fetch linux6.18

# Build
./xbps-src -A x86_64-musl pkg linux6.18 2>&1 | tee build.log

Without -A x86_64-musl, xbps-src builds a glibc binary that won't run on musl Void.

11.3 Install

sudo xbps-install --repository=$(pwd)/hostdir/binpkgs/ \
    -S linux6.18 linux6.18-headers

(Note: binpkgs/ for musl, not binpkgs/x86_64/ — musl uses a flat layout.)

11.4 Void Musl Gotchas

mkinitfs not mkinitcpio. Void musl uses mkinitfs (or dracut in newer setups) to build initramfs. Don't blindly copy CachyOS/Arch habits.

Some kernel-related userspace assumes glibc. perf, bpftool, bpftrace all build on musl but with reduced feature sets. Verification via bpftrace (Section 13) may have rough edges.

Smaller kernel packages. Musl kernel packages are typically 10-15% smaller because the userspace utilities packaged alongside them are smaller. The kernel binary itself is identical to glibc Void.

Cross-libc package set is limited. Most kernel-related deps exist on musl Void, but if the script complains about a missing dep, check if a -musl-specific package exists.

The kernel binary is libc-independent. This means a kernel package built on glibc Void will technically boot on musl Void, but the userspace tools (udev, mkinitfs triggers, etc.) won't work. Always build with -A x86_64-musl for musl targets.


12. Phase 7 — Container Host Hardening

If you run containers (Podman, Docker, LXC, systemd-nspawn) on any of the above distros, the host kernel and host page cache are shared with all containers. A container exploit corrupts the HOST. Patching only the host (or only the containers) is incomplete.

12.1 The Threat Model

ESP variant in a container: attacker runs unshare(CLONE_NEWUSER) inside the container to get CAP_NET_ADMIN, registers an XFRM Security Association, sends a UDP packet, the host kernel processes it, the host page cache gets corrupted. They can now overwrite /usr/bin/su on the HOST, then execute it from inside the container if the container has access to /usr/bin/su (which most don't), OR escape using a different vector and use the now-corrupted host binary.

RxRPC variant: doesn't need user namespaces. Just socket(AF_RXRPC). Container restrictions on capabilities don't help.

12.2 Defense Layers

Layer 1: Host module blacklist (mandatory). Already done in Phase 0. This blocks the attack at the only point it can be exploited — the kernel module load.

Layer 2: Block user namespaces (breaks rootless tools).

# Block all user namespaces
sudo sysctl -w user.max_user_namespaces=0
echo "user.max_user_namespaces = 0" | sudo tee /etc/sysctl.d/99-dirtyfrag.conf
sudo sysctl --system

# Or, only block unprivileged user namespaces (Debian/Ubuntu specific)
sudo sysctl -w kernel.unprivileged_userns_clone=0

This blocks the ESP attack vector (which uses unshare(CLONE_NEWUSER) for CAP_NET_ADMIN). It does NOT block RxRPC. Trade-offs:

  • ❌ Rootless Podman / Docker stops working
  • ❌ Flatpak's bubblewrap sandboxing breaks
  • ❌ Chrome/Chromium sandbox falls back to setuid mode (or fails)
  • unshare -U for general process isolation breaks
  • firejail partial sandbox features break

Layer 3: Harden BPF JIT.

sudo sysctl -w net.core.bpf_jit_harden=2
echo "net.core.bpf_jit_harden = 2" | sudo tee -a /etc/sysctl.d/99-dirtyfrag.conf

This makes BPF JIT-compiled code harder to use as gadgets. Doesn't directly prevent Dirty Frag but raises the bar for chained exploits.

Layer 4: Seccomp profile to deny xfrm syscalls.

For Podman/Docker, edit your seccomp profile (typically at /usr/share/containers/seccomp.json or specified in --security-opt seccomp=...):

{
  "names": [
    "xfrm_state_add",
    "xfrm_policy_add",
    "xfrm_state_modify",
    "xfrm_policy_modify"
  ],
  "action": "SCMP_ACT_ERRNO",
  "args": [],
  "comment": "Block xfrm syscalls (Dirty Frag mitigation)"
}

Add this to the syscalls array in your seccomp profile. New containers using this profile will get EPERM when they try the relevant syscalls.

Layer 5: AppArmor/SELinux.

For AppArmor (Ubuntu, Debian, openSUSE):

# In /etc/apparmor.d/abstractions/dirtyfrag-mitigation
deny capability net_admin,
deny @{PROC}/net/xfrm_** rw,
deny @{PROC}/sys/net/core/xfrm** rw,

Then include in your container profile and reload:

sudo apparmor_parser -r /etc/apparmor.d/abstractions/dirtyfrag-mitigation
sudo systemctl restart apparmor

For SELinux: write a custom policy module that denies xfrm_socket to your container types. (Out of scope for this guide, but the manpage xfrm_selinux(8) is the starting point.)

12.3 Worked Example: Void RALPH Host

A multi-agent Claude orchestration system running containers on a Void glibc host. The containers share the host kernel.

# 1. Apply host module blacklist (Phase 0)
sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
install esp4      /bin/false
install esp6      /bin/false
install rxrpc     /bin/false
install ipcomp4   /bin/false
install ipcomp6   /bin/false
install xfrm_user /bin/false
EOF

# Unload anything running
for m in esp4 esp6 rxrpc ipcomp4 ipcomp6 xfrm_user; do
    sudo modprobe -r "$m" 2>/dev/null || true
done

# Update initramfs (Void uses dracut on some setups)
if command -v dracut &>/dev/null; then
    sudo dracut --force
elif command -v mkinitfs &>/dev/null; then
    sudo mkinitfs
fi

# 2. Decide on user namespaces (RALPH probably uses rootful Podman)
# If RALPH uses rootless: skip this step (would break it)
# If RALPH uses rootful (recommended for orchestration): block userns
sudo sysctl -w user.max_user_namespaces=0
echo "user.max_user_namespaces = 0" | sudo tee /etc/sysctl.d/99-dirtyfrag.conf

# 3. Harden BPF
sudo sysctl -w net.core.bpf_jit_harden=2
echo "net.core.bpf_jit_harden = 2" | sudo tee -a /etc/sysctl.d/99-dirtyfrag.conf

# 4. Seccomp deny in podman config
# Edit /usr/share/containers/seccomp.json or RALPH's container security context
# to add the xfrm_state_add deny rule shown in 12.2

# 5. Verify
sudo modprobe -n -v esp4
# Expected: install /bin/false

sudo sysctl user.max_user_namespaces
# Expected: user.max_user_namespaces = 0

# 6. Restart RALPH containers to pick up new seccomp
podman ps -q | xargs -r podman restart

The RALPH host now blocks both vulnerabilities at multiple layers.


13. Phase 8 — Verification (Cross-Distro)

Three methods, in decreasing order of reliability.

13.1 Method 1 — bpftrace Runtime Probe (Best)

This actually proves the patched code is executing on the running kernel. No theory, no source-code grepping — runtime evidence.

# Install bpftrace if needed
sudo apt install bpftrace                # Ubuntu
sudo pacman -S bpf                        # CachyOS / Arch
sudo apk add bpftrace                     # Alpine
sudo xbps-install -Sy bpftrace            # Void

# Run the probe
sudo bpftrace -e '
    kfunc:vmlinux:esp_input {
        @esp_calls += 1;
    }
    kfunc:vmlinux:skb_cow_data /pid != 0/ {
        @cow_calls += 1;
        @cow_stacks[kstack(5)] = count();
    }
' &
BPF_PID=$!

# Generate IPsec traffic if you have a tunnel
# Or just wait for ambient traffic / scheduled tasks
sleep 120

sudo kill $BPF_PID

Expected output looks like:

@esp_calls: 47
@cow_calls: 31
@cow_stacks[
    skb_cow_data+1
    esp_input+524
    xfrm_input+862
    xfrm4_rcv_encap+115
    udp_queue_rcv_skb+356
]: 12

The presence of esp_input → skb_cow_data paths in the stack traces means the patched code is doing the cow when needed. If you only see esp_input calls but no skb_cow_data, the cow is being skipped — your kernel may not have the patch.

13.2 Method 2 — Disassemble esp_input

This proves the patched bytecode is in the kernel binary.

# Find vmlinux
VMLINUX=""
for path in \
    "/usr/lib/modules/$(uname -r)/build/vmlinux" \
    "/usr/lib/debug/boot/vmlinux-$(uname -r)" \
    "/boot/vmlinuz-$(uname -r)"; do
    [[ -f "$path" ]] && { VMLINUX="$path"; break; }
done
echo "Using: $VMLINUX"

# Disassemble esp_input and look for the new flag-test instructions
sudo objdump -d "$VMLINUX" 2>/dev/null \
    | awk '/<esp_input>:/,/^esp_input_done|^[a-f0-9]+ <[a-z_]/' \
    | grep -E 'test|and|cmp' | head -20

Expected output:

ffffffff81a1234e:	48 8b 47 50          	mov    0x50(%rdi),%rax
ffffffff81a12352:	f6 c4 01             	test   $0x1,%ah          # SKBFL_SHARED_FRAG check
ffffffff81a12355:	75 1c                	jne    ffffffff81a12373  # if set, take cow path
ffffffff81a12357:	48 8b 47 28          	mov    0x28(%rdi),%rax
ffffffff81a1235b:	48 85 c0             	test   %rax,%rax          # data_len check
ffffffff81a1235e:	75 13                	jne    ffffffff81a12373  # if non-zero, cow

If you see test $0x1, ... instructions in esp_input near the original skb_has_frag_list jump target, the flag check is in the binary. If the disassembly shows just a single test with no flag-related comparison after the skb_has_frag_list call, the patch isn't there.

13.3 Method 3 — Source Presence (Weakest)

This only proves the source had the patch, not that the running kernel was built from that source. Use it as a sanity check, not a final verification.

# Generic
sudo find /usr/src -name 'esp4.c' -exec grep -l skb_has_shared_frag {} \;

# CachyOS
ls /usr/src/linux-cachyos*/net/ipv4/esp4.c 2>/dev/null \
    | xargs -r grep -l skb_has_shared_frag

# Ubuntu
ls /usr/src/linux-headers-*/net/ipv4/esp4.c 2>/dev/null \
    | xargs -r grep -l skb_has_shared_frag

# Alpine
ls /usr/src/linux-*/net/ipv4/esp4.c 2>/dev/null \
    | xargs -r grep -l skb_has_shared_frag

# Void
ls /usr/src/linux*-headers/net/ipv4/esp4.c 2>/dev/null \
    | xargs -r grep -l skb_has_shared_frag

13.4 What Does NOT Verify The Patch

These are common false-friends. None of them reliably prove anything:

  • grep skb_has_shared_frag /proc/kallsymsskb_has_shared_frag() is a static inline function. The compiler inlines it directly into its callers. It NEVER appears in kallsyms regardless of patch state.
  • dmesg | grep esp — the patched code doesn't emit any log message. dmesg is silent on patch state.
  • ❌ Trusting the kernel changelog — distros sometimes silently skip backports. Always verify with method 1 or 2.
  • ❌ Checking that the Fixes: tags appear in source — those are metadata, not a runtime check. They prove someone wrote a patch, not that it was applied.

13.5 Confirm Blacklist Status

After verifying the kernel is patched:

# Is the blacklist still in place?
ls -la /etc/modprobe.d/dirtyfrag-mitigation.conf

# Is it active?
sudo modprobe -n -v esp4
# Expected: install /bin/false

# Once you've VERIFIED the patch is in, remove the blacklist:
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf

# Distro-specific persistence updates:
sudo update-initramfs -u                       # Ubuntu/Debian
sudo mkinitcpio -P                             # Arch/CachyOS
sudo mkinitfs && [[ -f /etc/lbu/lbu.conf ]] && sudo lbu commit -d   # Alpine
sudo dracut --force                            # Void (if using dracut)

14. Phase 9 — Cleanup When Distros Ship Official Fix

When your distro ships an official patched kernel through normal updates, you can revert to stock and remove your custom build.

14.1 CachyOS

# Pull official patched kernel
sudo pacman -Syu

# Verify the patch is in (Section 13)
sudo bpftrace -e 'kfunc:vmlinux:skb_cow_data { @[comm] = count(); }' &
sleep 30; sudo killall bpftrace

# Remove your custom kernel
sudo pacman -R linux-cachyos-dirtyfrag linux-cachyos-dirtyfrag-headers

# Update bootloader
if [[ -d /boot/loader/entries ]]; then
    sudo bootctl update
elif command -v limine-update &>/dev/null; then
    sudo limine-update
elif [[ -f /boot/grub/grub.cfg ]]; then
    sudo grub-mkconfig -o /boot/grub/grub.cfg
fi

# Remove blacklist (after verification!)
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
sudo mkinitcpio -P

# Remove sources directory
rm -rf ~/linux-cachyos

14.2 Ubuntu

# Pull official kernel
sudo apt update && sudo apt upgrade

# Remove custom build (if you did Path C)
sudo apt remove --purge \
    'linux-image-unsigned-*+dirtyfrag1*' \
    'linux-headers-*+dirtyfrag1*' \
    'linux-modules-*+dirtyfrag1*'

# Or remove mainline (if you did Path B)
sudo mainline list-installed
sudo mainline uninstall v7.0.4   # whichever version

# Update GRUB
sudo update-grub

# Remove blacklist (after verification!)
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
sudo update-initramfs -u

# Reboot to confirm official kernel boots
sudo reboot

14.3 Alpine

# Pull updates
sudo apk update && sudo apk upgrade

# Remove custom kernel
sudo apk del linux-lts-dirtyfrag linux-lts-dirtyfrag-dev linux-lts-dirtyfrag-doc
# Or for RPi:
sudo apk del linux-rpi-dirtyfrag linux-rpi-dirtyfrag-dev linux-rpi-dirtyfrag-doc

# Reinstall stock
sudo apk add linux-lts linux-lts-dev linux-lts-doc
# or: linux-rpi linux-rpi-dev linux-rpi-doc

# Bootloader
sudo update-extlinux 2>/dev/null || true

# RPi: revert /boot/config.txt
# sudo nano /boot/config.txt → change kernel= back

# Diskless persist
[[ -f /etc/lbu/lbu.conf ]] && sudo lbu commit -d

# Remove blacklist
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
[[ -f /etc/lbu/lbu.conf ]] && sudo lbu commit -d

14.4 Void Linux

# Update
sudo xbps-install -Suy

# Remove custom kernel (if pkgname-renamed)
sudo xbps-remove -y linux6.18-dirtyfrag

# Or revert to stock revision
sudo xbps-install -f linux6.18

# Reinstall NVIDIA/ZFS for the official kernel
sudo xbps-install -Sy nvidia zfs

# Bootloader hooks
sudo xbps-reconfigure -f linux6.18

# Remove blacklist
sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
sudo dracut --force   # or mkinitfs for musl

15. Troubleshooting Reference

15.1 Patch Hunks FAILED

Expected on current kernels (7.0.x, 6.18.x) because the patch targets ~6.13 source. The default --fuzz=5 should handle most cases, but if hunks fail:

# Try wider fuzz first
patch -p1 --fuzz=10 < dirtyfrag-esp-fix.patch

# Look at the failed hunk in the .rej file
cat net/ipv4/esp4.c.rej

If --fuzz=10 fails, apply by hand. The changes are tiny:

# ESP esp4.c — find the line
grep -n "skb_has_frag_list" net/ipv4/esp4.c
# 875:		} else if (!skb_has_frag_list(skb)) {

# Edit the file
nano net/ipv4/esp4.c
# Change line 875 from:
#   } else if (!skb_has_frag_list(skb)) {
# To:
#   } else if (!skb_has_frag_list(skb) &&
#              !skb_has_shared_frag(skb) &&
#              !skb->data_len) {

# Same for net/ipv6/esp6.c
grep -n "skb_has_frag_list" net/ipv6/esp6.c
nano net/ipv6/esp6.c

# ip_output.c — find line above wmem_alloc_delta
grep -n "wmem_alloc_delta += copy" net/ipv4/ip_output.c
# 1233:			wmem_alloc_delta += copy;

# Edit and add ABOVE the wmem_alloc_delta line:
nano net/ipv4/ip_output.c
# Insert these two lines:
#   if (!(flags & MSG_NO_SHARED_FRAGS))
#       skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;

# Same for ip6_output.c
grep -n "wmem_alloc_delta += copy" net/ipv6/ip6_output.c
nano net/ipv6/ip6_output.c

For RxRPC if you're applying it:

# net/rxrpc/call_event.c — find the line
grep -n "skb_cloned(skb)" net/rxrpc/call_event.c
# Change "skb_cloned(skb)" to "(skb_cloned(skb) || skb->data_len)"

grep -n "skb_cloned(skb)" net/rxrpc/conn_event.c
# Same change

15.2 MSG_NO_SHARED_FRAGS Undefined

grep -rn MSG_NO_SHARED_FRAGS include/
# Empty? Strip the conditional from the patch:

sed -i '/^+\s*if (!(flags & MSG_NO_SHARED_FRAGS))$/d' \
    dirtyfrag-esp-fix.patch

15.3 NVIDIA Blank Screen After Reboot

You forgot to rebuild DKMS. From your fallback kernel:

# CachyOS
sudo dkms autoinstall
# 5060 Ti needs:
sudo pacman -S nvidia-open-dkms

# Ubuntu
sudo dpkg-reconfigure $(dpkg -l | awk '/nvidia-dkms-/{print $2}' | head -1)

# Void
sudo xbps-install -Sy nvidia

15.4 Build OOMs During LTO

# Halve parallelism
MAKEFLAGS="-j$(($(nproc) / 2))" makepkg -s --skippgpcheck   # CachyOS

# Ubuntu
LANG=C fakeroot debian/rules binary-headers binary-generic binary-perarch \
    skipabi=true skipmodule=true skipretpoline=true \
    -j$(($(nproc) / 2))

# Cap link parallelism specifically (LTO link is the OOM hotspot)
MAKEFLAGS="-j$(nproc) LDFLAGS_vmlinux=--threads=4"

15.5 Diskless Alpine Forgot lbu commit

If you forgot to lbu commit and rebooted, your custom kernel is gone. Rebuild and remember this time:

cd ~/aports/main/linux-lts
abuild -r
sudo apk add --allow-untrusted ~/packages/main/x86_64/linux-lts-dirtyfrag*.apk
sudo update-extlinux
sudo lbu commit -d   # <-- THIS TIME
sudo reboot

15.6 Void Boots Wrong Kernel

# Force reconfiguration of the kernel hooks
sudo xbps-reconfigure -f linux6.18-dirtyfrag

# If that doesn't help, regenerate bootloader config manually
sudo grub-mkconfig -o /boot/grub/grub.cfg
# Or for syslinux/extlinux
sudo /usr/libexec/xbps-kernel-hook.sh post-install linux6.18 6.18.26_99

15.7 RPi Boots Stock Kernel Despite Custom Install

# Check config.txt
cat /boot/config.txt | grep -i kernel
# If it doesn't reference your custom image:

sudo nano /boot/config.txt
# Make sure the [all] (or [default]) section has:
# kernel=vmlinuz-rpi-dirtyfrag
# initramfs initramfs-rpi-dirtyfrag

# Also check the file actually exists in /boot
ls /boot/vmlinuz-rpi-dirtyfrag /boot/initramfs-rpi-dirtyfrag

sudo reboot

15.8 Secure Boot Refuses To Boot Custom Kernel

# Confirm SB state
sudo bootctl status | grep -i 'secure boot'
mokutil --sb-state

# Two options:
# Option A: disable in firmware (easiest)
# Option B: sign with sbctl (CachyOS) or sbsigntool (Ubuntu)

# CachyOS / Arch
sudo pacman -S sbctl
sudo sbctl status
sudo sbctl create-keys                  # one-time
sudo sbctl enroll-keys -m               # one-time, needs reboot+confirm
sudo sbctl sign -s /boot/vmlinuz-linux-cachyos-dirtyfrag

# Ubuntu
sudo apt install sbsigntool mokutil
# Generate MOK key/cert
openssl req -new -x509 -newkey rsa:2048 -keyout MOK.priv -out MOK.pem \
    -nodes -days 36500 -subj "/CN=Local Custom Kernel"
sudo mokutil --import MOK.pem           # reboot+confirm in MOK Manager
sudo sbsign --key MOK.priv --cert MOK.pem \
    --output /boot/vmlinuz-7.0.0-12-generic+dirtyfrag1 \
    /boot/vmlinuz-7.0.0-12-generic+dirtyfrag1

15.9 kexec Hangs Or Refuses To Load

Kexec under Secure Boot is unreliable. kexec_load() is typically blocked by lockdown mode; kexec_file_load() requires the new kernel to be signed by a key the firmware trusts.

# Just reboot
sudo reboot

# If you really want to try kexec:
sudo kexec --type=Image-bzImage \
    -l /boot/vmlinuz-... \
    --initrd=/boot/initramfs-... \
    --reuse-cmdline
sudo systemctl kexec
# (systemctl kexec is cleaner than `kexec -e` because it runs shutdown hooks)

15.10 ccache + clang Stale Cache Errors

ccache -C   # nuke cache
export CCACHE_NOCOMPRESS=1
export CCACHE_SLOPPINESS=time_macros,include_file_mtime,include_file_ctime

# Pin build timestamp so it doesn't bust cache on every build
export KBUILD_BUILD_TIMESTAMP="$(date -u -d @$(git log -1 --format=%ct) '+%Y-%m-%dT%H:%M:%SZ')"

16. Worked Examples

16.1 Example A: CachyOS Desktop Where Patch Is Already In

The most common case for May 2026+ readers.

# t=0min — verify what we're on
$ uname -r
7.0.3-1-cachyos

# t=1min — apply Phase 0 mitigation as defense-in-depth while we check
$ sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
> install esp4      /bin/false
> install esp6      /bin/false
> install rxrpc     /bin/false
> install ipcomp4   /bin/false
> install ipcomp6   /bin/false
> install xfrm_user /bin/false
> EOF

$ sudo modprobe -r esp4 esp6 rxrpc ipcomp4 ipcomp6 xfrm_user 2>/dev/null

# t=3min — clone the kernel repo, fetch source for inspection
$ cd ~
$ git clone --depth 1 https://github.com/CachyOS/linux-cachyos.git
$ cd linux-cachyos/linux-cachyos
$ makepkg -o --skippgpcheck
==> Making package: linux-cachyos 7.0.3-1
==> Retrieving sources...
  -> Downloading linux-7.0.3.tar.xz...
==> Validating source files with b2sums...
==> Extracting sources...

# t=5min — check if patch is in
$ grep -A 4 "skb_has_frag_list(skb)" src/linux-7.0.3/net/ipv4/esp4.c | head
		} else if (!skb_has_frag_list(skb) &&
			   !skb_has_shared_frag(skb)) {
			nfrags = skb_shinfo(skb)->nr_frags;
			nfrags++;

# Patch is in! No build needed. Verify.

# t=6min — install bpftrace
$ sudo pacman -S bpf

# t=7min — runtime probe
$ sudo bpftrace -e '
> kfunc:vmlinux:esp_input { @esp_calls += 1; }
> kfunc:vmlinux:skb_cow_data /pid != 0/ { @cow_calls += 1; @stk[kstack(5)] = count(); }
> ' &
[1] 14523

# Generate ambient traffic / scheduled tasks for 60 sec
$ sleep 60
$ sudo kill %1
@esp_calls: 12
@cow_calls: 8
@stk[
    skb_cow_data+1
    esp_input+524
    xfrm_input+862
    ...
]: 3

# t=8min — patched kernel verified. Remove blacklist.
$ sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
$ sudo mkinitcpio -P

# Done. Total time: 8 minutes.

16.2 Example B: Ubuntu 26.04 Custom Build

Less common but illustrative for someone who needs IPsec and hasn't gotten the SRU yet.

# t=0min
$ uname -r
7.0.0-12-generic

$ cat /etc/os-release | head -3
PRETTY_NAME="Ubuntu 26.04 LTS"
NAME="Ubuntu"
VERSION_ID="26.04"

# t=1min — Phase 0 mitigation
$ sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
> install esp4      /bin/false
> install esp6      /bin/false
> install rxrpc     /bin/false
> install ipcomp4   /bin/false
> install ipcomp6   /bin/false
> install xfrm_user /bin/false
> EOF
$ sudo update-initramfs -u

# t=3min — install build deps
$ sudo apt update
$ sudo apt install -y fakeroot dpkg-dev libelf-dev libssl-dev \
>     libncurses-dev bison flex bc rsync zstd dwarves git
$ sudo apt build-dep -y linux linux-image-unsigned-$(uname -r)

# t=8min — enable deb-src
$ sudo sed -i 's/^Types: deb$/Types: deb deb-src/' \
>     /etc/apt/sources.list.d/ubuntu.sources
$ sudo apt update

# t=10min — clone Launchpad source
$ mkdir -p ~/ubuntu-kernel
$ cd ~/ubuntu-kernel
$ git clone --depth 1 \
>     git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/resolute \
>     -b master-next
$ cd resolute

# t=15min — pre-flight check
$ grep -A 4 "skb_has_frag_list(skb)" net/ipv4/esp4.c | head
		} else if (!skb_has_frag_list(skb)) {
			nfrags = skb_shinfo(skb)->nr_frags;
# Vulnerable. Continue with patch.

$ grep -rn MSG_NO_SHARED_FRAGS include/
include/linux/socket.h:354: #define MSG_NO_SHARED_FRAGS 0x100000
# OK, symbol exists, patch applies as-is.

# t=16min — apply patch
$ patch -p1 --fuzz=5 < ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch
patching file net/ipv4/esp4.c
Hunk #1 succeeded at 875 with fuzz 2 (offset 2 lines).
patching file net/ipv4/ip_output.c
Hunk #1 succeeded at 1241 (offset 8 lines).
patching file net/ipv6/esp6.c
Hunk #1 succeeded at 919 with fuzz 2 (offset 4 lines).
patching file net/ipv6/ip6_output.c
Hunk #1 succeeded at 1802 (offset 8 lines).

# t=17min — set version suffix and configure
$ sed -i '0,/^linux (/{s/^linux (\([^)]*\))/linux (\1+dirtyfrag1)/}' \
>     debian.master/changelog

$ head -1 debian.master/changelog
linux (7.0.0-12.13+dirtyfrag1) resolute; urgency=medium

$ chmod +x debian/rules debian/scripts/* debian/scripts/misc/*
$ fakeroot debian/rules clean
$ scripts/config --set-str SYSTEM_TRUSTED_KEYS ""
$ scripts/config --set-str SYSTEM_REVOCATION_KEYS ""

# t=20min — build (long)
$ LANG=C fakeroot debian/rules binary-headers binary-generic binary-perarch \
>     skipabi=true skipmodule=true skipretpoline=true \
>     -j$(nproc) 2>&1 | tee build.log
... (45 minutes pass) ...

# t=65min — install
$ cd ..
$ ls *.deb
linux-headers-7.0.0-12_*+dirtyfrag1*_all.deb
linux-headers-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb
linux-image-unsigned-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb
linux-modules-7.0.0-12-generic_*+dirtyfrag1*_amd64.deb

$ sudo dpkg -i \
>     linux-headers-*+dirtyfrag1*.deb \
>     linux-image-unsigned-*+dirtyfrag1*.deb \
>     linux-modules-*+dirtyfrag1*.deb

# t=68min — DKMS rebuild
$ nv_pkg=$(dpkg -l | awk '/nvidia-dkms-/{print $2}' | head -1)
$ sudo dpkg-reconfigure "$nv_pkg"

$ sudo update-grub
$ sudo systemctl restart apparmor

# t=70min — reboot
$ sudo reboot

# After reboot:
$ uname -r
7.0.0-12-generic+dirtyfrag1

# Verify
$ sudo bpftrace -e '...' &  # see Section 13.1
... evidence of skb_cow_data calls ...
$ sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
$ sudo update-initramfs -u

# Done. Total: ~75 minutes.

16.3 Example C: Alpine On Raspberry Pi 5

# t=0min — verify environment
$ uname -m
aarch64
$ cat /proc/device-tree/model | tr -d '\0'
Raspberry Pi 5 Model B Rev 1.0
$ uname -r
6.18.26-0-rpi
$ cat /etc/alpine-release
3.23.2

# t=1min — Phase 0 mitigation (and lbu commit immediately, since diskless)
$ sudo tee /etc/modprobe.d/dirtyfrag-mitigation.conf > /dev/null << 'EOF'
> install esp4      /bin/false
> install esp6      /bin/false
> install rxrpc     /bin/false
> install ipcomp4   /bin/false
> install ipcomp6   /bin/false
> install xfrm_user /bin/false
> EOF
$ grep "tmpfs / " /proc/mounts && sudo lbu commit -d
tmpfs / tmpfs ...
[lbu output...]

# t=3min — set up build
$ sudo apk add alpine-sdk build-base bc bison flex elfutils-dev openssl-dev \
>     linux-headers perl python3 diffutils findutils xz zstd tar git sed coreutils
$ sudo addgroup $(whoami) abuild
$ newgrp abuild
$ abuild-keygen -a -n
$ sudo cp ~/.abuild/*.rsa.pub /etc/apk/keys/
$ sudo chmod 644 /etc/apk/keys/*.rsa.pub

# t=8min — clone aports
$ git clone --depth 1 --branch 3.23-stable \
>     https://gitlab.alpinelinux.org/alpine/aports.git ~/aports
$ cd ~/aports/main/linux-rpi

# t=10min — pre-flight check
$ abuild fetch
$ abuild unpack
$ grep -A 4 "skb_has_frag_list(skb)" \
>     src/linux-6.18.26/net/ipv4/esp4.c | head
		} else if (!skb_has_frag_list(skb)) {
# Vulnerable, continue.

# t=12min — stage patch and edit APKBUILD
$ cp ~/dirtyfrag-patches/dirtyfrag-esp-fix.patch .
$ nano APKBUILD
# Add _flavor="rpi-dirtyfrag" near top
# Add dirtyfrag-esp-fix.patch to source=
# Add patch invocation in prepare()

$ abuild checksum

# t=15min — build (long on Pi 5)
$ abuild -r 2>&1 | tee build.log
... (45 minutes on Pi 5 with NVMe) ...

# t=60min — install
$ ls ~/packages/main/aarch64/linux-rpi-dirtyfrag*.apk
linux-rpi-dirtyfrag-6.18.26-r0.apk
linux-rpi-dirtyfrag-dev-6.18.26-r0.apk
linux-rpi-dirtyfrag-doc-6.18.26-r0.apk

$ sudo apk add --allow-untrusted \
>     ~/packages/main/aarch64/linux-rpi-dirtyfrag*.apk

# t=63min — Pi-specific bootloader update
$ ls /boot/ | grep dirtyfrag
vmlinuz-rpi-dirtyfrag
initramfs-rpi-dirtyfrag

$ cat /boot/config.txt
[all]
arm_64bit=1
kernel=vmlinuz-rpi
initramfs initramfs-rpi
include usercfg.txt

$ sudo nano /boot/config.txt
# Change to:
# [all]
# arm_64bit=1
# kernel=vmlinuz-rpi-dirtyfrag
# initramfs initramfs-rpi-dirtyfrag
# include usercfg.txt

# t=65min — diskless persist!
$ sudo lbu commit -d

$ sudo reboot

# After reboot:
$ uname -r
6.18.26-0-rpi-dirtyfrag

# Verify (Section 13.1 / 13.3)
$ sudo find /usr/src -name esp4.c -exec grep -l skb_has_shared_frag {} \;
/usr/src/linux-headers-6.18.26-0-rpi-dirtyfrag/net/ipv4/esp4.c

$ sudo rm /etc/modprobe.d/dirtyfrag-mitigation.conf
$ sudo lbu commit -d

# Done.

17. Glossary

ABI — Application Binary Interface. The contract between compiled binaries and the kernel (system call numbers, struct layouts, etc.). Ubuntu's skipabi=true disables enforcement of this for non-official builds.

APKBUILD — Alpine's package build recipe, similar to PKGBUILD or rpm's spec file.

bpftrace — A high-level eBPF tracing language. Used for runtime kernel probing.

Cc: stable — A tag in upstream commit messages indicating the patch should be backported to all active stable kernel trees. The stable kernel team's automation handles this.

DKMS — Dynamic Kernel Module Support. Framework for rebuilding out-of-tree kernel modules (NVIDIA, ZFS, VirtualBox) when the kernel is upgraded.

EEVDF — Enhanced Earliest Virtual Deadline First. The default Linux scheduler since 6.6. CachyOS uses BORE on top of it for desktop responsiveness.

ESP — Encapsulating Security Payload. The IPsec protocol that provides encryption and authentication. CVE-2026-43284 is in esp_input().

Fuzz / Offset — When patch applies a hunk and the surrounding context doesn't exactly match (because line numbers shifted), it tries to find the right location with some flexibility. "Fuzz" is how many context lines it's allowed to ignore. "Offset" is by how many lines the hunk moved.

kexec — A syscall that lets one Linux kernel "boot" another without a full firmware reset. Useful for fast iteration.

LBU — Local Backup Utility. Alpine's tool for persisting /etc changes to /boot in diskless mode.

Limine — A modern bootloader, recently adopted as default by CachyOS.

LTO — Link-Time Optimization. Compiler optimization that operates on the whole program at link time. Produces faster code at the cost of much higher RAM use and longer build times.

MOK — Machine Owner Key. Under Secure Boot, the firmware's allow-list of trusted keys. mokutil manages it.

Page Cache — RAM kernel-managed cache of file contents. The target of Dirty Frag.

pkgrel / pkgbase / pkgname — Arch package versioning. pkgrel is the local revision (bumped for rebuilds). pkgbase is the upstream package name. pkgname is what gets installed.

runit — Void Linux's init system. Replaces systemd.

SKB / sk_buff — Socket Buffer. The kernel's representation of a network packet.

SKBFL_SHARED_FRAG — A flag in skb_shared_info->flags indicating the skb's frags[] contain pages from a shared/external source. Set in patched __ip_append_data(), checked in patched esp_input().

splice() — A Linux syscall that moves data between file descriptors without copying through userspace. The bridge that enables Dirty Frag.

SRU — Stable Release Update. Canonical's process for delivering kernel updates to released Ubuntu versions.

xbps / xbps-src — Void Linux's package manager and build system.

XFRM — Linux's IPsec framework. xfrm_state_add is the syscall that registers a new Security Association.


18. References

  • Upstream ESP commit: f4c50a4034e6 — Kuan-Ting Chen, "esp,ip: fix page-cache write via splice + in-place crypto", merged late April 2026 with Cc: stable.
  • RxRPC patch: Hyunwoo Kim, message-id <afKV2zGR6rrelPC7@v4bel>, submitted to netdev 2026-04-29, unmerged as of writing.
  • Original Kim v1 ESP patch: message-id <afLDKSvAvMwGh7Fy@v4bel>, April 30 2026, superseded by Chen's v2.
  • CachyOS kernel repo: https://github.com/CachyOS/linux-cachyos
  • CachyOS kernel-patches repo: https://github.com/CachyOS/kernel-patches
  • CachyOS wiki kernel page: https://wiki.cachyos.org/features/kernel/
  • Alpine aports: https://gitlab.alpinelinux.org/alpine/aports
  • Alpine Custom Kernel wiki: https://wiki.alpinelinux.org/wiki/Custom_Kernel
  • Alpine APKBUILD reference: https://wiki.alpinelinux.org/wiki/APKBUILD_Reference
  • Ubuntu kernel team docs: https://canonical-kernel-docs.readthedocs-hosted.com/
  • Ubuntu mainline builds: https://kernel.ubuntu.com/mainline/
  • Ubuntu Launchpad kernel git (26.04 = resolute): git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/resolute
  • Void packages: https://github.com/void-linux/void-packages
  • Void documentation: https://docs.voidlinux.org/
  • Stable kernel trees: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
  • Hyunwoo Kim's Theori publications: https://theori.io/
  • Phoronix CachyOS benchmarks: https://www.phoronix.com/
  • Raspberry Pi config.txt reference: https://www.raspberrypi.com/documentation/computers/config_txt.html
  • Linux kernel Documentation/networking/skbuff.rst for SKB internals
  • Linux kernel Documentation/networking/xfrm/index.rst for XFRM/IPsec architecture
  • CVE-2022-0847 (Dirty Pipe) — predecessor of this bug class

Final Disclaimers

  • These instructions are best-effort. Always read what commands do before running with sudo.
  • Each step should be logged. The provided helper scripts log to /tmp/dirtyfrag-<distro>-<timestamp>.log — keep these for forensics and audit.
  • The local hardened ESP patch (with !skb->data_len) is stronger than upstream. When you switch back to the stock kernel after distros ship the official fix, you lose the data_len check but keep upstream's SKBFL_SHARED_FRAG check, which is sufficient for the known attack vectors.
  • If this guide is older than 60 days from the date at the top, upstream has likely shipped a complete fix — check distro changelogs before rebuilding.
  • The RxRPC patch is unmerged upstream at the time of writing. If you must run kAFS, watch netdev for the maintainer-blessed version and switch to it when it lands.
  • For a kernel as critical as the production system you depend on: build twice, test once, reboot once, verify always. There is no shame in being slow when the cost of an error is a non-booting workstation.

Document ends. Patches in §2.1 and §2.2. Worked examples in §16. Verification in §13.

— Compiled 2026-05-08, Toronto.

About

Patching guide for alpine, void, cachyos, debian ubuntu to fix the dirty-frag vulnerability that was leaked without giving distros time to fix it

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages