Post

A note on kROP chains for newer linux kernel versions

A (detailed) note on prepare_kernel_cred changes in newer linux kernel versions. Additionally, a bypass to the introduced hardening update is described.

TL;DR

The changes introduced to kernel/cred.c starting from kernel version 6.2 (as well as LTS kernel versions) prevent calling prepare_kernel_cred with a NULL value for (easily) gaining root privileges.

Instead, the address of init_task must be extracted or calculated (based on the kernel base address) and passed as an argument. The kROP chain must now call prepare_kernel_cred(&init_task) to retrieve a cred struct with root privileges.

Background

While working with newer linux kernels and creating a kernel exploitation CTF challenge, I came across an unusual error when executing my kROP chain: Calling prepare_kernel_cred(0) didn’t return a cred struct as expected, but instead returned NULL. This stopped my kROP chain in-place by causing a kernel panic and resulting in a unrecoverable crash.

After searching extensively for hours on how kROP chains for newer linux look like, I came up mostly empty and the few blog posts/writeups that I did find, wouldn’t really explain what changes are required and why these needed to be done in the first place.

So finally, I chose to do the sensible thing at look at the actual linux kernel source code itself.
Why I didn’t do this at first? I really don’t know.
Nonetheless, after spending some more hours reading source code and debugging, I finally found my long-awaited answers to What changed and Why.

But now enough with the backstory, let’s jump in!

Short summary of previous kROP chains

After gaining control of RSP register (specifically the address rsp points to), gaining root privileges is fairly straight forward.

You can either overwrite modprobe_path to your exploit script, overwrite the cred struct of your current process or finally, directly call commit_creds(prepare_kernel_cred(0)). We will exclusely look at the later as many other resources exist for the former 2 kROP techniques.

In older versions, the chain would look something like this:

  • set RDI to 0 -> a pop rdi ; ret gadget followed by 0x0
  • get a cred struct from the kernel -> prepare_kernel_cred(0)
    • Note that passing 0 will give us the credentials of the first task executed by the kernel, which runs as root
  • move the cred struct from RAX to RDI -> a mov rax, rdi ; ret gadget
  • replace the credentials of our current process -> commit_creds
  • return to userland (and bypass KPTI if necessary) -> swapgs ; iretq or swapgs_restore_regs_and_return_to_usermode

Now that we got the basics down, let’s get to the actual interesting part.

Changes to kernel functions

The changes to our important kernel function can be traced down to the following mail thread. This states the following:

A common exploit pattern for ROP attacks is to abuse prepare_kernel_cred() in order to construct escalated privileges[1]. Instead of providing a short-hand argument (NULL) to the “daemon” argument to indicate using init_cred as the base cred, require that “daemon” is always set to an actual task. Replace all existing callers that were passing NULL with &init_task.

Additionally, we can see the changes made to the kernel/cred.c source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
	const struct cred *old;
	struct cred *new;

	if (WARN_ON_ONCE(!daemon))
		return NULL;

	new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
	if (!new)
		return NULL;

	kdebug("prepare_kernel_cred() alloc %p", new);

	old = get_task_cred(daemon);

	*new = *old;
    // ...
	return new;
}

Whereas older kernel versions looked like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
	const struct cred *old;
	struct cred *new;

	new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
	if (!new)
		return NULL;

	kdebug("prepare_kernel_cred() alloc %p", new);

	if (daemon)
		old = get_task_cred(daemon);
	else
		old = get_cred(&init_cred);

	validate_creds(old);

	*new = *old;
    // ...
    return new;
}

What’s the difference?

Before, prepare_kernel_cred would return the credentials struct for &init_cred (Lines 12-15). This would essentially give us root privileges.
Now, however, the function will now just fail and return NULL (Lines 6 & 7).

Bypassing hardening measures

So with this information in hand, how can we modify our kROP chain to bypass this hardening measure? It’s quite simple actually.

We can simply replicate the functionality of the old prepare_kernel_cred in our kROP chain:

  • Get the address of init_task
    • init_task isn’t exposed through /proc/kallsyms but can be retrieved with gdb:
      1
      2
      
      gdb> p &init_task
      $2 = (struct task_struct *) 0xffffffff8200a900 <init_task>
      

      Note that this requires access to debug symbols of the kernel. If you don’t have access to these symbols, you can also break on prepare_kernel_cred (b *prepare_kernel_cred) and get the address from the first call (remember how init_task is the first task executed by the kernel?)

    • If kASLR is enabled, you can calculate the offset from the base address of the kernel. The address of init_task should have a static offset. This might not be the case for kernels with FG-KASLR.
  • move the address into RDI -> this will require a pop rdi ; ret gadget followed by the address retrieved previously
  • call prepare_kernel_cred(&init_task)

This bypass will require full control of the RDI register as simply setting RDI to 0 won’t suffice anymore. Additionally, at least a partial (if not arbitrary) read primitive is needed to get the address of init_task.

This bypass might seem trivial - and it is. However, getting to this point took some time (at least for me).

Sidetrack: Finding useful gadgets

Work in Progess…

Conclusion

The implemented change to kernel/cred.c and the prepare_kernel_cred function might prevent some attacks where partial / arbitrary read primitives aren’t avaiable.
However, simply reproducing the original functionality in the kROP chain is already enough to bypass this measure.

So starting from linux 6.2, commit_creds(prepare_kernel_cred(&init_task)) is the new commit_creds(prepare_kernel_cred(0)) :D

This post is licensed under CC BY 4.0 by the author.