转自绿盟科技博客
这几天看了一下linux内核提权的一个漏洞,里面涉及到了驱动程序漏洞及驱动调试内容,由于各类linux操作系统版本的不同,如果不能在自己机器上亲自调试驱动程序,可以说即使给了漏洞利用的POC源码也根本无法成功利用。因为内核漏洞的利用涉及到指令集的POC构造,不同内核版本模块加载指令地址不同,导致即使有POC也根本无法利用,只有在自己系统中亲自调试,才能做出相应的修改,达到内核漏洞利用的效果。这样就要求我们对linux内核驱动的调试过程,调试方法有个深入了解。经过两天的各处查找,配置,调试,终于弄清楚了内核调试的基本方法,为之后内核调试,漏洞分析提供技术支持。
目标
在驱动程序开发或是内核漏洞分析过程中经常需要对内核模块进行调试。在通常情下对于驱动程序的调试是利用最直接的方式即打印调试的方式,在驱动程序中通过printk,加入调试信息。同时通过动态加载模块的方式,即可实现对驱动的动态调试,这也是最简单的调试方式。而对于内核漏洞的分析,由于linux系统是开源项目,所有不管对于应用程序的调试还是对内核驱动程序的调试都可以通过查看源码找到漏洞的触发点。
那如果想像调试用户态应用程序一样对内核驱动做动态的源码即调试或是更进一步的对驱动程序进行汇编级调试或是开发内核漏洞利用程序那又该怎么办呢?也许有人会说一般没有必要进行汇编级调试。但是在对内核漏洞利用过程中经常需要调试内核驱动程序,并且需要对内核驱动进行汇编指令级单步跟踪,这样才能确定程序的走向。或是我们需要构造特殊的指令块来完成某项功能。这样就对我们调试内核带来的新的挑战。
那内核里面又是怎么实现的呢,又该如果能够去跟进内核内部去调试呢?
本文就是要解决这个问题:在动态汇编调试用户态应用程序的同时,能够跟进应用程序的的系统调用接口,直接源码级或是汇编级的调试(如果没有符号表)调试驱动程序。
本文演示的程序是通过一个应用程序demo,调用自己写的一个驱动程序接口,通过在调试应用程序的是时候能够跟进调试驱动程序。搭建这样的环境我们使用了vmware虚拟机,该虚拟机使用普遍,安装简单。为了能够调试程序,需要一个目标机和一个客户机。
目标机是用来安装驱动程序,同时运行应用程序,应用程序会调用驱动程序中的接口。同时目标机自己调试应用程序(用户态使用GDB调试)。
客户机是用来连接客户机,同时在客户机中调试目标机中的驱动程序(使用GDB调试)。
需要注意几点:
- 为了能够能够调试目标机的驱动程序,要求目标机需要支持KGDB调试。
- 为了能让客户机与目标机通信,我们在vmware中配置这两台机器通过串口通信调试。
- 客户机如果要支持驱动的源码级调试需要将驱动程序的符号表加载的客户机的调试器中。
- 本文用的vmware虚拟机需要将目标机和客户机同时安装在虚拟机中。
- 由于在文章中会包含客户机和目标机的操作过程和调试过程,本文中会使用绿框表示客户机相关操作,使用红框表示目标机上的相关操作。
1. 基础环境搭建
下图是一个调试应该程序与驱动程序的一个框图,两个操作系统都安装在虚拟机中,一个客户机,一个目标机,客户机通过串口调试目标机中的驱动程序。
1.1 资源环境
Vmware虚拟机
Ubuntu操作系统
Ubuntu源码
1.2 安装vmware虚拟机及ubuntu操作系统
这里使用的宿主机win7操作系统,所以直接在网上下载了VMWARE WORKSTATION 10.0.4版本的虚拟机。
安装虚拟机过程就省略。。。。
在安装完虚拟机后需要在虚拟机中安装好ubuntu操作系统,在虚拟机中安装ubuntu操作系统也省略。。。。
上面说到目标机和客户机两个操作系统,而这里我们在虚拟机里只安装了一个操作系统,先别急,后面就知道了。接下来从官网下载一个内核源码,如下图:
这里下载的是X86-64版本的3.2.86版本(上面忘了说了,之所以用X86-64版本是因为上面装的ubuntu操作系统也是这个架构)。
下载完成后将该源码拷贝到ubuntu虚拟机中,并解压,如下图所示:
2. 编译内核
为了能够调试驱动程序,需要让目标机的操作系统支持调试模式,这样就需要重新编译内核,让目标机支持调试模式。
2.1 配置内核参数
首先进入目录linux-3.2.86,之后执行命令make menconfig,如下图所示:
就会出现如下图形界面:
这里每一项就是在编译内核之前需要选择的条目,可以根据需要来编译不同的条目。为了能够让操作系统支持内核级调试,需要打开KGDB调试开关参数,根据ubuntu版本不同该调试开关的位置有所不同。如下图所示:
为了能够支持KGDB调试上面这几项都需要选择上,在内核驱动调试过程中需要在驱动中下断点,这样就需要在内核地址上进行写操作,所以需要将下面这个选项去掉:
内核参数设置完成后,保持设置的config文件,默认保存文件名为.config文件,保持在当前目录下。为了确保我们对内核写保护已经禁止,在开始编译内核之前,再检查一次config文件,打开.config文件,如下图:
打开之后直接搜索“RO”找到下面两项:
确保红框中的两项是注释状态,如果不是注释状态可以直接在这里修改将他们的值改成N。这样基本上就完成了内核参数的配置。
2.2 编译内核
保持好设置后,编译内核:
make
make bzImage
make modules
make modules_install
make install
此时在当前目录下产生新的内核模块vmlinux:
同时在/boot/目录下产生新的内核系统,符号表等信息,如下图:
红框是新文件,绿框是之前老的内核文件。
内核更新到了最新的版本,说明我们的内核已经编译成功。
3. 配置双机通信
驱动调试需要目标机与客户机两台虚拟机。在这里用了一个取巧的方式,不是安装两台虚拟机,而是直接将上面编译出来的ubuntu操作系统直接在vmware克隆一份,这样就有了两台ubuntu虚拟机,一台作为目标机,一台作为客户机,当然这两个系统都支持了kgdb调试模式,都使用了相同的内核。
3.1 双机串口通信
所以,将编译好的ubuntu关闭,然后利用vmware克隆功能,克隆一份(如果不关闭虚拟机没法克隆)。
下面的操作会用绿色框表示客户机,使用红色框表示目标机。
- 配置客户机,两台机器采用串口通信。配置客户机串口,如下图:
需要注意的是,在安装虚拟机的时候,会默认安装上并口,而没有串口,此时需要我们先将并口删除,然后再添加串口,并安装图中显示配置串口。 - 配置目标机,目标机作为服务端也要配置,如下图所示:
对于目标机端也同样需要注意,如果有并口,需要先删除并口,再添加一个串口,并进行相应配置。
3.2 验证串口通信配置
在配置完串口后,可以验证一下配置的串口是否起作用。启动客户机和目标机,
在一端向串口输入数据,在另一端接受数据,这里我们选择目标机输入数据,客户机接收数据。如下图所示:
当然此过程是先让客户机打开接收,再让目标机发送,这样客户机才能接收到数据。从上图可以看出,串口正常传输数据没有问题。
4. 配置串口调试
上面的配置完成后,相当于在两台虚拟机之间连了一根串口线,如果想让两个系统之间通过串口线调试,还需要配置串口调试模式。
4.1 客户机调试配置
采用root模式登陆客户机,修改grub启动配置文件。如下图:
黄框中的内容表示要串口连接,当然加在下面一项的”GRUB_COMLINE_LINUX”中也可以。
配置完后需要更新一下grub,让配置生效:
update-grub
这样grub就完成了更新,重启设备后就会加载串口通信。Grub更新配置后,会自动修改/boot/grub/grub.cfg文件,如下图所示:
记住,直接修改grub.cfg不是不行,而是如果/etc/default/grub更新后,如果运行update-grub就又会更新一下grub.cfg,导致直接在grub.cfg中的配置失效(当然只改变/etc/default/grub,而没有运行Update-grub命令就不是使grub.cfg失效)。
这样客户端口的串口通讯模块就配置好了。
4.2 目标机调试配置
配置目标机和配置客户机基本一致,不过也有一些差别。
启动客户机系统,采用root模式登陆系统,修改grub启动配置文件。如下图:
在这里与客户机配置比多加了一个参数text,这个参数的意思是系统启动后以text界面而不是图形界面显示(当然这个不是必须的,但是作为目标机我们进入系统直接用text界面界面就可以了)。
配置完后需要更新一下grub,让配置生效:
update-grub
更新完成后,自动修改/boot/grub/grub.cfg文件,如下图所示:
图中已经更改了配置。从上图看又多出来了一个“Ubuntu,with Linux 3.2.86—wait”选项,这个选项是从上面那个选项复制下来,同时在里面又添加了新的“kgdbwait”标签,添加一个新的选项就是在grub启动时多了一个启动项,添加”kgdbwait参数就为了在系统刚启动时就可以进入调试模式。而对于上面一个启动选项没有添加该参数,所以在系统启动后才能进行调试。这样目标机就支持了两种调试,一种是系统刚开始启动时调试内核,一种是系统启动后调试内核或驱动。
到目前为止我们的调试模式已经建立好了。
5. GDB双机调试环境
重新启动目标机进入grub,会看到多出了的启动选项如下图所示。
如果我们选择”Ubuntu,with Linux 3.2.86—wait”启动模式,系统就会进入如下图模式,等待远程调试器的连接,也就是客户机上的GDB调试器的连接。
到这里说明对目标机的配置没有问题,可以启动客户机去连接调试。
由于是调试自己写的应用程序和驱动程序,而驱动程序又是通过动态加载。所以没必要在系统刚启动时就对系统调试。只要在系统启动后,动态加载模块后,再调试模块也不晚。所以重启目标机,进入“Ubuntu,with Linux 3.2.86”启动模式,直接启动系统。
5.1驱动代码及编译
在调试代码之前,先看一下我们应用程序和驱动所实现的功能。在目标机上实现了程序代码。
首先看看驱动程序代码,由于本文只是讲解驱动程序调试,所以对驱动程序代码只是实现了一个小的功能,驱动程序中的文件打开功能,如下图:
代码很简单,就是生成一个驱动程序drv1,该驱动程序实现一个接口,就是打开功能,在应用程序中调用该驱动的系统调用时就会打印两条信息“device opened”“device opened return!”。如果驱动加载成功会显示“drv1 init……”,如果驱动加载失败会显示“Driver unloaded”.
编写Makefile文件编译该驱动程序:
直接编译生产drv.ko文件:
5.2 应用程序及编译
最后看一下应用程序如下图:
应用程序只是调用了驱动程序的打开功能,我们关心的是如何搭建调试环境,所以demo没有提供更多的代码。驱动程序默认加载路径为/dev/下面,所以应用程序打开的文件为/dev/drv1,在系统调用前后打印信息,能够直观感受打印是否成功。
编译该应用程序:
gcc drv1_app.c –o drv1_app
5.3 验证驱动程序
在调试驱动程序之前,先要验证驱动程序,保证应用程序能够正确调用驱动程序的接口。
在应用程序系统调用之前,要保证驱动模块已经被加载到了内核。
从上面的驱动打印可以看出在驱动加载完成后会先打印”drv1 init…..”,那接下来加载驱动程序并验证该驱动程序是否加载成功:
如上图,先查看dmesg信息,在驱动为加载前没有任何信息输出,在加载模块后,打印出来了“/drv1 init….”,表明驱动已经加载成功,之后我们运行应用程序。
通过应用程序代码我们已经知道了,如果能在系统调用前后打印出信息,则系统调用接口open已经被执行,从下图可知信息被正确打印。
在驱动内部,对open函数的调用也会打印相关信息,包括卸载模块时的打印信息,如下图所示:
由此可知,我们的驱动程序和应用程序都没有问题。
6. 调试应用程序及驱动程序调用接口
接下来就到了最关键的一步,就是如何利用现有的环境去调试应用程序的同时也能够调试驱动程序。
6.1 配置驱动调试
重新启动目标机,启动完成后加载驱动程序(具体过程已经演示了一遍了)。
启动客户机,由于我们客户机和目标机是同一版本系统。所以内核模块也一致。进入包含内核模块的路径,gdb调试内核模块,如下图所示。
同时设置串口信息(这一步不是必须的,因为我们的客户机启动时已经设置了串口调试,如果客户机启动时没有设置串口调试可以在这里设置,但是目标机必须在系统启动时设置串口调试模式,当然也不是必须的,够绕吧,除非你会在目标机启动进入系统后设置串口,否则你就要在目标机启动时设置串口调试,目标机启动进入系统后怎么设置串口调试,自己网上查去我也没查,我也不知道),设置串口调试,如下图:
设置完成后,就需要先打开目标机调试模式(要不然客户机调试谁呀),打开目标机调试模式,如下图所示:
输入该命令后回车,目标机就会进入假死状态,目标机没有任何反应,这是正常现象,这是在等待调试器的链接。我们这个时候就要返回到客户机中启动串口调试如下图:
客户机启动调试后就会出现如上信息。此时客户机与目标机的调试环境就建立成功了。
这样就可以进行驱动调试了,为了能够查看到驱动中的符号信息,需要在客户机中加载符号文件。客户机加载符号文件如下:
首先因为要调试的是目标机的驱动程序,需要知道在目标机中模块的加载地址。
由于目前目标机还处在调试模式假死状态,所以目标机还不能做任何动作。为了能够让目标机继续运行,需要在客户机中让目标机可以继续运行,这样在客户机的GDB调试中,就可以像调试普通应用程序一样让目标级继续运行,如下图:
此时目标机就恢复到了运行模式,如下图所示:
此时就可以查看驱动的加载基地址。如下图两种查看方式:
两种方式均可查到加载基地址。
此时就可以在客户机中加载符号表,由于目前目标机运行,所以需要先将目标机断下来才能让客户机处于调试模式,所以目标机再一次下断下来:
这样目标机就又处于假死状态,等待客户机的调试,同时客户机又进入调试模式,如下图:
加载符号文件同时为驱动的Open函数设置断点,如下图所示:
需要注意的是不管是图中的驱动文件还是驱动程序源文件都应该是位于客户机中(所以,前面忘了介绍,要把目标机编译好的驱动文件及源文件,拷贝一份给客户机),需要注意的是客户机中拷贝过来的驱动文件可以放置在任何位置,而源文件的位置应该和目标机中源文件位置保持一致,这样才能进行源码级调试(上面黄框中源代码的位置表示是在目标机中的位置,所以客户机驱动的源码也应该放在这样的位置,这样驱动调试器才能找到符号位置。如果对驱动程序不进行源码调试,只进行汇编调试就不需要源文件),而加载地址是目标机的驱动加载基地址,这一点千万别搞错了。
在给device_open函数下断点后断点又指向了目标机的驱动文件源文件drv1.c,如上图。
到此为止驱动加载完成,符号文件加载完成。就可以在目标机启动应用程序并调试,同时在客户机上调试驱动程序。
客户机让调试机继续运行,如下图:
6.2调试应用程序
此时在目标机已经解除假死状态,可以在目标机中启动gdb调试应用程序。如下图。
应用程序的调试应该不用多说。我们在系统调用处下一个断点,运行到系统调用为止:
单步运行到open函数调用处0x4005ac:
运行到0x4005ac处有就进入了假死状态,程序不能再运行,这是因为已经在客户机上将目标机的驱动程序中的device_open函数设置了断点。
6.3 调试驱动程序
返回到客户机,客户机已经处于调试模式如下图:
这就进入了驱动程序的调试模式。和调试普通应用程序一样对驱动就可以进行调试,由于前面已经加载了符号文件,可以查看驱动源码,并单步调试,如下图:
从图中可以现在驱动程序不但可以查看源码还可以源码级调试,汇编级调试。最后在用continue让驱动运行起来。这样在目标机上应用程序又可以运行,知道应用程序成功退出,如图:
那对于驱动程序调试器与应用程序调试模式正式结束形式如下:
- 让目标机处于被调试模式即运行echo g >/proc/sysrq-trigger(如果GDB刚好也正在调试目标机中的应用程序则为
shell echo g >/proc/sysrq-trigger) - 此时客户机处于调试模式,在调试模式中直接退出
quit.此时客户机就推出了调试模式。 - 目标机也就推出了内核调试模式显示内容为:
驱动程序调试结束。
至此应用程序和驱动程序的调试环境和调试方式就介绍完毕。
备注
- 如果在用gdb调试应用程序过程中,突然想回到驱动调试模式,可以在应用程序中直接输入
shell echo g > /proc/sysrq-trigger
,这样应用程序调试器就处于假死状态,内核调试器的GDB就可以调试了。如果要回复到应用程序调试器gdb中,那就直接在内核调试器的GDB使用continue命令即可,回到应用程序调试器。 - 在调试包含多个函数或是循环函数的驱动程序或是应用程序时,还可以使用finish命令来直接运行到函数结束,方便调试。