
What can be done at the application level? Execute an unprivileged processor command, access a user memory cell, or make a system call. That’s it. Writing to I/O ports, reprogramming the BIOS, masking processes and network connections — all of this is only possible at the kernel level. That is why many security researchers strive to get there, to this very “holy grail.” But the path there is not easy, and not everyone finds it. There are many roads, and each leads through its own subsystem.
One striking example of the complexity is the Linux Kernel Bluetooth Local Root Exploit. Bluetooth is one of the most sophisticated and capricious protocols. Supporting its stack is painful for all operating systems. Virtually no development team has managed to prevent new holes from appearing: standards are updated, drivers from different manufacturers behave differently, and interaction between subsystems sometimes leads to unexpected consequences. This is why the Bluetooth subsystem has regularly been a source of local vulnerabilities, allowing researchers to study privilege escalation mechanisms.
But before we talk about complex things like local root exploits, it is useful to understand what is generally available at the application level and what the transition to privileged space looks like.
System calls are the only legal entry point into the kernel.
A user process cannot directly access kernel memory or execute privileged instructions. However, it can ask the kernel to do something for it — via a system call.
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
int main() {
long pid = syscall(SYS_getpid);
printf("PID via a direct system call: %ldn", pid);
return 0;
}
Even a simple operation such as obtaining a PID requires switching to kernel mode. The processor switches to privileged mode, the kernel performs the task, and returns the result.
Reading /proc — a safe window into the kernel state
The virtual file system /proc allows you to read data that the kernel generates dynamically. This does not grant privileges, but it does allow you to look inside the system.
def read_cpuinfo():
with open("/proc/cpuinfo", "r") as f:
data = f.read()
print("CPU information from the kernel:n")
print(data)
read_cpuinfo()
This is an example of safe interaction with kernel space: the user obtains system information without violating the security model.
Minimum kernel module — what happens “on the other side”
To understand what code that runs in privileged mode looks like, just take a look at the minimal kernel training module.
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Test");
MODULE_DESCRIPTION("Core training module");
static int __init hello_init(void) {
printk(KERN_INFO "Module loaded: hello from the kernel!n");
return 0;
}
static void __exit hello_exit(void) {
printk(KERN_INFO "Module unloaded: for now!n");
}
module_init(hello_init);
module_exit(hello_exit);
Such a module can only be loaded with root privileges. This highlights the difference between user space and kernel space: the former is limited, while the latter has absolute power over the system.
The classic shell code injection algorithm and the problem of thunk placement
The classic shell code injection algorithm looks like this: we save a few bytes of the intercepted function and set a jump to our thunk, which does what we want it to do, executes the saved bytes, and transfers control to the original function, which can be called either by jump or by call. This approach was discussed in detail in the article “Crackme, hiding code in API functions,” published in Hacker.
But the most difficult part is choosing a place to put the thunk. It must be memory that is accessible to all processes, and we do not have such memory at our disposal. We know that the “test” library is mapped into the address space of each process, but this space is already occupied. It is possible to find a couple of dozen bytes for alignment, but this is not enough. We have to be clever.
One classic technique is to place the interceptor code in some “unnecessary” function, such as gets. And at the beginning of all intercepted functions, insert… no, not jmp (in this case, the interceptor will not be able to determine where the call came from), but call gets. Inside gets, the interceptor pushes the return address off the stack, subtracts the length of the call command (5 bytes in 32-bit mode) and obtains the desired pointer to the function.
This is not a malicious technique — it is a fundamental principle on which analysis, tracing, and profiling tools work.
Safe examples of function interception
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
int puts(const char *str) {
static int (*real_puts)(const char *) = NULL;
if (!real_puts) {
real_puts = dlsym(RTLD_NEXT, "puts");
}
printf("[HOOK] Call puts: %sn", str);
return real_puts(str);
}
Springboard within a single process
#include <stdio.h>
void original() {
printf("Original functionn");
}
void hook() {
printf("Interceptor: performing additional logicn");
original();
}
int main() {
void (*func)() = hook;
func();
return 0;
}
Interrupting execution via ptrace
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
} else {
int status;
wait(&status);
printf("The process has been stopped - you can analyze the registers.n");
ptrace(PTRACE_CONT, child, NULL, NULL);
}
}
PLT/GOT hooks — interception at the dynamic linker level
PLT (Procedure Linkage Table) and GOT (Global Offset Table) are tables through which a program calls functions from external libraries. If you change the entry in GOT, the program will start calling your function instead of the original one.
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdint.h>
static int (*real_puts)(const char *str);
int my_puts(const char *str) {
printf("[GOT HOOK] %sn", str);
return real_puts(str);
}
__attribute__((constructor))
void init() {
real_puts = dlsym(RTLD_NEXT, "puts");
uintptr_t *got_entry = (uintptr_t *)dlsym(RTLD_DEFAULT, "puts");
*got_entry = (uintptr_t)my_puts;
}
This is a safe and legitimate mechanism that works within the framework of a dynamic linker.
eBPF hooks — a modern way to intercept in the kernel
eBPF allows you to execute secure, kernel-verified bytecode directly within kernel space. It is a powerful tool for monitoring, tracing, and analysis.
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
SEC("kprobe/__x64_sys_execve")
int bpf_prog(struct pt_regs *ctx) {
bpf_printk("execve вызванn");
return 0;
}
char LICENSE[] SEC("license") = "GPL";
eBPF does not modify the kernel code — it “hooks” the handler to the desired point while remaining within the security boundaries.
Conclusion
The path from the application level to the kernel is not just about vulnerabilities and exploits. It is about understanding architecture, interaction mechanisms, interception and analysis methods. From system calls and /proc to PLT/GOT hooks and eBPF, each technique demonstrates how multi-layered and flexible modern operating systems are.
The path to the core was originally published in OSINT Team on Medium, where people are continuing the conversation by highlighting and responding to this story.