首先是pwncollege的学习笔记:
介绍
内核分为三种,linux内核属于宏内核,即内核本质是一个大的二进制文件加载在内存中来处理各种的系统调用。没有内核代码运行在用户态(windows就是一个混合内核,既有一个大的ring 0文件,也有部分代码运行在用户态)。有些汇编代码和寄存器只能在Ring 0被使用。内核永远加载在最高地址处,syscall并不改变内存的映射,只是最高地址的虚拟地址映射的具体部分需要Ring 0才能访问。
调用syscall
本质是一个跳转加切换权限的过程。(ring 3到ring 0)具体来讲是将当前的用户态地址存入rcx后,切换权限至ring 0然后跳转至MSR_LSTAR
寄存器指向的系统调用处理代码。下面是syscall相关的伪代码:
1 | IF (CS.L ≠ 1 ) or (IA32_EFER.LMA ≠ 1) or (IA32_EFER.SCE ≠ 1) |
针对内核的攻击包括用户态的权限提升,rootkit,外部设备攻击(可编程u盘)等等。
环境配置
参考pwncollege的脚本(github pwncollege/pwnkernel)。自己下个内核然后修改一下它的sh脚本。
busybox在linux内核6.8及以上编译不了,我的arch目前是6.9,麻了。linux 6.8即以上的版本在内核中移除了一些东西导致busybox的源码network/tc.c编译不了。参考 https://github.com/gramineproject/gramine/issues/1909 和 https://gitweb.gentoo.org/repo/gentoo.git/commit/?id=d8ad860a1ed9aa92adaa7dcf1c3fc78d0e2f80ce
gentoo的开发者为他们自己的版本做了修复,这个bug在今年一月就对busybox提了但是一直没修。。。最新版本还是2023年5月的。然而看commit记录也就是加了ifndef能让编译通过,else分支就搞了个printf,也是神人。后面两条路,一个是我自己对着编译报错修busybox,另一个是不用busybox自己手动链接搞内核命令。牛大了。不行先去pwncollege上玩算了。
没事了官网上有个patch,打上之后就好了然后我在github镜像仓库提了个issue. 补丁链接放在这:https://bugs.busybox.net/attachment.cgi?id=9751
pwnkernel也提供了qemu启动参数,比较方便。
内核模块
.ko
文件。类似与.so
一样,这是对于内核的库,可以挂载各种驱动网络功能等等。古早的内核模块可以在内核system call table上注册新的条目,现在已经不行了。内核模块还可以注册新的中断int等等。一般内核模块会注册一个设备文件比如/dev/tty, 设备文件分为字符型和块(block)型。
对于一个内核驱动,pwncollege上给出的这些例子至少说明了这么几个函数:
-
init_module
:使用insmod装载模块的时候调用,可以理解为一个建构函数 -
clear_module
:使用rmmod的时候调用,析构函数 -
device_read
:
交互方式
一般来说一个内核模块大概是干这么些事:
-
用户空间读
copy_from_user
-
do sth (打开文件,文件读写,硬件交互)
-
用户空间写
copy_to_user
-
返回用户态其中调用的两个copy_to_user和copy_from_user内部的实现决定了他们是安全的(仅限内核和用户态通信的过程中),而内核内部的内存读写很可能存在漏洞。
-
文件 如下面这几个目录下挂载的节点:/dev /proc /sys。直接open就可以进行读取内核模块注册的设备。读取返回的操作一般在内核模块编写代码的时候定义好了
device_read
操作。 -
ioctl接口。允许自定义交互,借助文件描述符和一个操作码:
ioctl(fd, COMMAND_CODE, pointer)
。 -
每个内核模块在data段的开头包含一个结构体,里面有注册文件操作的函数指针,也就是通过read/write等方法操作文件的时候可以使用驱动内部实现的read和write函数。见下一篇文章。
提权
进程由内核中的task_struct管理,权限部分则是由cred结构体管理(记录了各种id)。利用内核中的函数,执行commit_creds(prepare_kerkel_creds(0))
。将会获得完全root权限,这些函数的地址如果有root权限可以在/proc/kallsyms查到。
从内核层面绕过seccomp
一个进程的seccomp同样也被task_struct
管理,在其中有一个thread_info
内联结构,里面有一个flag 域,其中就有TIF_SECCOMP
位,控制着seccomp的开关。(即在seccomp内部真正实现函数的包装函数有关于这一位的判断)
因此只要让
1 | current_task_struct->thread_info.flags &= ~(1<<TIF_SECCOMP) |
即可。
当前的task_struct地址被gs寄存器保存(类似于用户态的fs),同时内核提供了一个简单的访问方法:current
。因此只需要current->thread_info.flags被修改,当前进程就不再受seccomp保护(但子进程仍然会,它们的seccomp信息存储在别的地方)。
内存管理
我们讨论的是现代操作系统的linux。由于内存的需求增长越来越恶搞,现代操作系统都支持4级的页表,然后用cr3寄存器(至少在amd64和x86)指向这个开头来进行映射,页表里面存放的都是每一级的物理地址。利用这种4级页映射技术我们可以把一个进程的48位(没错64位是假的,高12位本质上是最高位的符号扩展,全1的话代表内核,4级页表只支持48位寻址)地址换成我们的物理地址,当然这一切都是在MMU里进行的。现代(大部分)linux系统并没有在内核中实现关于地址的查找映射等等操作,它需要MMU模块来运行。所谓的cr3寄存器就是指向这么一套页表的最高级.
对于虚拟机的隔离,每一个虚拟机都认为自己有着全部的真实物理内存访问权限,实现方法无非也是再为他分配一套4级的页表。
内核视角下没有进程的隔离,并且内核映射着整个物理地址空间。在内核中有这么两个宏virt_to_phys()
和phys_to_virt()
来帮助进行转换。本质上也是一些偏移
安全措施
kALSR(启动时内核加载在随机地址), NX(堆栈不可执行),kernel stack canary,和用户空间很像。
除此以外,内核还有一些独有的东西,比如intel的SMEP(Supervisor Mode Execution Prevention)和SMAP(Supervisor Mode Access Prevention)技术,内核希望我们只用安全的api来和用户空间进行交互,他们能防止内核跳转至用户空间执行代码或者获取数据。
SMEP能够在任何时候组织内核执行用户空间的代码,否则会触发一个页错误(当然也是除了sysret这种正常途径)。而SMAP能够在AC flag未设置的时候组织内核访问用户空间的内存(防止非预期的获取)。RFLAGS中的AC位可以用ring 0下的stac
和clac
指令进行设置。
当然,攻击端也有应对的办法。如果能拿到内核的code excution和一个参数控制,通过这个内核函数run_cmd(char*)
,它能接受一个以空格分隔的字符串并以root身份在用户空间执行,可以一定程度上绕过SMEP。
shellcode
首先,如果拥有内核空间代码执行的能力,syscall肯定是不能用的。syscall会假定这一指令的执行来自用户空间然后跳转到syscall_entry执行调用,但是如果在内核中的话,某些操作会导致当前线程的崩溃。反映出结果就是syscall引起了一个seg fault. 因此在有内核空间代码执行的情况下我们只要call对应函数就好了。
但是call函数和用户空间不同,编译时根本没法为内核函数符号生成重定位,不能直接call xxx
。因此我们要知道这些内核api的位置才能调用。
获取内核函数位置如果没有kaslr,只要拿一份相同的镜像(理想状态下应该是相同的内核相同的硬件,包括内核版本config)读取/proc/kallsyms
就行了。而如果有kaslr,就要泄漏。
拿到内核api地址之后有两种选择,间接跳转和直接跳转。大部分情况下会选择后者。间接跳转就是计算shellcode和指定位置api之间的距离,如果在32位范围内的话可以用地址差间接跳转但是太过繁琐。一般都是用直接跳转到内核api,不过直接跳转需要用寄存器,执行类似这样的shellcode:
1 | mov rax, 0xffff414142424242 |
kernel shellcode和userspace shellcode另一个不同是,需要有一个完整的退出或者结束。用户空间段错误就段错误无所谓拿了flag就行,但是对内核来说(比如进程提权),崩溃了之后还没等flag输出系统就挂了。因此务必保证跳转调用等流程的完整。比如如果通过劫持某个函数指针来实现跳转,那么后续shellcode就要让他像正常函数一样返回,栈也要清理干净能让内核继续运转就行。
for seccomp
如果我们要修改当前的task_struct怎么办?kernel始终在gs寄存器中保存着这个变量,但是利用比较困难,另外kernel中还有一个神奇的宏current指向这里,可是我们如何在shellcode中获取current的值?
方法是,用C写一个内核模块,为你要攻击的内核构建他然后逆向得到汇编直接copy到你要攻击的shellcode里(是的就是这样)。虽然结果很简单,但是编译器帮你完成了翻内核源码找对应版本各种调用等等的bad for sanity的工作。
pwncollege教学原话称通过读内核源码理解然后寻找current的位置“is bad for your sanity"。
其他的问题?
fs和gs寄存器是什么?内核和用户态的切换发生了什么?
fs和gs两个段寄存器并没有硬件上的强绑定作用,他们的作用取决于操作系统。比如在linux下,fs用于用户态的进程寻址canary和 glibc的TLS寻址,用户态一般(至少glibc)不使用gs寄存器,但是gs并不对用户态透明,应用想访问还是能访问的。而在内核态,gs寄存器用于内核的canary和percpu变量寻址。percpu简单来说就是内核态的TLS, 每个cpu各自拥有一份独立copy,内核态不使用fs寄存器。而在windows下,fs寄存器存放有关SEH的地址。
简单来说,用户态陷入内核的时候会保存当前的上下文,包括rip, cs,rflags,rsp,ss这些寄存器。然后内核返回用户态的时候通过swapgs
和pop还原用户上下文。这里的swapgs主要还是隔离的作用,防止用户态获取内核信息。
不同的linux镜像有什么区别?
-
vmlinux:未压缩的内核文件,直接从内核源码编译得到,包含全部的内核代码,大小较大,因此常用于调试或生成其他格式内核文件
-
zImage:vmlinux经过gzip压缩后得到,早期使用,一般为较小内核所使用。
-
bzImage:big zImage, 同样使用gzip算法压缩的vmlinux,比zImage支持更大的内核,同时又比vmlinux小,加载和启动更快,现代linux一般镜像都是这个。
-
vmlinuz:bzImage/zImage的拷贝或者指向bzImage/zImage的链接
-
uImage, U-boot专用,zImage前面加了一个0x40的tag
-
initrd:initial ramdisk, 用来临时引导硬件到实际内核能接管的状态