Let's try and run our previous code, but with the latest kernel version (as of writing, 6.10-rc5). The offsets of commit_creds and prepare_kernel_cred() are as follows, and we'll update exploit.c with the new values:
The major number needs to be updated to 253 in init for this version! I've done it automatically, but it bears remembering if you ever try to create your own module.
Instead of an elevated shell, we get a kernel panic, with the following data dump:
I could have left this part out of my blog, but it's valuable to know a bit more about debugging the kernel and reading error messages. I actually came across this issue while trying to get the previous section working, so it happens to all of us!
One thing that we can notice is that, the error here is listed as a NULL pointer dereference error. We can see that the error is thrown in commit_creds():
[ 1.480065] RIP: 0010:commit_creds+0x29/0x180
We can check the source here, but chances are that the parameter passed to commit_creds() is NULL - this appears to be the case, since RDI is shown to be 0 above!
Opening a GDBserver
In our run.sh script, we now include the -s flag. This flag opens up a GDB server on port 1234, so we can connect to it and debug the kernel. Another useful flag is -S, which will automatically pause the kernel on load to allow us to debug, but that's not necessary here.
What we'll do is pause our exploit binary just before the write() call by using getchar(), which will hang until we hit Enter or something similar. Once it pauses, we'll hook on with GDB. Knowing the address of commit_creds() is 0xffffffff81077390, we can set a breakpoint there.
$ gdb kernel_rop.ko
pwndbg> target remote :1234
pwndbg> b *0xffffffff81077390
We then continue with c and go back to the VM terminal, where we hit Enter to continue the exploit. Coming back to GDB, it has hit the breakpoint, and we can see that RDI is indeed 0:
pwndbg> info reg rdi
rdi 0x0 0
This explains the NULL dereference. RAX is also 0, in fact, so it's not a problem with the mov:
pwndbg> info reg rax
rax 0x0 0
This means that prepare_kernel_cred() is returning NULL. Why is that? It didn't do that before!
Let's compare the differences in prepare_kernel_cred() code between kernel version 6.1 and version 6.10:
struct cred *prepare_kernel_cred(struct task_struct *daemon){conststruct cred *old;struct cred *new; new =kmem_cache_alloc(cred_jar, GFP_KERNEL);if (!new)returnNULL;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;new->non_rcu =0;atomic_long_set(&new->usage,1);set_cred_subscribers(new,0);get_uid(new->user);get_user_ns(new->user_ns);get_group_info(new->group_info);// [...]if (security_prepare_creds(new, old, GFP_KERNEL_ACCOUNT)<0)goto error;put_cred(old);validate_creds(new);return new;error:put_cred(new);put_cred(old);returnNULL;}
The last and first parts are effectively identical, so there's no issue there. The issue arises in the way it handles a NULL argument. On 5.10, it treats it as using init_task:
if (daemon) old =get_task_cred(daemon);else old =get_cred(&init_cred);
i.e. if daemon is NULL, use init_task. On 6.10, the behaviour is altogether different:
if (WARN_ON_ONCE(!daemon))returnNULL;
If daemon is NULL, return NULL - hence our issue!
Unfortunately, there's no way to bypass this easily! We can fake cred structs, and if we can leak init_task we can use that memory address as well, but it's no longer as simple as calling prepare_kernel_cred(0)!