
Linux LPE - Reliable Jail/Container Escape
A reliable unprivileged container / jail escape proof of concept for CentOS / RHEL 10.
It rides a now fixed IPv6 fragmentation bug in __ip6_append_data() (closed upstream by
38becddc, no CVE), an in-slab linear overflow into the skb_shared_info at the tail of a
packet's own head object. This README documents the exploitation chain only. It does not
cover the trigger.
6.12.0-242.el10).This is a proof of concept, deliberately not a turnkey weapon. The reliability scaffolding
is intentionally left out: no per-CPU PCP grooming, no cross-cache SLUB armoring, and the
skb head is placed in a shared, not so quiet cache. What ships fires often enough to
prove the chain, and not much more. It is scoped to CentOS / RHEL 10 only.
CONFIG_INIT_ON_ALLOC_DEFAULT_ON off, the RHEL / CentOS default. This PoC plants a stale
pointer in uninitialised slab bytes; with init_on_alloc=1 that slot is zeroed and this
particular technique only crashes the kernel (a NULL deref). That is a limit of the
technique, not of the bug: a full exploit that also covers those kernels, with a
different technique, will follow once the fix and CVE are public./sys/kernel/btf/vmlinux present and world readable (stock on RHEL / CentOS).| Distro | Kernel | Result |
|---|---|---|
| CentOS Stream 10 | 6.12.0-242.el10 | root, container escape |
| RHEL 10 | 6.12.0-228.el10 | httpd_t context escape |
In-slab overflow to a self-UAF. The bug yields control of the single nr_frags
byte in skb_shared_info. The free path (skb_release_data()) walks
frags[0 .. nr_frags) and put_page()s each entry, and frags[] is never initialised.
We pre-plant a struct page * for a pipe buffer page into that slab slot through
controlled cache reuse, then set nr_frags to 1, so the teardown drops a reference on a
page we still own. The linear overflow becomes a page use-after-free. The overflow never
touches frags[0], so it need not be timed against the spray, which is most of what
makes it forgiving.
Page UAF to Dirty-Pagetable. The freed pipe page is reclaimed as a last-level page table by faulting a fresh anonymous mapping. One physical page is now both a live leaf page table and the page the pipe still reads and writes. Writing eight bytes to the pipe installs a forged PTE; reading the pipe reads the table back. This is a finite arbitrary physical read/write, on the order of 460 PTE windows per table.
Defeat KASLR. With the physical read we scan the fixed low-memory SMP trampoline
page table, which KASLR never relocates; its kernel half entry points at
level4_kernel_pgt, the kernel's physical base. From there (recognisable
by its self-referencing entry 511) is a universal virtual to physical translator,
recovering the kernel virtual base and tying our address space to physical memory.
init_top_pgtFinite to infinite read/write. We forge one PTE in the leaf table that points at the
table's own physical address. The matching virtual window then aliases the page table
itself, so PTEs become plain memory: unlimited, random access kernel read/write, with no
pipe in the loop. Ring 3 TLB coherence is handled by forcing a full mm flush with an
oversized mprotect().
Resolve offsets and take credentials. Struct member offsets are read at runtime from
/sys/kernel/btf/vmlinux, not baked in. We locate our own task_struct through the
vmemmap struct page walk and mm_struct.owner, zero the credential ids, and fill all
capability sets on cred and real_cred. Symbol addresses come from an internal resolver
that parses the kernel's own kallsyms tables through our arbitrary read; only if that
fails do we fall back to pointing cred.user_ns at init_user_ns (so CAP_SYSLOG holds
in the initial namespace) and reading the real addresses from /proc/kallsyms.
Disable SELinux without flipping enforcing. We overwrite the prologue of
avc_denied() with a xor eax, eax ; ret stub (stepping over endbr64 under IBT).
Every denial now returns granted, process wide, while getenforce still reports
Enforcing. This is also a prerequisite for the escape: the core-dump handler's cross
domain re-exec would otherwise be denied.
Escape through core_pattern. We overwrite the global core_pattern with a |
prefixed handler pointing back at our own binary through /proc/<PID>/root (the crashing
task's container chroot), then crash a child. The kernel runs the handler as a
usermodehelper, root in the initial namespaces, and we relay an interactive root shell
over a Unix socket bound inside the container. The handler is born inside the init
namespaces, so no in-place namespace surgery, and none of its PID-namespace hazards, is
needed.