Friday, May 26, 2023

Bypassing SELinux with init_module

 

TL;DR


There are two Linux system calls for loading a kernel module - init_module and finit_module. By leveraging init_module, I bypassed a filesystem-based SELinux rule that prevented me from loading a kernel module through traditional means (e.g., insmod). I then disabled SELinux from kernel-space. Proof of concept code can be found on my GitHub.

 Scenario

Recently I've been looking at an undisclosed Linux-based device as a personal weekend side project. Using some simple techniques that I won't discuss here, I had acquired root shell access. To avoid disclosing unnecessary details about the target implementation, information such as file names and SELinux contexts have been modified in the write-up below. Some code snippets and shell output listings have also been truncated for brevity (indicated by "[...]").

SELinux

Upon connecting with my reverse shell, I quickly realized that the system had SELinux enabled. Although the policy wasn't nearly as strict as the standard policy that you might find on a typical Android device, it was strict enough to prevent me from doing a lot of useful things (e.g., mounting filesystems and accessing files in /etc/). Luckily, I was able to write and execute files in /tmp/, so I still had an easy way to build and run custom tools.

The developers did a good job of removing unnecessary user-space binaries from the filesystem, so the standard SELinux tools (getenforce, setenforce, sestatus, etc.) were unavailable. When I tried to manually interact with the SELinux enforce file, I was unable to do so:

# ls -la /sys/fs/selinux/enforce
ls: /sys/fs/selinux/enforce: Permission denied

# cat /sys/fs/selinux/enforce
cat: can't open '/sys/fs/selinux/enforce': Permission denied

# echo 0 > /sys/fs/selinux/enforce
/bin/sh: can't create /sys/fs/selinux/enforce: Permission denied

An inspection of the system logs indicated that SELinux was preventing this:

# dmesg | grep audit
[...]
[   57.800935] audit: type=1400 audit(1684520112.159:6): avc:  denied  { write } for  pid=524 comm="sh" name="enforce" dev="selinuxfs" ino=4 scontext=system_u:object_r:userapp_t tcontext=system_u:object_r:security_t tclass=file permissive=0
[...]

Unsurprising, I guess.

Kernel Module

After trying a number of failed techniques that aren't described here, I decided to see if I could get kernel execution. With kernel execution, I wouldn't necessarily need to disable SELinux, but it would make my life a lot easier if I wanted to do anything in user-space.

The easiest way to obtain kernel execution is to load a kernel module. The device was running on an ARM processor, so I figured it would be a lot of effort to cross-compile a custom kernel module, and I didn't want to expend that effort unless I was sure that I could load a module at all. I decided I would first make a simple proof of concept module that printed to the system log when loaded, thereby proving kernel execution.

I quickly cloned one of the existing kernel modules from the device filesystem using my semi-functional kernel module duplicator script, and then patched the module constructor function (module_init) to print my canary message:

printk("\x01[SeanP] Loaded kernel module!\n");

If the module was successfully loaded, I would see the message in the output of dmesg.

I attempted to load the module with insmod:

# insmod /tmp/dummy.ko

However, the system logs revealed (yet again) that SELinux was blocking it:

# dmesg | grep audit
[...]
[ 1223.106922] audit: type=1400 audit(1680885266.249:5): avc:  denied  { module_load } for  pid=5658 comm="insmod" path="/tmp/dummy.ko" dev="tmpfs" ino=573 scontext=system_u:object_r:userapp_t tcontext=system_u:object_r:tmpfs_t tclass=system permissive=0
[...]

Frustrating, but expected.

init_module

While reading various documentation about kernel functionality and Linux system calls, I discovered that there are actually two different system calls that can be used to load a kernel module:

int init_module(void* module_image, unsigned long len, const char* param_values);

int finit_module(int fd, const char* param_values, int flags);

The documentation explains the difference:

init_module() loads an ELF image into kernel space, performs any necessary symbol relocations, initializes module parameters to values provided by the caller, and then runs the module's init function. This system call requires privilege.

The module_image argument points to a buffer containing the binary image to be loaded; len specifies the size of that buffer. The module image should be a valid ELF image, built for the running kernel.

 [...]

The finit_module() system call is like init_module(), but reads the module to be loaded from the file descriptor fd. It is useful when the authenticity of a kernel module can be determined from its location in the file system[.]

So, init_module loads a kernel module stored in memory, while finit_module loads a kernel module from disk.

Revisiting the SELinux audit message, I noticed that my attempt to load a kernel module was blocked (at least in part) due to the target context of the module file dummy.ko (which included the filesystem type tmpfs_t):

tcontext=system_u:object_r:tmpfs_t

This seemed to indicate that insmod was using the finit_module system call to load kernel modules. What if I tried to load a module with init_module instead? Assuming that SELinux was enforcing this rule based on a filesystem context, I could load the module from a buffer in memory, and there wouldn't be any filesystem association.

Custom Loader

I found an existing implementation of a kernel module loader that uses init_module (created by Ciro Santilli), modified it to add more error checks, and compiled it with arm-linux-gnueabi-gcc (final code and Makefile can be found here). Then, I attempted to load my kernel module with the custom loader:

# /tmp/modload /tmp/dummy.ko

# dmesg | grep SeanP
[   10.466012] calling  dummy_module_init+0x0/0x1000 [SeanP_DummyModule] @ 119
[   10.466058] [SeanP] Loaded kernel module!
[   10.466194] initcall dummy_module_init+0x0/0x1000 [SeanP_DummyModule] returned 22 after 84 usecs

It worked! This was huge; I had obtained kernel execution. Even if I couldn't disable SELinux, I could do (basically) anything I wanted to do in user-space with a bit more effort from kernel-space.

Disabling SELinux

Now that I knew I could load kernel modules, I needed to cross-compile a custom module to do anything more advanced (technically I could patch an existing module, but that would probably be more effort). I'm not going to get into the details of cross-compiling kernel artifacts, but this post from Yunjong Jeong (AKA blukat29) has lots of good information for anyone looking to do so.

Once I had my build environment set up, I needed to create the kernel module. After a bit of research, I determined that my module would need to do two things:

  • Shed the user-space SELinux context that the kernel module was loaded with
  • Write 0 to /sys/fs/selinux/enforce

I'm sure you can also disable SELinux by modifying kernel data structures in memory, but my way seemed easier.

This post by Ashfaq Ansari has a lot of good information on Linux exploitation, including an explanation of how to get rid of your user-space SELinux context after transitioning to kernel-space:

commit_creds(prepare_kernel_cred(NULL));

Ashfaq thoroughly explains how this code works, but it can be briefly summarized with this quote:

[...] if we provide NULL as the pointer to task_struct it will get the default credentials which is init_cred. init_cred is a global struct cred defined in kernel/cred.c which is used to initialize the credentials for the init_task which is the first task in Linux.

So we're not really removing the SELinux context; rather, we're transitioning to the context of the init task (generally speaking, this context probably has very few restrictions, if any).

My second goal (writing zero to the enforce file) was easier to figure out; I simply had to use the standard function for writing files from kernel-space (kernel_write).

The final code for my kernel module was less than 100 lines (and can be found here). I compiled it and loaded it with my custom loader:

# /tmp/modload /tmp/disable_selinux.ko

# dmesg | grep SeanP
[   35.808464] [SeanP] SELinux disabler kernel module loaded
[   35.818566] [SeanP] [SUCCESS] Wrote 0 to SELinux enforce file

# cat /sys/fs/selinux/enforce
0

And just like that, I had disabled SELinux.


Resources

Source code and build scripts for the custom tools I described in this blog post (module loader and kernel module) can be found on my GitHub. Below is a list of references that I found helpful during this adventure:


1 comment:

  1. Excellent work...with worrisome results for any Linux in production.

    ReplyDelete