kernel basic
linux kernel
一.基础知识
1.操作系统内核
2.分级保护环
通过硬件实现

**用户态:**CPU属于ring 3级别,运行平常写的代码(C/C++)
**内核态:**CPU属于ring 0级别
3.状态切换
CPU 在不同的特权级间进行切换主要有两个途径:
(1)中断与异常(interrupt & exception):当 CPU 收到一个中断 / 异常时,会切换到 ring0,并 根据中断描述符表索引对应的中断处理代码以执行。
(2)特权级相关指令:当 CPU 运行这些指令时会发生运行状态的改变,例如 iret 指令(ring0->ring3)或是 sysenter 指令(ring3->ring0)。
基于这些特权级切换的方式,现代操作系统的开发者包装出了系统调用(syscall),作为由 “用户态” 切换到 “内核态” 的入口,从而执行内核代码来完成用户进程所需的一些功能。当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求。
user space to kernel space (系统调用)
当发生 系统调用,产生异常,外设产生中断 等事件时,会发生用户态到内核态的切换,进入到内核相对应的处理程序中进行处理。
CPU进入内核态会进行的以下操作:
通过
swapgs切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用。将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
通过 push 保存各寄存器值,具体的 代码 如下:
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
27ENTRY(entry_SYSCALL_64)
/* SWAPGS_UNSAFE_STACK是一个宏,x86直接定义为swapgs指令 */
SWAPGS_UNSAFE_STACK
/* 保存栈值,并设置内核栈 */
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
/* 通过push保存寄存器值,形成一个pt_regs结构 */
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx tuichu /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */4.通过汇编指令判断是否为
x32_abi。5.通过系统调用号,跳到全局变量
sys_call_table相应位置继续执行系统调用。
kernel space to user space
退出时,流程如下:
- 通过
swapgs恢复 GS 值。 - 通过
sysretq或者iretq恢复到用户控件继续执行。如果使用iretq还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp 等)。
4.虚拟内存空间
32位

64位

5.进程权限管理
进程描述符
在内核中用task_struct表示一个进程
一个进程描述符表示:由多个struct组成

进程权限凭证
task_struct源码存在一个cred结构体,cred用于管理一个进程的权限
一个 cred 结构体中记载了一个进程四种不同的用户 ID,在通常情况下这几个 ID 应当都是相同的:
- 真实用户 ID(real UID):标识一个进程启动时的用户 ID
- 保存用户 ID(saved UID):标识一个进程最初的有效用户 ID
- 有效用户 ID(effective UID):标识一个进程正在运行时所属的用户 ID,一个进程在运行途中是可以改变自己所属用户的,因而权限机制也是通过有效用户 ID 进行认证的,内核通过 euid 来进行特权判断;为了防止用户一直使用高权限,当任务完成之后,euid 会与 suid 进行交换,恢复进程的有效权限
- 文件系统用户 ID(UID for VFS ops):标识一个进程创建文件时进行标识的用户 ID
用户组 ID 同样分为四个:真实组 ID、保存组 ID、有效组 ID、文件系统组 ID,与用户 ID 是类似的,这里便不再赘叙。
进程权限改变
前面我们讲到,一个进程的权限是由位于内核空间的 cred 结构体进行管理的,那么我们不难想到:只要改变一个进程的 cred 结构体,就能改变其执行权限。
在内核空间有如下两个函数,都位于 kernel/cred.c 中:
struct cred* prepare_kernel_cred(struct task_struct* daemon):该函数用以拷贝一个进程的 cred 结构体,并返回一个新的 cred 结构体,需要注意的是 daemon 参数应为有效的进程描述符地址。int commit_creds(struct cred *new):该函数用以将一个新的 cred 结构体应用到进程。
6.Loadable Kernel Modules(LKMs)
Linux Kernel 采用的是宏内核(但其本身是一个单内核)架构,一切的系统服务都需要由内核来提供,
LKMS 位于内核空间的 LKMs
常见的 LKMs 包括:
- 驱动程序(Device drivers)
- 设备驱动
- 文件系统驱动
- …
- 内核扩展模块 (modules)
LKMs 的文件格式和用户态的可执行程序相同,Linux 下为 ELF,Windows 下为 exe/dll,mac 下为 MACH-O,因此我们可以用 IDA 等工具来分析内核模块。
模块可以被单独编译,但不能被单独运行
相关指令
- insmod: 讲指定模块加载到内核中
- rmmod: 从内核中卸载指定模块
- lsmod: 列出已经加载的模块
- modprobe: 添加或删除模块,modprobe 在加载模块时会查找依赖关系
7.内核交互
系统调用:ioctl(可以直接寻址)
在 *NIX 中一切都可以被视为文件,因而一切都可以以访问文件的方式进行操作,为了方便,Linux 定义了系统调用 ioctl 供进程与设备之间进行通信。
ioctl 是一个专用于设备输入输出操作的一个系统调用,其调用方式如下:
1 | int ioctl(int fd, unsigned long request, ...) |
其第一个参数为打开设备 (open) 返回的 文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关。
对于一个提供了 ioctl 通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过 ioctl 系统调用完成不同的对设备的 I/O 操作。
8.常见内核态函数
常用的功能函数如下:
- printf() -> printk(),但需要注意的是 printk() 不一定会把内容显示到终端上,但一定在内核缓冲区里,可以通过
dmesg查看效果 - memcpy() ->copy_from_user()/copy_to_user()
- copy_from_user() 实现了将用户空间的数据传送到内核空间
- copy_to_user() 实现了将内核空间的数据传送到用户空间
- malloc() -> kmalloc(),内核态的内存分配函数,和 malloc() 相似,但使用的是
slab/slub 分配器 - free() -> kfree(),同 kmalloc()
此外,kernel 管理进程,因此 kernel 也记录了进程的权限。kernel 中有两个可以方便的改变权限的函数:
- int commit_creds(struct cred *new)
- struct cred* prepare_kernel_cred(struct task_struct* daemon)
从函数名也可以看出,执行 commit_creds(prepare_kernel_cred(&init_task)) 即可获得 root 权限,即拷贝 init 进程的 cred 作为当前进程的新的 credentials。
执行 commit_creds(prepare_kernel_cred(&init_task)) 也是最常用的提权手段,这些函数与变量的地址都可以在 /proc/kallsyms 中查看(较老的内核版本中是 /proc/ksyms),该文件的内容通常需要 root 权限才能正确查看。
1 | $ sudo cat /proc/kallsyms | grep "T commit_creds" |
9.保护机制
通用保护
kaslr:内存地址随机化,加个偏移值; 在未开启 KASLR 保护机制时,内核代码段的基址为 0xffffffff81000000 ,direct mapping area 的基址为 0xffff888000000000。
内存布局: 这里
*fgaslr
canary
smap|smep:SMAP 即管理模式访问保护,SMEP 即管理模式执行保护,这两种保护通常是同时开启的,用以阻止内核空间直接访问 / 执行用户空间的数据,完全地将内核空间与用户空间相分隔开,用以防范 ret2usr(return-to-user,将内核空间的指令指针重定向至用户空间上构造好的提权代码)攻击。
SMEP 保护的绕过有以下两种方式:
- 利用内核线性映射区对物理地址空间的完整映射,找到用户空间对应页框的内核空间地址,利用该内核地址完成对用户空间的访问(即一个内核空间地址与一个用户空间地址映射到了同一个页框上),这种攻击手法称为 ret2dir 。
- Intel 下系统根据 CR4 控制寄存器的第 20 位标识是否开启 SMEP 保护(1 为开启,0 为关闭),若是能够通过 kernel ROP 改变 CR4 寄存器的值便能够关闭 SMEP 保护,完成 SMEP-bypass,接下来就能够重新进行 ret2usr,但对于开启了 KPTI 的内核而言,内核页表的用户地址空间无执行权限,这使得 ret2usr 彻底成为过去式 。
kpie:
在这两张页表上都有着对用户内存空间的完整映射,但在用户页表中只映射了少量的内核代码(例如系统调用入口点、中断处理等),而只有在内核页表中才有着对内核内存空间的完整映射,但两张页表都有着对用户内存空间的完整映射

KPTI 同时还令内核页表中属于用户地址空间的部分不再拥有执行权限,这使得 ret2usr 彻底成为过去式。
堆保护
1.Hardened Usercopy
hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界:
- 读取的数据长度是否超出源 object 范围。
- 写入的数据长度是否超出目的 object 范围。
这一保护被用于 copy_to_user() 与 copy_from_user() 等数据交换 API 中,不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段。
2.Hardened freelist
类似于 glibc 2.32 版本引入的保护,在开启这种保护之前,slub 中的 free object 的 next 指针直接存放着 next free object 的地址,攻击者可以通过读取 freelist 泄露出内核线性映射区的地址,在开启了该保护之后 free object 的 next 指针存放的是由以下三个值进行异或操作后的值:
- 当前 free object 的地址。
- 下一个 free object 的地址。
- 由 kmem_cache 指定的一个 random 值。
攻击者至少需要获取到第一与第三个值才能篡改 freelist,这无疑为对 freelist 的直接利用增添不少难度。
3.Random freelist
slub allocator 刚从 buddy system 拿到新 slub 的时候,运行时 freelist 的构成仍遵循 LIFO。

二.内核源码下载与编译
1.下载内核源码
根据 Archive kernel releases,我们可以知道内核主要有以下几种类别:
- Prepatch (RC) :主线内核的预发布版本,包含了最新的待测试的内核特性,由 Linus Torvalds 维护。
- Mainline:主线内核版本,RC 版本的新特性经过测试后便会合并到主线,每 9~10 周发一个版本,由 Linus Torvalds 维护。
- Stable:主线内核发布后便会变为 Stable 状态,其仅会被 stable kernel 维护者从主线树后向移植一些漏洞修复,直到下个内核版本释出。Stable kernel 根据需要进行更新,通常是一周一次。
- Longterm:部分内核版本会被选作长期支持版(LTS),相比起 Stable 内核有着更久的支持时长,通常仅会被后向移植重要的漏洞修复,且更新周期较慢(尤其是对于更老的版本)。
下载内核
1 | wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.12.16.tar.xz |
1 | unxz ./linux-6.12.16.tar.xz |
验证签名
导入 Linus Torvalds 和 Greg Kroah-Hartman 的公钥
1 | gpg2 --locate-keys torvalds@kernel.org gregkh@kernel.org |
下载内核签名
1 | wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.12.16.tar.sign |
校验
1 | gpg2 --verify linux-6.12.16.tar.sign |
结果会有warning,因为导入的公钥没有可信签名,那我们就让其可信
1 | gpg2 --tofu-policy good 38DBBDC86092693E |
重新验证
1 | gpg2 --trust-model tofu --verify ./linux-6.12.16.tar.sign |
2.配置编译选项
首先我们要先进入源码目录
1 | ls 一下找到源码然后 |
这样我们就进入了文件源码目录
动态生成编译配置系统
1 | make menuconfig |
根据当前系统进行微调
1 | make defconfig |
1 | make localyesconfig # 将驱动编译到内核当中 |
3.调试相关选项
依次进入到 Kernel hacking -> Compile-time checks and compiler options,然后勾选如下选项 Compile the kernel with debug info ,以便于调试。这通常是默认开启的。
如果要使用 kgdb 调试内核,则需要选中 KGDB: kernel debugger,并选中 KGDB 下的所有选项。
4.编译内核
还是要在内核源码目录中进行
获得的是压缩后的内核镜像文件 bzImage
1 | make bzImage #狗屎啊,怎么要这么久 |
-j 参数指定了同时进行编译的内核数量,(nproc) 变量则通常代表你所使用的机器所拥有的硬件线程数:
1 | make -j(nproc) bzImage |
最后,当终端出现如下信息时,说明编译完成:
1 | Kernel: arch/x86/boot/bzImage is ready (#1) |
产物中的两个文件:
vmlinux:编译生成的 ELF 格式的原始内核镜像文件,通常位于源码根目录下。bzImage:前者进行压缩后的内核镜像文件,通常位于arch/架构/boot/bzImage(注意对于 x86-64 而言仍是x86目录)。
四.搭建内核运行环境
1.获取内核镜像文件(make -j$(nproc) bzImage)
2.使用 BusyBox 搭建基本的文件系统
busybox是一个包含许多命令的软件、
下载busybox源码
1 | wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2 |
编译busybox(在busybox目录下进行)
1 | make menuconfig |
配置文件系统(在_install目录下进行)
创建文件系统结构
1 | $ cd _install |
在 ./etc/inittab 中写入如下内容:
1 | ::sysinit:/etc/init.d/rcS ::askfirst:/bin/login ::ctrlaltdel:/sbin/reboot ::shutdown:/sbin/swapoff -a ::shutdown:/bin/umount -a -r ::restart:/sbin/init |
etc/init.d/rcS :
1 | \#!/bin/sh |
然后为这个脚本添加可执行权限
1 | chmod +x ./etc/init.d/rcS |
接下来配置用户组相关权限,在这里建立了两个用户组 root 和 ctf ,以及两个用户 root 和 ctf,并配置了一条文件系统挂载项:
1 | $ echo "root:x:0:0:root:/root:/bin/sh" > etc/passwd |
打包文件系统(在busybox目录下进行)
qcow2格式
1 | qemu-img create -f qcow2 rootfs.qcow2 8M #创建一个qcow2格式的镜像文件 |
然后把前面我们构建的文件系统内容拷贝进去:
1 | $ sudo cp -auv _install/* /mnt |
最后常规卸载并解绑 nbd 即可:
1 | $ sudo umount /mnt |
3.启动内核
将bzImage和rootfs.qcow2,kernel.sh放在同一个文件夹,
1 | ./kernel.sh |
kernel.sh内容:
1 | \#!/bin/sh |
各参数说明如下,详细说明可以参照 QEMU 的官方文档:
-m:虚拟机内存大小。-kernel:内核镜像路径。-hda:文件系统路径,我们将 qcow2 镜像挂载为一个真正的硬盘设备,优点在于更贴近真实环境。-monitor:将监视器重定向到主机设备/dev/null,这里重定向至 null 主要是防止 CTF 中被人通过监视器直接拿 flag。-appendchown -R root:root / chmod 700 /root chown -R ctf:ctf /home/ctf mount -t proc none /proc mount -t sysfs none /sys mount -t tmpfs tmpfs /tmp mkdir /dev/pts mount -t devpts devpts /dev/pts echo 1 > /proc/sys/kernel/dmesg_restrict echo 1 > /proc/sys/kernel/kptr_restrict insmod /root/a3kmod.ko echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n" cd /root su root -c sh poweroff -d 0 -f1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
:内核启动参数选项
- `root=/dev/sda rw`:该参数设定了根文件系统所在设备,因为我们使用 `-hda` 将其挂载为一个 SATA 硬盘,而 Linux 中第一个 SATA 硬盘的路径为 `/dev/sda` ,因此我们将根文件系统路径指向设备路径,并通过 `rw` 标识来给予可读写权限。
- `kaslr`:开启内核地址随机化,你也可以改为 `nokaslr` 进行关闭以方便我们进行调试。
- `rdinit`:指定初始启动进程,这里我们指定了 `/sbin/init` 作为初始进程,根据我们前面的配置其会默认以 `/etc/init.d/rcS` 作为启动脚本。
- `loglevel=3` & `quiet`:不输出 log。
- `console=ttyS0`:指定终端为 `/dev/ttyS0`,这样一启动就能进入终端界面。
- `-cpu`:设置 CPU 选项,在这里开启了 smep 保护。
- `-smp`:设置对称多处理器配置,这里设置了两个核心,每个核心一个线程。
- `-nographic`:不提供图形化界面,此时内核仅有串口输出,输出内容会被 QEMU 重定向至我们的终端。
- `-snapshot`:使用快照的方式启动,这样在虚拟机当中对文件系统的修改不会 “落盘”。
- `-s`:相当于`-gdb tcp::1234`的简写(也可以直接这么写),后续我们可以通过 gdb 连接本地端口进行调试。
启动后效果:
### 4.加载驱动
将生成的.ko文件放在一起的文件夹,然后在init.d中添加insmod命令添加
1 |
|
su root -c sh #以root用户启动
su ctf -c sh #以使用者启动
1 |
|
cat /proc/kallsyms | grep prepare_kernel_cred
1 |
|
lsmod
1 |
|
cat /sys/module/a3kmod/sections/.text
cat /sys/module/a3kmod/sections/.data
1 |
|
gdb -q -ex “target remote localhost:1234”
1 |
|
add-symbol-file ./test_kmod/src/a3kmod.ko -s .text 0xffffffffc008f000 -s .data 0xffffffffc0091038 -s .bss 0xffffffffc0091540
1 |
|
└─$ head System.map
0000000000000000 A VDSO32_PRELINK
0000000000000000 D __per_cpu_start
0000000000000000 D per_cpu__irq_stack_union 0000000000000000 A xen_irq_disable_direct_reloc 0000000000000000 A xen_save_fl_direct_reloc
0000000000000040 A VDSO32_vsyscall_eh_frame_size 00000000000001e7 A kexec_control_code_size
00000000000001f0 A VDSO32_NOTE_MASK
0000000000000400 A VDSO32_sigreturn
0000000000000410 A VDSO32_rt_sigreturn
1 |
|
import idaapi
def load_system_map(file_path):
with open(file_path, “r”) as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
addr = int(parts[0], 16)
symbol_type = parts[1]
symbol_name = parts[2]
# if symbol_type in ['T', 't', 'D', 'd', 'B', 'b']: #如果符号表太大,可以针对性添加
if not idaapi.add_entry(addr, addr, symbol_name, 0):
print(f"Failed to add symbol: {symbol_name} at {hex(addr)}")
else:
print(f"Added symbol: {symbol_name} at {hex(addr)}")
system_map_path = idaapi.ask_file(0, “*.map”, “Select System.map file”)
if system_map_path:
load_system_map(system_map_path) #!!!路径在这
else:
print(“No file selected”)