Debugging a Kernel Module
A practical example
Trying on the Latest Kernel
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:
commit_creds 0xffffffff81077390
prepare_kernel_cred 0xffffffff81077510
Instead of an elevated shell, we get a kernel panic, with the following data dump:
[ 1.472064] BUG: kernel NULL pointer dereference, address: 0000000000000000
[ 1.472064] #PF: supervisor read access in kernel mode
[ 1.472064] #PF: error_code(0x0000) - not-present page
[ 1.472064] PGD 22d9067 P4D 22d9067 PUD 22da067 PMD 0
[ 1.472064] Oops: Oops: 0000 [#1] SMP
[ 1.472064] CPU: 0 PID: 32 Comm: exploit Tainted: G W O 6.10.0-rc5 #7
[ 1.472064] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
[ 1.472064] RIP: 0010:commit_creds+0x29/0x180
[ 1.472064] Code: 00 f3 0f 1e fa 55 48 89 e5 41 55 65 4c 8b 2d 9e 80 fa 7e 41 54 53 4d 8b a5 98 05 00 00 4d 39 a5 a0 05 00 00 0f 85 3b 01 00 00 <48> 8b 07 48 89 fb 48 85 c0 0f 8e 2e 01 07
[ 1.472064] RSP: 0018:ffffc900000d7e30 EFLAGS: 00000246
[ 1.472064] RAX: 0000000000000000 RBX: 00000000004a8220 RCX: ffffffff81077390
[ 1.472064] RDX: 0000000000000000 RSI: 00000000ffffffea RDI: 0000000000000000
[ 1.472064] RBP: ffffc900000d7e48 R08: ffffffff818a7a28 R09: 0000000000004ffb
[ 1.472064] R10: 00000000000000a5 R11: ffffffff818909b8 R12: ffff88800219b480
[ 1.472064] R13: ffff888002202e00 R14: 0000000000000000 R15: 0000000000000000
[ 1.472064] FS: 000000001b323380(0000) GS:ffff888007800000(0000) knlGS:0000000000000000
[ 1.472064] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 1.472064] CR2: 0000000000000000 CR3: 00000000022d7000 CR4: 00000000000006b0
[ 1.472064] Call Trace:
[ 1.472064] <TASK>
[ 1.472064] ? show_regs+0x64/0x70
[ 1.472064] ? __die+0x24/0x70
[ 1.472064] ? page_fault_oops+0x14b/0x420
[ 1.472064] ? search_extable+0x2b/0x30
[ 1.472064] ? commit_creds+0x29/0x180
[ 1.472064] ? search_exception_tables+0x4f/0x60
[ 1.472064] ? fixup_exception+0x26/0x2d0
[ 1.472064] ? kernelmode_fixup_or_oops.constprop.0+0x58/0x70
[ 1.472064] ? __bad_area_nosemaphore+0x15d/0x220
[ 1.472064] ? find_vma+0x30/0x40
[ 1.472064] ? bad_area_nosemaphore+0x11/0x20
[ 1.472064] ? exc_page_fault+0x284/0x5c0
[ 1.472064] ? asm_exc_page_fault+0x2b/0x30
[ 1.472064] ? abort_creds+0x30/0x30
[ 1.472064] ? commit_creds+0x29/0x180
[ 1.472064] ? x64_sys_call+0x146c/0x1b10
[ 1.472064] ? do_syscall_64+0x50/0x110
[ 1.472064] ? entry_SYSCALL_64_after_hwframe+0x4b/0x53
[ 1.472064] </TASK>
[ 1.472064] Modules linked in: kernel_rop(O)
[ 1.472064] CR2: 0000000000000000
[ 1.480065] ---[ end trace 0000000000000000 ]---
[ 1.480065] RIP: 0010:commit_creds+0x29/0x180
[ 1.480065] Code: 00 f3 0f 1e fa 55 48 89 e5 41 55 65 4c 8b 2d 9e 80 fa 7e 41 54 53 4d 8b a5 98 05 00 00 4d 39 a5 a0 05 00 00 0f 85 3b 01 00 00 <48> 8b 07 48 89 fb 48 85 c0 0f 8e 2e 01 07
[ 1.484065] RSP: 0018:ffffc900000d7e30 EFLAGS: 00000246
[ 1.484065] RAX: 0000000000000000 RBX: 00000000004a8220 RCX: ffffffff81077390
[ 1.484065] RDX: 0000000000000000 RSI: 00000000ffffffea RDI: 0000000000000000
[ 1.484065] RBP: ffffc900000d7e48 R08: ffffffff818a7a28 R09: 0000000000004ffb
[ 1.484065] R10: 00000000000000a5 R11: ffffffff818909b8 R12: ffff88800219b480
[ 1.484065] R13: ffff888002202e00 R14: 0000000000000000 R15: 0000000000000000
[ 1.484065] FS: 000000001b323380(0000) GS:ffff888007800000(0000) knlGS:0000000000000000
[ 1.484065] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 1.484065] CR2: 0000000000000000 CR3: 00000000022d7000 CR4: 00000000000006b0
[ 1.488065] Kernel panic - not syncing: Fatal exception
[ 1.488065] Kernel Offset: disabled
[ 1.488065] ---[ end Kernel panic - not syncing: Fatal exception ]---
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)
{
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;
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);
return NULL;
}
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))
return NULL;
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)
!
Last updated
Was this helpful?