0%

Kernel基础知识(持续更新)

POSIX 简析

POSIX:可移植操作系统接口( Portable Operating System Interface,缩写为 POSIX )

POSIX是一套标准,为了提高Unix的 兼容性应用程序可移植性 而诞生,这套标准涵盖了很多方面,比如:Unix系统调用的C语言接口,shell程序和工具,线程及网络编程

  • POSIX兼容也就指定了接口函数兼容,但是并不管API具体如何实现

Kernel 简析

Kernel 是一个程序,是操作系统底层用来管理上层软件发出的各种请求的程序,Kernel 将各种请求转换为指令,交给硬件去处理,简而言之,Kernel 是连接软件与硬件的中间层

Kernel 主要提供两个功能,与硬件交互,提供应用运行环境

在 intel 的 CPU 中,会将 CPU 的权限分为 Ring 0,Ring 1,Ring 2,Ring 3,四个等级,权限依次递减,高权限等级可以调用低权限等级的资源

在常见的系统(Windows,Linux,MacOS)中,内核处于 Ring 0 级别,应用程序处于 Ring 3 级别

Kernel 信息获取

先关闭 kaslr:

1
-append "console=ttyS0 nokaslr pti=on quiet oops=panic panic=1"

在 root 权限下启动 kernel:

1
2
/ # lsmod 
d3kheap 16384 2 - Live 0xffffffffc0133000 (OE)
1
2
3
4
/ # grep anon_pipe_buf_ops /proc/kallsyms
ffffffff88c3fe40 r anon_pipe_buf_ops
/ # grep commit_creds /proc/kallsyms
ffffffff87cd25c0 T commit_creds

Kernel 提权

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

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

Change Self:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 */
...
}

因此,提权方式为:

  • 直接修改 cred 结构体的内容(需要先定位 cred,然后将其修改)
  • 修改 task_struct 结构体中的 cred 指针指向一个满足要求的 cred

Change Others:

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

  • 改数据
  • 改代码

修改 cred 结构体的内容

想要修改 cred 结构体,首先需要确定该结构体的位置:

cred定位-直接扫描 cred

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

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

cred定位-通过task_struct间接定位

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

  • 定位当前进程 task_struct 结构体的地址
  • 根据 cred 指针相对于 task_struct 结构体的偏移计算得出 cred 指针存储的地址
  • 获取 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
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
struct task_struct {
volatile long state; // 说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; // Flage 是进程号,在调用fork()时给出
int sigpending; // 进程上是否有待处理的信号
mm_segment_t addr_limit; // 进程地址空间,区分内核进程与普通进程在内存存放的位置不同
/* 0-0xBFFFFFFF for user-thead */
/* 0-0xFFFFFFFF for kernel-thread */
volatile long need_resched; // 调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
int lock_depth; // 锁深度
long nice; // 进程的基本时间片
unsigned long policy; // 进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
struct mm_struct *mm; // 进程内存管理信息
int processor;
unsigned long cpus_runnable, cpus_allowed; // 若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1(这个值在运行队列被锁时更新)
struct list_head run_list; // 指向运行队列的指针
unsigned long sleep_time; // 进程的睡眠时间
struct task_struct *next_task, *prev_task; // 用于将系统中所有的进程连成一个双向循环链表,其根是init_task
struct mm_struct *active_mm;
struct list_head local_pages; // 指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; // 进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; // 父进程终止时向子进程发送的信号
unsigned long personality;
int did_exec:1; // Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
pid_t pid; /* 进程标识符,用来代表一个进程 */
pid_t pgrp; /* 进程组标识,表示进程所属的进程组 */
pid_t tty_old_pgrp; /* 进程控制终端所在的组标识 */
pid_t session; /* 进程的会话标识 */
pid_t tgid;
int leader; // 表示进程是否为会话主管
struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
struct list_head thread_group; // 线程链表
struct task_struct *pidhash_next; // 用于将进程链入HASH表
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; // 供wait4()使用
struct completion *vfork_done; // 供vfork()使用
unsigned long rt_priority; // 实时优先级,用它计算实时进程调度时的weight值

/* it_real_value,it_real_incr用于REAL定时器(单位为jiffies):
系统根据it_real_value设置定时器的第一个终止时间,在定时器到期时,向进程发送SIGALRM信号,同时根据it_real_incr重置终止时间 */

/* it_prof_value,it_prof_incr用于Profile定时器(单位为jiffies):
当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送
信号SIGPROF,并根据it_prof_incr重置时间 */

/* it_virt_value,it_virt_value用于Virtual定时器(单位为jiffies):
当进程运行时,不管在何种状态下,每个tick都使it_virt_value值减一,当减到0时,向进程发送信号SIGVTALRM,根据it_virt_incr重置初值 */

unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
struct timer_list real_timer; // 指向实时定时器的指针
struct tms times; // 记录进程消耗的时间
unsigned long start_time; // 进程创建的时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS]; // 记录进程在每个CPU上所消耗的用户态时间和核心态时间

/* 内存缺页和交换信息:
min_flt,maj_flt:累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换
设备读入的页面数)
nswap:记录进程累计换出的页面数,即写到交换设备上的页面数
cmin_flt,cmaj_flt,cnswap:记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数,在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
*/
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1; // 表示进程的虚拟地址空间是否允许换出

/* 进程认证信息:
uid,gid:为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
euid,egid:为有效uid,gid
fsuid,fsgid:为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件系统的访问权限时使用他们
suid,sgid:为备份uid,gid
*/
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups; // 记录进程在多少个用户组中
gid_t groups[NGROUPS]; // 记录进程所在的组
kernel_cap_t cap_effective, cap_inheritable, cap_permitted; // 进程的权能,分别是有效位集合,继承位集合,允许位集合
int keep_capabilities:1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; // 与进程相关的资源限制信息
unsigned short used_math; // 是否使用FPU
char comm[16]; // 进程正在运行的可执行文件名
int link_count, total_link_count; // 文件系统信息
struct tty_struct *tty; // 指向进程所在的控制终端,如果不需要控制终端,则该指针为空
unsigned int locks;
struct sem_undo *semundo; // 进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; // 当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
struct thread_struct thread; // 进程的CPU状态,切换时,要保存到停止进程的task_struct中
struct fs_struct *fs; // 文件系统信息
struct files_struct *files; // 打开文件信息
spinlock_t sigmask_lock; // 信号处理函数
struct signal_struct *sig; // 信号处理函数
sigset_t blocked; // 进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; // 进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;
spinlock_t alloc_lock;
void *journal_info;
};

cred定位-通过comm间接定位

comm 用来标记可执行文件的名字,位于进程的 task_struct 结构体中,我们可以发现 comm 其实在 cred 的正下方,所以我们也可以先定位 comm ,然后定位 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
    /* Process credentials: */

/* 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;

#ifdef CONFIG_KEYS
/* Cached requested key. */
struct key *cached_requested_key;
#endif

/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];

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

cred间接定位-UAF使用同样堆块

虽然我们确实想要修改 cred 的内容,但是不一定非得知道 cred 的具体位置,我们只需要能够修改 cred 即可

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

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

cred修改

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

  • 修改 cred 指针为内核镜像中已有的 init_cred 的地址(这种方法适合于我们能够直接修改 cred 指针以及知道 init_cred 地址的情况)
  • 伪造一个 cred,然后修改 cred 指针指向该地址即可(这种方式比较麻烦,一般并不使用)
  • 使用 commit_creds(prepare_kernel_cred(0)) 来进行提权,该方式会自动生成一个合法的 cred,并定位当前线程的 task_struct 的位置,然后修改它的 cred 为新的 cred(该方式比较适用于控制程序执行流后使用,例如ROP后)

改变特权进程的执行轨迹

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

改数据-符号链接

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

改数据-利用 call_usermodehelper

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

  • 获取 modprobe_path 的地址
  • 修改 modprobe_path 为指定的程序(当前进程)
  • 触发执行 call_modprobe,从而实现提权 ,这里我们可以利用以下几种方式来触发:
    • 执行一个非法的可执行文件,非法的可执行文件需要满足相应的要求
    • 使用未知协议来触发

使用 modprobe_path 的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
// step 1: 将modprobe_path修改为目标值

// step 2: 创建相关文件
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.1: 使用未知的可执行文件触发它
system("echo -ne '\\xff\\xff\\xff\\xff' > /home/pwn/dummy");
system("chmod +x /home/pwn/dummy");
system("/home/pwn/dummy");

// step 3.2: 使用未知协议触发它
socket(AF_INET,SOCK_STREAM,132);
  • 由于 modprobe_path 的取值是确定的,所以我们可以直接扫描内存,寻找对应的字符串,这需要我们具有扫描内存的能力
  • 考虑到 modprobe_path 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 modprobe_path 的地址

改数据-修改 poweroff_cmd

  • 获取 poweroff_cmd 的地址
  • 修改 poweroff_cmd 为指定的程序(当前进程)
  • 劫持控制流执行 __orderly_poweroff

关于如何定位 poweroff_cmd,我们可以采用类似于定位 modprobe_path 的方法

改代码-修改 vDSO 代码

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

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

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

  • 定位 vDSO(IDA中查看为:raw_data)
  • 修改 vDSO 的特定函数为指定的 shellcode
  • 等待触发执行 shellcode

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

在 IDA 中定位:

  • 在 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_64”或者“vdso_image_x32”后,就可以看到“raw_data”了
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

在内存中定位:

  • vDSO 其实是一个 ELF 文件,具有 ELF 文件头,同时,vDSO 中特定位置存储着导出函数的字符串,因此我们可以根据这两个特征来扫描内存,定位 vDSO 的位置
  • 考虑到 vDSO 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 vDSO 的地址

Kernel 目录结构

通常一个文件系统映像(core.cpio)解压以后有如下目录/文件:

1
2
3
➜  core ls
bin etc init lib64 proc sbin tmp vmlinux
core.ko lib linuxrc root sys usr
  • bin目录:普通用户使用的二进制文件,包括了各种终端命令
  • sbin目录:超级用户专用的二进制代码存放目录,主要用于系统管理
  • usr目录:usr 是 unix shared resources(共享资源) 的缩写,用户的很多应用程序和文件都放在这个目录下

    • /usr/bin:普通用户在后期安装的一些软件的运行脚本
    • /usr/sbin:存放了超级用户使用的,对于boot启动时非必须的二进制程序文件
  • etc目录:配置文件目录
  • sys目录:包含硬件设备的驱动程序信息(一般为NULL)
  • proc文件:proc虚拟文件系统在内核空间和用户空间之间打开了一个通信窗口(一般为NULL)
  • init文件:初始化脚本(拥有关键信息)
  • lib64目录:包含大量库文件(通常 lib32,lib64 只会有一个,而 lib 是其中一个的符号链接)
  • lib目录:包含基本的共享库和内核模块,arch(架构),drivers(驱动),fs(文件系统),net(网络)
  • tmp目录:用于存放临时文件(一般为NULL)
  • vmlinux文件:静态链接的可执行文件格式的 Linux 内核,分析的目标之一
  • core.ko文件:驱动文件,分析的目标之一(文件名可能不是“core”,主要看这个“ko”后缀)
  • linuxrc链接:链接目标 [bin/busybox]
  • root目录:root用户使用的目录,通常装有“flag”

驱动函数

当我们用 IDA 分析一个驱动文件时,会得到以下函数:

这些函数就是驱动函数,也被称为 ioctl 函数:

  • ioctl 是设备驱动程序中对设备的 I/O 通道进行管理的函数
  • ioctl 函数是文件结构中的一个属性分量,就是说如果你的驱动程序提供了对 ioctl 的支持,用户就可以在用户程序中使用 ioctl 函数控制设备的 I/O 通道
  • 在驱动程序中实现的 ioctl 函数体内,实际上是有一个 switch{case} 结构,每一个 case 对应一个命令码,做出一些相应的操作,怎么实现这些操作,这是由每一个程序员自己控制的,因为设备都是特定的

驱动函数是 kernel 中容易出问题的点,是该重点分析的对象

内核结构体

这里整理了一些内核常用的结构体:

1
2
3
4
5
6
7
00000000 list            struc ; (sizeof=0x28, mappedto_3)
00000000 item dq ? ; offset
00000008 mutex dq ? ; offset
00000010 field_10 dq ?
00000018 field_18 dq ?
00000020 field_20 dq ?
00000028 list ends
1
2
3
4
00000000 fd              struc ; (sizeof=0xD0, mappedto_6)
00000000 field_0 db 200 dup(?)
000000C8 using_list dq ? ; offset
000000D0 fd ends
1
2
3
4
5
6
00000000 input           struc ; (sizeof=0x10, mappedto_4)
00000000 ; XREF: add_item/r
00000000 size dq ? ; XREF: add_item+1A/r
00000000 ; add_item+35/r
00000008 ptr dq ? ; XREF: add_item+39/r
00000010 input ends
1
2
3
4
5
6
7
00000000 item            struc ; (sizeof=0x20, mappedto_5)
00000000 refcount dd ?
00000004 null dd ?
00000008 size dq ?
00000010 next dq ? ; offset
00000018 data dq ?
00000020 item ends