Creating an Unkillable Process by Abusing Character Devices
TL;DR: This is probably a bad idea and you don’t actually want use this. Whatever problem you’re trying to solve, there’s probably a better way. I was researching ways to prevent a process from dying prematurely and created a simple “device driver” that will prevent a given process from being killed until the system is powered down or the process decides to end on its own terms.
Why create an unkillable process?
I was looking for ways to protect a process from being accidentally or
maliciously halted for a research project at work. Signal handlers did the
trick, but unfortunately signal handlers can’t handle SIGKILL
:
The signals SIGKILL and SIGSTOP cannot be caught or ignored.
The section 7 entry for signal
basically says the same:
The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.
In fact, this piece of advice kept
popping up
everywhere.
The Internet seems to have taken the man page at face value as ultimate truth.
SIGKILL
cannot be caught and it cannot be ignored. The signal is never sent
to the process, the kernel manages SIGKILL
on its own, cleans up the process,
and the process is never heard from again. It simply vanishes!
Except that’s not quite true. Process 1, init
, will ignore any terminating signals sent to it.
That includes SIGKILL
. init
is a userland process,
so clearly this is possible. And that means the Internet is wrong!
I couldn’t find a better source for init
being immune to SIGKILL
than
stackexchange, unfortunately. It doesn’t appear to be well documented.
But if you’re following along at home, go ahead and try it! It’s perfectly
harmless. Send some SIGKILL
s at your init
process, pid 1.
$ sudo kill -9 1
$ ps aux | grep init
root 1 0.1 0.0 168580 12384 ? Ss 17:11 0:12 /sbin/init splash
$ sudo kill -9 1
$ ps aux | grep init
root 1 0.1 0.0 168580 12384 ? Ss 17:11 0:12 /sbin/init splash
$ Why won't you die?
> bash: unexpected EOF while looking for matching `''
Maybe it was out of morbid curiosity, or a desire to prove the Internet wrong, but I wanted to create another user process which could not be killed.
Creating an unkillable process.
So how do we prove the Internet wrong (for this very specific case)? Let’s look
at what actually makes init
unkillable. I’ll spare you how I got here
and skip right the point. First, we have to define task_struct
.
I recommend reading through the kernel source,
it’s not too complex. The gist is that task_struct
holds information about a process
for use by the kernel.
Further down
in task_struct
we find a reference to a signal_struct
called *signal
:
/* Signal handlers: */
struct signal_struct *signal;
struct sighand_struct __rcu *sighand;
The comment hints that the purpose of this structure is handling signals. If we
dig into signal_struct
’s definition
we see this interesting line:
unsigned int flags; /* see SIGNAL_* flags below */
Scroll down to where signal flags are defined and we find what we’re looking for:
#define SIGNAL_UNKILLABLE 0x00000040 /* for init: ignore fatal signals */
A flag, for init
, that marks the signal handler for this init
’s task_struct
as “unkillable” and tells it to ignore fatal signals. Now we’re getting
somewhere! If we want to make an arbitrary process unkillable we simply
need to add the SIGNAL_UNKILLABLE
flag to the process’s task_struct
’s
signal_struct
.
The correct way to do this would be to add a new syscall that marks an arbitrary process as unkillable with the flag. Unfortunately, adding a syscall required recompiling the kernel and I want to avoid doing that. This is already a bit of a hack, so why make things harder. Luckily, we can get our kernel to run arbitrary code by loading a new module. That only requires root privileges, no recompiling.
We will need a program we want to make immortal and a Linux Kernel Module that marks it as such. Let’s start with the LKM.
First we’ll take a boilerplate character device driver and use take advantage
of the read()
function to communicate an arbitrary pid. Then we’ll find
that pid’s task_struct
and assign the signal_struct
the SIGNAL_UNKILLABLE
flag.
unkillable.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/types.h>
#include <linux/proc_fs.h>
#include <linux/sched.h>
#include <linux/sched/signal.h>
#include <linux/pid.h>
MODULE_LICENSE("GPL");
void unkillable_exit(void);
int unkillable_init(void);
/* device access functions */
ssize_t unkillable_write(struct file *filp, const char *buf, size_t count, loff_t *f_pos);
ssize_t unkillable_read(struct file *filp, char *buf, size_t count, loff_t *f_pos);
int unkillable_open(struct inode *inode, struct file *filp);
int unkillable_release(struct inode *inode, struct file *filp);
struct file_operations unkillable_fops = {
.read = unkillable_read,
.write = unkillable_write,
.open = unkillable_open,
.release = unkillable_release
};
/* Declaration of the init and exit functions */
module_init(unkillable_init);
module_exit(unkillable_exit);
int unkillable_major = 117;
int unkillable_init(void)
{
if (register_chrdev(unkillable_major, "unkillable", &unkillable_fops) < 0 ) {
printk("Unkillable: cannot obtain major number %d\n", unkillable_major);
return 1;
}
printk("Inserting unkillable module\n");
return 0;
}
void unkillable_exit(void)
{
unregister_chrdev(unkillable_major, "unkillable");
printk("Removing unkillable module\n");
}
int unkillable_open(struct inode *inode, struct file *filp)
{
return 0;
}
int unkillable_release(struct inode *inode, struct file *filp)
{
return 0;
}
ssize_t unkillable_read(struct file *filp, char *buf, size_t count, loff_t *f_pos)
{
struct pid *pid_struct;
struct task_struct *p;
/* interpret count to read as target pid */
printk("Unkillable: Got pid %d", (int) count);
/* get the pid struct */
pid_struct = find_get_pid((int) count);
/* get the task_struct from the pid */
p = pid_task(pid_struct, PIDTYPE_PID);
/* add the flag */
p->signal->flags = p->signal->flags | SIGNAL_UNKILLABLE;
printk("Unkillable: pid %d marked as unkillable\n", (int) count);
if (*f_pos == 0) {
*f_pos+=1;
return 1;
} else {
return 0;
}
}
ssize_t unkillable_write(struct file *filp, const char *buf, size_t count, loff_t *f_pos)
{
return 0;
}
The key part is the unkillable_read()
function. This function is called when
we try to read from the device. The amount of bytes we are trying to read
is sent as the count
parameter, which we abuse by reinterpreting as a pid.
With the pid in hand, we find the task_struct
and signal_struct
and mark
the process unkillable. The following makefile will build and install the
module.
Makefile
obj-m += unkillable.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
install:
sudo insmod unkillable.ko
uninstall:
sudo rmmod unkillable
mknod:
sudo mknod /dev/unkillable c 117 0
sudo chmod 666 /dev/unkillable
Now we just need a simple program to test our module with.
immortal_process.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
int fd;
char c;
char buffer[10];
int my_pid = getpid();
printf("My pid: %d\n", my_pid);
fd = open("/dev/unkillable", O_RDWR);
if (fd < 0)
printf("Error opening /dev/unkillable\n");
printf("Opened /dev/unkillable\n");
read(fd, &c, my_pid);
printf("We are now unkillable!\n");
read(STDIN_FILENO, buffer, 10);
printf("exiting on user input...\n");
return 0;
}
Now let’s put it all together. You can copy the above files into the same folder.
$ ls
immortal_process.c Makefile unkillable.c
$ cc immortal_process.c
$ make
make -C /lib/modules/5.4.0-7634-generic/build M=/home/user/unkillable modules
make[1]: Entering directory '/usr/src/linux-headers-5.4.0-7634-generic'
CC [M] /home/user/unkillable/unkillable.o
Building modules, stage 2.
MODPOST 1 modules
CC [M] /home/user/unkillable/unkillable.mod.o
LD [M] /home/user/unkillable/unkillable.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.4.0-7634-generic'
$ make mknod
sudo mknod /dev/unkillable c 117 0
sudo chmod 666 /dev/unkillable
$ make install
sudo insmod unkillable.ko
$
And if we check dmesg:
[14538.243110] Inserting unkillable module
Now we can run our application:
$ ./a.out
My pid: 45953
Opened /dev/unkillable
We are now unkillable!
From a second terminal we can check dmesg again:
[14750.576795] Unkillable: Got pid 45953
[14750.576796] Unkillable: pid 45953 marked as unkillable
And try to kill the process:
$ kill -9 45953
$ kill -2 45953
$ kill -15 45953
$ sudo kill -9 45953
[sudo] password for user:
$ sudo kill -9 45953
$ ps aux | grep 'a\.out'
user 45953 0.0 0.0 2492 584 pts/4 S+ 21:16 0:00 ./a.out
Try as we might, we cannot kill the process. Not even as root! The only way to end the process is by giving it some input it can read so it will end on its own. Reading input here is just an example, the process could be performing any task. The point is that the process will continue, unkillable, until it decides to end or the system shuts down.
$ ./a.out
My pid: 45953
Opened /dev/unkillable
We are now unkillable!
exiting on user input...
$
Can SIGKILL be caught?
So what comes next? It’s worth pointing out that while our process is immortal,
it still cannot catch a SIGKILL
signal. It just ignores them. Why is this?
If we search the kernel source for references to SIGNAL_UNKILLABLE
we find
the following snippet
in kernel/signal.c
.
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
{
void __user *handler;
handler = sig_handler(t, sig);
/* SIGKILL and SIGSTOP may not be sent to the global init */
if (unlikely(is_global_init(t) && sig_kernel_only(sig)))
return true;
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
return true;
/* Only allow kernel generated signals to this kthread */
if (unlikely((t->flags & PF_KTHREAD) &&
(handler == SIG_KTHREAD_KERNEL) && !force))
return true;
return sig_handler_ignored(handler, sig);
}
If we look for where sig_task_ignored()
is called
we find:
static bool sig_ignored(struct task_struct *t, int sig, bool force)
{
/*
* Blocked signals are never ignored, since the
* signal handler may change by the time it is
* unblocked.
*/
if (sigismember(&t->blocked, sig) || sigismember(&t->real_blocked, sig))
return false;
/*
* Tracers may want to know about even ignored signal unless it
* is SIGKILL which can't be reported anyway but can be ignored
* by SIGNAL_UNKILLABLE task.
*/
if (t->ptrace && sig != SIGKILL)
return false;
return sig_task_ignored(t, sig, force);
}
And if we go one layer further up, we find where sig_ignored()
is called
we find the prepare_signal()
function
which has the following helpful comment:
/*
* Handle magic process-wide effects of stop/continue signals. Unlike
* the signal actions, these happen immediately at signal-generation
* time regardless of blocking, ignoring, or handling. This does the
* actual continuing for SIGCONT, but not the actual stopping for stop
* signals. The process stop is done as a signal action for SIG_DFL.
*
* Returns true if the signal should be actually delivered, otherwise
* it should be dropped.
*/
Indeed, we see the final line of this function is:
return !sig_ignored(p, sig, force);
Thus, if our process has SIGNAL_UNKILLABLE
set, then sig_task_ignored()
returns true, which causes sig_ignored()
to return true, which causes
prepare_signal()
to return false, which indicates that the signal should
be ignored. At no point do we get to our process’s signal handling functions,
the signal is simply dropped.
Additionally, in the main loop of get_signal()
we find the following code segment:
/*
* Global init gets no signals it doesn't want.
* Container-init gets no signals it doesn't want from same
* container.
*
* Note that if global/container-init sees a sig_kernel_only()
* signal here, the signal must have been generated internally
* or must have come from an ancestor namespace. In either
* case, the signal cannot be dropped.
*/
if (unlikely(signal->flags & SIGNAL_UNKILLABLE) &&
!sig_kernel_only(signr))
continue;
Now, the comment says “global init,” but the code only checks for
SIGNAL_UNKILLABLE
. If it finds that flag, it continues to the next loop
iteration and ignores the signal. So that’s two places where SIGKILL
might
be ignored on a process flagged SIGNAL_UNKILLABLE
.
I experimented with registering a kthread
and using that task_struct
as the signal->group_exit_task
. The idea is
that when receiving a SIGKILL
the kernel thread would use a
helper function
that could call kill
to send a SIGUSR1
to the immortal process. The
immortal process can then interpret this SIGUSR1
as “someone is trying
to kill you,” and act appropriately. I have not, however, been able to get
this working. Attempting to replace the group_exit_task
just causes the
process to hang when receiving signals. I’m probably doing something wrong,
I just have to figure it out.
[ 364.138308] INFO: task unkillable signal thread:4948 blocked for more than 120 seconds.
[ 364.138316] Tainted: P OE 5.4.0-7634-generic #38~1592497129~20.04~9a1ea2e-Ubuntu
[ 364.138319] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
[ 364.138322] test thread D 0 4948 2 0x80004000
[ 364.138328] Call Trace:
[ 364.138340] __schedule+0x2e3/0x740
[ 364.138345] schedule+0x42/0xb0
[ 364.138351] kthread+0xd5/0x140
[ 364.138361] ? init_module+0x6b/0x6b [memory]
[ 364.138365] ? kthread_park+0x90/0x90
[ 364.138371] ret_from_fork+0x35/0x40
And by posting this publicly on the Internet, I’m hoping some kind stranger sets out to prove me wrong and show me how it can be done.