Skip to content

LSM

Source Code

Full code for the example in this chapter is available here

What is LSM

LSM stands for Linux Security Modules which is a framework which allows developers to write security systems on top of the Linux kernel. It's also briefly described in the Linux kernel documentation.

LSM is used by kernel modules or (since kernel 5.7) by eBPF programs. The most popular modules that make use of LSM are AppArmor, SELinux, Smack and TOMOYO. eBPF LSM programs allow developers to implement the same functionality implemented by the modules just mentioned, using eBPF APIs.

The central concept behind LSM is LSM hooks. LSM hooks are exposed in key locations in the kernel, and eBPF programs can attach to them to implement custom security policies. Examples of operations that can be policied via hooks include:

  • filesystem operations
  • opening, creating, moving and removing files
  • mounting and unmounting filesystems
  • task/process operations
  • allocating and freeing tasks, changing user and group identify for a task
  • socket operations
  • creating and binding sockets
  • receiving and sending messages

Each of those actions has a corresponding LSM hook. All LSM hooks are listed in the lsm_hooks.h header inside the Linux kernel source code. Each hook takes a number of arguments, which provide context based on which programs can implement policy decisions and are listed in the lsm_hook_defs.h header.

For example, consider the task_setnice hook, which has the following definition:

LSM_HOOK(int, 0, task_setnice, struct task_struct *p, int nice)

The hook is triggered when a nice value is set for any process in the system. If you are not familiar with the concept of process niceness, check out this article. As you can see from the definition, this hook takes the following arguments:

  • p is the instance of task_struct which represents the process on which the nice value is set
  • nice is the nice value

By attaching to the hook, an eBPF program can decide whether to accept or reject the given nice value.

In addition to the arguments found in the hook definition, eBPF programs have access to one extra argument - ret - which is a return value of potential previous eBPF LSM programs.

Ensure that BPF LSM is enabled

Before proceeding further and trying to write a BPF LSM program, please make sure that:

  • Your kernel version is at least 5.7.
  • BPF LSM is enabled.

The second point can be checked with:

$ cat /sys/kernel/security/lsm
capability,lockdown,landlock,yama,apparmor,bpf

The correct output should contain bpf. If it doesn't, BPF LSM has to be manually enabled by adding it to kernel config parameters. It can be achieved by editing the GRUB config in /etc/default/grub and adding the following to the kernel parameters:

GRUB_CMDLINE_LINUX="lsm=[YOUR CURRENTLY ENABLED LSMs],bpf"

Then rebuilding the grub configuration with:

grub-mkconfig -o /boot/grub/grub.cfg

And finally, rebooting the system.

Writing LSM BPF program

Let's try to create an LSM eBPF program which which is triggered by task_setnice hook. The purpose of this program will be denying setting the nice value lower than 0 (which means higher priority), for a particular process.

The renice tool can be used to change niceness values:

renice [value] -p [pid]

With our eBPF program, we want to make it impossible to call renice for a given pid with a negative [value].

eBPF projects come with two parts: eBPF program(s) and the userspace program. To make our example simple, we can try to deny a change of a nice value of the userspace process which loads the eBPF program.

The first step is to create a new project:

cargo generate --name lsm-nice -d program_type=lsm -d lsm_hook=task_setnice https://github.com/aya-rs/aya-template

That command should create a new Aya project with an empty program attaching to the task_setnice hook. Let's go to its directory:

cd lsm-nice
One of the arguments passed to the task_setnice hook is a pointer to a task_struct type. Therefore we need to generate a binding to task_struct with aya-tool.

If you are not familiar with aya-tool, please refer to this section.

aya-tool generate task_struct > lsm-nice-ebpf/src/vmlinux.rs

Now it's time to modify the lsm-nice-ebpf project and write an actual program there. The full program code should look like this:

lsm-nice-ebpf/src/main.rs
#![no_std]
#![no_main]

use aya_bpf::{cty::c_int, macros::lsm, programs::LsmContext};

// (1)
#[allow(non_upper_case_globals)]
#[allow(non_snake_case)]
#[allow(non_camel_case_types)]
#[allow(dead_code)]
mod vmlinux;

use vmlinux::task_struct;

// (2)
/// PID of the process for which setting a negative nice value is denied.
#[no_mangle]
static PID: i32 = 0;

#[lsm(name = "task_setnice")]
pub fn task_setnice(ctx: LsmContext) -> i32 {
    match unsafe { try_task_setnice(ctx) } {
        Ok(ret) => ret,
        Err(ret) => ret,
    }
}

// (3)
unsafe fn try_task_setnice(ctx: LsmContext) -> Result<i32, i32> {
    let p: *const task_struct = ctx.arg(0);
    let nice: c_int = ctx.arg(1);
    let ret: c_int = ctx.arg(2);

    // If previous eBPF LSM program didn't allow the action, return the
    // previous error code.
    if ret != 0 {
        return Err(ret);
    }

    // Deny setting the nice value lower than 0 for the defined PID.
    if (*p).pid == core::ptr::read_volatile(&PID) && nice < 0 {
        return Err(-1);
    }

    // Otherwise allow it.
    Ok(0)
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}
  1. We include the autogenerated binding to task_struct:
  2. Then we define a global variable PID. We initialize the value to 0, but at runtime the userspace side will patch the value with the actual pid we're interested in.
  3. Finally we have the program and the logic what to do with nice values.

After that we also need to modify the userspace part. We don't need as much work as with the eBPF part, but we need to:

  1. Get the PID.
  2. Log it.
  3. Write it to the global variable in the eBPF object.

The final result should look like:

lsm-nice/src/main.rs
use std::process;

use aya::{include_bytes_aligned, programs::Lsm, BpfLoader, Btf};
use log::info;
use tokio::signal;

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    env_logger::init();

    // (1)
    let pid = process::id() as i32;
    info!("PID: {}", pid);

    // This will include your eBPF object file as raw bytes at compile-time and load it at
    // runtime. This approach is recommended for most real-world use cases. If you would
    // like to specify the eBPF program at runtime rather than at compile-time, you can
    // reach for `Bpf::load_file` instead.
    // (2)
    let mut bpf = BpfLoader::new().set_global("PID", &pid).load(
        include_bytes_aligned!(
            "../../target/bpfel-unknown-none/release/lsm-nice"
        ),
    )?;
    let btf = Btf::from_sys_fs()?;
    let program: &mut Lsm =
        bpf.program_mut("task_setnice").unwrap().try_into()?;
    program.load("task_setnice", &btf)?;
    program.attach()?;

    info!("Waiting for Ctrl-C...");
    signal::ctrl_c().await?;
    info!("Exiting...");

    Ok(())
}
  1. Where we start with getting and logging a PID:
  2. And then we set the global variable:

After that, we can build and run our project with:

RUST_LOG=info cargo xtask run

The output should contain our log line showing the PID of the userspace process, i.e.:

16:32:30 [INFO] lsm_nice: [lsm-nice/src/main.rs:22] PID: 573354

Now we can try to change the nice value for that process. Setting a positive value (lowering the priority) should still work:

$ renice 10 -p 587184
587184 (process ID) old priority 0, new priority 10

But setting a negative value should not be allowed:

$ renice -10 -p 587184
renice: failed to set priority for 587184 (process ID): Operation not permitted

If doing that resulted in Operation not permitted, congratulations, your LSM eBPF program works!