linux内核初探及环境搭建

开始步入linux内核的学习。打算实践为主导,先自己搭一个内核调试的环境,并在过程中学习遇到的相关知识

内核态与用户态的分割

  • 安全性的考虑:用户态中恶意程序无法执行特权指令,破坏系统。其中内核态空间用户态应用无权访问。
  • 稳定性的保障:用户态中程序发生错误并不会导致内核的运作发生错误而影响整个系统的稳定性
  • 抽象化硬件调度操作:内核为用户态实现统一的设备访问接口(系统调用),用户程序无需关注不同设备的交互细节

在用户态程序中,通常利用read等系统调用后就能实现对应的写内存等操作,其中的实现细节便是在内核态中完成的。

编译运行一个linux内核

网上很多相关的步骤,算是步入内核大门的第一步。这里详细记录一下本人通过这个实践的收获:

忽略内核模块签名验证

Linux内核从3.7版本开始引入了模块签名验证机制。开启后,内核在加载模块时会使用内置的公钥对模块的签名进行校验,以确保模块的完整性和来源可信。这对于安全至关重要,但在开发和编译阶段,它可能会带来不便,因为默认情况下,内核会尝试使用自己编译过程中生成的一套密钥来签名模块。

因为后面会利用该内核编译一些自己写的内核模块,所以在编译之前需要禁用相应选项。

内核镜像与磁盘镜像

在实验中我分别下载linux内核文件以及busybox分别编译,最终获得下述两个文件:bzImagerootfs.img

bzImage

  • bzImage 的意思是 “big zImage”,是经过压缩的Linux内核二进制文件
  • 它包含了Linux内核的所有代码:进程调度、内存管理、设备驱动、文件系统支持、网络协议栈等

rootfs.img

  • 一个包含了完整Linux系统目录结构必要文件的磁盘映像。
  • 由BusyBox等工具制作,包含了Shell、核心工具(ls, cp, mkdir等)、库文件、配置文件和初始化脚本

启动流程

+———————————+
BIOS/UEFI ← 硬件启动
+———————————+

+———————————+
Bootloader (GRUB) ← 加载 bzImage 到内存
+———————————+

+———————————+
Linux Kernel (bzImage) ← 解压、初始化硬件、驱动
+———————————+
↓ (通过内核参数指定 rootfs)
+———————————+
挂载根文件系统 (rootfs.img) ← 从磁盘/内存/网络找到 rootfs
+———————————+

+———————————+
执行 /init 或 /sbin/init ← 第一个用户空间进程 (PID 1)
+———————————+

+———————————+
Shell 或系统服务 ← 完整的用户空间环境
+———————————+

挂载:一开始的内核只有临时的目录结构,真正健全的目录系统rootfs.img还在磁盘上,挂载能够将存储设备上的文件系统关联到目录树的某个位置,使得内核可以通过文件路径访问存储设备的内容,内核系统能够正常启动离不开若干次成功的挂载。

编译一个简单内核模块

Linux Kernel 采用的是宏内核架构,一切的系统服务都需要由内核来提供,虽然效率较高,但是缺乏可扩展性与可维护性,同时内核需要装载很多可能用到的服务,但这些服务最终可能未必会用到,还会占据大量内存空间,同时新服务的提供往往意味着要重新编译整个内核。——>,可装载内核模块(Loadable Kernel Modules)应运而生, LKMs 可以像积木一样被装载入内核 / 从内核中卸载,大大提高了 kernel 的可拓展性与可维护性。

常见的 LKMs 包括:

  • 驱动程序(Device drivers)
    • 设备驱动
    • 文件系统驱动
    • 各种驱动…
  • 内核扩展模块 (modules)

内核模块的文件后缀是.ko

一般用c实现内核模块的编写,通过同目录下的Kbuild文件以及Makefile实现编译操作。便可以在系统中使用insmod载入模块,rfmod卸载模块。

当在内核模块能够控制到函数的rip(程序执行流),就能够通过某些方法实现提权一般来说躲不开两个函数:commit_creds_kfunc(prepare_kernel_cred_kfunc(0))这两个函数配合的效果会将当前进程变成0环,从而实现提取。同时要注意内核态要正确过渡到用户态我们才能自由利用提权成果

注意内核模块的逻辑运行环境是内核态,这其中有许多机制与用户态不同,这也是后续我需要学习的内容。

内核模块与用户交互机制

用户与内核模块程序交互相对于用户态程序较为麻烦,存在多种方式:

  • /proc ,/sys文件系统
  • ioctl 命令
  • netlink 套接字
  • 注册系统调用/中断

与破解内核模块的第一步就是搞清楚它实现的与用户交互的机制从而正确测试各项功能,这里有篇文章详细地解释并实现了各种交互方式:https://www.cnblogs.com/adam-ma/p/18084237

file_operations结构体

1
2
3
4
5
6
7
8
9
static struct file_operations my_device_fops = {
.owner = THIS_MODULE, //指向拥有者
.open = my_device_open, //处理打开操作
.release = my_device_release, //处理close操作
.read = my_device_read, // 处理读取操作
.write = my_device_write, // 处理写入操作
.unlocked_ioctl = my_device_ioctl,// 处理控制命令
.llseek = my_device_llseek, // 处理文件定位
}; //这里只展示了常见的操作

cdev - 字符设备对象

代表内核中的一个字符设备实例

1
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; // 内嵌的kobject,用于设备模型
struct module *owner; // 指向拥有此设备的模块
const struct file_operations *ops; // 文件操作函数集
struct list_head list; // 连接到cdev_map的链表
dev_t dev; // 设备号(主设备号 + 次设备号)
unsigned int count; // 设备数量(次设备号范围)
};

字符串设备init常见流程

1
2
3
4
5
6
7
dev_t devno = MKDEV(DEVICE_MAJOR, DEVICE_MINOR);   //构造设备号
register_chrdev_region(devno, 1, DEVICE_NAME); //向内核“登记”设备号区间
cdev_init(&my_device_cdev, &my_device_fops); //初始化字符设备对象,并赋予文件操作指针
my_device_cdev.owner = THIS_MODULE; //指定模块所有者
cdev_add(&my_device_cdev, devno, 1); //把字符设备正式放进内核
char_class = class_create(THIS_MODULE, CLASS_NAME); //创建设备类
char_device = device_create(char_class, NULL, devno, NULL, DEVICE_NAME); //创建设备文件

对于dev/DEVICE_NAME文件的操作会映射成内核模块的操作

如何pwn?

一般来说题目会给一个bzImage对应编译好的内核镜像,roofts.img对应着文件系统的镜像,并给一个启动脚本,利用启动脚本能够qemu启动内核,在启动脚本中加入-g方便gdb调试。exp的形式一般是用c语言编译成elf文件,在题目给定的内核环境中与有漏洞的ko内核模块进行交互实现提权的操作。

本地打法:

构造sh文件编译exp并整合到rootfs.img文件镜像中,shell脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 编译 exploit
echo "[*] Compiling exploit..."
gcc exp.c -static -masm=intel -g -o exploit || {
echo "[-] Compilation failed!"
exit 1
}
# 打包文件系统
echo "[*] Creating rootfs.cpio..."
sudo bash -c "find . | cpio -o --format=newc > ../rootfs.cpio" || {
echo "[-] Failed to create cpio archive!"
exit 1
}
# 返回上级目录
cd ..
# 启动内核
echo "[*] Launching kernel..."
sudo ./start.sh

远程打法:

将攻击程序传输到远程中并运行,这时会出现攻击可执行文件过大的问题(因为是静态编译),比较通用的解决方法是将 exploit 进行 base64 编码后传输。python脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())

p = remote("127.0.0.1", 11451) #连接远程地址
#p = process('./run.sh')
try_count = 1
while True:
p.sendline()
p.recvuntil("/ $")

count = 0
for i in range(0, len(exp), 0x200):
p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> /tmp/b64_exp")
count += 1
log.info("count: " + str(count))

for i in range(count):
p.recvuntil("/ $")

p.sendline("cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendline("chmod +x /tmp/exploit")
p.sendline("/tmp/exploit ")
break

p.interactive()

常见保护机制

KASLR 保护

KASLR(Kernel Address Space Layout Randomization)是一种用于保护操作系统内核的安全技术。它通过在系统启动时随机化内核地址空间的布局来防止攻击者确定内核中的精确地址

值得注意的是随机方式是通过函数基地址 +随机值=内存运行地址方式来随机化

本质上与用户态的aslr绕过思路相近,通过泄露地址活的基址

这里贴出实现的原理:

  1. 引导阶段

    • 引导加载程序在将内核镜像加载到内存时,会选择一个随机的物理地址偏移。
    • 这个偏移量通常被称为“KASLR偏移”。
  2. 内核重定位

    • 内核启动早期,在建立页表之前,会应用这个随机偏移。
    • 内核需要具备位置无关可重定位的能力,能够正确处理自身地址的变动。这意味着内核在编译时需要使用-fPIC等选项,并且内部的重定位表需要被正确处理。
    • 最终,内核的虚拟地址(在x86_64上,通常是0xffffffff80000000开始的-2GB空间)也会被加上这个随机偏移。

    这里的重定位表和PE/ELF文件中的并无差别!

SMEP&SMAP保护

通过在CR4寄存器中定位其中两个bit位,代表内核是否开启保护

  • SMEP:控制内核态不能执行用户态的shellcode
  • SMAP:控制用户态空间的指针不能被内核态解引用,即不能访问内核空间中的敏感信息

CR4控制了CPU的工作模式和安全特性,内核统一管理所有CPU的CR4(策略保持一致)

KPTI保护

kernel page-table isolation(内核页表隔离),通过完全分离用户空间与内核空间页表来解决页表泄露。

一旦开启了 KPTI,由于内核态和用户态的页表不同,所以如果使用 ret2user或内核执行 ROP返回用户态时,由于内核态无法确定用户态的页表,就会报出一个段错误。