RetSpill
RetSpill is a ROP technique that might make life easier if we have the required primitives beforehand. RetSpill abuses the kernel design where there are certain areas that allow userspace data to land on the kernel stack. This is especially useful if we have a control flow hijack primitive that allows us to control RIP/PC. We do not need an additional write primitive in kernel space because RetSpill already provides user-controlled data on the kernel stack.
Usually, to plant a ROP chain into kernel memory (which requires an arb write or something like msg_msg), the ROP chain is already there because the kernel by design copies userspace data onto the kernel stack during normal syscall operation.
The minimum primitive requirement is therefore:
- CFHP (control of RIP/RSP)
- Kernel stack address leak or in many case is not necessary because we can use stack shifting gadget to force RSP point to our ROP chain
Example: add rsp, 0x158 ; ret; - KASLR leak to find gadgets
This is a simplified note to myself for quick reference. Check out the paper for more details, https://dl.acm.org/doi/epdf/10.1145/3576915.3623220
The Four Spill Types
1. Valid Data
Some syscalls copy userspace buffers directly onto a stack instead of heap-allocating them for performance reasons. The most cited example is poll(). For a little number of fds, the kernel copies the pollfd array from userspace directly onto the kernel stack.
How it is abused:
- Attacker fills the
pollfdarray with ROP gadget addresses - Calls
poll()→ kernel doescopy_from_user()onto kernel stack - CFHP fires → RSP is pivoted to the stack region containing the copied array
- ROP executes from user-controlled data
2. Preserved Registers (pt_regs)
On syscall entry, the CPU saves the userspace general-purpose registers onto the kernel stack as a struct pt_regs. This is necessary so the kernel can restore them on sysret/iretq.
Because pt_regs is at a fixed offset from the top of the kernel stack (stack_top - sizeof(pt_regs)), once you know the kernel stack base address, you know exactly where all user register values landed.
How it is abused:
-
Attacker sets registers to ROP gadget addresses before triggering the syscall:
asm volatile ( "mov $gadget1, %%rbx\n" "mov $gadget2, %%r12\n" "mov $gadget3, %%r13\n" "syscall\n" ::: "memory"); pt_regson the kernel stack now contains attacker-controlled values- CFHP fires → stack pivot to
pt_regsregion → ROP executes
Why RANDKSTACK partially protects this: pt_regs position relative to stack frames is randomized by the gap RANDKSTACK inserts. So the attacker may not know the exact offset. However, this is bypassable (see RANDKSTACK bypass section).
3. Calling Convention
Per the x86-64 System V ABI, callee-saved registers (rbx, rbp, r12, r13, r14, r15) must be preserved across function calls. Any kernel function that uses these registers must push them onto the stack in its prologue and pop them in its epilogue.
If those registers happened to hold user supplied values at the time of the call, those values get spilled onto the kernel stack as a side effect of normal function bookkeeping .
How it is abused:
- Attacker crafts syscall arguments such that the kernel loads user-controlled values into callee-saved registers during processing
- Any function call after that point spills those values onto the stack
- The spill location is deterministic within the stack frame
- CFHP → ROP
Why RANDKSTACK does NOT protect this: The spills live inside stack frames. RANDKSTACK only randomizes the gap between pt_regs and the frames.
4. Uninitialized Memory
The kernel does not zero stack frames on entry/exit. After a syscall returns and a new syscall is made, the new syscall’s kernel stack may overlap with previous stack usage and those old bytes may have contained user-controlled data.
How it is abused:
- Attacker makes a first syscall that causes user data to spill deep into the kernel stack
- Makes a second syscall that does not fully initialize its stack frame
- Old data from the first call sits in the uninitialised region of the new frame
- CFHP on the second syscall → pivot to uninitialised region → ROP executes
Mitigations: CONFIG_INIT_STACK_ALL_ZERO zero the kernel stack on syscall return, eliminating this venue entirely. High performance overhead though.
RANDKSTACK Bypass
RANDKSTACK inserts a random gap between pt_regs and kernel stack frames:
Without RANDKSTACK: With RANDKSTACK:
┌─────────────┐ ┌─────────────┐
│ pt_regs │ │ pt_regs │
├─────────────┤ ├─────────────┤
│ stack frame │ │ random gap │ ← 5-bit entropy (32 values)
│ stack frame │ ├─────────────┤
└─────────────┘ │ stack frame │
│ stack frame │
└─────────────┘
Only 5 bits of entropy = 32 possible gap sizes.
Bypass : Ret-Sled
Prepend a large ret sled to the actual ROP chain, long enough to cover all 32 possible landing positions:
[ret|ret|ret|ret|ret| ... sled ... |gadget1|gadget2|gadget3...]
↑ ↑ ↑ ↑ ↑
any of the 32 possible RSP landings hit somewhere in the sled