Linux Kernel Pwn 学习笔记
Linux Kernel Pwn 学习笔记
Basic Intro
内核漏洞利用的特点
简单介绍 Kernel Pwn 和 User-Space Pwn 两者间的区别。
攻击目标
二者最大区别在于攻击目标的不同。
以往我们遇见的用户态Pwn大多以 "命令执行" 为目标编写漏洞利用程序。而内核态Pwn则通常以 "提升权限" 为目标编写漏洞利用程序。在假设攻击者已通过某种手段入侵目标机器的前提下,攻击者利用内核漏洞进一步获取root权限。类似于这样在本地机器上进行权限提升的行为,被称为 LPE (Local Privilege Escalation,本地提权) 。
当然,用户态中也存在可以提升权限的漏洞,但这是因为攻击目标的程序以特权用户身份运行的。在内核漏洞利用中,主要攻击目标有以下两种:
- Linux内核
- 内核模块(Loadable Kernel Modules - LKMs)
Linux内核中的代码(系统调用、文件系统等)以root权限运行,因此当内核本身存在缺陷时,可能导致LPE(本地权限提升)漏洞。
另一类是包含在设备驱动程序等内核模块中的漏洞。设备驱动程序是用于简化用户空间与外部设备(如打印机等)交互而提供的接口。由于设备驱动程序也必须以root权限运行,因此如果存在漏洞,同样可能导致LPE。
攻击方法
在用户态漏洞利用(User-Space Exploit)的情况下,通常通过向目标服务提供输入来触发漏洞利用。因此,使用Python等语言编写漏洞利用程序是主流做法。
而在内核漏洞利用(Kernel Exploit)中,攻击目标是操作系统或驱动程序。由于这些操作属于底层操作,故使用C语言等编写漏洞利用程序是主流做法。当然也可以用Python编写,但由于攻击目标机器(特别是CTF或实验环境中准备的小型Linux系统)上通常不存在Python,因此无法运行的可能性很高。
本文中的漏洞利用程序也将使用C语言编写。详细内容将在后续章节中介绍,我们主要将使用musl-gcc编译器。
资源共享
内核漏洞利用的另一个特点是资源共享。
在用户态中,通常存在一个攻击目标进程,通过利用该进程的漏洞,可以实现获取shell等攻击。然而,Linux内核或设备驱动程序等程序是与系统中所有进程共享的。系统调用可以被任何进程在任意时间调用,而设备驱动程序也无法预知是何人在何时进行操作。因此,在编写运行于内核空间的代码时,必须始终以多线程编程的思路进行设计,否则很容易引入漏洞。
使用可能产生竞争条件的数据时,例如全局变量,必须加锁。
堆区域共享
此外,内核的堆区域具有一个重要特征,即所有驱动程序和内核之间共享。
在用户态漏洞利用中,由于每个程序都有独立的堆,即使某个程序发生堆溢出,其可利用性也依赖于该程序本身。然而,当设备驱动程序发生一次堆溢出时,其他设备驱动程序或Linux内核在堆中分配的周边数据也会被污染。
从攻击者的角度来看,这一特征既有优点也有缺点。优点在于,即使是堆周边的微小漏洞也极有可能导致LPE(内存权限提升)。比如说,Linux内核中存在大量包含函数指针的对象,我们可以利用其中任意一个对象,即可轻易获取RIP控制权。缺点在于,由于影响范围涉及所有程序,我们无法去预测堆的状态。不像用户态程序那么简单,且其堆状态相对于输入是确定的,我们可以实现复杂的堆漏洞利用技术(俗称“House of XXX”等)。然而,在内核中,我们无法预知堆溢出发生的内存块后存在何种数据,也无法确定在Use-after-Free中释放后谁会使用该地址。
也就是说,在内核漏洞利用中,堆喷射(Heap Spray)是关键技术。
内核态漏洞本身与用户态漏洞并无显著差异。例如 Stack Overflow (栈溢出) 和 Use-after-Free (UAF) 等漏洞同样可能存在于内核态中。此外,设备驱动程序的栈中也可以部署 Stack Canary 等安全机制。不过,内核空间也存在特有的漏洞类型,这些内容将在后续章节中介绍。
QEMU的使用
在编写Linux内核漏洞利用程序时,为了调试,需在虚拟机上运行内核。由于QEMU兼容性更强,后续主要将采用QEMU作为研究工具。
QEMU的安装参考:https://www.qemu.org/download/
磁盘映像
在使用QEMU启动虚拟机时,除了Linux内核外,还需要一个作为根目录挂载的磁盘镜像。
磁盘映像通常以文件系统(如ext)的原始二进制格式,或以名为cpio的格式创建和分发。
对于文件系统格式的镜像,可通过mount命令进行挂载后,对其中的文件进行编辑。
1 | mkdir root |
下文的练习中,我们将使用到CTF中常见且更轻量级的cpio格式。
通过以下命令解压cpio文件:
1 | mkdir root |
对文件编辑完成后,通过以下命令可重新打包为cpio文件:
1 | find . -print0 | cpio -o --format=newc --null > ../rootfs_updated.cpio |
如果cpio文件还经过gzip压缩,请根据实际情况解压后再重新压缩。
此外,cpio会附带权限信息,因此在编辑文件系统时,需确保将文件的所有者正确设置为root。上述命令均以root权限执行,因此不存在问题,但若觉得繁琐,可使用--owner=root
选项进行打包。
1 | find . -print0 | cpio -o --format=newc --null --owner=root > ../rootfs_updated.cpio |
通过run.sh
脚本启动QEMU
如果想要以root身份启动,可以将rootfs/etc/init.d/S99pawnyable
中setsid
命令的参数1337改为0,然后再次打包rootfs启动即可
Debug
获取root权限
在本地调试内核漏洞利用程序时,普通用户权限往往存在诸多限制。尤其是在对内核或内核驱动程序的处理过程设置断点时,或是查询泄露地址对应的函数信息时,若没有root权限,将无法获取内核空间的地址信息。
在调试内核漏洞前,首先需要获取root权限。
内核启动后,首先会执行一个程序。该程序的执行路径因系统配置而异,但通常位于/init
或/sbin/init
等目录下。在LK01的rootfs.cpio
中展开后,可找到/init
目录。
1 |
|
此处虽然没有编写特别重要的处理逻辑,但执行了/sbin/init
程序。需注意的是,在CTF等小型环境中,/init
文件中可能直接包含驱动程序安装或shell启动等处理逻辑。实际上,在最后的exec
行之前添加/bin/sh
,即可在内核启动时以root权限启动shell。然而,这样做会导致驱动程序安装等其他必要的初始化处理无法执行,因此本次不对该文件进行修改。
接下来,/sbin/init
最终会执行/etc/init.d/rcS
这个
shell
脚本。该脚本会依次执行/etc/init.d
目录下以S
开头的文件。本次实验中的S99pawnyable
脚本,包含各种初始化处理,但请注意最后的以下行:
1 | setsid cttyhack setuidgid 1337 sh |
这行代码是本次内核环境中在启动时,选择以用户权限启动shell的关键代码。cttyhack
是一个使Ctrl+C等输入操作可用的命令工具。随后通过setuidgid
命令将用户ID和组ID设置为1337,并启动/bin/sh
。需要将此数值修改为0(即root用户)。
1 | setsid cttyhack setuidgid 0 sh |
此外,为了禁用部分安全机制,请将以下行也注释并删除掉:
1 | #echo 2 > /proc/sys/kernel/kptr_restrict # 变更前 |
修改后,请重新将文件打包为cpio格式,然后执行run.sh
脚本,此时应能以root权限使用shell,如下图所示。(打包方法请参阅前文)
将QEMU附加到调试器
QEMU内置了支持gdb调试的功能。通过向QEMU传递-gdb
选项,可以指定协议、主机地址和端口号来建立监听服务。编辑run.sh
脚本,添加如下选项,即可在本地主机的TCP
12345端口上建立gdb调试监听。
1 | -gdb tcp::12345 |
在后续实验中,将默认使用12345端口进行调试,看个人喜好,使用其他端口号也可。
要将gdb调试器附加到目标进程,首先需要通过target
命令设置目标。
1 | pwndbg> target remote localhost:12345 |
若连接成功完成,即表示配置正确。接下来,便可使用常规的gdb命令进行寄存器和内存的读写操作,以及设置断点等操作。内存地址对应的是“设置断点所在上下文中的虚拟地址”。换言之,现在我们可以直接在内核驱动程序或用户空间程序使用的常见地址处下断点。 本次实验的目标架构为x86-64。如果你的gdb无法自动识别调试目标的架构,可通过以下方式手动设置架构。(通常情况下会自动识别)
1 | pwndbg> set arch i386:x86-64:intel |
内核调试
通过procfs中的/proc/kallsyms
文件,可以查看Linux内核中定义的地址和符号表。在后文的KADR节所述,由于安全机制的存在,内核地址可能即使在root权限下也无法查看。
前文获取root权限的操作中已经提及,请务必不要忘记在初始化脚本中注释掉以下行。否则将无法查看内核空间的指针。
1 | #echo 2 > /proc/sys/kernel/kptr_restrict # 变更前 |
现在,让我们来看看kallsyms
的内容。由于内容庞大,我们先用head命令查看开头部分。
如图所示,输出内容依次为:符号地址、地址所在段、符号名称。段标识符中,“T”表示文本段,“D”表示数据段,大写字母表示全局导出的符号。这些字符的详细规范可通过man nm
命令查看。
在上图中,0xffffffff81000000是_stext
这个符号的地址。这对应于内核加载的基地址。
接下来,通过grep命令,我们查找名为commit_creds
的函数地址。我这里显示是0xffffffff8106e390。使用pwndbg在该函数下断点并继续执行。
该函数实际上是在创建新进程等情况下被调用的函数。在终端中执行ls命令等操作时,应当会在断点处触发gdb响应。
第一个参数RDI寄存器中包含内核空间的指针。让我们查看该指针所指向的内存内容。
像这样,在内核空间中也可以像用户空间一样使用gdb命令。虽然可以使用pwndbg等扩展功能,但请注意,只有针对内核空间编写的扩展功能才能正常工作。此外,还有一些内置了内核调试功能的调试器,可根据个人习惯选择使用。
驱动程序调试
接下来我们尝试调试内核模块。
在练习中加载了一个名为vuln的内核模块。已加载模块的列表及其基地址可通过/proc/modules
进行查看。
从这里可以看出,vuln
模块已被加载到0xffffffffc0000000地址处。此外,该模块的源代码和二进制文件位于分发文件的src
目录下。源代码的详细分析将在后续章节中进行,我们先在此模块的函数中设置断点。
在IDA等工具中打开src/vuln.ko
,可以看到若干函数。例如查看module_close
,可发现其相对地址为0x20f。
因此,该函数的起始地址在当前内核中应位于0xffffffffc0000000 + 0x20f处。让我们在此处设置断点。
详细分析将在后续章节中进行,但该模块映射到了/dev/holstein
文件。使用cat
命令可以调用module_close
函数。让我们确认程序会在断点处停止执行。
如需获取驱动程序的符号信息,可以使用add-symbol-file命令。,将本地驱动程序文件作为第一个参数,基地址作为第二个参数传入,系统就会读取符号信息。这样就可以使用函数名来设置断点。
1 | cat /dev/holstein |
stepi
或nexti
等命令也可使用。如此可见,内核空间的调试仅在附加方式上有所不同,可用的命令及调试方法与用户空间完全一致。
Kernel安全机制
在Linux内核中,同样存在多种安全机制,作为缓解内核漏洞利用的措施。与在用户态出现的NX一样,也存在硬件级别的安全机制,因此一些知识可以直接应用于Windows的内核漏洞利用。本文重点讨论内核特有的保护措施。虽然类似Stack Canary的安全机制也存在于设备驱动程序中,但其特殊性不值得特别说明,因此在此不作赘述。关于内核启动参数的详细信息,可参考官方文档。
SMEP (Supervisor Mode Execution Prevention)
内核安全机制中最具代表性的是SMEP和SMAP。
SMEP是一种安全机制,用于禁止在内核空间代码执行过程中突然跳转执行用户空间代码。从概念上来说,它类似于NX(No-eXecute)机制。
SMEP属于缓解机制范畴,其本身并非强力的防御手段。例如,假设攻击者利用内核空间的漏洞获得了RIP(指令指针寄存器)的控制权。如果SMEP机制被禁用,攻击者就可能执行预先部署在用户空间中的shellcode,如下所示:
1 | char *shellcode = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXECUTE, |
然而,当SMEP有效时,尝试执行用户空间中准备的shellcode会引发内核 panic。这使得攻击者即使获取了RIP,也更难实现权限提升。
SMEP可以在qemu运行时通过命令行参数启用。如果在-cpu选项后添加+smep,则SMEP将被启用。
1 | -cpu kvm64,+smep |
通过查看机器内部的/proc/cpuinfo
文件也可以确认。
1 | cat /proc/cpuinfo | grep smep |
SMEP是一种硬件安全机制。当CR4寄存器的第21位被置位时,SMEP将被启用。
SMAP (Supervisor Mode Access Prevention)
虽然从安全角度来看,用户空间无法读写内核空间内存是理所当然的,但实际上,还存在一种名为SMAP(Supervisor
Mode Access
Prevention)的安全机制,该机制阻止内核空间读写用户空间的内存。内核空间需要通过copy_from_user
和
copy_to_user
等专用函数来读写用户空间的数据。
然而,为什么需要禁止高权限的内核空间读写低权限的用户空间的数据呢?
虽然对历史背景不甚了解,但SMAP的益处主要体现在两个方面。
首先是防止栈迁移(Stack Pivot)攻击。
在SMEP的示例中,虽然可以控制RIP寄存器,但无法执行shellcode。然而,由于Linux内核包含大量的机器码,必然存在如下所示的ROP gadget:
1 | mov esp, 0x12345678; ret; |
无论ESP寄存器中的值为何,当调用此ROP
gadget时,RSP寄存器都会被修改为该值。由于这种低地址可以通过用户态的mmap
进行分配,因此即使启用了SMEP,攻击者仅需获取RIP控制权,就能够按如下方式执行ROP链:
1 | void *p = mmap(0x12340000, 0x10000, ...); |
如果启用了SMAP,由于用户空间通过mmap分配的数据(ROP链)无法从内核空间访问,因此在栈迁移的ret指令处会引发内核崩溃。
如此,通过同时启用SMEP和SMAP,可以有效缓解基于ROP的攻击。
SMAP的第二个益处是防止内核编程中容易出现的错误。
这涉及到设备驱动程序等开发人员可能引入的内核特有漏洞。假设驱动程序编写了如下代码(目前无需理解函数定义的含义):
1 | char buffer[0x10]; |
可以想象该代码通过memcpy
对名为buffer
的全局变量进行数据读写操作。
该模块可以通过用户空间按如下方式使用,以存储0x10字节的数据。
1 | int fd = open("/dev/mydevice", O_RDWR); |
对于习惯用户空间编程的开发者而言,这段代码并无异常之处。memcpy的大小是固定的,看起来似乎没有问题。
然而,如果SMAP被禁用,则会允许如下调用方式:
1 | ioctl(fd, 0xdead, 0xffffffffdeadbeef); |
0xffffffffdeadbeef
是一个在用户空间中的无效地址,但假设这个地址存储了Linux内核中的机密数据。在这种情况下,设备驱动程序会
1 | memcpy(buffer, 0xffffffffdeadbeef, 0x10); |
执行上述操作,从而读取机密数据。如本例所示,若不经任何检查,直接使用从用户空间接收的地址进行memcpy
操作,将导致用户空间能够任意读写内核空间的地址。
对于不熟悉内核编程的开发者而言,这是一个极难察觉的漏洞,但由于可以实现任意地址读写(AAR/AAW),其影响极为严重。SMAP正是为了防止此类错误而发挥作用的。
SMAP可以通过qemu执行时的参数进行启用。如下所示,在-cpu
选项中添加+smap
即可启用SMAP。
1 | -cpu kvm64,+smap |
也可以通过查看虚拟机内部的/proc/cpuinfo进行确认。
1 | cat /proc/cpuinfo | grep smap |
SMAP同SMEP一样,属于硬件安全机制。通过设置CR4寄存器的第22位即可启用SMAP。
KASLR / FGKASLR
在用户空间中,存在地址空间布局随机化(ASLR, Address Space Layout Randomization)机制。与此类似,针对Linux内核和设备驱动程序的代码与数据区域地址随机化,也存在着内核地址空间布局随机化(KASLR, Kernel Address Space Layout Randomization)这一缓解机制。
由于内核一旦加载后便不再移动,因此KASLR仅在系统启动时执行一次。若能够泄露Linux内核中任意一个函数或数据的地址,便可推算出基地址。
自2020年以来,出现了被称为函数粒度内核地址空间布局随机化(FGKASLR, Function Granular Kernel Address Space Layout Randomization)的更强化KASLR机制。截至2022年,该机制默认处于禁用状态,其技术原理是对Linux内核中的每个函数地址进行随机化。即使能够泄露Linux内核中某个函数的地址,也无法推算出基地址。
然而,FGKASLR并不对数据段等进行随机化,因此若能泄露数据地址,仍可推算出基地址。虽然无法从基地址推算出特定函数的地址,但在后续将要介绍的特殊攻击向量中仍可加以利用。
需要注意的是,地址在内核空间中是通用的。即使某个设备驱动程序由于KASLR的保护而无法被利用,若其他驱动程序泄露了内核地址,由于地址的通用性,该驱动程序仍可能被成功利用。
KASLR可通过内核启动参数进行禁用。若在QEMU的-append
选项中包含nokaslr
参数,则KASLR将被禁用。
1 | -append "... nokaslr ..." |
KPTI (Kernel Page-Table Isolation)
2018年,在Intel等厂商的CPU中发现了名为 "Meltdown" 的侧信道攻击。此处不对该漏洞进行详细阐述,其本质是一个能够以用户权限读取内核空间内存的严重安全漏洞,可实现KASLR绕过等攻击。近年来,Linux内核为应对Meltdown,启用了内核页表隔离(KPTI, Kernel Page-Table Isolation)机制,该机制的早期名称为KAISER。
众所周知,在虚拟地址向物理地址转换过程中需要使用页表,而该安全机制的核心在于将页表在用户模式和内核模式之间进行分离。由于KPTI本质上是为防范Meltdown而设计的安全机制,因此在常规的内核漏洞利用中并不构成障碍。然而,当在内核空间中执行ROP(Return-Oriented Programming)攻击时,若KPTI处于启用状态,在最终返回用户空间的过程中将会产生问题。具体的解决方案将在内核ROP章节中进行详细说明。
KPTI可通过内核启动参数进行控制。若在QEMU的-append
选项中包含pti=on
参数,则KPTI将被启用;若包含pti=off
或nopti
参数,则将被禁用。
1 | -append "... pti=on ..." |
KPTI的状态也可通过/sys/devices/system/cpu/vulnerabilities/meltdown
进行确认。若显示"Mitigation:
PTI",则表明KPTI已启用。
1 | cat /sys/devices/system/cpu/vulnerabilities/meltdown |
若KPTI处于禁用状态,则显示"Vulnerable"。
由于KPTI本质上是页表切换机制,因此可通过CR3寄存器操作实现用户空间与内核空间之间的切换。在Linux系统中,通过对CR3寄存器执行OR
0x1000操作(即修改PDBR,页目录基址寄存器),可实现从内核空间向用户空间的切换。该操作在swapgs_restore_regs_and_return_to_usermode
函数中定义,具体细节将在实际编写漏洞利用代码的章节中进行详细阐述。
KADR (Kernel Address Display Restriction)
在Linux内核中,可通过/proc/kallsyms
读取函数名称与地址信息。此外,部分设备驱动程序会使用printk
函数等将各种调试信息输出至日志,用户可通过dmesg
命令等查看这些日志。
通过上述机制,Linux 系统能够防止内核空间中的函数、数据、堆等地址信息的泄露。虽然该机制尚无正式名称,但参考文献将其称为内核地址显示限制(KADR, Kernel Address Display Restriction),本文亦采用此命名。
该功能可通过/proc/sys/kernel/kptr_restrict
的值进行配置。当kptr_restrict
为0时,地址显示不受任何限制。当kptr_restrict
为1时,仅向具有CAP_SYSLOG
权限的用户显示地址。当kptr_restrict
为2时,即使用户具有特权级别,内核地址仍被隐藏。
当KADR处于禁用状态时,由于无需进行地址泄露,在初始确认阶段可能会使漏洞利用过程得到简化。
Exploit编译与传输
前文我们已经学习了关于内核启动方法、调试方法以及安全机制等开始进行内核漏洞利用所需的知识。接下来我们将学习如何实际编写漏洞利用程序,以及如何在qemu上运行所编写的漏洞利用程序。
在QEMU上执行
如果在QEMU上编写漏洞利用程序,并进行构建和执行时,每当遇见内核崩溃,都需要重新开始,这将极为繁琐。因此,我们先在本地构建用C语言编写的漏洞利用程序,然后将其传输到QEMU中。
由于每次手动输入此流程的命令较为繁琐,建议预先准备shell脚本等模板。例如,可以准备如下所示的transfer.sh
脚本:
1 |
|
简而言之,通过GCC编译exploit.c并将其添加到cpio中,然后启动QEMU即可。为了避免破坏原有的rootfs.cpio,我们使用了一个名为debugfs.cpio的磁盘,但可以根据个人需要进行修改。
此外,在创建cpio时,如果没有root权限,文件权限会发生变化,因此请注意需要以root权限执行transfer.sh
脚本。
现在,让我们在exploit.c
中添加如下代码并执行transfer.sh
。
1 |
|
然后,会出现以下错误。这是为什么呢?
实际上,这里的镜像使用的并不是标准的libc,而是采用了更紧凑的uClibc库。因此,在编译exploit时所使用的环境(即GCC)默认使用libc库,导致动态链接失败,使得exploit无法正常运行。
我们在qemu上运行漏洞利用程序时,需注意使用静态链接方式。
1 | gcc exploit.c -o exploit -static |
按照上述方式修改并执行后,程序应当能够正常运行。
在远程机器上的执行:musl-gcc的使用
至此,我们已成功地在qemu上执行漏洞利用程序。由于本次发布的环境配置了网络连接功能,因此在需要远程执行的情况下,可以在qemu上使用wget等命令传输漏洞利用程序。
然而,在CTF等部分小型环境中无法使用网络功能。在这种情况下,需要利用busybox中的内置命令从远程传输二进制文件。通常使用base64编码进行传输,但GCC编译的文件大小可能从数百KB到数十MB不等,因此传输耗时极长。文件体积过大的原因是静态链接了外部库(libc)中的函数。
若要使用GCC缩小文件大小,需避免使用libc,并通过系统调用(内联汇编)自行定义read和write等函数。当然,这非常复杂。
因此,许多CTF选手会使用名为musl-gcc的C编译器来实现内核漏洞利用的目的。请从以下链接下载、构建并完成安装:
安装完成后,请按如下方式修改transfer.sh
中的编译部分。musl-gcc的路径请根据实际安装目录进行指定。
1 | /usr/local/musl/bin/musl-gcc exploit.c -o exploit -static |
在作者的开发环境中,之前的 Hello, World 程序使用gcc编译时大小为851KB,使用musl-gcc编译时大小为18KB。如果需要进一步减小程序大小,可以使用strip等工具删除调试符号。
部分头文件(Linux内核相关)在musl-gcc中不存在,因此需要设置包含路径或使用gcc进行编译。在这种情况下,可以通过先进行汇编再编译的方式,既能利用gcc的功能又能控制文件大小。
1
2 gcc -S sample.c -o sample.S
musl-gcc sample.S -o sample.elf
完成上述步骤后,请编写通过远程(nc方式)使用base64传输二进制文件的脚本。该上传工具在CTF比赛中会频繁使用,因此建议创建一个模板并保存以备后用。
1 | from ptrlib import * |
Reference
[1] https://pawnyable.cafe/linux-kernel