写在前面
作为一个学习二进制攻防的初学者,我从栈溢出、格式化字符串等相对简单而经典的技术起步,学习最基础的调试技巧,并第一次写出exploit,拿到flag;当我接触到glibc的堆管理器时,我第一次面对一个相对复杂的系统,并在堆利用上花费了相当长的时间,期间我学习了很多堆利用的技巧(或者说是套路),并且随着linux系统的不断发展(其实主要是ubuntu系统所采用的libc版本的更新),我学会了对不同版本的源码进行比较阅读,并尝试着找出其中的差异以及共同点,尤其是这些改动对exploit有什么具体影响。
有一些朋友在经过了这个阶段之后,会对linux用户态进行进一步的研究,并且逐渐地开始形成自己的自动化工具,也有一些转向虚拟化技术等等方向,也有相当一部分转向了Windows平台。在我看来,CTF(或者说二进制安全)在前几年经历了一个较大的转变——”菜单堆“曾经是CTF比赛中的绝对主角,大家对堆利用的技巧可以说是挖掘得淋漓尽致,而堆利用也确实是CTF中非常精彩的一类技巧,需要精巧地构造、严密地推理,也需要非常丰富的经验。但是在此之后,CTF的题型进入了一个”大爆炸“的阶段,各种新奇的题型层出不穷,涉及到的内容非常多,让人目不暇接。那种比较”经典“的CTF题目可以让人更加快地上手二进制安全(然而即使是这样,二进制安全的学习曲线也非常陡峭),而当下的CTF的风格则更多地拓宽了我们的眼界,并且对各个方向有了一个比较笼统地体验。
人的精力是有限的,我认为自己没有那个能力学习全栈,至少短时间内,我应该把精力放在一个比较小的点上。出于个人兴趣,我更倾向于对一个庞大的系统进行解构,看看底层的机制到底是如何运行的,于是我选择着手学习内核,希望能够回答自己的疑惑。
这篇文章是我给自己挖的大坑,因为我是从头开始学习内核,所以我不奢望能够写出体系,甚至有很多东西在一段时间的学习之后,会觉得当初的想法是错误的。但是我希望能够将学习过程中的东西记录下来,一方面作为备忘,方便以后查阅,另外一方面,希望每一个阶段都进行一次总结,留下一点东西,增加一点仪式感。
编译环境搭建
Linux的各种发行版往往可以通过包管理器直接下载对应内核版本的符号以及调试信息,这在最初级的调试中可以给我们带来很大的方便,但是站在研究的角度,自己有一套比较完善的编译环境是非常必要的。
网上的各种编译内核的教程非常多,我尝试了很多种方式,最终选择了我觉得最顺手最舒服的一套环境,在这里与大家共享。
发行版选择
内核编译与编译环境的版本有很大关系,在实践中,我经常碰到各种奇奇怪怪的报错,内核源码都是从源码仓库中拖下来的源码,.config文件也是正常生成的,但是在编译时就是会报一些错。这些问题的出现原因千奇百怪,在这里我们不能也不会去一一进行记录,但是有一个最好的方式解决——使用相匹配的发行版。打个比方,Ubuntu18.04所使用的内核版本是4.15.0,那么我们编译4.15.0版本的内核就使用Ubuntu18.04作为编译环境,用VMware快速装一个最简单的Ubuntu18.04,然后使用apt安装build-essential包,就几乎能够得到完整的编译环境,再稍微补一些漏掉的包,就可以顺利编译。
但是很多时候我们需要调试的漏洞在较老一些的内核版本,我们要调试应该怎么做呢?一种方式是顺着内核版本去寻找合适的发行版以及发行版的版本,找到相匹配的版本,安装后编译;另外一种方式是在我与师兄进行交流时得到的,我们可以选择将漏洞”移植“到当前版本的源码中,直接编译,既能复现出场景,调试的时候也会逼着我们去亲手写利用。
这两种方式没有优劣,我个人更加倾向于”移植“漏洞的方式,虽然看起来比较麻烦,但是真的省了很多事——至少我们不用频繁地配置开发环境。
但是个人非常不推荐强行用不合适的环境编译内核,除了编译时可能会碰到各种各样的问题,在调试时碰到某些问题,很有可能就是环境不匹配所带来的,而这种时候又很难排查了(毕竟大多数人对内核并没有这么了解)。
distcc
对于PC来说,编译内核相比于编译用户态的玩具程序,实在是一个非常重的任务,而在我们进行调试之前,可能会对不同选项进行编译,也会多次移植不同的漏洞进行编译,会浪费大量的时间。作为一个喜欢折腾的人,我非常乐意去折腾一些能够提高效率的事情(虽然省出来的时间完全赶不上节约的那一点点时间),于是我的目光就瞄向了分布式编译。我们尝试搭建一个分布式编译的环境。
首先,我们需要一个局域网,其中交换机和路由器的带宽和延迟不能太差,千兆以上的以太网是最低要求,在具体测试的时候,wifi的效率极低,不推荐使用。同时局域网最好配有防火墙,因为distcc的协议是否有漏洞还有待考证,换句话说,需要局域网内部是一个可以相互信任的环境。
其次我们需要有一些闲置的机器——其实在局域网内部如果有一些小伙伴的话,完全可以将闲置的计算资源共享出来。当然这些机器最好是linux,windows的分布式编译应该也是可以实现的,但是我没有专门去折腾。同样,机器的主频与内存也并不是关键,关键的还是网速,如果网速太慢会成为全局的瓶颈,就不太推荐了。核数多少无所谓,但是推荐至少双核以上,核数更多更好。
现在我们有了一些在同一个内网中的闲置机器,开始着手配置分布式编译环境了。以下内容都以我使用的实际环境为例(Ubuntu 18.04,内网为172.18.18.0/24)
首先应该安装distcc:
sudo apt-get install distcc distccmon-gnome distcc-pump
distcc是本体,distccmon-gnome是一个监视器,用来观察分布式编译的效果。其中distcc在host和slave上都必须安装,distccmon-gnome只需要在host安装即可(当然如果是局域网内p2p共享的机制下,都安装一下也不错),distcc-pump在编译内核时用不上,但是姑且装上。
然后对于每一台机器都修改配置文件/etc/default/distcc,这个文件是作为distcc的slave提供服务时的配置:
STARTDISTCC="true"
ALLOWDENTS="127.0.0.1 172.18.18.0/24"
LISTENER="0.0.0.0"
NICE="2"
JOBS=""
ZEROCONF="false"
这几个参数需要稍微解释一下,STARTDISTCC这个改成true就行,ALLOWDENTS改成相应的子网和127.0.0.1,即接受本地连接和子网的链接,LISTENER有一些教程建议为空,但是设置为空总是跑不起来,设置成0.0.0.0就可以运行,具体原因没有考证。NICE参数即预期的任务数,这个跟本机的性能有关,一些调度插件会调用这个值用于更加科学的调度,但是咱们这里用不上,因为在host分配任务时会再指定一次,这里就填为物理核数的1-1.5倍,其他的两个参数照填就行。
配置完成后执行:
sudo systemctl restart distcc
就能启动distcc服务。
对于需要调用这些slave机器的host机器,需要再配置另外一个文件/etc/distcc/hosts:
# +zeroconf
172.18.18.0:3632/32
172.18.18.1:3632/16
# ....
这里首先把”+zeroconfig”注释掉,然后添加上每一台机器的ip,端口,任务数,例如第一行就是ip为172.18.18.0的机器的3632端口(这个是distcc服务的默认端口),并且一次可以接受36个任务(这个机器是一台服务器,物理36核,但是普通的闲置机器没有这么多核,酌情考虑)。同时有教程给出的配置是这样的:
172.18.18.0:3632/32,cpp,lzo
经过实际操作,发现会出现一些问题,会频繁地提示头文件找不到,然后回退到本机进行编译,效率极低,具体原因就不再纠结,直接删去”,cpp,lzo”,毕竟linux内核是用C语言编写的(虽然处处都是面向对象的思想)。
接下来就可以使用distcc对内核进行编译了,这里对于内核编译的细节就不再赘述,只提一点点大致步骤:
make menuconfig
这里对内核进行设置,更多的时候,我们选择直接使用目标机发行版的.config文件,这个在后面的部分详细解释。这里我们只需要知道,make menuconfig本质上就是提供.config文件。
distcc -j
这条指令其实不是很必要,返回的值就是前面/etc/distcc/hosts文件中设置的任务数之和。例如我们之前设置的48。
make -j48 CC="distcc gcc"
这里的-j后的数字与我们前一条指令的返回值相同,CC=”distcc gcc”指定使用distcc进行分布式编译。
这时,我们只需要打开distcc-mon,就可以观察分布式编译的情形了。
接下来还需要补充一点,就是有些时候会使用ccache做缓存加快编译速度,使用方式较为简单,读者可以自行了解。ccache与distcc可以同时使用。
调试环境搭建
内核题目中常用的方式
在CTF题目中,我们一般拿到的东西是一个qemu启动脚本,这个脚本通常是这样:
#!/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 smep"
-enable-kvm
-cpu host,+smep
-s
-nographic
这里bzImage就是压缩的内核镜像,core.cpio为文件系统,-append选项后的字符串,其实相当于grub引导内核时附加的命令行参数。
这种调试方式最大的问题在于,采用的是ramdisk,这种文件系统为易失性存储,在正常的发行版中,ramdisk一般在启动后就会挂载我们平常使用的文件系统,例如机械硬盘、固态硬盘等。这其中的过程我也还没有了解得太清楚。
在打远端环境时,我们一般会选择写一个脚本使用base64编码将我们的exp上传(内核题目中的exp就是一个binary),但是在本地调试时这么做不是很干净,因此我一般会直接修改ramdisk的内容,将我们的内容打包进文件系统。
首先解压文件系统,例如我们的文件路径为~/core.cpio
mkdir initrd
cd initrd
cpio -i < ../core.cpio
此时,本文件夹下就是解压后的内容:
bin core.ko etc gen_cpio.sh init init.root init.usr lib lib64 linuxrc root sbin usr vmlinux
不难发现这就是根目录的内容,我们可以对它进行修改。
修改后需要重新打包:
cd ~/inird
find . | cpio --create --format=newc > ../core.cpio
此时就会得到一个未经压缩的、新的文件,这个文件可以被qemu直接使用。
每次都要重新打包一次显然很麻烦,因此我放一份makefile供读者参考:
usr:go
mv go core/bin/go
cp start_smep.sh start.sh
cd core && cp init.usr init
cd core && find . | cpio --create --format=newc > ../core.cpio
./start.sh
root:go
mv go core/bin/go
cp start_smep.sh start.sh
cd core && cp init.root init
cd core && find . | cpio --create --format=newc > ../core.cpio
./start.sh
go:go.c
gcc -z execstack -o go go.c -static -masm=intel
附上两份启动脚本:
start_smep.sh
#!/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 smep"
-enable-kvm
-cpu host,+smep
-s
-nographic
start_pure.sh
#!/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
-nographic
Makefile的大致意思就是编译出我们的exp,打包进文件系统,并且选择合适的启动脚本启动。
这一份环境是我在调我学习的第一个内核题时搭建的,其他的内容无非就是用gdb直接连接,在网上都有详细教程,不再赘述。
双机调试
上述方法在调试gameworld的漏洞时比较好用,应付ctf题目没有问题。ctf的题目很多时候是一个单独的内核模块中存在出题人预先埋好的漏洞,但是实际的内核漏洞调试时,我们不得不去面对庞大的内核本身,对于vmlinux这个二进制文件中(即bzImage)的漏洞,我们使用上述方式,就会面对一个巨大的二进制文件,这时我们就会无比强烈地想要进行源码调试——linux内核使用ida打开就会解析很久,直接调试binary无比困难,我本人水平低下是一个问题,但是有更有力的手段,谁会拒绝呢?
源码调试的条件不难达到,我们的内核都是自己编译的,完全可以保留调试信息,于是我们将内核使用前一种方式启动,尝试去进行源码的调试和跟进,于是发现在gdb中,行号到处乱跳!这种问题早在我进行libc源码调试的时候就已经遇到过,所以我很肯定是因为编译器优化而导致的。当时在libc调试时,我选择使用-Og(即专门为调试优化的选项)进行编译,解决了这个问题。但是我花了两天的时间查找资料,发现linux内核仅仅为-O2和-Os进行了优化,并没有-Og的优化选项(当然已经有issue提到了这一点,但是linux主要分支还并不支持,强烈建议加入-Og选项)。我尝试强行将Makefile中的-O2改为-Og,无法通过编译。在查阅资料后发现,linux的很多底层代码使用汇编编写,都是在假定-O2优化的条件下量身定制,因此更改优化选项会造成错误,因此这个问题暂时无解。
行号与汇编不能准确对应会对我们造成什么影响呢?比如当我想要跟踪一个执行路径时,我很难确定我现在到底在哪里——对漏洞的认知在源码层面,但是调试的时候在二进制层面。我发现内核开发者们经常使用的一个工具是SystemTap,这个工具可以很轻松地对内核函数进行hook,并且在定制下可以简单地实现很多功能。虽然本质是一个内核模块,但是极大地提升了效率。
SystemTap的使用需要一个内核一次编译,对于我们之前构建的简单的文件系统,我们再去构建一整套工具链——尤其是gcc,这是一个非常浩大的工程,因此我们只能另想途径,因此我选择直接使用发行版调试,内核由我更换,但是工具链直接沿用发行版的工具链。
以上是我最终选择双机调试的心路历程,拐弯抹角曲线救国,之前的环境勉强一下也能用,但是折腾是人类进步的阶梯(划掉),我们就折腾到底。
vmware双机调试
最先开始我查找到的方式就是使用vmware提供的串口功能进行双机调试。因此首先我们应该配置一个串口。
这种方式的配置五花八门,网上的教程非常多,总之,我们只需要保证调试机和被调试机有一个串口是相连的即可(我折腾环境换了好几次系统,windows调linux,linux调linux,虚拟机调虚拟机、物理机调虚拟机),串口连起来是第一步。怎么配置串口读者自行了解。
配置完串口之后,我们假定已经成功地让被调试机的串口(假定为/dev/ttyS0)与被调试机相连(假定也是/dev/ttyS0),此时拓扑图如下:
此时我们还需要改变一下被调试机的启动选项——设为在串口等待kgdb连接。大致做法就是在正常的linux启动项后添加一点点内容,我们查看/boot/grub/grub.cfg文件,找到如下内容:
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-4f6758f8-5ec3-4b2e-a4b5-603885c6384e' {
recordfail
load_video
gfxmode $linux_gfx_mode
insmod gzio
if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
insmod part_gpt
insmod ext2
if [ x$feature_platform_search_hint = xy ]; then
search --no-floppy --fs-uuid --set=root 4f6758f8-5ec3-4b2e-a4b5-603885c6384e
else
search --no-floppy --fs-uuid --set=root 4f6758f8-5ec3-4b2e-a4b5-603885c6384e
fi
linux /boot/vmlinuz-5.0.0-29-generic root=UUID=4f6758f8-5ec3-4b2e-a4b5-603885c6384e ro quiet splash $vt_handoff
initrd /boot/initrd.img-5.0.0-29-generic
}
注意这是第一次出现menuentry,也就是我们默认启动的那个grub选项,在
linux /boot/vmlinuz-5.0.0-29-generic root=UUID=4f6758f8-5ec3-4b2e-a4b5-603885c6384e ro quiet splash $vt_handoff
这一行之后添加内容,变为:
linux /boot/vmlinuz-5.0.0-29-generic root=UUID=4f6758f8-5ec3-4b2e-a4b5-603885c6384e ro quiet splash $vt_handoff kgdbwait kgdb8250=io,03f8,ttyS0,115200,4 kgdboc=ttyS0,115200 kgdbcon nokaslr
注意,这一份文件在每次我们替换内核后都需要重新更新一次,因此我写了一份脚本自动化地完成这件事情:
#!/usr/bin/python
import sys
import os
def ustrip(s):
res = ''
for c in s:
if c == ' ' or c == 't':
continue
res += c
return res
class important_info:
def __init__(self, entryline = -1, startupline = -1):
self._entryline = entryline
self._startupline = startupline
def parse_menuentry(path):
content = ''
with open(path) as f:
content += f.read()
contentlist = content.split('n')
infolist = []
for i in range(len(contentlist)):
if 'menuentry' in contentlist[i] and 'class' in contentlist[i]:
# print contentlist[i]
infolist.append(important_info(i))
if 'linux' in contentlist[i] and contentlist[i].split()[0] == 'linux':
# print contentlist[i]
infolist[-1]._startupline = i
return contentlist, infolist
def append_entry(contentlist, infolist, idx, baud = 115200):
print '[+] Editing entry {0}'.format(contentlist[infolist[idx]._entryline].split()[1])
if 'kgdb' in contentlist[infolist[idx]._startupline]:
print '[!] Kgdb have been enabled yet.'
return
else:
contentlist[infolist[idx]._startupline] += ' quiet kgdbwait kgdb8250=io,03f8,ttyS0,{0},4 kgdboc=ttyS0,{0} kgdbcon nokaslr'.format(baud)
# print contentlist[infolist[idx]._startupline]
def go(path, output = None):
contentlist, infolist = parse_menuentry(path)
append_entry(contentlist, infolist, 0)
res = ''
for l in contentlist:
if l == '':
continue
res += l + 'n'
if output == None:
output = path
with open(output, 'w') as f:
f.write(res)
if __name__ == '__main__':
os.system('cp /boot/grub/grub.cfg /boot/grub/grub.cfg.bak')
go('/boot/grub/grub.cfg')
此时我们启动机器,如果之前的操作都没有错误的话,此时应该会在启动时卡住,等待gdb的连接,此时我们在调试机上启动gdb,执行如下命令
> file vmlinux
> target remote /dev/ttyS0
连接上被调试机。但是注意,一旦在gdb中continue,不能使用ctrl+c挂起被调试机的内核,而需要在被调试机上手动地触发,被调试机上地命令如下:
sudo su && echo g > "/proc/sysrq-trigger"
把它写成脚本丢进/usr/bin来干这件事吧。
此时我们在被调试机中编译内核并安装内核,用脚本修改grub参数,就能够开始调试了。
似乎这里我们可以比较完美地进行内核调试了,但是这种方法比较麻烦的地方在于:
- 串口慢
- 需要在被调试机手动挂起内核
我们要停止折腾了吗?不,我们继续折腾。
qemu双机调试
回想之前我们使用qemu进行调试,qemu最大的优势在于,有一个比较好用的调试接口,gdb挂上去之后可以像调试二进制程序一样调试内核,而且可以多次连接——之前双机调试的时候,一不小心gdb断开了,全都白给。最大的劣势在于,我们只有一个简陋的文件系统,难以构建完备的编译工具链(虽然折腾到这里,我已经不太在意这一点了,就是折腾)。
那么作为目前世界上最强的开源硬件模拟器,我们完全可以模拟一台真实的电脑,在上面装一个发行版!这里我们选择Ubuntu 18.04 Server版本,没有图形界面的额外开销,用起来还是非常流畅的。
我们使用构建好的发行版,主要是借用发行版的完备的仓库,例如apt。那么联网就成了我们现在最迫切的需求,联网的qemu选项为:
-netdev tap,id=virtnet0,script=${thispath}/ifstart.sh,downscript=${thispath}/ifdown.sh
-device e1000,netdev=virtnet0,mac=fa:06:8a:4a:18:06
-netdev与-device选项在qemu的文档中已经有详细的说明,不想去读的也可以直接拿来用。这里我手动指定了网卡的mac地址,此时的qemu工作在桥接状态,与物理机处于同一子网,我的网关会静态地为这个mac地址分配ip,保证每次都能连接同一个ip——这是我的习惯,我调试时重度依赖ssh。
这个启动选项制定了一个启动脚本,一个结束脚本,它们用于对网卡的选项进行更改,这两个脚本依赖于bridge-utils工具,可以用apt安装,分别如下:
ifstart.sh
#!/bin/sh
echo ""
echo ""
echo "----------------[!] kjdy-QUMU Netdev Tap Startup Script----------------"
echo "[+] Generating br0 and linking enp24s0."
brctl addbr br0
ip addr flush dev enp24s0
brctl addif br0 enp24s0
ifconfig enp24s0 up
ifconfig br0 up
echo "[.] Done."
echo "[+] Distributing ip to br0."
dhclient -q -v br0
echo "Done."
echo "[+] Editing iptables.(docker may drop the forward packets)"
iptables -A FORWARD -p all -i br0 -j ACCEPT
echo "[.] Done."
echo "[+] Activating $1 and link it to bridge."
brctl addif br0 $1
ip link set dev $1 up
echo "[.] Done."
echo "----------------[.] kjdy-QUMU Netdev Tap Startup Script----------------"
echo ""
echo ""
ifdown.sh
#!/bin/sh
echo ""
echo ""
echo "----------------[!] kjdy-QUMU Netdev Tap Down Script----------------"
echo "[-] Turning down $1."
brctl delif br0 $1
ip link set dev $1 down
ip link delete $1
echo "[.] Done."
echo "[-] Deleting br0 and unlinking enp24s0."
brctl delif br0 enp24s0
ip link set dev br0 down
brctl delbr br0
echo "[.] Done."
echo "[-] Restoring iptables."
iptables -D FORWARD -p all -i br0 -j ACCEPT
echo "[.] Done."
echo "[+] Re-Alloc ip to enp24s0."
ip addr flush dev enp24s0
dhclient -q -v enp24s0
echo "[.] Done."
echo "----------------[.] kjdy-QUMU Netdev Tap Down Script----------------"
echo ""
echo ""
大意是添加了一个网桥,将以太网卡连接在了网桥上,并将qemu的虚拟网卡也连在了这个网桥上,达到了一个对等的逻辑关系。
其中对iptables有操作,主要是针对docker的优化——docker会修改iptables,安装docker后iptables会drop我们发往qemu网卡的包(这个问题比较扯,查了很多资料才确定)。
安装与启动有两套脚本,我都放在了我的github上(这个repo也许会更新也许不会):
https://github.com/kongjiadongyuan/How2LearnKernel/tree/master/ubuntu
使用start_install脚本安装,安装完成后,每次都使用start脚本启动。
这里使用9p文件系统将被调试机与调试机的文件夹进行共享,在被调试机中加载9p的几个模块(9p,9p_virtio,9p_fs等),并且挂载文件系统即可:
mkdir /home/kongjiadongyuan/linux/linux-4.15.0
mount -t 9p -o trans=virtio,version=9p2000.L common /home/kongjiadongyuan/linux/linux-4.15.0
注意,这里/home/kongjiadongyuan/linux/linux-4.15.0为挂载点,common为脚本中指定的device的id,这里要对应起来,否则不能使用。
这里的目的是为了在调试机与被调试机之间共享一份linux编译文件夹,省得来回搬运——linux编译产生的中间文件非常多。
回归初心,我们在这时可以编译systemtap了,下载源码后直接编译即可,中间可能会碰到找不到sys目录下的头文件的问题,用如下命令可以解决:
ln -s /usr/include/x86_64-linux-gnu/ /usr/include/sys
总结
工欲善其事必先利其器,绕了一个大圈子,终于得到了一个让自己比较满意的调试环境。这篇文章极力避免成为网上的很多教程那样的事无巨细的清单,而是着重于提供一个配置的思路,更加具体的细节可以去查看提供的脚本,这里面私货很多,难免出现谬误,欢迎探讨。