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 | / |
1 | / |
Kernel 提权
内核提权指的是普通用户可以获取到 root 用户的权限,访问原先受限的资源,这里从两种角度来考虑如何提权
- 改变自身:通过改变自身进程的权限,使其具有 root 权限
- 改变别人:通过影响高权限进程的执行,使其完成我们想要的功能
Change Self:
内核会通过进程的 task_struct
结构体中的 cred 指针来索引 cred 结构体,然后根据 cred 的内容来判断一个进程拥有的权限,如果 cred 结构体成员中的 uid-fsgid 都为 0,那一般就会认为进程具有 root 权限
1 | struct cred { |
因此,提权方式为:
- 直接修改 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 | struct task_struct { |
cred定位-通过comm间接定位
comm 用来标记可执行文件的名字,位于进程的 task_struct
结构体中,我们可以发现 comm 其实在 cred 的正下方,所以我们也可以先定位 comm ,然后定位 cred 的地址
1 | /* Process credentials: */ |
然而,在进程名字并不特殊的情况下,内核中可能会有多个同样的字符串,这会影响搜索的正确性与效率,因此,我们可以使用 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 | // step 1: 将modprobe_path修改为目标值 |
- 由于 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 | __int64 init_vdso() |
- 进入“vdso_image_64”或者“vdso_image_x32”后,就可以看到“raw_data”了
1 | .rodata:FFFFFFFF81A01300 public vdso_image_64 |
在内存中定位:
- vDSO 其实是一个 ELF 文件,具有 ELF 文件头,同时,vDSO 中特定位置存储着导出函数的字符串,因此我们可以根据这两个特征来扫描内存,定位 vDSO 的位置
- 考虑到 vDSO 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 vDSO 的地址
Kernel 目录结构
通常一个文件系统映像(core.cpio)解压以后有如下目录/文件:
1 | ➜ core ls |
- 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 | 00000000 list struc ; (sizeof=0x28, mappedto_3) |
1 | 00000000 fd struc ; (sizeof=0xD0, mappedto_6) |
1 | 00000000 input struc ; (sizeof=0x10, mappedto_4) |
1 | 00000000 item struc ; (sizeof=0x20, mappedto_5) |