Introduction to Linux Kernel Exploitation

Kernel exploitation sits at the top of the difficulty curve in offensive security, and for good reason. Userspace bugs get you code execution as some unprivileged process, but the kernel runs at Ring 0 (or EL1, if you’re on ARM) with unrestricted access to physical memory, every process’s address space, and every privilege check on the system. Pop a bug in the kernel and you’re not just compromising an application, you’re compromising the thing that enforces the rules for every application on the box. That’s why a single kernel LPE (local privilege escalation) bug is often worth more on the exploit market than a browser RCE: it’s the last mile between “I have a foothold” and “I own the machine.”

What makes this space genuinely hard, and genuinely interesting, is that the kernel has spent the last decade hardening itself against exactly the techniques that used to make this easy. KASLR, SMEP, SMAP, stack canaries, control-flow integrity, hardened freelists… every one of these exists because someone demonstrated a working exploit technique and the kernel maintainers closed the door behind them. So modern kernel exploitation isn’t really about finding a bug and writing a payload. It’s about understanding the layered defenses well enough to know which layer your particular bug lets you slip past, and chaining together an info leak, a memory corruption primitive, and a privilege escalation payload into something that survives all of them at once.

This post walks through that whole pipeline: where the bugs tend to live, how you turn a raw memory corruption primitive into arbitrary code execution, and how the standard mitigations get bypassed in practice. I’ll use illustrative code throughout rather than a specific CVE, because the goal here is to build intuition for the mechanics, which transfer across specific bugs far better than any single exploit does.

Understanding the Linux Kernel Attack Surface

Before you can exploit anything, you need somewhere for user-controlled input to reach kernel code. The kernel is enormous (tens of millions of lines across the tree), but only a fraction of that code is actually reachable from an unprivileged process. Finding the reachable, unusual, or poorly-audited paths is most of the work.

Kernel Modules

Loadable kernel modules (LKMs) are a favorite target because they’re often written by hardware vendors under deadline pressure, reviewed far less rigorously than core kernel code, and directly exposed to userspace through device files and ioctl handlers. A driver that copies data from userspace without validating the length is depressingly common, and it’s exactly the kind of bug you’d find in fuzzing campaigns against out-of-tree drivers.

C
static ssize_t vulnerable_write(struct file *file, const char __user *buffer, 
                              size_t count, loff_t *ppos) {
    char kernel_buffer[64];
    // Vulnerable: No bounds checking
    if (copy_from_user(kernel_buffer, buffer, count))
        return -EFAULT;
    return count;
}
Click to expand and view more

Look closely at what’s missing here: count comes straight from userspace, and nothing clamps it before it’s handed to copy_from_user(). copy_from_user() will happily copy however many bytes you ask it to, as long as the source pages are mapped and readable in the calling process. The destination, kernel_buffer, is a 64-byte array sitting on the kernel stack. Since the kernel stack is small (8 KB or 16 KB depending on architecture and CONFIG_VMAP_STACK settings) and shared with the thread_info structure and other adjacent frames, overflowing past those 64 bytes tramples whatever sits next in memory: saved registers, a return address, or another local variable that a later code path trusts. On a system without stack canaries or CONFIG_VMAP_STACK guard pages, this is a direct route to controlling execution flow the moment the function returns. Even with modern mitigations, an overflow like this is still useful as a corruption primitive, it’s just that “smash the return address and jump to shellcode” stopped being viable years ago, so you’d pivot toward corrupting adjacent data (a function pointer, a length field, a flag) instead of the return address directly.

System Calls

Every syscall is, by definition, a deliberate boundary crossing from userspace into the kernel, which makes the syscall table one of the most heavily scrutinized interfaces in the whole codebase. That scrutiny is a double-edged sword for an attacker: bugs here are rarer because the code gets more review, but when they do show up they’re reachable from literally any unprivileged process, no special device file or capability required.

C
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/syscall.h>

#define VULNERABLE_SYSCALL 548

int main() {
    char *payload = malloc(1024);
    long result = syscall(VULNERABLE_SYSCALL, payload, 1024);
    printf("Syscall result: %ld\n", result);

    if (getuid() == 0) {
        printf("Exploit successful! Root shell.\n");
        system("/bin/bash");
    }
    return 0;
}
Click to expand and view more

That syscall number is a stand-in, obviously; real syscall tables are much smaller and every entry is public, so a “hidden vulnerable syscall” isn’t really how this works in practice. The realistic version of this bug class is subtler: a legitimate syscall (or more often, an ioctl() on a device it exposes) that mishandles a size argument, trusts a userspace pointer it shouldn’t, or has a logic error in how it validates a flag combination. The important structural point the snippet is making is still correct, though: the payload buffer and its size are entirely attacker-controlled, and whatever kernel code processes them inherits every assumption (or lack thereof) baked into that validation logic. The getuid() == 0 check at the end is the tell that this is a privilege escalation exploit: the actual bug happens somewhere inside the syscall, but the goal of the whole chain is to walk out of main() running as root, which is the same end goal behind essentially every LPE exploit you’ll ever look at.

Kernel Data Structures

A huge amount of kernel code is built around structs full of function pointers, sometimes called “ops” structures (file_operations, inode_operations, net_device_ops, and so on, plus countless driver-specific variants). This is the kernel’s version of a vtable: instead of hardcoding which function handles a read, write, or ioctl, the kernel looks it up through a pointer in one of these structs and calls through it. It’s a clean design for polymorphism in C, and it’s also, structurally, a giant table of “if you can corrupt this pointer, you get to pick where execution goes next.”

C
struct vulnerable_ops {
    int (*init)(struct device *);
    void (*release)(struct device *);
    int (*ioctl)(struct device *, unsigned int, unsigned long);
};

struct vulnerable_device {
    struct vulnerable_ops *ops;
    void *private_data;
};
Click to expand and view more

If an attacker can overflow into vulnerable_device and overwrite the ops pointer (or overwrite one of the function pointers inside the vulnerable_ops struct itself), then the next time the kernel calls device->ops->ioctl(...), it’s calling wherever the attacker pointed it. This is exactly why kernel hardening work in the last several years has focused so heavily on protecting function pointers specifically: CONFIG_CFI_CLANG (kernel control-flow integrity) checks that an indirect call target actually matches the expected function signature before allowing the call, and RANDSTRUCT randomizes struct layouts so an attacker can’t reliably guess the offset of ops relative to a field they can overflow from. Neither of these makes the attack impossible, but both raise the bar: CFI limits you to redirecting a call to some other valid function with a matching prototype rather than anywhere you like, and layout randomization means the exploit needs either a leak of the actual struct layout or a build-specific offset, which kills a lot of “works everywhere” exploit portability.

Memory Corruption Exploitation Techniques

Finding the bug is step one. Turning it into something useful is where the real engineering happens. Broadly, kernel memory corruption bugs fall into a few families (overflows, use-after-free, double-free, type confusion), and each one gives you a different starting primitive that you then have to shape into “arbitrary read,” “arbitrary write,” or “arbitrary code execution.”

Use-After-Free (UAF)

A use-after-free happens when the kernel keeps a reference to an object after that object’s memory has been returned to the allocator, and then dereferences that stale reference as if it were still valid. The memory itself hasn’t gone anywhere, it’s just been marked “free” and is eligible for reuse. The danger is entirely about what happens between the free and the next use: if an attacker can get the allocator to hand that same chunk of memory back out to something they control before the kernel touches the dangling pointer again, they’ve effectively planted their own data where the kernel expects to find a trusted object.

C
struct vulnerable_struct {
    void (*callback)(void);
    char data[128];
};

static struct vulnerable_struct *obj = NULL;

static int device_release(struct inode *inode, struct file *file) {
    kfree(obj);
    return 0;
}

static long device_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
    if (cmd == COMMAND_INVOKE_CALLBACK && obj) {
        obj->callback(); // Potentially hijacked
    }
    return 0;
}
Click to expand and view more

The bug here is that obj gets freed in device_release() but never set back to NULL, so device_ioctl() has no way of knowing the pointer is stale. If an attacker closes the file descriptor (triggering the free), reallocates memory of the same size with content they control, then calls the ioctl before anyone else grabs that memory, obj->callback() calls into wherever the attacker’s fake callback field points. This is a textbook case for why “free but don’t clear the pointer” bugs are so dangerous: the vulnerability window is entirely about timing and allocator behavior, not about any single line of obviously wrong code.

Turning that race into a reliable exploit is a multi-stage process, and it’s worth walking through why each stage exists rather than just treating it as a checklist:

StepActionPurpose
1Identify UAFLocate freed memory usage
2Analyze object layoutUnderstand memory structure
3Heap sprayControl freed memory contents
4Craft malicious objectPrepare payload with function pointers
5Trigger vulnerabilityExecute controlled code

Step 1 and 2 are reconnaissance: you need to know not just that a UAF exists, but the exact size and layout of the freed object, because that determines which allocator size class you need to spray into and which byte offset your fake function pointer needs to land at. Step 3, the heap spray, exists because you generally don’t get to choose which physical chunk of freed memory gets reused, you can only bias the odds heavily in your favor by flooding the allocator with same-sized allocations so that, statistically, one of them lands in the freed slot. Step 4 is where you actually shape the sprayed data to look like a legitimate object from the kernel’s point of view, with a callback field that points at gadgets or shellcode-equivalent kernel code instead of a real function. Step 5 is just triggering the vulnerable code path again, now that the trap is set. The reason this is described as a “workflow” rather than a single step is that UAF exploitation is inherently probabilistic. Even well-tuned exploits sometimes need several attempts, and a big part of an exploit developer’s job is grinding that reliability up from “works sometimes” to “works basically every time.”

Heap Feng Shui

“Heap feng shui” is the somewhat whimsical name for the broader discipline of deliberately shaping the heap’s layout before you trigger a bug, so that the outcome of the corruption is predictable instead of a coin flip. The SLUB allocator (the default in modern Linux) services allocation requests from per-size-class caches, and within a given cache, freed objects tend to get reused in a fairly predictable order (LIFO within a slab, roughly). That predictability is exactly what an attacker leans on: if you can control the sequence of allocations and frees leading up to the bug, you can arrange for a specific attacker-controlled object to occupy the exact memory the vulnerable code is about to touch.

C
#define ALLOC_SIZE 128
#define SPRAY_COUNT 100

// Spray the heap with controlled objects
for (int i = 0; i < SPRAY_COUNT; i++) {
    ioctl(fd, SPRAY_HEAP, spray_buffer);
}
ioctl(fd, USE_OBJ, 0);
Click to expand and view more

Spraying a hundred identically-sized allocations before triggering the bug isn’t just brute force for its own sake, it’s compensating for the fact that you don’t have visibility into the allocator’s internal state from userspace. You can’t just ask “which slab is free right now,” so instead you flood the size class with your own objects until the odds that one of them ends up in the slot the vulnerable code depends on become overwhelming. The tradeoff is noise: spraying tends to be detectable (a sudden burst of identical allocations is exactly the kind of pattern a kernel integrity monitor or a suspicious sysadmin watching slabtop might notice), and it doesn’t always work cleanly against allocators with randomized freelists or per-CPU caching that fragments your spray across multiple cores. CONFIG_SLAB_FREELIST_RANDOM exists specifically to make this kind of layout prediction harder by shuffling the order objects come off the freelist.

Return-Oriented Programming (ROP) in Kernel Space

Once you have some kind of write primitive, or you’ve hijacked a function pointer, the next problem is the non-executable stack. NX/DEP (called “W^X” more generally) means you can’t just drop shellcode on the stack and jump to it; the CPU will refuse to execute code from a page marked non-executable, full stop. ROP sidesteps this entirely by never introducing new code at all. Instead, it chains together short instruction sequences (“gadgets”) that already exist in the kernel’s own executable pages, each one ending in a ret (or similar) that hands control to the next gadget in the chain. Since every byte executed is legitimately part of the kernel binary, NX has nothing to object to.

C
#define POP_RDI_RET (KERNEL_BASE + 0x123456)
#define COMMIT_CREDS (KERNEL_BASE + 0x789abc)
#define PREPARE_KERNEL_CRED (KERNEL_BASE + 0xdef012)

void prepare_rop_payload(void *buffer) {
    unsigned long *rop_chain = buffer;
    rop_chain[0] = POP_RDI_RET;
    rop_chain[1] = 0;
    rop_chain[2] = PREPARE_KERNEL_CRED;
    rop_chain[3] = POP_RDI_RET;
    rop_chain[4] = COMMIT_CREDS;
}
Click to expand and view more

This particular chain is the single most famous payload shape in Linux kernel exploitation, and it’s worth understanding exactly why it’s built this way. Every process on Linux carries a struct cred describing its privileges (uid, gid, capabilities, and so on). prepare_kernel_cred(NULL) is a real kernel function, normally used internally by kernel threads, that allocates and returns a fully-privileged cred structure equivalent to what init (PID 1, running as root) has. commit_creds() takes a cred pointer and installs it as the current process’s credentials. Chain those two calls together (prepare_kernel_cred(NULL) to get a root cred, then commit_creds() to apply it to yourself) and your unprivileged process becomes root without needing to forge a cred struct by hand or fight over exact struct layouts, which is what made this pairing so popular: it repurposes trusted kernel logic to do the privilege escalation for you. The POP_RDI_RET gadget exists purely for calling convention reasons: on x86-64, the first function argument goes in rdi, and a pop rdi; ret gadget is how you load an arbitrary value into that register from your fake stack before “calling” the next function in the chain (which, in ROP, just means returning into it).

Advanced ROP for Kernel Exploitation

Getting commit_creds(prepare_kernel_cred(NULL)) to run gives you root credentials on the current task, but there’s still a problem: your ROP chain is executing in kernel context, on the kernel stack, and if you just let it fall through or return normally, you’ll likely crash the machine rather than getting a nice shell back. You need a controlled way to hop back down to userspace, at your original privilege-escalated process, and keep running as if nothing happened.

C
chain[i++] = SWAPGS_POPFQ_RET;
chain[i++] = 0; // RFLAGS
chain[i++] = IRETQ_GADGET;
chain[i++] = (unsigned long)escalate_privileges; // RIP
chain[i++] = USER_CS;
chain[i++] = USER_RFLAGS;
chain[i++] = (unsigned long)user_stack + 1024; // RSP
chain[i++] = USER_SS;
Click to expand and view more

This tail end of the chain is doing the delicate work of manually reconstructing an iret frame, the same mechanism the CPU itself uses when returning from an interrupt or exception. SWAPGS matters because the kernel and userspace use different values in the GS segment base (the kernel uses it to find per-CPU data structures), and if you iret back to userspace without swapping GS back to the user value first, the next thing that touches %gs-relative memory in userspace will be reading kernel data through the wrong base, which usually means an instant crash. IRETQ then pops RIP, CS, RFLAGS, RSP, and SS off the stack in one shot and resumes execution at that RIP with those exact register values, which is exactly the mechanism that lets you land cleanly back in your own userspace code (escalate_privileges, here just a landing pad that spawns a shell) with a valid stack and the correct code/stack segment selectors. Getting any one of these fields wrong, especially RSP or the segment selectors, is one of the most common ways a kernel exploit goes from “gets root” to “instantly panics the box,” so this part of the chain gets tested and re-tested far more than people expect.

Kernel Heap Spraying Techniques

Heap spraying in the kernel works on the same underlying idea as spraying in a browser JS engine (flood the allocator with attacker-shaped data), but the mechanics are different because you don’t get to call malloc() directly from userspace. Instead, you trigger kernel-side allocations indirectly, through syscalls or subsystems that allocate kernel memory on your behalf and let you control at least part of the content.

C
for (int i = 0; i < SPRAY_COUNT; i++) {
    sockets[i] = socket(AF_INET, SOCK_DGRAM, 0);
    sendmsg(sockets[i], &msg, MSG_DONTWAIT);
}
Click to expand and view more

Sockets are a favorite spraying vehicle for a couple of very practical reasons. First, socket buffers (sk_buffs) and their associated control structures come in a wide range of sizes, so whatever size class your target object lives in, there’s usually a socket operation you can abuse to allocate something of a matching size. Second, and just as important, a lot of that content is directly attacker-controlled: ancillary data in sendmsg(), for instance, gets copied more or less verbatim into kernel memory, which means you’re not just occupying a slot, you’re occupying it with bytes you chose. That combination (flexible sizing plus controlled content) is exactly what you need for the “craft malicious object” step from the UAF workflow earlier.

SLUB Allocator Manipulation

None of this heap-spraying and heap-shaping talk means much without a mental model of how the allocator actually organizes memory, so it’s worth spending a minute on SLUB specifically, since it’s what mainline Linux uses by default.

ComponentDescriptionExploitation Relevance
SlabBasic allocation unitOverflow/UAF target
FreelistList of free objectsManipulated for exploitation
kmem_cacheCache of objects of same sizeDifferent caches, different properties
Partial listsSlabs with some free objectsKey for cross-cache attacks

A slab is a contiguous run of one or more pages, carved up into fixed-size objects, and SLUB tracks the free ones through a freelist. The clever (and, for defenders, slightly alarming) part of SLUB’s design is that in the unhardened default, the “next free object” pointer for a freed chunk is stored inline, inside the freed object’s own memory, since nothing else needs that space while the object is free. That means if your overflow bug lets you write into a freed object before it’s reallocated, you can potentially overwrite its freelist pointer and trick the allocator into believing an attacker-chosen address is the next “free” chunk. Get that right and your very next allocation from that cache returns memory anywhere you pointed it, which is about as strong a primitive as kernel exploitation offers. This exact technique is why CONFIG_SLAB_FREELIST_HARDENED exists: it XORs the freelist pointer with a random per-boot value and the object’s own address before storing it, so a naive overwrite produces a freelist pointer that decodes to garbage instead of your chosen address, and the kernel panics on the corruption rather than handing you a controlled allocation.

kmem_caches matter because objects of different sizes, or objects explicitly created with their own dedicated cache (many struct cred allocations, for instance, or other security-sensitive structures), don’t share memory with unrelated objects by default. That’s a deliberate isolation boundary: it stops you from overflowing a generic kmalloc-128 buffer directly into a cred structure, because they’re not adjacent and never will be under normal operation. Which brings us to why “partial lists” earn a mention of their own.

Cross-Cache Attacks

The per-cache isolation above sounds like a solid defense, until you notice that it only holds while the cache itself still exists. When a slab has no more live objects in it, SLUB doesn’t necessarily keep it reserved for that cache forever; under memory pressure, or through normal slab lifecycle management, an empty slab’s underlying pages can be returned to the buddy allocator and later handed out to a completely different cache, of a completely different object type. A cross-cache attack exploits exactly that moment: free an object so its slab becomes empty and gets released, force that same physical memory to be reallocated to a different kmem_cache holding a type you actually want to corrupt, and now you’ve achieved type confusion across a boundary that was supposed to keep your buffer and their sensitive struct apart.

C
memset(data.buffer, 'A', 1024);
*(unsigned long *)(data.buffer + offset_to_type_a) = malicious_value;
ioctl(fd, USE_TYPE_A, 0);
Click to expand and view more

The mechanics here are less about a single API call and more about winning a timing and allocator-state game: you need to reliably force the slab to empty out and get released, then reliably win the race to have it reused by the target cache before anything else claims it, all without visibility into the allocator’s internal bookkeeping. It’s a finicky, allocator-version-sensitive technique, which is exactly why it tends to show up in more sophisticated, publicly-documented exploit chains rather than as a beginner’s first kernel bug. It’s also precisely the kind of attack that motivated dedicated, non-mergeable caches for the most security-critical structures, since isolating struct cred (and similar) into their own caches that never get merged with general-purpose ones closes off this particular path for those specific targets, even if it doesn’t solve the underlying cross-cache problem everywhere.

Bypassing Kernel Protections

Every technique above assumes you can get from “I have a corruption primitive” to “I control execution,” but modern kernels stack several independent defenses in your way, and a serious exploit has to defeat (or route around) each one in turn.

KASLR (Kernel Address Space Layout Randomization)

KASLR randomizes where the kernel image and its various regions get loaded at boot, which breaks any exploit technique that hardcodes addresses (like those KERNEL_BASE + offset gadget addresses from the ROP examples above). Without knowing where the kernel actually lives in memory, your gadget addresses are just wrong, and a wrong gadget address means you’re jumping into essentially random code, which is a fast way to panic the kernel rather than exploit it.

C
unsigned long leak_kernel_address() {
    FILE *f = fopen("/proc/kallsyms", "r");
    while (fgets(line, sizeof(line), f)) {
        if (strstr(line, " T _text")) { addr = strtoul(line, NULL, 16); break; }
    }
    return addr;
}
Click to expand and view more

/proc/kallsyms is the most direct example of an info leak defeating KASLR: on systems where it’s readable by unprivileged users (many distros restrict this via kptr_restrict, but plenty of embedded and older systems don’t), it hands you the exact runtime address of every exported kernel symbol, no exploitation required at all. When that’s locked down, attackers fall back to indirect leaks instead: a memory disclosure bug elsewhere in the kernel that happens to leak a pointer, or side-channel techniques that infer address bits from timing (cache timing, TLB timing, or the more exotic speculative-execution leaks in the Spectre family, which can leak kernel memory contents including addresses across the userspace/kernel boundary that KASLR is supposed to protect). The general lesson here, and it holds across basically every mitigation in this section, is that randomization defenses are only as strong as the absence of a leak. Find any way to read kernel memory or kernel state, even indirectly, and KASLR stops being a wall and starts being a puzzle you’ve already solved.

SMEP/SMAP Bypass

SMEP and SMAP are a matched pair of CPU features (surfaced as bits in the CR4 control register) that specifically target the classic “redirect execution into a userspace payload” trick that ROP into shellcode used to rely on.

  • SMEP (Supervisor Mode Execution Prevention) stops the CPU from executing code from a page mapped as user-accessible while the CPU is running in kernel (supervisor) mode. Before SMEP, a common technique was to hijack a kernel control-flow primitive and jump straight into shellcode sitting in your own process’s user memory. SMEP kills that outright.
  • SMAP (Supervisor Mode Access Prevention) is the read/write equivalent: it stops the kernel from even reading or writing user-mapped memory unless it explicitly says it means to, via the STAC/CLAC instructions that toggle the EFLAGS.AC bit around legitimate accesses (which is exactly what functions like copy_from_user() do internally).
C
// ROP-based SMEP bypass
rop_chain[i++] = POP_RDI_RET;
rop_chain[i++] = cr4_without_smep;
rop_chain[i++] = CR4_WRITE_GADGET;
Click to expand and view more

If you already have a solid ROP primitive (which, as covered above, doesn’t itself require executing user-mapped code), one direct bypass is to just turn SMEP back off: find a gadget that writes to CR4 (or call native_write_cr4() if it’s reachable), clear the SMEP bit, and now the kernel will happily jump into your userspace shellcode again, exactly like the pre-SMEP era. This is a good example of the general principle that mitigations implemented as toggleable CPU flags are only as strong as how tightly the kernel controls who can flip them; if your exploit already has an arbitrary-write-ish primitive, disabling CR4-based protections is often just one more gadget away.

C
// SMAP bypass using STAC/CLAC
rop_chain[i++] = STAC_GADGET;
// Access user memory
rop_chain[i++] = CLAC_GADGET;
Click to expand and view more

SMAP has a similar story, except here you don’t even need to disable the feature, you just need to invoke it the way legitimate kernel code does: bracket your user-memory access between STAC (which sets the AC flag and tells the CPU “yes, I mean to touch user memory right now”) and CLAC (which clears it again immediately after). Finding STAC/CLAC gadgets in the kernel image isn’t hard, since the kernel itself uses this pattern constantly for any legitimate copy_from_user/copy_to_user call, which means the gadgets you need are baked into virtually every kernel build.

Ret2dir

Ret2dir is the technique that made a lot of security engineers rethink SMEP/SMAP as a complete solution, because it achieves the same practical outcome (execute or access attacker-controlled data from kernel context) without ever touching a directly user-mapped page, and without needing to disable anything.

C
void *prepare_ret2dir_payload(size_t size) {
    void *mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memset(mem, 0x90, size); // NOP sled
    return mem;
}
Click to expand and view more

The trick relies on a structural fact about how Linux manages physical memory: alongside whatever virtual mapping your process gets for its mmap()’d memory, the kernel also maps essentially all of physical RAM into its own address space at a fixed, predictable offset, called the direct physical map (or “physmap”). That mapping exists so the kernel can access any physical page directly, without needing a process-specific virtual mapping for it. The catch, from a security point of view, is that the physical page you get back from mmap() in userspace doesn’t stop being a physical page just because you’re viewing it through your process’s page tables; the exact same bytes are also reachable through the kernel’s physmap alias, at a kernel-mapped address. And since that physmap region is mapped as kernel memory, jumping into it doesn’t trip SMEP at all, SMEP only cares about whether the target mapping is marked user-accessible, and the physmap mapping isn’t. So the play is: allocate memory in userspace, fill it with a NOP sled and shellcode (or in this case, a ROP-friendly payload), figure out or leak the corresponding physmap address for those physical pages, and redirect kernel execution there instead of to the user-mapped virtual address. You get the effect of “jump to my shellcode” back, fully intact, while sailing straight past a mitigation that was specifically built to stop that exact move. It’s a great illustration of why security engineers get nervous about mitigations that protect one view of memory without accounting for every other view of the same physical page.

Race Condition Exploitation

Not every kernel bug is a straightforward memory corruption you can trigger deterministically. A large and growing class of bugs are TOCTOU (time-of-check to time-of-use) issues, where the kernel validates something (a pointer, a length, a permission, a reference count) and then, some instructions later, acts on it again, assuming nothing changed in between. If another thread can slip in during that window and change the underlying state, the kernel ends up acting on stale assumptions, which can mean a use-after-free that only exists during a specific timing window, a double-free, or an operation performed on an object that’s no longer the one that was validated.

C
pthread_t threads[10];

// Thread triggering vulnerable ioctl
void *ioctl_thread(void *arg) { while (!success) { if (ioctl(fd, VULNERABLE_IOCTL, 0)==0) success=1; }}

// Thread modifying conditions
void *condition_thread(void *arg) { while (!success) { int tmp_fd=open("/tmp/race_file", O_CREAT|O_RDWR,0666); write(tmp_fd,"AAAA",4); close(tmp_fd); unlink("/tmp/race_file"); } }
Click to expand and view more

The pattern here is deliberately simple: one thread hammers the vulnerable ioctl in a tight loop, while a second thread continuously flips the condition the ioctl’s logic depends on (in this contrived example, creating and immediately deleting a file, but in a real bug it might be closing a file descriptor, remapping memory, or modifying a shared structure through a different syscall entirely). Neither thread on its own does anything unsafe; the vulnerability only manifests in the narrow window where both threads’ operations interleave in exactly the wrong order. That’s also what makes races so miserable to both find and exploit reliably: the window might be a handful of CPU cycles wide, which means a naive two-thread spinloop like this one might only win the race once in a few thousand tries.

In practice, serious race-condition exploits invest a lot of effort into widening or controlling that window rather than just brute-forcing it. Pinning the racing threads to specific CPU cores with sched_setaffinity() removes scheduler-induced jitter that would otherwise blur the timing. Priming caches or deliberately causing page faults on one thread can slow it down just enough to widen the window for the other. Some of the more advanced techniques abuse FUSE (writing a userspace filesystem that intentionally stalls on a read() or write() right in the middle of a syscall) to convert a microsecond-wide race into one that’s effectively unbounded, because the kernel is now blocked waiting on your own userspace code to respond. And at the discovery end, this whole bug class is exactly why coverage-guided kernel fuzzers like syzkaller lean so heavily on running many threads with overlapping syscalls: races are hard for a human to spot by reading code, but they fall out naturally when you’re hammering the kernel with concurrent, semi-random syscalls and watching for KASAN or lockdep to complain.

Closing Thoughts

If there’s one theme running through all of this, it’s that kernel exploitation today is rarely about one clever trick. It’s a chain: an info leak to defeat KASLR, a corruption primitive shaped by careful heap grooming, a payload built to route around SMEP/SMAP without ever tripping them, and a safe landing back in userspace that doesn’t just panic the box. Every individual mitigation covered here is genuinely effective against the specific technique it was designed for, which is exactly why exploit development has become a game of composing several different bypasses together rather than relying on any single one. That’s also the best reason to actually understand the mechanics instead of memorizing payload templates: the specific gadget addresses and struct offsets go stale with every kernel release, but the underlying reasoning about how allocators, page tables, and privilege boundaries behave is durable, and it’s what lets you adapt when the next mitigation lands and closes off whatever you were relying on before.

Copyright Notice

Author: Kernelstub

Link: https://blog.kernelstub.dev/posts/advanced-linux-kernel-exploitation-techniques/

License: CC BY-NC-SA 4.0

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Please attribute the source, use non-commercially, and maintain the same license.

© 2026 Prepakis Georgios | Kernelstub | Security Researcher. All rights reserved.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut