Need something less technical? Take a quick look at our bug summary.
CVE-2023-2156 (“Route of Death”) is a Linux kernel vulnerability that was believed to only lead to a DoS attack, and was considered patched in April 2023. However, Nebula Security discovered a bypass of the patch and found that it is actually exploitable and can lead to LPE on any Linux distribution that has IPv6 and namespaces enabled. This writeup covers the technical details of the exploit.
Vulnerability Summary
CVE-2026-43501 “Route of Root” is a Linux kernel vulnerability lets an attacker:
- 100% remotely trigger a 14-byte, partially controlled OOB write in almost any
kmalloc-x-sized cache with a single crafted IPv6 packet. - With that 14-byte, partially controlled OOB write, overwrite critical objects
- Gain arbitrary kernel memory read/write, and eventually kernel shellcode execution or a root shell.
The target Linux system is vulnerable if:
- It supports IPv6
- Its version is in the affected range:
5.7~7.1(May 2020 ~ May 2026). net.ipv6.conf.*.rpl_seg_enabledis set to1or unprivileged{user,net}namespaces are enabled.
The Bug
CVE-2026-43501 “Route of Root” is a corner case (but more powerful) of CVE-2023-2156, and the two share almost the same cause, trigger path, and primitive.
Summary
One step further from Route of Death (CVE-2023-2156)
Three years ago there was a bug on this same receive path, CVE-2023-2156 (“Route of Death”). The RPL SRH receive code was added in 8610c7c6e3bd (“net: ipv6: add support for rpl sr exthdr”, v5.7), and the original writeup covers this path, with good figures showing how the OOB happens.
Everyone who looked at CVE-2023-2156 considered it crash-only:
- The Interrupt Labs’ original writeup stated that
skb_push()will detect the bug and panic before the out-of-bounds write can happen. - ZDI-23-547: “Assertion Denial-of-Service”
- Red Hat: “reachable assertion leading to DoS”
- CVSS vector classified as:
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H(availability only, nothing on confidentiality or integrity).
The 2023 PoC used CmprI = 15, CmprE = 0 and segments_left = 1, the largest amplification the header format allows, turning a 48-byte address vector into 528 bytes. The panic log reads put:576 against 16 bytes of reserve, so data lands hundreds of bytes below head and gets easily detected by skb_push().
Here, ipv6_rpl_srh_rcv() in net/ipv6/exthdrs.c processes incoming RPL type-3 routing headers. It decompresses the SRH, swaps the current segment into ipv6_hdr(skb)->daddr, recompresses, pulls the old header, then pushes the new IPv6 header and the recompressed SRH back.
skb_pull(skb, ((hdr->hdrlen + 1) << 3));... // header may have grownskb_push(skb, ((chdr->hdrlen + 1) << 3) + sizeof(struct ipv6hdr));skb_reset_network_header(skb);skb_mac_header_rebuild(skb); // still may have grown!In skb_push():
skb->data -= len; skb->len += len; if (unlikely(skb->data < skb->head)) skb_under_panic(skb, len, __builtin_return_address(0));But actually, that skb_push() is the only safety check on the vulnerability trigger path, and it only compares data against head. However, after passing that check, skb_mac_header_rebuild() will subtract mac_len from data - head, and store the result into a __u16 with no check of its own.
So if we make the evil packet perfectly fit into head <= data < head + mac_len, skb_push() will return quietly (no panic any more!) and the later subtraction will get a negative value (underflow from u16). The truncated offset then puts the MAC header copy roughly 64 KB past head.
With that, we can copy a MAC header as OOB payload at the offset ~65,535. Which is enough to turn this “DoS only” bug into Local Privilege Escalation.
Turn an N-day into a 0-day
Now we know we can exploit that “DoS only” N-day, but we won’t stop here.
Let’s take a deeper look at the code after the fix:
The fix added pskb_expand_head() to grow the headroom when the recompressed header no longer fits. Normally,
pskb_expand_head() is called only when segments_left drops to zero, on the assumption that the recompressed SRH can only grow at the final hop (but that assumption is not correct):
skb_pull(skb, ((hdr->hdrlen + 1) << 3));...if (unlikely(!hdr->segments_left)) { if (pskb_expand_head(skb, sizeof(struct ipv6hdr) + ((chdr->hdrlen + 1) << 3), 0, GFP_ATOMIC)) ... oldhdr = ipv6_hdr(skb);}skb_push(skb, ((chdr->hdrlen + 1) << 3) + sizeof(struct ipv6hdr));skb_reset_network_header(skb);skb_mac_header_rebuild(skb);On an intermediate hop segments_left stays non-zero, so the headroom is never grown, even though the recompressed SRH can still get bigger. This gives the same initial primitive Route of Death had:
The skb_push() that follows leaves fewer than mac_len bytes of headroom. skb_mac_header_rebuild() then calls skb_set_mac_header(skb, -skb->mac_len), which resets skb->mac_header to data - head and subtracts mac_len from that, which will cause an integer underflow and return the offset as something around 0xffff.
The skb_mac_header() on the next line resolves to head plus that offset (~0xffff), and the memmove() copies the MAC header there, which gives us a 14-byte controllable OOB write.
As long as
CONFIG_IPV6is enabled, the vulnerable code is compiled into the kernel (even in Android).
Triggering the wrap
Now, with the intermediate hop bypass, we can exploit “Route of Root” on the latest kernel using the same so-called “DoS only” primitive to get LPE and win a $10,500 bounty from Google!
The RPL receive path is reachable by an unprivileged user. RPL is gated by
net.ipv6.conf.*.rpl_seg_enabled, which only needsCAP_NET_ADMINin the current namespace. At the time we exploited this bug, Container-Optimized OS was still a valid target and unprivileged namespaces were enabled in COS.
To trigger the exploitable bug, we can use the following SRH parameters to produce a 16-byte growth:
CmprI = 5,CmprE = 15N = 5compressed segmentssegments_left = 2- no padding
Original SRH:
old_segdata_len = 5 * (16 - 5) + (16 - 15) = 56old_rh_len = 8 + 56 = 64segments_left after the decrement is 1, so the pskb_expand_head() will be skipped. The selected segment index is:
i = N - (segments_left - 1) = 4Two bytes of the compressed segment data are controlled:
rh->data[4 * (16 - CMPRI)] = 0x44;rh->data[N * (16 - CMPRI)] = 0x77;After the destination swap the last segment no longer shares the original 15-byte prefix with the new daddr. CmprE drops to 5, the compressed segment data grows to 66 bytes, and padding rounds the SRH to 80 bytes, so we get 16 bytes of growth.
Raw IPv6 with IPV6_HDRINCL on loopback puts the IPv6 header at head + 16 (hard_header_len = 14, LL_RESERVED_SPACE() gives 16). On loopback receive eth_type_trans() leaves skb->mac_len = 14. The pull/push sequence then leaves:
final data - head = 16 + old_srh_len - new_srh_len = 16 - growthTo wrap skb->mac_header, data - head must be below 14. To pass the skb_push() underflow check it must be non-negative. SRH lengths are 8-byte aligned, so only two growth values are useful:
growth = 8 -> data - head = 8 -> mac_header = 0xfffa -> page offset ffagrowth = 16 -> data - head = 0 -> mac_header = 0xfff2 -> page offset ff2This exploit uses growth = 16, landing the wrapped write at page offset ff2.
The final primitive from the bug
Pulling the primitives together, we can use only one packet to trigger the bug remotely and reliably to get a 14-byte OOB write.
We can also change the packet size to move the overflow into any kmalloc-x cache of 512 and above.
However, we can only pick the overflow offset between two choices (fffa or fff2).
The 14 bytes that land are roughly:
- the length is fixed at 14
- the first 12 bytes are the L2 destination + source addresses (6 + 6), derived from a user-selected
loaddress - bytes 12–13 are fixed ethertype (
86 dd, for IPv6) - the first octet must be a MAC-valid value
The capability we end up using is offset fff2 together with the nearly fully controllable first 6 bytes and we don’t care what the last 8 bytes end up being (just treat them as junk data).
Exploit Details
Exploit Summary
- prefetch -> Leak the KASLR base.
- CVE-2026-43501 -> Splice the high bytes of a kernel
.textaddress into a TPACKETpg_vecpointer, keeping its low 16 bits. - pg_vec remap ->
mmap()the corrupted ring so the aliased pointer maps that kernel.textpage RW into userspace, letting us overwrite a syscall handler with our kernel shellcode. - kernel shellcode -> Trigger the matching syscall, modify
core_patternthen finish from userspace.
Background of used tricks
Prefetch ASLR Leak
A prefetch on a given address runs in a different number of cycles depending on whether that address is mapped in the current page tables, so an unprivileged process can time prefetch across the kernel range and read off which addresses are mapped (the prefetch paper has the details).
It works here as Linux barely randomizes the base of its default kernel image (~9 bits of entropy for text base), so a little averaging can recover the KASLR base with near 100% reliability.
In theory any CPU with prefetch and without proper Kernel Page-Table Isolation is affected. But in practice it is more of an x86 technique (unless the ARM target runs KPTI off).
kernelCTF images keep KPTI disabled, but even with KPTI on,
prefetchpaired with EntryBleed can still recover the kernel image base through the trampoline.
pgv spray
pg_vec is the page vector behind an AF_PACKET mmap ring, the kernel’s fast path for passing packets to and from userspace. It is basically an array of pointers (every 8-byte entry points at a page-sized buffer). And its length is almost free to choose, which is what makes it easy to spray.
The more useful part is that the ring can be mmaped back into userspace, and that mmap does not check the permissions of the buffer a pg_vec entry points at.
So if we overwrite one of those pointers and aim it at kernel .text, mmap will hand back a writable mapping of that code page, and we patch the kernel straight from userspace.
That is basically the USMA trick.
However there’s one constraint. Attaching a pg_vec to a socket needs CAP_NET_RAW in the current namespace, so this only works where unprivileged {user + network} namespaces are available.
Shaping the OOB write into a pg_vec pointer
The exploit packet is the trigger SRH plus 2000 bytes of payload:
IPv6 header = 40RPL SRH = 64payload = 2000 // decides which kmalloc-x cache the overflow `skb` head goes tototal length = 2104The object we want to corrupt is a sprayed TPACKET_V3 RX ring, specifically its pg_vec array page:
#define PAGE_NUM ((4096 - 8) / 8) // 511 in totalpacket_socket_setup(0x1000, 2048, PAGE_NUM, 0, 10000);alloc_pg_vec() allocates an array of struct pgv, one pointer per entry. 511 entries make that array 4088 bytes, which rounds up to kmalloc-4096, so the last pointer pg_vec[510] starts at offset 0xff0, leaving 8 bytes of tail slack.
The ring map is one-shot, once 511 entries are allocated, we cannot map only a prefix of them.
That’s the main reason we choose offsetff2instead offfa.
With mac_header wrapped to 0xfff2, the 14-byte MAC-header copy starts at offset ff2 of the pgv array page and overwrites bytes 2..7 of pg_vec[510].buffer, splicing the destination MAC into the high bytes of the pointer while leaving its low 16 bits intact:
The loopback hardware address carries the high bytes of the KASLR-adjusted text target:
target = leaked_base + 0x1e0000;lo_addr_pattern[0] = target >> 16;lo_addr_pattern[1] = target >> 24;lo_addr_pattern[2] = 0xff;lo_addr_pattern[3] = 0xff;lo_addr_pattern[4] = 0xff;lo_addr_pattern[5] = 0xff;The netdevice setter forces the first MAC byte to a unicast, locally-administered value. In the chosen window that byte is 0x1e, which already satisfies the rule, so no adjustment is needed. After the copy the pointer is:
before: [old0 old1 |old2 old3 | old4 old5 old6 old7]after: [old0 old1 |0x1e target24| 0xff 0xff 0xff 0xff]Patching a kernel .text page
pg_vec[i].buffer is a kernel virtual address, and packet_mmap() resolves it through virt_to_page() and maps the backing physical page into userspace RW. Point the pointer at a kernel .text address and the mmap() hands back a writable alias of read-only kernel text.
The preserved low 16 bits mean the aliased page is one of 16 4K pages in the 64K window at leaked_base + 0x1e0000 (the page-aligned original buffer supplies bits 12..15, picking the page). To learn which one landed, we read the first qword and match it against 16 known page signatures.
Only pages 0..8 are used. These are the nine pages that lie on a path reachable by a plain syscall, each with a fixed in-page patch offset:
page 0 -> 0xde0 -> pidfd_send_signalpage 1 -> 0xbe0 -> umaskpage 2 -> 0x860 -> unamepage 3 -> 0x7f0 -> sysinfopage 4 -> 0x100 -> setprioritypage 5 -> 0x6b0 -> prlimit64page 6 -> 0xd30 -> setuidpage 7 -> 0xea0 -> getrusagepage 8 -> 0x100 -> prctlThe remaining pages are dropped. Some of them have no clean patch site, and the others sit on workqueue or module-autoload code, which we found painful to write shellcode for.
The shellcode is 9 qwords (72 bytes) and position-independent. We used the rdmsr + core_pattern + msleep route from CVE-2024-36972_lts_cos. After patching, we simply trigger all the listed syscalls to hit the patched kernel code at least once and then we are good to perform the common core_pattern UMH trick to get a container escape.
Appendix
The full exploit code can be found in our open source security research project, CyberMeowfia.
Timeline
- 2026-02-22: We exploited Google’s kernelCTF with this bug.
- 2026-02-23: We reported the bug to security@kernel.org.
- 2026-03-09: We sent draft patch v1 to security@kernel.org.
- 2026-04-05: We sent a follow-up to security@kernel.org.
- 2026-04-10: We re-sent the draft patch v1 and the report to security@kernel.org.
- 2026-04-17: The report was rejected by the maintainers because it requires namespaces to trigger.
- 2026-04-20: We re-sent the report and patch v2 to security@kernel.org.
- 2026-04-21: The bug was fixed by another patch.
- 2026-06-16: The fix was backported.
- 2026-06-26: Google acknowledged our kernelCTF submission and rewarded us $10,500.
- 2026-08-18: We published this blog post.
Mitigation
This bug was finally fixed in 9e6bf146b559. The bug affected v5.7-rc1 through v7.1-rc1.
diff --git a/net/ipv6/exthdrs.c b/net/ipv6/exthdrs.cindex 95558fd6f447e..03cbce842c1a7 100644--- a/net/ipv6/exthdrs.c+++ b/net/ipv6/exthdrs.c@@ -491,6 +491,7 @@ static int ipv6_rpl_srh_rcv(struct sk_buff *skb) struct net *net = dev_net(skb->dev); struct inet6_dev *idev; struct ipv6hdr *oldhdr;+ unsigned int chdr_len; unsigned char *buf; int accept_rpl_seg; int i, err;@@ -592,8 +593,10 @@ looped_back: skb_pull(skb, ((hdr->hdrlen + 1) << 3)); skb_postpull_rcsum(skb, oldhdr, sizeof(struct ipv6hdr) + ((hdr->hdrlen + 1) << 3));- if (unlikely(!hdr->segments_left)) {- if (pskb_expand_head(skb, sizeof(struct ipv6hdr) + ((chdr->hdrlen + 1) << 3), 0,+ chdr_len = sizeof(struct ipv6hdr) + ((chdr->hdrlen + 1) << 3);+ if (unlikely(!hdr->segments_left ||+ skb_headroom(skb) < chdr_len + skb->mac_len)) {+ if (pskb_expand_head(skb, chdr_len + skb->mac_len, 0, GFP_ATOMIC)) { __IP6_INC_STATS(net, ip6_dst_idev(skb_dst(skb)), IPSTATS_MIB_OUTDISCARDS); kfree_skb(skb);@@ -603,7 +606,7 @@ looped_back:
oldhdr = ipv6_hdr(skb); }- skb_push(skb, ((chdr->hdrlen + 1) << 3) + sizeof(struct ipv6hdr));+ skb_push(skb, chdr_len); skb_reset_network_header(skb); skb_mac_header_rebuild(skb); skb_set_transport_header(skb, sizeof(struct ipv6hdr));This patch is effective at mitigating the exploit, though it does not preserve the functionality of a crafted packet. We proposed a larger fix that would also keep the integrity of the metadata. However, since RPL is not widely used, this is the final version that was accepted upstream.
Affected versions
The bug was introduced in Linux 5.7-rc1 and fixed in Linux 7.1-rc1.
Any Linux distribution with IPv6 and unprivileged namespaces enabled is affected and should consider upgrading to the latest LTS version or disabling unprivileged net namespaces.