linux kernel pwn 基础知识
linux 内核概述
Linux 内核由 Linus Torvalds 于 1991 年开发,最初的目的是为 Intel 80386 处理器编写一个类 UNIX 系统的内核。当前版本的 Linux 内核由全球社区共同开发维护,通过 Git 管理源代码。
内核概述
内核(Kernel)是操作系统的核心部分,负责管理硬件资源并提供给用户程序使用。它充当硬件和应用程序之间的桥梁,是操作系统最基础和最关键的部分。
内核的职责
进程管理:
负责调度 CPU,确保多个进程可以并发运行。
通过调度算法分配 CPU 时间片。
进程的创建、终止、状态切换和通信。
内存管理:
- 分配和释放内存,防止内存冲突。
- 提供虚拟内存支持,使每个进程有独立的地址空间。
- 管理分页(Paging)和分段(Segmentation)。
文件系统管理:
- 提供文件读写接口。
- 管理存储设备(如硬盘)的文件组织和访问权限。
- 提供文件系统抽象层,支持多种文件系统(如 FAT32、NTFS、ext4)。
设备驱动管理:
- 通过驱动程序控制硬件设备(如键盘、显示器、网卡)。
- 提供统一的设备访问接口。
网络管理:
- 提供网络协议栈(如 TCP/IP)支持网络通信。
- 管理数据包发送、接收和路由。
安全和权限管理:
- 用户权限隔离,防止进程间相互干扰。
- 控制资源访问权限,保障系统安全。
内核的分类
- 单体内核(Monolithic Kernel):所有的核心功能(如进程管理、内存管理、文件系统等)都在内核态运行。
- 优点:性能高,系统调用开销低。
- 缺点:模块之间耦合度高,出错影响整个系统。
- 示例:Linux、BSD。
- 微内核(Microkernel):仅保留最基础功能(如进程管理、内存管理)在内核态,其他功能移到用户态。
- 优点:模块化设计,稳定性高。
- 缺点:性能低,系统调用开销大。
- 示例:Minix、QNX。
- 混合内核(Hybrid Kernel):结合单体内核和微内核的优点,将部分功能运行在内核态,部分功能在用户态。
- 示例:Windows NT、macOS。
提示
Linux 内核属于单体内核,所有核心功能(如进程管理、内存管理、设备驱动、文件系统、网络协议栈等)都运行在内核态。相较于微内核,单体内核性能更高,但模块之间高度耦合可能导致稳定性问题。
Linux 内核的特点
- 模块化设计:Linux 内核的模块(Kernel Module)可以在运行时动态加载(
insmod
)或卸载(rmmod
)。这使得开发人员无需重启系统或重新编译整个内核即可调试或扩展功能。 - 高性能:
- 任务调度优化:使用
CFS
调度器(完全公平调度器),实现低延迟和公平的任务分配。 - 多核支持:充分利用多核架构,通过锁机制(如自旋锁、读写锁)实现高效并发。
- 零拷贝机制:网络通信中采用零拷贝技术,减少内存复制,提高吞吐量。
- 任务调度优化:使用
- 可移植性:支持多种硬件架构(x86、ARM、RISC-V 等)。可裁剪内核以适配嵌入式设备,同时也支持大规模服务器的多线程与并发。
- 开源性:Linux 内核使用 GPLv2 许可证,全球社区通过邮件列表和补丁贡献,推动内核发展,并且所有修改和发布的代码必须开源。
- 强大网络支持:内核内置高性能的网络协议栈,支持 IPv4、IPv6、UDP、ICMP 等。支持现代网络协议(如 QUIC)、SDN(软件定义网络)和虚拟网络(如 VXLAN)。
Linux 内核版本
Linux 内核版本是社区开发和维护的核心之一,用于标识不同阶段的功能、性能和稳定性。
版本命名规则
Linux 内核版本号通常由三个或四个部分组成,格式为 X.Y.Z
:
X
(主版本号):表示重大更新,例如架构变更或核心功能的大幅改进。例如:从 4.x 升级到 5.x。
Y
(次版本号):表示新功能和优化的引入。例如:5.10 中引入新的文件系统优化。
Z
(修订号):表示漏洞修复或小的改进。
提示
在 2.x 内核中,奇数表示开发版本,偶数表示稳定版本;从 3.x 开始废弃这种规则。
版本的类型
Linux 内核由社区主导开发,Linux 内核大约每 8-10 周发布一个新版本。每个版本的开发分为两个阶段:
合并窗口(Merge Window):开始时为期两周的功能合并窗口,开发者提交新功能和重大改进。结束后,停止接收新功能,只修复问题。
修复阶段:剩下的时间用于 Bug 修复和代码优化。随着 RC(Release Candidate,候选版本)的发布逐步接近稳定。
内核版本在发布前会经历多个 RC 版本(X.Y-rcN
),如 5.15-rc3
。每个 RC 版本修复前一版本发现的问题,直到版本足够稳定。
一些特定版本由社区标记为 LTS,生命周期通常为 2-6 年,适合生产环境。
当前活跃的内核版本
- 4.14:嵌入式设备常用,2017 年 11 月发布,支持至 2024 年。
- 4.19:许多稳定性优化,2018 年 10 月发布,支持至 2024 年。
- 5.4:企业环境常见,2019 年 11 月发布,支持至 2025 年。
- 5.10:多场景支持,稳定,2020 年 12 月发布,支持至 2026 年。
- 5.15:改进
IO_uring
和文件系统,2021 年 11 月发布,支持至 2028 年。 - 6.x 系列:最新的长期支持版本。
查看系统版本
uname 命令
uname
是一个标准命令,用于显示系统信息,其中 -r
参数用于显示当前运行的内核版本。如果想获取更详细的信息,可以使用 uname
的 -a
参数:
1 | / $ uname -r |
Linux
:内核名称。ubuntu
:主机名。5.15.0-50-generic
:内核版本。5.15.0
:内核的主版本号和次版本号。-50
:分配给当前内核版本的修订编号。generic
:特定的内核类型,表示为通用内核。其他可能的值:
lowlatency
:低延迟内核。rt
:实时内核。
#56~20.04.1-Ubuntu SMP Thu Sep 29 19:22:06 UTC 2022
:#56
:编译版本号。SMP
:表示支持多处理器(Symmetric Multi-Processing)。Thu Sep 29 19:22:06 UTC 2022
:编译日期和时间。
x86_64
:CPU 架构。
查看 /proc/version
文件
Linux 将内核版本信息存储在 /proc/version
文件中,可以通过 cat
命令查看:
1 | cat /proc/version |
kernel pwn 环境基础
在 ctf 中通常一个 kernel pwn 的题目包含下面三个部分:
boot.sh
/run.sh
/start.sh
:启动脚本bzImage
:内核镜像rootfs.cpio
/rootfs.img
:文件系统
另外还有可能提供用于编译内核的配置文件。
内核镜像
内核镜像的类别
通常我们见到的内核镜像如下:
vmlinux:vmlinux 是一个包含完整调试符号的内核映像文件,通常用于内核的开发和调试。它是未压缩的、包含所有调试符号的原始内核镜像。
bzImage:bzImage 是一个压缩过的内核映像,通常用于系统启动时的内核加载。
bzImage
的名字来自于bzip2
压缩工具,但实际上,它可以使用多种压缩算法(如gzip
、bzip2
等)来压缩内核。vmlinuz:
vmlinuz
是 Linux 内核的压缩映像文件,通常是bzImage
文件的一个符号链接。它的名称遵循惯例,并且是广泛用于启动过程中的标准内核文件名称。1
2ls -l /boot/vmlinuz
lrwxrwxrwx 1 root root 22 2024-12-01 16:23 /boot/vmlinuz -> vmlinuz-5.10.0-7-amd64
获取 vmlinux
编译内核
首先通过 file
命令查看内核版本:
1 | $ file bzImage |
并且最好确定编译内核的 gcc 版本,防止因为 gcc 版本差异过大导致内核编译失败。
1 | $ strings ./bzImage | grep gcc |
ubuntu 切换 gcc 版本的方法
首先
apt-cache
查看可用的 gcc 版本信息:
1
2
3
4
5
6
7
8 $ apt-cache policy gcc-13
gcc-13:
Installed: 13.2.0-23ubuntu4
Candidate: 13.2.0-23ubuntu4
Version table:
*** 13.2.0-23ubuntu4 500
500 http://archive.ubuntu.com/ubuntu noble/main amd64 Packages
100 /var/lib/dpkg/status安装一个版本比较接近的 gcc。(对于比较上古的 linux 内核,需要找一些同样比较上古版本的 ubuntu 才能找到合适的 gcc 以及其他编译时用到的工具链。)
1 sudo apt install gcc-13=13.2.0-23ubuntu4
update-alternatives
是一个 Debian 和 Ubuntu 系统中的命令行工具,用于管理系统中多个版本的程序之间的选择。这里将系统上安装的不同版本的 gcc 都添加到update-alternatives
中:
1
2 sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 14
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 13之后只需要使用
sudo update-alternatives --config gcc
切换 gcc(本质就是更改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 $ gcc --version
gcc (Ubuntu 14.2.0-4ubuntu2~24.04) 14.2.0
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ sudo update-alternatives --config gcc
There are 2 choices for the alternative gcc (providing /usr/bin/gcc).
Selection Path Priority Status
------------------------------------------------------------
* 0 /usr/bin/gcc-14 14 auto mode
1 /usr/bin/gcc-13 13 manual mode
2 /usr/bin/gcc-14 14 manual mode
Press <enter> to keep the current choice[*], or type selection number: 1
update-alternatives: using /usr/bin/gcc-13 to provide /usr/bin/gcc (gcc) in manual mode
$ gcc --version
gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
从清华源下载与题目所给内核版本相同的内核源码。
1 | wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.17.tar.gz |
在编译前先安装相关的依赖:
1 | sudo apt-get install libncurses5-dev libncursesw5-dev -y |
在内核源码的根目录运行如下命令配置内核:
1 | make menuconfig |
保证勾选如下配置(默认都是勾选了的):
- Kernel hacking —> Kernel debugging
- Kernel hacking —> Compile-time checks and compiler options —> Compile the kernel with debug info
- Kernel hacking —> Generic Kernel Debugging Instruments –> KGDB: kernel debugger
- kernel hacking —> Compile the kernel with frame pointers
一般来说不需要有什么改动,直接保存退出即可。此时会在源码根目录生成一个 .config
配置文件。这个配置文件在编译的时候会被翻译成 include\generated\autoconf.h
中的宏定义,供内核镜像和内核模块编译的时候使用。
注意
如果题目提供了配置文件,最好和自己生成的配置文件比较一下,看一下出题人修改了内核的那些配置,因为有时候出题人为了达成特定的利用条件会开启或关闭一些内核的保护选项。我们要做的就是把这些选项修改得和出题人提供的配置文件相同。
之后运行如下命令编译内核。其中 bzImage
编译选项可以避免一些不必要的内核模块编译,节省时间。
1 | make bzImage -j$(nproc) |
最终会生成如下文件:
- 在
arch/x86/boot/
目录下生成bzImage
。 - 在源码根目录生成
vmlinux
(有时候虽然会编译报错退出,但可能只是生成bzImage
那一步出错了,而vmlinux
已经生成了)。
提示
缺少证书文件
1
2make[1]: *** No rule to make target 'debian/canonical-certs.pem', needed by 'certs/x509_certificate_list'. Stop.
make: *** [Makefile:1868: certs] Error 2需要将
CONFIG_SYSTEM_TRUSTED_KEYS
和CONFIG_SYSTEM_REVOCATION_KEYS
中的内容置空。CONFIG_SYSTEM_TRUSTED_KEYS
和CONFIG_SYSTEM_REVOCATION_KEYS
是 Linux 内核配置选项,主要用于与内核的公钥和证书管理相关。它们控制的是内核如何验证签名的模块以及其他敏感操作的公钥和证书。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19--- ./.config.bak 2024-12-06 16:41:46.982198997 +0800
+++ ./.config 2024-12-06 16:42:19.650711503 +0800
CONFIG_MODULE_SIG_KEY_TYPE_RSA=y
# CONFIG_MODULE_SIG_KEY_TYPE_ECDSA is not set
CONFIG_SYSTEM_TRUSTED_KEYRING=y
-CONFIG_SYSTEM_TRUSTED_KEYS="debian/canonical-certs.pem"
+CONFIG_SYSTEM_TRUSTED_KEYS=""
CONFIG_SYSTEM_EXTRA_CERTIFICATE=y
CONFIG_SYSTEM_EXTRA_CERTIFICATE_SIZE=4096
CONFIG_SECONDARY_TRUSTED_KEYRING=y
CONFIG_SYSTEM_BLACKLIST_KEYRING=y
CONFIG_SYSTEM_BLACKLIST_HASH_LIST=""
CONFIG_SYSTEM_REVOCATION_LIST=y
-CONFIG_SYSTEM_REVOCATION_KEYS="debian/canonical-revoked-certs.pem"
+CONFIG_SYSTEM_REVOCATION_KEYS=""
# end of Certificates for signature checking
CONFIG_BINARY_PRINTF=yBTF 加载失败
1
FAILED: load BTF from vmlinux: Invalid argument
这是在生成内核时加载 BTF(BPF Type Format) 数据时出现的错误。需要将
CONFIG_DEBUG_INFO_BTF
和CONFIG_DEBUG_INFO_BTF_MODULES
设置为n
来禁用 BTF 相关的配置项。BTF 是 Linux 内核中用于 BPF 程序和调试的类型信息格式。此错误通常表示在构建过程中,内核映像
vmlinux
的 BTF 数据无法正确加载或生成,可能与内核配置或工具链版本不兼容有关。1
2
3
4
5
6
7
8
9
10
11
12
# CONFIG_DEBUG_INFO_SPLIT is not set
CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT=y
# CONFIG_DEBUG_INFO_DWARF4 is not set
-CONFIG_DEBUG_INFO_BTF=y
+# CONFIG_DEBUG_INFO_DWARF5 is not set
+# CONFIG_DEBUG_INFO_BTF is not set
CONFIG_PAHOLE_HAS_SPLIT_BTF=y
-CONFIG_DEBUG_INFO_BTF_MODULES=y
CONFIG_GDB_SCRIPTS=y
CONFIG_FRAME_WARN=1024
# CONFIG_STRIP_ASM_SYMS is not set
vmlinux-to-elf
vmlinux-to-elf
是一个用于将 Linux 内核映像 vmlinux
转换为 ELF 格式的工具。
安装:
1 | sudo apt install python3-pip |
使用方法:
1 | vmlinux-to-elf <input_kernel.bin> <output_kernel.elf> |
这个方法获取的 vmlinux 带调试符号,不过结构体相关的调试符号只有编译内核可以获得。
下载镜像
对于实际的 linux 系统,我们可以通过其内置的包管理工具在其仓库中搜索与内核镜像相关的包。
1 | $ apt search linux-image- |
如果我们找到对应版本的内核镜像以及内核头文件和内核模块可以将其下载下来。
1 | apt download linux-image-6.8.0-49-generic # 内核镜像 |
linux-image
:包含了整个内核的可执行镜像。linux-headers
:包含内核的头文件,头文件定义了内核中使用的各种数据结构、函数声明、宏等,供用户空间程序或内核模块编译时使用。linux-modules
:包含内核模块。
下载下来的安装包可以安装在本地的操作系统上,这样本地的操作系统就和远程环境拥有完全一致的内核镜像以及内核模块。另外我们还可以通过内核头文件进行内核模块的开发。
1 | sudo dpkg -i ./llinux-image-6.8.0-49-generic_6.8.0-49.49_amd64.deb |
我们可以通过 dpkg -x
命令解压安装包。这样就可以获取到内核模块和 vmlinuz(bzImage 格式)内核镜像。通过 vmlinux-to-elf
即可提取出 vmlinux 镜像。
1 | dpkg -X ./llinux-image-6.8.0-49-generic_6.8.0-49.49_amd64.deb image |
bzImage 解压(不推荐)
使用 extract-vmlinux 脚本从 bzImage 解压出 vmlinux 。
1 | !/bin/sh |
运行如下命令就可以解压出 vmlinux 了。
1 | ./extract-vmlinux ./bzImage > vmlinux |
文件系统
文件系统是存储数据的一种方法,它定义了如何在存储设备(如硬盘、SSD、U盘)上存储、组织和检索文件。它包括以下内容:
- 数据存储结构:管理文件存储的方式,如块、扇区。
- 文件元数据:描述文件的信息,如名称、大小、权限、时间戳。
- 目录结构:支持文件的层次化组织。
- 访问接口:提供文件的读取、写入和删除操作。
文件系统类别
文件系统类别可以根据文件系统镜像的格式和用途进行分类。常见的文件系统镜像格式包括 cpio
、img
、qcow2
( QEMU 的虚拟磁盘镜像格式)和 vmdk
(VMware 虚拟机使用的磁盘映像格式)等。其中 ctf 中最常见的是 cpio
和 img
这两种格式。
cpio
cpio 是一种归档工具与归档格式,用于将一组文件和目录打包成一个归档文件。与 tar 类似,它不带文件系统元数据的复杂结构,只是将文件挨个打包,从而形成一个线性的存档文件。
在 Linux 系统启动流程中,initramfs 常常使用 cpio 格式来打包初始根文件系统(内核早期启动所需的基础文件和脚本),内核可以直接解压 cpio 格式的 initramfs。
文件系统制作
准备一个目录树,其中包含要打包的文件和目录结构。例如,创建
rootfs/
目录,里面有bin/
,etc/
,lib/
,sbin/
等文件和目录,并且将 busybox 中的各种工具拷贝到rootfs
中。使用下面的命令将该目录打包成 cpio 格式,其中
-H=newc
(--format=newc
)是 initramfs 常用格式选项。1
2cd rootfs
find . | cpio -o -H newc > ../rootfs.cpio
文件系统解压
我可以通过下面这条命令将 cpio 格式的文件解压成文件目录。
1 | cpio -idmv < rootfs.cpio -D rootfs |
文件系统打包
cpio 类型文件系统的打包脚本如下,注意这里的 rootfs
是事先从 rootfs.cpio
中解压出来的。
1 |
|
img (Raw Image)
.img
文件常指原始磁盘镜像文件,它是一个逐字节的磁盘副本。里面可能有分区表、引导记录(MBR、GPT)以及每个分区上具体文件系统的数据(如 ext4、FAT32、NTFS 等)。
文件系统制作
创建一个空白的磁盘镜像文件。下面这条命令为生成一个大小为 32M 的文件,内容填充为 0。
1
dd if=/dev/zero of=rootfs.img bs=1M count=32
dd
一个用于复制和转换文件的工具。if=/dev/zero
表示输入文件是/dev/zero
,这是一个特殊设备文件,它会不断地提供零字节。of=rootfs.img
指定输出文件是rootfs.img
,也就是最终生成的磁盘镜像文件名。bs=1M
设置块大小为 1MB。这意味着dd
会以 1MB 为单位读取和写入数据。count=32
指定了要写入 32 个块,即总共 32MB 的数据。
将空白镜像文件格式化为
ext4
文件系统。这里mkfs.ext4
是用来创建ext4
文件系统的命令。1
mkfs.ext4 rootfs.img
将事先准备好的 linux 文件系统目录
rootfs
中的内容拷贝到rootfs.img
中,这里需要将rootfs.img
挂载出来才能往里面添加文件。1
2
3
4
5mkdir ./rootfs_tmp
mount ./rootfs.img ./rootfs_tmp
cp -rf ./rootfs ./rootfs_tmp
umount ./rootfs_tmp
rm -r ./rootfs_tmp
文件系统打包
1 |
|
QEMU 文件系统配置
在使用 QEMU 启动 Linux 内核时,通常会涉及到几个关键选项和参数来指定系统的启动方式与根文件系统所在的位置。
-initrd
-initrd
(Initial RAM Disk)是用来指定初始 RAM 磁盘(initrd)或者初始 RAM 文件系统(initramfs)映像文件。
initrd
(initial ramdisk)最早是一种在内核启动早期加载的内存中磁盘映像。它包含基础的文件系统、关键的驱动模块和简单的用户空间工具。内核会在加载自身后先挂载此内存盘为根环境,在其中执行初始化任务。
initramfs
(initial ram filesystem)是现代 Linux 内核中替代 initrd 的方式。它是一个打包成 CPIO 格式的归档文件(CPIO archive),在内核引导时会被直接解压缩到内存中形成一个根文件系统,内核使用内建解压与文件系统支持访问这套结构,无需使用外部文件系统驱动。
当使用该参数时,QEMU 会在内核启动后将此内存盘映像载入内存中。内核启动时会先解压并加载 initramfs/initrd,从中获取用户空间初始工具(如 init
程序、关键的驱动和脚本)。
initramfs 在启动过程中可用于:
- 提供挂载根文件系统前所需要的驱动(如存储驱动、文件系统驱动、LVM、RAID 等)。
- 在系统根文件系统未就绪时先执行一些初始化动作(挂载网络文件系统、解密加密分区、挂载 NFS 根目录等)。
- 从 initramfs 中的脚本解析内核启动参数并决定挂载哪个设备作为最终的根文件系统。
值得注意的是,-initrd
并不直接指定根文件系统,而是提供了引导阶段的一个过渡环境。真正的根文件系统通常还是需要内核启动参数或 initramfs 中的脚本来决定最终要挂载的设备或路径。
提示
很多 CTF 题目会将构建好的 rootfs(包括 busybox 工具和必要的目录结构、配置文件、库文件等)打包成一个 CPIO 格式的 initramfs 映像,然后通过 -initrd
参数将其与内核一起提供给 QEMU。
启动时,内核会加载并挂载这份 initramfs 到内存中作为根文件系统。此时系统已经具备一个最低限度的用户空间环境,以及各种基础工具(BusyBox 提供诸如 ls
、mount
、cat
、sh
等常用命令)。
这也意味着文件系统中的文件实际上是在内存中的,因此如果我们有无限次的任意地址读就可以扫描内存读取文件系统中不可读的文件。
-hda & root=
使用 -hda
(或较新的 QEMU 版本中推荐的 -drive file=...
)选项是为 QEMU 虚拟机指定一个块设备映像文件,一般是一个完整的磁盘镜像文件,如扩展名为 .img
或 .qcow2
的文件。这个镜像文件中通常包含了一个分区表和若干分区(如 /dev/sda1
、/dev/sda2
等),其中一个分区可作为根文件系统(rootfs)。
在 Linux 系统中,
/dev/sda
通常表示系统中的第一个 SCSI 或 SATA 类型的硬盘设备(实际上,现代 Linux 内核中,许多存储设备都使用类似 SCSI 的统一接口,因此无论你用的是SATA硬盘还是SSD,它们的名字常常都是sda
、sdb
之类)。这里的/dev/
是 Linux 系统中存放各种设备文件的目录,而sda
则是该目录下的一个设备文件,用来代表第一块被内核识别的磁盘。
简单来说,qemu 会将 -hda
参数指定的磁盘镜像文件模拟成虚拟机中的一块硬盘设备并呈现给操作系统,内核启动时会识别它为相应的块设备(如 /dev/sda
,同理 -hdb
参数指定第二块磁盘 /dev/sdb
),从而在后续的启动过程中可以根据内核启动参数 root=
将其中的某个分区挂载为根文件系统。
1 | static int __init root_dev_setup(char *line) |
文件系统加载过程
内核启动:当 QEMU 启动时,内核从
initrd
文件加载一个临时的根文件系统(initramfs
或initrd
),该文件系统通常包含启动所需的最小文件和驱动程序。硬件初始化:内核会使用
initrd
中的驱动程序来初始化硬件(如网络、磁盘、USB 等)。挂载持久根文件系统:一旦硬件初始化完成,内核会通过
root=/dev/sda1
等参数挂载真实的根文件系统(如硬盘上的ext4
文件系统),并将控制交给系统的实际根文件系统,initrd
文件系统会被卸载。
Linux 文件系统创建
这里主要介绍一下如何创建一个用于 ctf 比赛环境的简易 Linux 文件系统。
编译 busybox
busybox 集成了多种常用 Unix 工具(如 ls
、cp
、mv
、cat
、echo
等)到一个单一的可执行文件中。由于其体积小巧、功能集成,BusyBox 广泛应用于嵌入式系统、初始 RAM 文件系统(initramfs)、以及需要节省存储空间的简化 Linux 环境中。
编译 busybox 的步骤如下:
下载最新版本的 BusyBox 源代码:
1
2
3wget https://busybox.net/downloads/busybox-1.37.0.tar.bz2
tar -xjf busybox-1.37.0.tar.bz2
cd busybox-1.37.0运行如下命令配置 BusyBox 编译选项。
1
make menuconfig
首先进入 Settings
BusyBox 1.37.0 Configuration ───────────────────────────────────────────────────────────────────────────────────────────────────── ┌──────────────────────────────────── Busybox Configuration ─────────────────────────────────────┐ │ Arrow keys navigate the menu. <Enter> selects submenus --->. Highlighted letters are │ │ hotkeys. Pressing <Y> includes, <N> excludes, <M> modularizes features. Press <Esc><Esc> to │ │ exit, <?> for Help, </> for Search. Legend: [*] built-in [ ] excluded <M> module < > │ │ module capable │ │ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ Settings ---> │ │ │ │ --- Applets │ │ │ │ Archival Utilities ---> │ │ │ │ Coreutils ---> │ │ │ │ Console Utilities ---> │ │ │ │ Debian Utilities ---> │ │ │ │ klibc-utils ---> │ │ │ │ Editors ---> │ │ │ │ Finding Utilities ---> │ │ │ │ Init Utilities ---> │ │ │ │ Login/Password Management Utilities ---> │ │ │ │ Linux Ext2 FS Progs ---> │ │ │ │ Linux Module Utilities ---> │ │ │ └───────────↓(+)─────────────────────────────────────────────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │ <Select> < Exit > < Help > │ └────────────────────────────────────────────────────────────────────────────────────────────────┘
选择静态编译。如果不勾选的话,需要自行配置libc库,这样步骤会很繁琐。
BusyBox 1.37.0 Configuration ───────────────────────────────────────────────────────────────────────────────────────────────────── ┌─────────────────────────────────────────── Settings ───────────────────────────────────────────┐ │ Arrow keys navigate the menu. <Enter> selects submenus --->. Highlighted letters are │ │ hotkeys. Pressing <Y> includes, <N> excludes, <M> modularizes features. Press <Esc><Esc> to │ │ exit, <?> for Help, </> for Search. Legend: [*] built-in [ ] excluded <M> module < > │ │ module capable │ │ ┌───────────↑(-)─────────────────────────────────────────────────────────────────────────────┐ │ │ │ [ ] exec prefers applets (NEW) │ │ │ │ (/proc/self/exe) Path to busybox executable (NEW) │ │ │ │ [ ] Support NSA Security Enhanced Linux (NEW) │ │ │ │ [ ] Clean up all memory before exiting (usually not needed) (NEW) │ │ │ │ [*] Support LOG_INFO level syslog messages (NEW) │ │ │ │ --- Build Options │ │ │ │ [*] Build static binary (no shared libs) │ │ │ │ [ ] Force NOMMU build (NEW) │ │ │ │ () Cross compiler prefix (NEW) │ │ │ │ () Path to sysroot (NEW) │ │ │ │ () Additional CFLAGS (NEW) │ │ │ │ () Additional LDFLAGS (NEW) │ │ │ │ () Additional LDLIBS (NEW) │ │ │ └───────────↓(+)─────────────────────────────────────────────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │ <Select> < Exit > < Help > │ └────────────────────────────────────────────────────────────────────────────────────────────────┘
设置安装目录,这里我们选择的是
./rootfs
。BusyBox 1.37.0 Configuration ───────────────────────────────────────────────────────────────────────────────────────────────────── ┌─────────────────────────────────────────── Settings ───────────────────────────────────────────┐ │ Arrow keys navigate the menu. <Enter> selects submenus --->. Highlighted letters are │ │ hotkeys. Pressing <Y> includes, <N> excludes, <M> modularizes features. Press <Esc><Esc> to │ │ exit, <?> for Help, </> for Search. Legend: [*] built-in [ ] excluded <M> module < > │ │ module capable │ │ ┌───────────↑(-)─────────────────────────────────────────────────────────────────────────────┐ │ │ │ () Additional LDLIBS (NEW) │ │ │ │ [ ] Avoid using GCC-specific code constructs (NEW) │ │ │ │ [*] Use -mpreferred-stack-boundary=2 on i386 arch (NEW) │ │ │ │ [*] Use -static-libgcc (NEW) │ │ │ │ --- Installation Options ("make install" behavior) │ │ │ │ What kind of applet links to install (as soft-links) ---> │ │ │ │ (./rootfs) Destination path for 'make install' │ │ │ │ --- Debugging Options │ │ │ │ [ ] Build with debug information (NEW) │ │ │ │ [ ] Enable runtime sanitizers (ASAN/LSAN/USAN/etc...) (NEW) │ │ │ │ [ ] Build unit tests (NEW) │ │ │ │ [ ] Abort compilation on any warning (NEW) │ │ │ │ [ ] Warn about single parameter bb_xx_msg calls (NEW) │ │ │ └───────────↓(+)─────────────────────────────────────────────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │ <Select> < Exit > < Help > │ └────────────────────────────────────────────────────────────────────────────────────────────────┘
编译安装 busybox。
1
2make -j$(nproc)
make install
完成上述步骤后,编译好的 busybox 会被安装在源码目录下的 rootfs
文件夹中,结构如下:
1 | rootfs |
初始化文件系统
主要是在 rootfs
目录上创建一些基本的目录和文件。
1 | cd rootfs |
在 busybox 编译安装到的
rootfs
目录下创建必要的目录。1
mkdir -pv {bin,sbin,etc,dev,tmp,proc,sys,home,root,lib64,lib/x86_64-linux-gnu,usr/{bin,sbin}}
- **
bin
**:用户级别的命令二进制文件。 - **
sbin
**:系统管理命令二进制文件。 - **
etc
**:系统配置文件。 - **
dev
**:设备文件,表示系统中所有的硬件设备。 - **
tmp
**:临时文件目录,通常存放应用程序临时产生的数据。 proc
和 **sys
**:虚拟文件系统,分别提供进程信息和内核状态信息。- **
home
**:用户的家目录,通常每个普通用户会有一个子目录。 - **
root
**:root 用户的 home 目录。 - **
lib64
**:64 位的共享库文件。 - **
lib/x86_64-linux-gnu
**:这是特定平台(如 x86_64 架构)下的库文件目录。 usr/bin
和 **usr/sbin
**:存放应用程序和系统管理工具的二进制文件。
- **
创建
inittab
文件1
touch etc/inittab
inittab
是 SystemV init 制度下的初始化配置文件,用于定义系统启动时运行哪个程序(通常是init
),以及启动哪些运行级别(runlevel)。在某些非常精简的环境中你可能需要一个简化的或自定义的inittab
文件,即使是空的也可先占位。创建
etc/init.d
目录与rcS
文件1
2
3mkdir etc/init.d
touch etc/init.d/rcS
chmod +x ./etc/init.d/rcSinit.d
目录通常存放系统初始化脚本,用来在特定运行级别或启动阶段执行初始化任务,如挂载文件系统、启动网络服务、设置时区等。创建
rcS
文件作为一个初始启动脚本,并对其赋予可执行权限(chmod +x
)。该脚本在系统启动过程的早期阶段被运行。
配置初始化脚本
配置初始化脚本这里根据文件系统的类型有所区分。
initrd 磁盘启动脚本路径
初始化 RAM 磁盘(initrd
或 initramfs
)中运行的启动脚本或程序由内核参数 rdinit
指定,在 rdinit_setup
函数中会将该参数指定的路径赋值给全局变量 ramdisk_execute_command
。
1 | static int __init rdinit_setup(char *str) |
如何不指定 rdinit
参数则在 kernel_init -> kernel_init_freeable
中设置为默认路径 /init
。
1 | if (!ramdisk_execute_command) |
在 kernel_init
中会在用户空间运行 ramdisk_execute_command
指定的程序创建 init
进程。
1 | if (ramdisk_execute_command) { |
因此我们可以通过内核参数 rdinit
指定 init
进程对应的可执行程序,或者使用默认的 \init
路径。
hda 磁盘启动脚本路径
对于像 hda 这样的块设备作为磁盘的情况,需要通过内核参数 init
来指定初始化脚本路径。在 init_setup
函数中这个路径会被赋值到 execute_command
变量中。
1 | static int __init init_setup(char *str) |
在 kernel_init
函数中如果没有指定 initrd
类型磁盘的初始化脚本路径则会尝试执行 hda
类型磁盘的初始化脚本。如果 hda
类型磁盘没有指定初始化脚本则会依次尝试执行 /sbin/init
,/etc/init
,/bin/init
,/bin/sh
作为 init
进程。
1 | static int __ref kernel_init(void *unused) |
因此我们可以通过内核参数 init
指定 init
进程对应的可执行程序,或者使用默认的路径。
配置 init 启动脚本
init
进程是类 UNIX 操作系统中最初启动的进程,也是系统的祖先进程,所有其他进程都是它的子孙进程。init
进程的 PID(进程号)为 1
,是操作系统中的第一个用户级进程。
在 Linux 系统中,init
进程负责系统启动后的初始化工作,包括启动系统服务、挂载文件系统、设置网络等。它通过读取配置文件来启动其他系统进程,因此 init
是系统正常运行的基础。
init
进程的配置方式有多种,它随着操作系统的版本、架构以及具体使用的初始化系统(如 SysVinit
、Upstart
、systemd
等)而有所不同。每种方式都通过不同的配置文件和机制来管理系统启动、服务管理、进程监控等任务。
当然,对于我们搭建的简易 Linux 环境,由于不涉及复杂的系统服务启动和初始化,因此只需要配置一个简单的 shell 脚本作为 init
进程。
1 |
|
首先我们需要挂载一下基本的虚拟文件系统。
proc
:提供进程信息的虚拟文件系统。sysfs
:提供内核设备信息和其它相关系统信息的虚拟文件系统。devtmpfs
:用于自动创建设备文件,如/dev/null
、/dev/zero
等,且自动管理设备节点。自定义标题
注意,
hda
类型的磁盘在内核启动阶段自动挂载devtmpfs
,不需要在init
脚本中进行。tmpfs
:一个临时的文件系统,通常用于存放临时文件。
之后我们可能会加载一下需要的驱动或者挂载一些必要的设备。例如示例中挂载了一个伪终端设备
ptmx
,在我们打开这个设备时内核中会创建一个tty_struct
结构体,为一些内核利用创造了条件。1
2mkdir /dev/pts
mount -t devpts devpts /dev/pts启动一个 shell 进程,并将它绑定到终端上,允许用户登录系统。
1
setsid cttyhack setuidgid 1000 sh
setsid
:启动一个新的会话,通常用于启动后台进程。cttyhack
:这个命令用于确保新的终端会话能够获取控制终端。它是init
进程启动 shell 时用来保证 shell 进程可以与终端交互的工具。控制终端(Control Terminal,简称 TTY)是 Unix 和类 Unix 系统中的一个概念,用于描述用户和操作系统之间的交互界面。它是一个终端设备(例如控制台、虚拟终端、伪终端等),允许用户通过输入命令与系统交互,并输出运行结果。
setuidgid 1000
:这个命令是runit
工具中的一部分,用于设置进程的 UID 和 GID,这里将 UID 和 GID 设置为 1000。UID 1000 通常是第一个普通用户的 UID(通常是用户user
)。提示
在 ctf 题目中可以把 1000 改成 0 然后重新打包文件系统,这样启动系统之后可以获得一个 root 权限的 shell,从而可以完成查看符号地址等一些高权限行为。
sh
:执行一个 shell,用户可以与系统进行交互。
设置 shell 退出后关机。
1
poweroff -d 0 -f
-d 0
:指定关闭延迟为 0,即立刻关闭。-f
:强制关闭,不进行正常关机过程中的清理工作。
当我们不配置 init
脚本路径的时候,对于 hda
类型的文件系统,会尝试把 /sbin/init
作为 init
进程对应的可执行程序。而
busybox 在 /sbin/init
提供了一个可执行程序,这个程序会按照对应的配置进行系统的初始化操作。busybox 的 init
配置比较简单,配置文件位于 /etc/inittab
和 /etc/init.d/
中。
首先配置 etc/inttab
,写入如下内容:
1 | ::sysinit:/etc/init.d/rcS |
这些内容中最关键的是 ::sysinit:/etc/init.d/rcS
,它指定了 /etc/init.d/rcS
作为系统启动脚本。
我们只需要在 /etc/init.d/rcS
写入 init
进程启动脚本就行。所以这种配置方式本质上还是前一种方法,跟 busybox 的初始化系统关系不大。
配置用户组
在 /etc/passwd
中创建两个用户:root
和 ctf
。
1 | root:x:0:0:root:/root:/bin/sh |
在 /etc/group
中创建两个用户:root
和 ctf
。
1 | root:x:0: |
运行与调试
内核启动
内核启动脚本负责启动一个简易的 linux 环境。
1 |
|
在用 qemu-system 启动内核时,常用的选项如下
-m
:指定内存大小,默认 384MB。-kernel
:指定内核镜像文件。-initrd
:指定初始内存磁盘,用于启动时加载必要的文件系统和驱动。-smp [cpus=]n[,cores=cores][,threads=threads][,dies=dies][,sockets=sockets][,maxcpus=maxcpus]
:用于设置虚拟机使用的 CPU 数量和拓扑结构。cores=n
:指定 CPU 核心数。threads=n
:指定 CPU 每个核心的线程数。sockets=n
:指定 CPU 插槽数。dies=n
:设置 CPU die 数。maxcpus=n
:指定最大 CPU 数目,适用于多核虚拟化。
-cpu
:指定指定要模拟的处理器架构,可以同时开启一些保护。kvm64
:指定虚拟机使用 KVM 模拟的 64 位 CPU 类型。这个参数并不意味着并不意味着开启了 KVM 虚拟化加速,KVM 的启用需要通过-enable-kvm
参数来显式开启。但是一些特性例如 KPTI 是可以支持的。+smap
:开启 SMAP(Supervisor Mode Access Prevention)保护,用于防止内核访问用户空间数据。+smep
:启用 SMEP(Supervisor Mode Execution Protection),这是 Intel 或 AMD 处理器的一种安全功能,可以防止内核从用户空间执行代码,增强系统的安全性。
-nographic
:禁用 QEMU 的图形界面输出。使用此选项时,虚拟机的输出将通过当前终端显示,而不是在图形窗口中呈现。-monitor
:对 qemu 提供的控制台进行重定向,如果没有设置的话,可以直接进入控制台。-monitor /dev/null
后Ctrl + c
可以直接退出 QEMU,因为此时 QEMU 不会启动监控控制台,终端的输入会直接传递给 QEMU 进程。Ctrl+C
信号就直接发送给 QEMU,导致 QEMU 退出。
-append
:向内核传递命令行参数,可以在/proc/cmdline
中查看当前内核的命令行参数。另外在内核源码的Documentation/admin-guide/kernel-parameters.txt
有详细的内核参数说明。这里列举几个常见的内核参数:nokaslr
:禁用内核地址空间布局随机化(KASLR)。pti=1/0
:启用或禁用内核页表隔离(KPTI)。console=ttyS0
:和 nographic 一起使用,启动的界面就变成了当前终端。ttyS0
是第一个串行端口(通常是/dev/ttyS0
)。在 QEMU 中,ttyS0
是虚拟化的串行端口。QEMU 创建了一个虚拟串行端口设备,用于与虚拟机交互,类似于物理机器上的串行端口。quiet
:不打印内核日志信息。oops=panic
:当内核遇到错误时立即触发 panic,导致系统崩溃并重启,而不是继续执行。panic=1
:当内核触发 panic 时,系统将立即重新启动,而不是让系统停机。这样可以在发生内核崩溃时尽快恢复。
-enable-kvm
:QEMU 会尝试利用 KVM 加速来提升虚拟机的性能,前提是主机支持 KVM,并且要以管理员权限启动 QEMU。
安装 qemu 后运行启动脚本即可启动 linux 系统。
1 | sudo apt install qemu-system -y |
内核调试
关键地址的获取方法
获取模块基址的方法
lsmod
命令列出了所有加载的模块及其相关信息,其中就包含了模块基址。1
lsmod | grep <module_name>
例如:
1
2~ # lsmod
hello 16384 0 - Live 0xffffffffc0002000 (OE)查看
/sys/module
中的段信息。1
cat /sys/module/<module_name>/sections/.text
获取内核符号的方法
/proc/kallsyms
是 Linux 内核中的一个虚拟文件,它包含了内核中所有符号(函数、变量等)的名称、地址和类型信息。这个文件是内核调试、符号查找和分析的一个重要工具。这里给出从 /proc/kallsyms
获取符号地址的 C 代码模板。
1 | size_t get_symbol_address(const char *symbol_name) { |
获取内核基址的方法
可以利用符号 _text
来确定内核加载的起始地址。_text
是内核代码段的起始符号,它通常位于内核的起始地址位置。
1 | grep "T _text" /proc/kallsyms |
数据显示相关保护
Dmesg Restrictions
/proc/sys/kernel/dmesg_restrict
是一个用于控制内核日志访问权限的 Linux 内核参数,该参数控制了普通用户能否通过 dmesg
命令查看内核日志。
- **值为
0
**:默认情况下,所有用户都可以查看内核日志(包括普通用户)。 - **值为
1
**:限制普通用户访问内核日志,只有root
用户(或具有相应权限的用户)才能查看。
Kernel Address Display Restriction
内核提供控制变量 /proc/sys/kernel/kptr_restrict
用于控制系统中涉及内核地址的一些输出打印。
kptr_restrict == 2
:内核将符号地址打印为全 0 , root 和普通用户都没有权限。kptr_restrict == 1
: root 用户有权限读取,普通用户没有权限。kptr_restrict == 0
: root 和普通用户都可以读取
提示
dmesg
中的内核地址显示不受 kptr_restrict
影响。
QEMU 仿真的简易 Linux 环境
首先需要对内核启动脚本做如下修改,使其支持和方便 gdb 调试。
- 在内核启动参数即 qemu-system 中
-append
参数中添加nokaslr
关闭内核地址随机化,方便下断点。 - 添加 gdb 调试端口:
-gdb tcp::<port>
:开启 gdb 调试端口,例如-gdb tcp::1337
,gdb 连接该本地1337
端口即可调试 qemu 仿真的 linux 系统。-s
:-gdb tcp::1234
的简写。-S
(可选):会在启动时暂停虚拟机,直到 GDB 连接。
具体修改内容如下:
1 | --- ./boot.sh 2024-12-06 15:30:58.306382140 +0800 |
然后为了方便调试,这里直接使用 shell 脚本 gdb.sh
启动 gdb 调试。
1 |
|
注意
gdb.sh
必须以 root 权限启动,否则部分调试功能结果可能有误(例如 vmmap
)。
-ex "file ./vmlinux"
: 加载内核镜像vmlinux
中的符号。其中 vmlinux 是未压缩的 linux 内核镜像文件,关于它的获取后面有专门的讲解。-ex "add-symbol-file $(find . -name babydriver.ko) 0xffffffffc0000000"
:这行用于加载内核模块babydriver.ko
的调试符号,并指定该模块在内核中的加载地址(0xffffffffc0000000
)。
Vmware 运行的完整 Linux 系统
编辑 /etc/default/grub
,在 GRUB_CMDLINE_LINUX
选项添加 nokaslr
内核参数关闭内核地址随机化。
1 | --- /etc/default/grub.bak 2024-12-10 10:49:05.733806632 +0800 |
之后更新 GRUB 配置(不同的 Linux 发行版可能略有不同)。
1 | sudo update-grub |
关机之后在 vmx 文件中添加如下内容,使得虚拟机支持远程调试:
1 | debugStub.listen.guest64 = "TRUE" |
注意
在编辑 vmx 文件的时候一定要确保虚拟机处于关机状态,否则不会生效。
远程 exp 传输
通常 buxybox 中有 wget
、curl
、tftp
、nc
等可以传输文件的工具。不过很多时候出题人没有在 qemu 启动脚本中给 linux 虚拟环境配置网络,也就是说我们只能与 linux 环境的终端交互,而不能让该 linux 环境与本地建立链接传输文件。
因此我们只能通过终端往里写文件,常用的传输脚本如下:
1 | from pwn import * |
由于一般的 ctf 环境会在 init
脚本中设置定时关机(一般为 120 秒),这就需要我们的 exp 尽可能小。我们通常有如下角度 作优化:
借助编译参数
1
gcc -static -Os -s -ffunction-sections -fdata-sections -Wl,--gc-sections -flto -o exp exp.c
-Os
:GCC 提供的专门用于优化代码大小的选项。-s
:从生成的 ELF 文件中删除符号信息和调试信息。-ffunction-sections -fdata-sections
:将每个函数和数据放入单独的节中,便于链接器去除未使用的部分。-Wl,--gc-sections
:链接器会自动去除未使用的节,从而减小最终的可执行文件大小。-flto
:启用链接时优化,可以进一步消除未使用的代码和数据。
使用更小的标准库
标准 C 库(如
glibc
)通常较大,使用更小的替代品可以显著减小 ELF 文件大小。例如musl
是一个轻量级的 C 标准库,适用于嵌入式和资源受限的环境。安装
musl
工具链1
sudo apt-get install musl-tools
编译程序:用
musl-gcc
代替gcc
,要注意的是 musl 的支持性不如 glibc。
使用压缩工具
1
upx --best --lzma exp
Linux 内核模块
LKM(Loadable Kernel Module) 是指可加载内核模块,通常存储在 /lib/modules/$(uname -r)/kernel/
目录下。它是 Linux 内核的一个重要概念。LKM 使得 Linux 内核能够在运行时动态加载或卸载模块,而无需重新编译或重启操作系统。这种模块化设计可以弥补 Linux 内核作为宏内核的不足,使得 Linux 内核非常灵活,能够根据实际需求添加新功能或更新现有功能。
内核模块相关命令:
insmod
:将制定模块加载到内核中。rmmod
:从内核中卸载制定模块。lsmod
:列出已经加载的模块。modinfo
:查看模块信息。
万物皆文件
基本概念
“万物皆文件”是 Linux 和类 UNIX 系统的核心设计理念之一。它将所有系统资源(包括硬件设备、进程、网络套接字等)抽象为文件,提供了一种统一的接口(如open
、read
、write
、close
、ioctl
等)进行操作。这种设计不仅简化了系统资源的管理,也提升了系统的灵活性和可扩展性。
注意
现代 Linux 系统的内核模块种类繁多,功能需求多样化,因此许多内核模块并不完全遵守“万物皆文件”的设计理念,也不是所有类型的模块都对应文件或通过文件操作相关的 API 进行交互。
例如一些网络数据包过滤相关的模块不是通过用户主动的文件操作进行交互的,而是通过发送一些网络数据包被动的进行交互。
1 | int sockfd = socket(AF_INET, SOCK_STREAM, 0); |
不过在 ctf 题目中涉及到的内核模块主要是字符设备,这一类内核模块遵守万物皆文件的设计理念,也是这里主要介绍的内核模块。
Linux 内核通过一套统一的抽象层和数据结构来支持“万物皆文件”理念,使得用户可以以文件的形式访问各种底层资源。这个实现机制的核心在于 VFS (Virtual File System,虚拟文件系统)抽象层,以及围绕 VFS 的数据结构(super_block
、inode
、dentry
、file
)和文件描述符(File Descriptor)管理方式。另外,设备文件、管道、套接字和特殊的虚拟文件系统(如 /proc
、/sys
)也通过特定的内核机制与 VFS 对接,从而为用户提供统一的文件接口。
文件描述符
文件描述符(File Descriptor,简称 FD)是操作系统用来标识一个打开文件的整数值。在类 Unix 系统中,文件描述符是进程与操作系统内核之间进行文件操作的接口。每个文件描述符都关联着一个具体的文件或资源(例如:文件、管道、套接字等),并且通过文件描述符来访问这些资源。
文件描述符是一个非负整数,通常是一个从 0 开始的索引,用来标识进程打开的文件。
每个进程有自己的文件描述符表。在 Linux 系统中,进程结构体 task_struct
中有一个成员 struct files_struct *files
指向 files_struct
结构体。这个结构体主要是维护进程打开的文件,其中 fd_array
就是文件描述符表,这个表通过成员 fdt
指向的 fdtab
结构来进行维护。
1 | /* |
当进程启动时,操作系统会为其自动打开三个文件描述符,这些文件描述符分别对应标准输入、标准输出和标准错误:
- 0:标准输入(stdin)
- 1:标准输出(stdout)
- 2:标准错误(stderr)
这些文件描述符在程序中可直接使用,其他的文件描述符会在程序中打开新文件时分配。
例如当进程打开一个文件时,操作系统会分配一个文件描述符,并返回该文件描述符给程序。此时,程序可以使用该文件描述符来进行文件读写操作。
1 | int fd = open("file.txt", O_RDONLY); |
虚拟文件系统 (VFS)
VFS 是内核中的一个抽象层,负责为各种不同的文件系统提供统一的接口。VFS 不存储实际文件数据,它是一个中间层,实现了对不同类型文件系统的抽象和统一管理,使操作系统能以相同的方式访问不同类型的存储介质和资源。
VFS 的数据结构关系如下图所示:
其中 inode
结构体包含文件访问权限、所有者、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。
1 | struct inode { |
从其中的联合体成员我们可以看出 VFS 中的文件可以是管道文件、块设备文件、字符设备文件、符号链接文件和目录文件。
在操作系统中,尤其是 Unix 和 Linux 操作系统中,设备被分为 块设备(Block Device)和 字符设备(Character Device)。这两种设备类型主要根据数据的存取方式以及与硬件的交互方式来区分。
- 块设备是指可以以 块 为单位进行数据存取的设备。每个块通常是固定大小的(例如 512 字节、4 KB 或其他大小),并且这些设备支持 随机访问。块设备通常会在内存中进行缓冲(缓存),即数据块会先被读到内存中,然后再进行其他操作(例如写入磁盘),提高效率。硬盘(HDD)、固态硬盘(SSD)、光盘、USB 驱动器、虚拟磁盘等都属于块设备
- 字符设备是指按 字符 为单位进行数据存取的设备,这些设备是 顺序访问 的,通常不支持随机访问。字符设备与硬件设备的交互方式较为简单,数据是按字节流的形式传输的。键盘、鼠标、串行端口、终端、打印机、音频设备等都是字符设备。
file
结构体代表一个打开的文件,系统中每个打开的文件在内核空间都有一个关联的 struct file
。它由内核在打开文件时创建,并传递给在文件上进行操作的任何函数。在文件的所有实例都关闭后,内核释放这个数据结构。
1 | struct file { |
文件操作函数
file
结构体中的 f_op
成员指向的 file_operations
结构体中注册了操作这个文件所需的回调函数。
1 | struct file_operations { |
通常我们可以通过文件处理函数如 read
、write
等对文件进行操作。但是设备类的文件常常有一些自定义的功能,这些功能不太容易通过文件操作函数进行描述,我们通常会注册其中的 unlocked_ioctl
函数(对应 ioctl
系统调用)然后传递功能号和自定义参数来进行交互。
unlocked_ioctl
用于处理设备的ioctl
操作。它处理的是标准的ioctl
请求,通常在 64 位系统上运行时使用。compat_ioctl
用于处理来自 32 位用户空间程序的ioctl
请求。在 64 位系统上,如果ioctl
请求来自 32 位程序,它将调用compat_ioctl
来处理请求。
ioctl
(输入/输出控制,Input/Output Control)函数是一个在 Unix-like 操作系统中常用的系统调用,用于设备控制。通过 ioctl
,用户程序可以向设备驱动程序发送控制命令,以执行特殊的硬件操作或获取设备的状态信息。ioctl
函数在设备文件的操作中扮演了重要角色,尤其是在一些不符合标准文件操作(如读写操作)时,能够提供额外的控制功能。
1 |
|
- **
fd
**:文件描述符,表示一个已经打开的设备文件。你需要通过open
系统调用打开设备文件来获取该文件描述符。 - **
request
**:控制命令,指定操作的类型。每个设备驱动会定义自己支持的命令,这些命令通常是宏定义的常量。 - **
...
**:根据request
命令的不同,ioctl
可能会接受额外的参数。具体参数的数量和类型取决于request
所需的控制命令。
当用户程序调用 ioctl
系统调用时,内核调用栈如下:
pwndbg> bt #0 my_ioctl (file=0xffff888103392500, cmd=1, arg=140730326886248) at /home/ubuntu/Desktop/my_module/main.c:21 #1 0xffffffff813d91dd in vfs_ioctl (arg=140730326886248, cmd=<optimized out>, filp=0xffff888103392500) at fs/ioctl.c:51 #2 __do_sys_ioctl (arg=140730326886248, cmd=<optimized out>, fd=<optimized out>) at fs/ioctl.c:874 #3 __se_sys_ioctl (arg=140730326886248, cmd=<optimized out>, fd=<optimized out>) at fs/ioctl.c:860 #4 __x64_sys_ioctl (regs=<optimized out>) at fs/ioctl.c:860 #5 0xffffffff81eb11cc in do_syscall_x64 (nr=<optimized out>, regs=0xffffc90000587f58) at arch/x86/entry/common.c:50 #6 do_syscall_64 (regs=0xffffc90000587f58, nr=<optimized out>) at arch/x86/entry/common.c:80 #7 0xffffffff8200007c in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:113
ioctl
函数会调用到 ksys_ioctl
函数,该函数度调用的 fdget
会通过 task_struct->files_struct
的文件描述符表找到传入的句柄对应 file
结构体。之后将 file
结构体指针作为参数调用 do_vfs_ioctl
函数。
1 | struct fd { |
在 do_vfs_ioctl
中会针对一些特殊的功能号做一些特殊的处理,否则对于设备文件会调用 vfs_ioctl
函数。这里要注意我们自定义的功能号不能与这些特殊的功能号重合,否则不能调到我们自己注册的 ioctl
函数。
1 | /* |
之后在 vfs_ioctl
函数会调用 file
结构体的 f_op
函数表中的 unlocked_ioctl
函数,进而调用到模块注册的 my_ioctl
函数。
1 | long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) |
内核模块环境基础
内核模块开发库
内核模块通常是用 C 语言编写的,并且不依赖标准库(如 libc)。
因此如果针对发行版 Linux 系统开发内核模块需要我们安装相关的开发库。
1 | sudo apt-get install linux-headers-$(uname -r) # 适用于 Debian/Ubuntu 系统 |
另外如果是针对自己编译的简易 Linux 环境开发内核模块,则只需要有内核源码即可。然而编译内核模块的时候需要一些相关的依赖库,因此需要将内核源码的模块部分编译一遍。
1 | make modules -j$(nproc) |
内核模块代码基本组成
一个最基本的内核模块代码 my_module.c
如下:
1 |
|
模块的许可证
Linux 内核是根据 GNU General Public License (GPL) 发行的开源软件。为了保护内核的开源性质,Linux 内核对模块的许可证有严格的检查机制。
1 |
|
如果模块没有声明 MODULE_LICENSE("GPL")
则编译或时会失败。
1 | ERROR: modpost: missing MODULE_LICENSE() |
加载和卸载函数
加载和卸载函数分别用于初始化模块资源(如内存分配、硬件配置)和释放资源、清理状态。
1 | static int __init my_module_init(void) { ... } |
这些函数需要通过 module_init
和 module_exit
宏注册到 .init.text
和 .exit.text
段中,这样该内核模块加载和卸载的时候会分别调用 my_module_init
和 my_module_exit
函数。
1 | module_init(my_module_init); |
内核模块编译脚本
kbuild
是 Linux 内核的构建系统,它负责管理和构建内核及内核模块的编译过程。kbuild
并不是一个独立的工具,而是 Linux 内核构建的一部分。它在内核源代码树中作为一个“中央编译系统”工作,帮助开发者构建内核和模块。
内核的源代码树由多个目录组成,其中每个子目录都可以包含一个 Kbuild
文件,用来描述该目录下的构建规则。kbuild
系统会根据这些规则,编译目标文件并将其链接成内核模块或内核映像。
为了能够构建自己的内核模块,我们需要编写一个 Makefile 文件来告诉 kbuild
如何编译模块,并提供必要的编译选项。
提示
make
是一个通用的构建工具,它可以用来处理任何项目的构建任务,包括内核模块、用户空间程序等。make
在 Linux 内核模块的编译过程中有一些特殊的功能和行为。编译内核模块时,make
会调用 kbuild
提供的规则来管理编译任务,而不是自身指定编译模块的规则。
构建内核模块的常用 Makefile 脚本如下:
1 | MODULE_NAME ?= my_module |
解释一下这个编译脚本的一些细节:
MODULE_NAME ?= my_module
:设置模块的名称为my_module
,这样最后编译出来的模块为my_module.ko
。另外这里的
?=
运算符是为了给MODULE_NAME
赋默认值my_module
,如果MODULE_NAME
有值则不会覆盖原本的值,因此可以像下面这样在指定模块名称编译。1
MODULE_NAME=<module_name> make
obj-m += $(MODULE_NAME).o
obj-m
:在 Linux 内核的构建系统中,obj-m
是一个特定的变量,用于告诉内核构建系统哪些目标文件需要被编译成 内核模块。另外还有obj-y
表示将内核模块编译到内核镜像中,obj-n
表示不编译。+= $(MODULE_NAME).o
:+=
是一个追加赋值操作符,表示将右边的内容添加到左边的变量中。换句话说,+=
会将指定的目标文件添加到obj-m
变量的当前值中。obj-m
是一个 “模块目标” 列表,它指定了需要编译成模块的目标文件(.o
文件)。对于模块文件,obj-m
会指示kbuild
系统将这些.o
文件链接为.ko
文件,.ko
是内核模块的扩展名(即 “Kernel Object”)。
$(MODULE_NAME)-y := main.o
:编译该内核模块所需要的文件,例如这里我们需要main.c
,那么我们就在该变量中添加main.o
。KERNELDR
:指定了开发环境路径,可以是内核源码也可以是按照的内核头文件库。不过内核源码需要实现编译一遍模块。最后就是具体的编译命令了,例如
$(MAKE) -C $(KERNELDR) M=$(PWD) modules
这条编译命令有如下解释:-C $(KERNELDR)
:让make
进入内核源代码目录(即$(KERNELDR)
)。M=$(PWD)
:M
变量告诉kbuild
当前模块的源代码目录是$(PWD)
(即当前Makefile
所在的目录)。这将使得make
在内核源代码目录中进行构建,但实际编译的模块文件来自于$(PWD)
目录。modules
:指示make
构建模块。
如何让 clion 支持内核模块开发
由于内核构建工具的特殊性,目前 clion 官方并不支持 Linux 内核模块的开发。但是依靠下面这个 CMakeLists.txt
脚本与前面我们的 Makefile 编译脚本结合,我们可以让 clion 完美支持内核模块的代码分析以及编译。
其中 KERNEL_SOURCE_PATH
是源码路径,我们可以在脚本里手动指定,或者脚本会自动找 /usr/src/linux-headers-$(uname -r)
路径下的头文件作为开发环境。
1 | cmake_minimum_required(VERSION 3.0.0 FATAL_ERROR) |
内核模块加载
vermagic
(Version Magic) 是 Linux 内核模块中的一个版本控制字符串,用于描述模块在编译时所使用的内核版本和内核配置。vermagic
的存在确保模块能够被正确加载到内核中,避免由于版本或配置不匹配而导致的不兼容问题。
vermagic
是一个由多个部分组成的字符串,格式如下:
1 | <kernel version> <optional flags> |
内核版本:模块编译时使用的内核版本(通过
uname -r
获取),例如5.17.0
。可选标志:描述模块依赖的内核功能和编译配置,常见的标志包括:
**
SMP
**:表明模块支持对称多处理(SMP)。**
preempt
**:表明模块支持内核的抢占模式。**
mod_unload
**:表明模块支持卸载。**
modversions
**:表明模块启用了符号版本支持。
如果 vermagic 不匹配则加载内核模块会报 invalid module format
错误。
1 | ~ # insmod hello.ko |
我们可以通过 modinfo
命令或者 strings
搜索字符串的方式查看内核模块的 vermagic
。
1 | ~ # modinfo hello.ko |
而内核镜像中的 vermagic
可以通过报错得到。高版本内核为了防 rootkit 隐藏了 vermagic
内容,我们只能通过正则表达式在内核镜像中匹配可能的 vermagic
内容。
1 | strings vmlinux | grep -E "[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)*.*SMP" |
之后如果我们将内核模块 patch 与内核相同的 vermagic
则通常可以成功加载内核模块。
在开发环境中可以在 linux/vermagic.h
中的 VERMAGIC_STRING
宏定义了 vermagic
,有一些教程会让我们直接改源代码来实现 vermagic
的修改。
内核模块开发基础
日志打印
printk()
是 Linux 内核中最常用的日志打印函数,它与用户空间的 printf()
函数非常相似,但是它是专门为内核空间设计的。printk()
的主要功能是将信息打印到内核日志缓冲区中,可以通过 dmesg
命令查看。
1 | int printk(const char *fmt, ...); |
printk()
函数支持不同的日志级别,用于表示不同优先级的消息,日志级别由宏定义,如下:
- **
KERN_EMERG
**:紧急级别,用于系统崩溃或严重故障。 - **
KERN_ALERT
**:警报级别,表示发生了严重错误,需要立即处理。 - **
KERN_CRIT
**:严重级别,表示出现了严重的错误,可能影响系统的正常运行。 - **
KERN_ERR
**:错误级别,表示发生了错误,但系统仍能继续运行。 - **
KERN_WARNING
**:警告级别,表示有潜在问题,但不影响系统的正常运行。 - **
KERN_NOTICE
**:通知级别,表示一些正常的操作或状态变化。 - **
KERN_INFO
**:信息级别,表示一些正常的操作或信息。 - **
KERN_DEBUG
**:调试级别,表示调试信息,仅在调试时启用。
使用示例:
1 |
|
参数传递
内核模块参数允许用户在加载模块时向模块传递数据,从而使模块的行为可配置。内核提供了一套宏和函数来定义和管理这些参数。
定义参数
使用 module_param
宏可以定义模块参数。其基本语法如下:
1 | module_param(name, type, perm); |
- **
name
**:参数名,必须对应模块中的全局变量。 - **
type
**:参数类型,如int
,bool
,charp
(字符串指针)等。 - **
perm
**:参数的文件权限,决定了参数在sysfs中的可见性。通常设置为0444
(只读)或0644
(可读可写)。 - **
MODULE_PARM_DESC
**:提供参数的描述信息。
下面是一个简单的内核模块示例,展示了如何定义和使用模块参数:
1 |
|
加载模块时传递参数
使用 insmod
或 modprobe
命令加载模块时,可以传递参数:
1 | ➜ my_module insmod hello.ko && dmesg -c |
在 sysfs 中查看和修改参数
如果模块参数权限允许,可以在 /sys/module/<module_name>/parameters/
目录下查看和修改参数。
1 | ➜ my_module insmod hello.ko howmany=10 whom="sky123" |
内存管理相关
内核模块开发中,内存管理是一个核心环节,涉及到内存的分配、释放和数据拷贝。由于内核空间和用户空间的严格隔离,内核模块需要使用专门的内存管理函数来操作内存。
内存申请释放
内核提供了一系列函数用于在内核空间分配和释放内存。常用的内存分配函数包括 kmalloc
、kzalloc
、vmalloc
和 kcalloc
,对应的释放函数为 kfree
和 vfree
。
kmalloc
系列函数用于分配一块物理上连续的内存,适用于需要高性能和物理连续性的场景,如设备寄存器的缓冲区。1
2
3
4
5
6
void *kmalloc(size_t size, gfp_t flags);
void *kzalloc(size_t size, gfp_t flags); // 与 kmalloc 类似,但它会将分配的内存清零。
void *kcalloc(unsigned long n, size_t size, gfp_t flags); // 类似于 kzalloc,但用于分配数组,自动计算总大小并初始化为零。
void kfree(const void *objp);vmalloc
用于分配一块虚拟地址空间,适合于较大的内存分配,因为它不要求物理内存连续。1
2
3
4
void *vmalloc(unsigned long size);
void vfree(const void *addr);
内存拷贝
在内核模块中,常常需要在内核空间和用户空间之间传递数据。由于内核不能直接访问用户空间的内存,需要使用内核提供的拷贝函数来安全地进行数据传输。
1 |
|
这里用 __user
宏修饰的参数是用户空间地址,另一个是内核空间地址。
注意
copy_*_user
函数的返回值的含义和memcpy
不太一样。这里的返回值表示未能成功复制的字节数。copy_*_user
函数仅用于内核空间和用户空间之间的数据拷贝,而内核空间内部的数据拷贝则使用memcpy
即可。- 对于涉及内核堆上的数据拷贝,
copy_*_user
函数有一个CONFIG_HARDENED_USERCOPY
编译选项,开启后会判断数据拷贝是否在堆上的object
发生越界访问。
同步问题
在多核和多进程的环境中,内核模块需要处理数据共享和资源竞争的问题,避免数据不一致和竞态条件。为此,内核提供了一系列的同步原语,如锁、信号量、自旋锁等,用于保护共享数据和同步操作。
自旋锁(Spinlocks)
自旋锁是一种忙等待锁,适用于保护短时间的临界区,且不允许在锁定期间睡眠。自旋锁常用于中断处理程序和其他不允许睡眠的上下文中。
在内核中,“睡眠”指的是让当前执行的任务(线程或进程)暂停执行并让出 CPU,等待某些条件满足后才能继续执行。例如,等待 I/O 操作完成、等待某个资源变得可用等。
当内核线程或进程执行到某个地方无法继续执行时,会调用
schedule()
或者通过某些同步机制(如mutex_lock()
等)让当前执行上下文挂起,直到条件满足。此时它处于睡眠状态。不允许睡眠指的是,在某些特殊上下文(如中断上下文、内核线程或底半部等)中,当前代码执行时不能进行任何会导致任务睡眠的操作。
例如当处理硬件中断时,内核进入中断处理程序(Interrupt Handler)来响应硬件事件。此时中断上下文中是不允许进行睡眠操作的,因为中断处理程序执行时,CPU 正在处理中断,任何睡眠操作都意味着当前任务被挂起,而这将阻止中断的处理,可能导致严重的延迟或丢失中断。
1 | spinlock_t my_lock; |
除了睡眠之外,中断嵌套也会造成当前任务当前任务被挂起或阻塞。
例如中断处理程序 A 在进入临界区之后触发中断进入中断处理程序 B,而 B 也要请求该自旋锁进入临界区,结果造成死锁系统卡死。
因此 spin_lock_irqsave
/spin_unlock_irqrestore
宏在自旋锁的基础上加上了禁用中断和恢复中断的功能来解决这一问题。
1 | spinlock_t my_lock; |
互斥锁(Mutexes)
互斥锁用于保护可以睡眠的临界区,适用于较长时间的锁定和需要进行可能睡眠操作的场景。互斥锁在请求锁的操作方式上分为阻塞式和非阻塞式两种。
mutex_lock
是一个阻塞式操作,当一个线程调用 mutex_lock()
请求锁时,如果锁已经被其他线程占用,它会被挂起(阻塞),直到锁被释放。换句话说,当前线程会等待,直到它能够成功获取锁。
1 | struct mutex my_mutex; |
mutex_trylock
是一个 非阻塞式 操作。当线程调用 mutex_trylock()
请求锁时,它会尝试立即获取锁。
- 如果锁当前没有被其他线程占用,
mutex_trylock()
会返回成功(即获得锁)。 - 如果锁已经被其他线程占用,
mutex_trylock()
不会阻塞,它会立即返回-EBUSY
(即表示锁已经被占用),并且不会让当前线程挂起。调用者可以在返回失败时决定如何处理,比如立即返回、执行其他任务,或在稍后再次尝试获取锁。
1 | struct mutex my_mutex; |
读写锁(Read-Write Locks)
读写锁允许多个读者同时访问资源,但写者必须独占访问。适用于读操作多于写操作的场景。
读写锁有两个主要的操作类型:
- 读锁(Read Lock):多个线程可以同时获取读锁进行读取操作。当一个线程获取了读锁后,其他线程也可以获取读锁,只要没有线程持有写锁。
- 写锁(Write Lock):写锁是独占的,只有一个线程可以持有写锁,而且在持有写锁时,其他线程既不能获得读锁也不能获得写锁。写锁用于修改共享数据,确保写入操作的原子性。
1 |
|
信号量(Semaphores)
信号量用于控制多个执行上下文对共享资源的访问,表示资源的数量。适用于控制访问多个实例的资源。
信号量的工作机制主要包括 P操作(即 down()
或 wait()
)和 V操作(即 up()
或 signal()
):
- P操作(Proberen 或 Wait):P操作尝试降低信号量的值。如果信号量的值大于零,它就会将值减一,表示有资源被占用,并允许线程继续执行。如果信号量的值为零,表示没有资源可用,线程会被挂起,直到信号量的值变为正数。
- V操作(Verhogen 或 Signal):V操作增加信号量的值,表示资源被释放。当信号量的值从零增加时,操作系统会唤醒等待该信号量的线程,允许它们获取资源。
1 | struct semaphore my_sem; |
原子操作(Atomic Operations)
原子操作用于执行无锁的原子操作,适用于简单的计数器或标志变量。
1 | atomic_t my_atomic; |
设备模块开发
前面开发的模块功能都十分单一,都只是在模块加载和卸载的时候调用了模块注册的加载和卸载函数,用户程序不能与这些模块进行交互。
为了实现更复杂的功能,我们要把模块开发成设备模块。设备模块通常会在文件系统中对应一个文件,并且在 VFS 中为这个文件注册一些文件处理函数。这样用户程序就可以通过 open
函数获取这个文件的描述符,然后通过一些列文件处理函数与内核模块进行交互,完成一些更复杂的功能。
在 Linux 内核中,设备分为多种类型,其中最常见的是 字符设备 和 块设备,这两种设备类型通常需要进行复杂的资源管理、设备号分配以及文件操作结构体的设置等。而 misc
设备提供了一种简单、便捷的方式来注册一个没有过多要求的设备。因此这里仅介绍 misc
设备的开发。
首先 MISC 设备需要我们创建 miscdevice
设备结构体。miscdevice
是 Linux 内核中定义的结构体,用于表示 misc
设备。在我们注册设备时,必须填充这个结构体。
1 | struct miscdevice { |
miscdevice
结构体包含以下几个重要字段:
- **
name
**:设备名称,会在/dev
目录下创建一个同名文件,用户程序通过这个文件与设备交互。 - **
minor
**:次设备号,MISC_DYNAMIC_MINOR
表示让内核动态分配次设备号。 - **
fops
**:设备操作函数表,通过这个表,内核知道该如何操作设备。
这是我初始化的设备结构体,我将针对这个设备实现 memrw_ioctl
函数。
1 |
|
之后在模块加载函数中通过 misc_register
函数将设备添加到内核的设备管理系统中。对于 MISC 设备内核会为设备自动分配设备号并在 /dev
目录下创建设备节点。(其他类型的设备在这一步需要根据设备号使用 mknod
命令手动在 /dev
目录下创建设备节点)
1 | static int __init memrw_init(void) { |
在卸载函数中我们通过 misc_deregister
将该设备从内核的设备管理系统中移除。
1 | static void __exit memrw_exit(void) { |
剩下的部分就是实现注册在 memrw_fops
中的函数了,这里我实现的是任意地址读写的模块。
1 |
|
编写一个用户程序与内核交互实现修改 modprobe_path
。
1 |
|
Ring Model
基本概念
Ring Model 是一种 CPU 权限级别(Privilege Level) 的分层架构,主要用于操作系统中处理权限隔离和保护。它通过硬件机制,限制不同权限级别的代码对系统资源的访问,从而提高系统的安全性和稳定性。
Ring Model 的分层通常分为 4 个等级,编号从 0 到 3:Ring 0
, Ring 1
, Ring 2
, Ring 3
。
尽管 x86 架构支持 4 级 Ring 模型,但 Linux 只使用了 Ring 0 和 Ring 3:
Ring | 用途 |
---|---|
Ring 0 | 内核态(Kernel Mode):操作系统核心代码运行的位置,包括内存管理、设备驱动、进程调度等。 |
Ring 3 | 用户态(User Mode):用户程序运行的位置,如 bash 、vim 等。 |
提示
Ring Model 主要隔离和保护的是不同级别的内存访问权限(RWX)和特权指令执行。这是 Ring Model 的两个支柱,也是它提供安全隔离的根本所在。
权限保护机制
段保护机制
- 段(Segment) 是 x86 架构中内存管理的基础单元。每个段定义了一个线性地址范围,程序通过段选择子和偏移量访问内存。
- CPU 使用段保护机制,通过段选择子(Segment Selector)和段描述符(Descriptor)来控制不同权限级别(Ring)的内存访问,防止越权操作。
段选择子(Segment Selector)
段选择子是存储在 CPU 段寄存器(代码段 cs
、数据段 ds
、栈段 ss
等)中的低 16 位值,标识了当前程序使用的段。它是程序访问内存段的入口,索引全局或局部段描述符表(GDT 或 LDT)。
段选择子的格式如下:
Index(段索引,13 位):指向段描述符表(GDT 或 LDT)中的一个条目,标识具体的段。
TI(Table Indicator,1 位):决定段描述符表的类型:
- 值为
0
表示选择 GDT(Global Descriptor Table,全局描述符表)。 - 值为
1
表示选择 LDT(Local Descriptor Table,局部描述符表)。
- 值为
RPL(Requested Privilege Level,请求权限级别,2 位):
- 指定程序期望访问目标段时的权限级别,范围为
0-3
。 - 通常,RPL 由调用方设定,用于在段访问中动态降低权限。
- 指定程序期望访问目标段时的权限级别,范围为
段描述符(Descriptor)
每个段在 GDT 或 LDT 中对应一个段描述符。描述符包含了段的基地址、大小、类型和权限等信息。
段描述符的格式如下:
Base Address(基地址,32 位):段的起始地址。在 64 位模式下,基地址通常被固定为
0
,即扁平内存模型。Segment Limit(段大小,20 位):定义段的大小(单位为字节)。在 64 位模式下,通常被忽略,因为地址空间被扩展到 48 位或更高。
Access Rights(访问权限,8 位):包含段的类型和权限字段:
- 类型位(Type):定义段的用途,如代码段、数据段或系统段。不同的类型有不同的内存权限。
- S 位(Descriptor Type):区分系统段(S=0)和普通段(S=1)。
- S = 0:系统段(System Segment),包含特定于系统使用的结构或功能,例如任务状态段(TSS)、中断描述符表(IDT)或局部描述符表(LDT)。
- S = 1:代码段或数据段,表示常规的用户态或内核态段,用于存储可执行代码或数据。
- DPL(Descriptor Privilege Level):段的权限级别,范围为
0-3
,表示对该段的访问要求。
权限类型
- CPL(Current Privilege Level):
- 表示当前程序的运行权限级别,通常由 CS 寄存器的低两位(代码段段选择子)决定。
- CPL 的值必须低于或等于段描述符中的 DPL 才能访问该段。
- RPL(Requested Privilege Level):
- 请求访问段时指定的权限级别。通常由访存时使用的段寄存器的段选择子决定
- RPL 的值不能高于段的 DPL。
- DPL(Descriptor Privilege Level):
- 描述符中定义的段权限级别。
提示
简单总结一下,就是当前的执行代码的权限(CPL)和请求访问内存的权限(RPL)都不能低于(值要小于等于)段描述符描述的目标内存的权限。
权限检查过程
这里以 mov rax, qword ptr ds:[0xdeadbeef]
为例介绍一下段保护机制权限检查的过程。
读取
DS
段选择子:CPU 从DS
段寄存器中读取段选择子的值。读取段描述符:CPU 从
DS
段寄存器中读取段选择子的值。这里先根据段选择子的TI
位确定是 GDT,然后根据Index
字段中 GDT 中找到段描述符。检查 S 位(Descriptor Type):因为是访存操作,所以要求 S 位为 1(数据段)。
检查 RPL(Requested Privilege Level):CPU 会比较
max(CPL, RPL)
和目标段描述符的DPL
(Descriptor Privilege Level)。如果结果大于目标段的 DPL,则触发 **General Protection Fault (GPF)**。检查段描述符类型:根据段描述符的 Access Rights 字段的类型位(Type)确认操作(读取数据)符合段的权限(RW 位)。
页保护机制
在 64 位系统(如 x86_64 架构)中,页保护机制是内存管理的核心,通过分页(Paging)机制实现虚拟地址到物理地址的映射,同时提供细粒度的权限控制(例如用户空间与内核空间的隔离)。
提示
关于分页机制会在内存管理部分详细介绍,这里仅介绍权限管理相关内容。
权限字段
每个页表条目(Page Table Entry, PTE)包含物理地址和权限信息:
P(Present):第 0 位,表示页是否有效。如果为
0
,表示页不在内存中(可能在磁盘上),访问时会触发 Page Fault。R/W(Read/Write):第 1 位,表示页是否可写。如果为
0
,则该页只读。U/S(User/Supervisor):第 2 位,表示用户态(Ring 3)是否可以访问:
U = 1:用户态可访问。
U = 0:仅内核态(Ring 0)可访问。
NX(No Execute):第 63 位,表示页是否可执行:如果为
1
,则该页不可执行(需要 CPU 支持 NX 位)。
Linux 的 KPTI 机制
Kernel Page Table Isolation(KPTI) 是一种内核内存隔离机制,最初是在 Linux 4.15 (2018年1月发布)版本中引入,用于解决 Meltdown 漏洞(CVE-2017-5754)。
Meltdown 是一种硬件级漏洞,该漏洞利用了现代处理器的分支预测和缓存特性,可以通过侧信道攻击绕过用户态与内核态的隔离,使得用户态程序可以读取内核内存中的敏感数据。
当用户态访问内核地址时,尽管会触发权限检查失败,但在实际触发前,CPU 已经通过分支预测机制将数据加载到缓存中。
攻击者可以通过读取缓存侧信道(如时间测量等技术)获取这些数据。
基本原理
在传统未开启 KPTI 的 Linux 系统中,内核页表和用户页表共存于同一张全局页表(PGD)。开启 KPTI 后,内核为用户态和内核态分别维护两张独立的页全局目录(PGD)。
- 内核页表:包含用户和内核地址空间的完整映射。但是用对应户空间的页表项会添加
_PAGE_NX
标志,以阻止执行内核态页表所映射用户地址空间的代码。在KAISER patch
里把这一步骤叫 毒化(poison
)。 - 用户页表:完整映射用户地址空间。但内核地址空间仅保留必要的条目(如系统调用入口和中断处理)。
由于每张页全局目录表占用 4 KB,两张页表连续分配在内存中,因此两张全局页目录表的地址仅在第 13 位不同。
- 用户态进入内核态:当用户态程序通过 系统调用 或 中断 进入内核态时,会执行用户态页表映射的系统调用入口代码。在这段代码会将 CR3 寄存器的第 13 位取反,切换到内核页表,这样就可以访问完整的内核空间。
- 内核态返回用户态:内核完成系统调用或中断处理后,需要切换回用户态,此时内核通过取反 CR3 的第 13 位,切换回用户页表。切换完成后,内核地址空间的绝大部分被剥离,仅保留必要的条目。
保护效果
开启 KPTI 保护之后:
- 用户空间和内核空间的页表分开,从而实现了内核空间与用户空间的地址完全隔离,阻止了 Meltdown 攻击利用的内存泄漏路径。
- 由于内核页表中对应户空间的页表项会添加
_PAGE_NX
标志,内核空间无法执行用户态代码。 - 不过内核空间可以正常读写用户空间的内存,这部分操作不受影响。
查看 KPTI 开启情况
在现代 Linux 系统中,/sys/devices/system/cpu/vulnerabilities/
目录下会包含与 CPU 漏洞相关的信息。其中 /sys/devices/system/cpu/vulnerabilities/meltdown
文件中有关于 KPTI 保护的相关信息。如果开启 KPTI 则该文件的内容为 Mitigation: PTI
。
1 | / $ cat /sys/devices/system/cpu/vulnerabilities/* |
开启或关闭 KPTI
首先我们需要判断内核版本是否不低于 4.15,因为 KPTI 保护是从这个版本开始引入的。
另外 KPTI 的核心代码会根据内核编译选项 CONFIG_PAGE_TABLE_ISOLATION
来确定是否包含在内核中。如果在编译时未启用 CONFIG_PAGE_TABLE_ISOLATION
则 KPTI 的相关代码会被剔除,运行时无法开启或关闭。在这种情况下,KPTI 完全不可用。
通常 KPTI 是通过内核启动参数 kpti
来设置的,kpti=1
开启KPTI保护,kpti=0
关闭 KPTI 保护。但是对于 QEMU 仿真的简易 Linux 环境来说,,虚拟 CPU 模型会覆盖这些设置。
在虚拟化环境中,QEMU 提供了多个虚拟 CPU 模型,kvm64
和 qemu64
就是其中两种常见的模型。不同的 CPU 模型和虚拟化配置会影响虚拟机中运行的内核行为。
- **
kvm64
**:这是 KVM 默认的 CPU 模型,它模拟了一种更现代的 CPU,通常启用了现代的硬件安全特性,包括 KPTI。使用kvm64
模型时,虚拟机的内核会自动启用 KPTI 以保护系统免受 Meltdown 漏洞的影响。 - **
qemu64
**:这是 QEMU 提供的基本 CPU 模型,不启用 KVM 的加速功能(即使 QEMU 本身在运行时)。使用qemu64
模型时,QEMU 可能模拟的 CPU 特性不包括 KPTI,因此内核不会启用 KPTI 保护。
也就是说我们设置 -cpu kvm64
就可以开启 KPTI 保护,设置 -cpu qemu64
就可以关闭 KPTI 保护。
CPU 硬件保护机制
CR4
是 x86 架构中一个非常重要的控制寄存器,用于控制一些与操作系统安全、内存管理、异常处理等相关的硬件特性。
其中 SMEP(20 位)和 SMAP(21 位)是两个与权限保护相关的标志位。
通常我们可以通过设置 CR4
寄存器的值为 0x6F0 来关闭 SMEP 和 SMAP 保护。
SMEP
SMEP (Supervisor Mode Execution Prevention) 是一种用于提高计算机系统安全性的硬件级保护机制,旨在防止在内核模式下执行用户空间中的代码。
注意
开启 SMEP 不影响内核空间代码读写用户空间的内存。
SMAP
SMAP(Supervisor Mode Access Prevention)是现代 CPU 中的一项硬件安全特性,用于保护内核模式(Ring 0)和用户模式(Ring 3)之间的内存访问隔离。它通过限制内核代码访问用户空间的内存,减少了内核受到攻击的风险。
注意
开启 SMAP 后内核空间代码虽然不能读写用户空间的数据,但是可以执行用户空间的代码。
系统调用
基本概念
系统调用(System Call) 是操作系统提供给用户程序的一组接口,用于请求内核执行特定的服务或操作,如文件读写、进程控制、网络通信等。系统调用充当了 用户态(Ring 3) 与 内核态(Ring 0) 之间的桥梁,允许用户程序以安全且受控的方式访问系统资源。
由于内核具有更高的权限级别,用户程序不能直接操作硬件资源或访问敏感内存。因此,系统调用是用户程序请求内核服务的唯一合法途径。这种隔离保护机制有助于提升系统的安全性和稳定性。
在 x86_64 架构中,系统调用通常通过 syscall 指令 或 int 0x80 指令 实现,两者都可以用来触发用户态到内核态的切换。其中,syscall
是现代 x86_64 架构中常用的实现方式,性能更优。
x86 系统调用相关指令
系统调用指令
在 32 位架构中,主要的系统调用指令有 int
和 **sysenter
**,两者具有不同的性能和适用场景。另外调用门是原本 CPU 专门为操作系统的系统调用设计的,但是用于性能开销大而基本不被操作系统采用,这里不做介绍。
int 0x80
int
是 x86 架构中的 软件中断指令。它是通过生成一个中断信号来改变程序的执行流,跳转到一个特定的中断处理程序。
int
指令通过指定中断向量号来触发中断处理,其中中断向量号是一个 8 位的数字(范围:0~255),用来标识中断的类型。
考虑到硬件兼容性问题,在 32 位下的 linux 系统调用主要是通过 int 0x80
指令实现。也就是将断向量号为 0x80 的中断作为系统调用。
执行系统调用时,用户态程序需要设置系统调用号和参数,
EAX
:系统调用号。EBX
、ECX
、EDX
、ESI
、EDI
:系统调用的参数。
之后,用户态程序执行 int 0x80
指令进行系统调用。int
指令的执行过程十分复杂,这里简单介绍一下执行过程:
查找和加载中断描述符
在
int <vector>
指令中,CPU 使用 中断向量号 来查找 IDT 条目(IDT_ENTRY = IDTR.base + (vector * 8)
)。IDTR
寄存器(Interrupt Descriptor Table Register)是一个特殊寄存器,存储了 IDT 的起始地址(base
)和长度(limit
)。CPU 使用IDTR
寄存器来找到 IDT 的基地址和大小。1
2
3IDTR:
base -> IDT 的起始物理地址。
limit -> IDT 的大小(字节数 - 1)。权限和属性检查
在加载描述符后,CPU 会检查以下内容:
描述符类型:确保描述符是有效的中断门(Interrupt Gate)或陷阱门(Trap Gate),如果类型无效,会触发异常。
特权级检查:CPU 检查当前代码段的 CPL(Current Privilege Level)与描述符的 DPL(Descriptor Privilege Level),如果 CPL > DPL,则拒绝访问,触发 General Protection Fault。
保存当前上下文到栈
CPU 将当前执行环境保存到栈中,以便中断处理完成后能够恢复原状态。
- 如果没有特权级切换,即中断处理程序和中断触发点的特权级相同(如都在 Ring 0)则依次保存以下内容到当前栈:
EFLAGS
CS
EIP
- 如果发生特权级切换,即如果从用户态(Ring 3)切换到内核态(Ring 0)则首先切换到内核栈,新的栈指针(
ESP0
)从任务状态段(TSS)中读取。之后依次保存以下内容到内核栈:- 用户态
SS
- 用户态
ESP
EFLAGS
- 用户态
CS
- 用户态
EIP
- 用户态
- 如果没有特权级切换,即中断处理程序和中断触发点的特权级相同(如都在 Ring 0)则依次保存以下内容到当前栈:
跳转到中断处理程序
- 将描述符中的段选择子加载到
CS
。 - 将描述符中的偏移地址加载到
EIP
,跳转到目标处理程序入口。 - 清除或保留
IF
标志- 如果描述符类型是 中断门,清除
IF
标志,屏蔽中断。 - 如果是 陷阱门,保留
IF
标志,允许嵌套中断。
- 如果描述符类型是 中断门,清除
- 将描述符中的段选择子加载到
sysenter
由于中断指令 int
本身就不是为系统调用而设计的,因此 int 0x80
方式涉及查询中断向量表、权限判断等复杂且没有必要的操作,这对于系统调用这种频繁使用的功能来说会带来很大的开销。因此就有了专门用于系统调用的指令 sysenter
。
在内核初始化时,操作系统会配置以下 MSR 寄存器,为 sysenter
指令设置入口(这些寄存器需要在内核态配置,用户态无法直接访问):
寄存器 | 作用 |
---|---|
SYSENTER_CS_MSR |
指定内核代码段选择子,通常设置为内核代码段(0x10 )。 |
SYSENTER_EIP_MSR |
指定内核入口地址,指向内核的系统调用入口函数(如 Linux 中的 system_call 或 sysenter_entry )。 |
SYSENTER_ESP_MSR |
指定内核栈的初始栈指针。 |
当用户态程序执行 sysenter
指令时,CPU 直接进行如下操作完整权限和栈切换以及代码跳转:
设置目标代码段和指令指针:
- 将
SYSENTER_CS_MSR
中的值加载到CS
。 - 将
SYSENTER_EIP_MSR
的值加载到EIP
,跳转到内核的入口函数。
- 将
设置目标栈指针:
- 将
SYSENTER_ESP_MSR
的值加载到ESP
,切换到内核栈。
- 将
注意
sysenter
寄存器不保存寄存器,因此为了能正确返回用户态,需要在系统调用前的用户代码中保存 EIP
和 ESP
到指定寄存器中。
1 | lea edx, [next_instruction] ; 保存返回的 EIP |
因为 sysenter
需要用户态程序的配合导致兼容性很差,因此 intel 的这一方案没有被操作系统采用。进入 64 位后 AMD 推出的 syscall
吸取了这个教训,因此能够在 64 位操作系统中一统江湖。
返回用户态指令
retf(远返回指令)
retf
是用于跨段(特权级)返回的指令,在 x86 中用来从调用门(Call Gate)或其他改变段选择子的特权切换中返回。具体执行过程为:
- 恢复用户态:
- CPU 从当前栈中弹出
EIP
和CS
,更新当前的代码段和指令寄存器。
- CPU 从当前栈中弹出
- 根据特权级切换栈:
- 如果
CS
中的 CPL(Current Privilege Level)与当前特权级不同,则说明发生了特权级切换,CPU 从当前栈中弹出SS
和ESP
切换到用户态栈。 - 如果特权级相同,则继续执行,不切换栈。
- 如果
iretd(中断返回指令)
iretd
是中断处理程序返回用户态时最常用的指令,用于恢复中断发生前的寄存器状态。具体执行过程为:
- 恢复用户态:
- CPU 从栈中依次弹出以下内容:
EIP
(指令寄存器):恢复中断发生时的指令地址。CS
(代码段选择子):恢复用户态代码段。EFLAGS
(标志寄存器):恢复中断前的状态标志。
- CPU 从栈中依次弹出以下内容:
- 检查 CS 寄存器的有效性:
- 段存在检查:确保段描述符存在位是设置的,即段实际存在于内存中。
- 类型检查:确保描述符类型适用于代码执行,例如,不能是数据段或其他非执行类型的段。
- 权限检查:确保代码段的 DPL 至少与
CS
选择子的请求特权级(RPL)一致。
- 根据特权级切换栈:
- 如果
CS
中的 CPL(Current Privilege Level)不等于当前特权级,则说明发生了特权级切换,CPU 从栈中弹出用户态的SS
和ESP
,切换到用户态栈。
- 如果
- 重新启用中断(如果需要):
- 如果
EFLAGS
中的 IF 位(中断标志)被恢复为 1,则重新启用硬件中断。
- 如果
sysexit(快速系统调用返回指令)
sysexit
是 Intel 为了优化系统调用性能而引入的快速返回指令,配合 sysenter
使用。具体执行过程为:
- 恢复用户态寄存器:
EIP
被设置为EDX
中的值。ESP
被设置为ECX
中的值。
- 切换段寄存器:
- 将
CS
设置为SYSENTER_CS_MSR
。 - 将
SS
设置为SYSENTER_CS_MSR + 8
(内核代码段和内核数据段的选择子通常相差8
,因此由硬件设计直接规定)。
- 将
x86-64 系统调用相关指令
系统调用指令
syscall
syscall
是 x86-64 架构中用户态程序进入内核态执行系统调用的主要指令,设计目的是取代传统的 int
或 sysenter
指令,提供更高效的系统调用路径。
保存寄存器:
为了保证在执行完系统调用后可以正确地恢复到用户态。
- 当前的
RIP
(用户态下一条指令的地址)被保存在RCX
中。 - 当前的
RFLAGS
寄存器被保存在R11
中。
- 当前的
设置寄存器:
- 标志寄存器:通过
IA32_FMASK MSR
定义的RFLAGS
位被清除,通常包括中断标志(IF),以防止syscall
执行过程中被中断。 - 代码段和栈段选择子:从
IA32_STAR MSR
读取内核代码段(CS
)和栈段(SS
)选择子,并更新对应的寄存器。这不涉及栈指针RSP
的改变,只是段寄存器的更新。 - 指令寄存器:
RIP
被设置为IA32_LSTAR MSR
中的值,即内核定义的入口点地址。
- 标志寄存器:通过
返回用户态指令
iretq
iretq
(Interrupt Return)是 x86-64 架构中用于从中断、异常或其他低特权级(如用户态)代码的调用返回的指令,是 iretd
的 32 位形式。
sysret
sysret
指令是 x86-64 架构中与 syscall
指令配套使用的指令,用于从系统调用中返回用户态。
恢复用户态寄存器:
RIP
被设置为RCX
中的值。RFLAGS
被设置为R11
中的值。
切换段寄存器:
CS
(代码段寄存器)被设置为IA32_STAR MSR
的用户代码段选择子加 16。这通常是STAR_MSR
中指定的值,指向用户代码段。SS
(栈段寄存器)被设置为用户栈段选择子,通常是STAR_MSR
的用户栈段选择子加 8。
Linux 系统调用内核实现
这里仅介绍 64 位 syscall
指令的系统调用过程。
系统调用入口
entry_SYSCALL_64
entry_SYSCALL_64
是系统调用进入内核态的入口函数,定义在 arch/x86/entry/entry_64.S
中。具体步骤为:
切换 GS 寄存器的基地址。在用户态,
GS
通常指向用户空间的数据结构;在内核态,GS
指向内核的per-CPU
数据结构,因此需要切换 GS 寄存器的基地址。1
swapgs
保存用户态的栈指针 rsp 到内核 TSS 的 sp2 字段。sp2 是内核中的一个暂存区域,用于存储用户态的 rsp。
1
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
切换到内核地址空间(通过
CR3
切换页表),具体见前面 KPTI 机制。1
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
实际对应的汇编代码如下:
1
2
3mov rsp, cr3
and rsp, 0xffffffffffffe7ff
mov cr3, rsp切换到内核栈。
cpu_current_top_of_stack
是当前 CPU 对应的内核栈顶。1
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
构造 struct pt_regs。
struct pt_regs
是 Linux 内核用来保存用户态寄存器状态的一个关键结构体。它通常包含以下字段:1
2
3
4
5
6
7struct pt_regs {
unsigned long r15, r14, r13, r12; // Callee-saved registers
unsigned long rbp, rbx, r11, r10; // Callee-saved and caller-saved registers
unsigned long r9, r8, rax, rcx, rdx, rsi, rdi; // General-purpose registers
unsigned long orig_rax; // Original value of rax
unsigned long rip, cs, eflags, rsp, ss; // Instruction pointer, segment selectors, etc.
};在系统调用中,内核需要构造
struct pt_regs
来保存用户态的寄存器状态,以便后续调试、错误处理或恢复用户态时使用。这部分对应汇编代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
; __USER_DS 是用户态的数据段选择子,通常用于恢复用户态栈段(SS)。
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
; PER_CPU_VAR(cpu_tss_rw + TSS_sp2) 是用户态的栈指针(RSP),从 TSS 的 sp2 字段中获取。
pushq %r11 /* pt_regs->flags */
; r11 寄存器保存了用户态的 RFLAGS,在进入内核态时由 syscall 指令保存。
pushq $__USER_CS /* pt_regs->cs */
; __USER_CS 是用户态的代码段选择子,通常用于恢复用户态的代码段。
pushq %rcx /* pt_regs->ip */
; rcx 寄存器在 syscall 指令执行时存储了用户态的返回地址(RIP)。
(entry_SYSCALL_64_after_hwframe)
pushq %rax /* pt_regs->orig_ax */
; rax 寄存器存储了系统调用号,保存其原始值以便后续处理。
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
; 这是一个宏,用于压入所有通用寄存器(如 r15、r14、r13 等)到栈上。
; 其中 rax 寄存器实际保存的是 -ENOSYS(-38) 即 Function not implemented (功能未实现)。
; -ENOSYS 属于系统调用的一个默认返回值。
; 之后把 rbp rbx rcx rdx r8 r9 r10 r11 r12 r13 r14 r15 寄存器清零。调用
do_syscall_64
函数执行分发至具体的系统调用处理函数。1
2
3movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */其中
do_syscall_64
函数的定义如下,因此第一个参数为系统调用号,第二个参数为pt_regs
结构体地址即当前栈顶。1
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
do_syscall_64
do_syscall_64
函数是 Linux x86-64 平台上系统调用的核心分发和处理逻辑。这个函数的核心逻辑是根据系统调用号在 sys_call_table
中找到对应的处理函数并执行:
1 | nr &= __SYSCALL_MASK; |
另外 do_syscall_64
函数中还有一些与调试(ptrace
)、系统调用跟踪( strace
)相关的处理逻辑,不过这些并不重要。另外像栈偏移随机化的 RANDOMIZE_KSTACK_OFFSET
保护也是在这里实现的。
系统调用返回
do_syscall_64
在 do_syscall_64
中,系统调用完成后会进入 syscall_return_slowpath
:
1 | void do_syscall_64(...) |
该函数主要执行一些延迟工作(如调试器事件、审计、跟踪等),并调用其他函数(如 prepare_exit_to_usermode
)为最终返回用户态做好准备。
entry_SYSCALL_64 剩余部分
之后从 do_syscall_64
函数返回至 entry_SYSCALL_64
函数。entry_SYSCALL_64
函数的剩余部分主要做的工作是对上下文环境进行检查,判断是否存在异常,从而决定是采用快速返回路径(syscall_return_via_sysret
)还是慢速返回路径(swapgs_restore_regs_and_return_to_usermode
)来返回用户态。
中断返回检查,标记即将切换中断标志位(
IF
位),为后续的返回路径准备。1
TRACE_IRQS_IRETQ /* we're about to change IF */
检查栈中保存的 rcx 和 rip 是否相等。正常情况下系统调用的时候会用
rcx
保存系统调用的返回地址,在初始化pt_regs
的时候会把rcx
放到结构体的rip
字段上。因此如果rcx
和rip
不相等说明用户态的返回地址被恶意修改,或内核在处理过程中修改了返回上下文,此时内核会选择使用更通用的iretq
返回路径(swapgs_restore_regs_and_return_to_usermode
),因为iretq
不依赖rcx
和rip
的一致性。1
2
3
4
5movq RCX(%rsp), %rcx
movq RIP(%rsp), %r11
cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */
jne swapgs_restore_regs_and_return_to_usermode检查返回地址的规范性。根据页表级数对
RCX
的高位进行掩码,判断返回地址是否 在 48 位或 57 位虚拟地址范围内,确保地址是规范的。如果地址非规范,跳转到慢速路径。1
2
3
4
5
6
7
8
9
10
11#ifdef CONFIG_X86_5LEVEL
ALTERNATIVE "shl $(64 - 48), %rcx; sar $(64 - 48), %rcx", \
"shl $(64 - 57), %rcx; sar $(64 - 57), %rcx", X86_FEATURE_LA57
#else
shl $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
sar $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
#endif
/* If this changed %rcx, it was not canonical */
cmpq %rcx, %r11
jne swapgs_restore_regs_and_return_to_usermode代码段选择子检查,检查栈中保存的代码段选择子(
CS
)是是用户态的段选择子(0x33)。1
2cmpq $__USER_CS, CS(%rsp) /* CS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode检查栈中保存的 r11 和 rflags 是否相等。和检查栈中保存的 rcx 和 rip 是否相等原因相同。
1
2
3movq R11(%rsp), %r11
cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */
jne swapgs_restore_regs_and_return_to_usermode特殊标志位检查。检查
RFLAGS
中的RF
(恢复标志)和TF
(单步调试标志),如果设置了这些标志,sysret
无法正确处理,需要跳转到慢速路径。1
2testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11
jnz swapgs_restore_regs_and_return_to_usermode栈段选择子检查。检查栈段选择子(
SS
)是否为用户态数据段选择子(0x2b)。1
2cmpq $__USER_DS, SS(%rsp) /* SS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode
快速返回路径:syscall_return_via_sysret
syscall_return_via_sysret
是快速返回路径的实现,通过高效的 sysret
指令从内核态返回用户态。
从栈上恢复通用寄存器。
pop_rdi=0
:RDI
寄存器不在此处恢复,因为后续切换栈到跳板栈时要用rdi
暂时保存原本内核的栈顶。skip_r11rcx=1
:跳过恢复R11
和RCX
,因为在entry_SYSCALL_64
的相关检查中它们已经被恢复(实际上对应的位置被pop rsi
填充)。
1
POP_REGS pop_rdi=0 skip_r11rcx=1
保存旧栈指针并切换到跳板栈。
跳板栈(trampoline stack):
- 跳板栈是每个 CPU 专属的栈,用于返回用户态时的中间处理。
- 切换到跳板栈有助于清理返回路径,避免使用用户态或内核态栈。
PER_CPU_VAR(cpu_tss_rw + TSS_sp0)
:指向当前 CPU 的跳板栈指针。
1
2movq %rsp, %rdi ; 将当前栈指针(%rsp)保存到 RDI 中,供后续切换栈时使用。
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp ; 切换到跳板栈切换到用户页表。在这之前需要先将原本内核栈中保存的 rsp 和 rdi 保存到跳板栈中,因为这
rdi
寄存器在切换页表时会用到;rsp
寄存器在恢复rdi
时会用到。1
2
3
4
5pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
popq %rdi
popq %rsp对应的汇编代码为:
1
2
3
4
5
6
7
8
9push qword ptr [rdi + 0x28] /* RSP */
push qword ptr [rdi] /* RDI */
mov rdi, cr3
or rdi,0x1000
mov cr3, rdi
pop rdi
pop rsp提示
关闭 KPTI 后由于内核代码会被动态补丁(patch)修改,导致页表切换代码(
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
)被跳过。恢复 gs 寄存器并返回。
1
2
USERGS_SYSRET64 ; 实际汇编为:swapgs; sysretq
慢速返回路径:swapgs_restore_regs_and_return_to_usermode
从栈上恢复通用寄存器。
pop_rdi=0
表示rdi
寄存器不恢复,因为RDI
在后续的栈切换过程中被用作临时寄存器。1
POP_REGS pop_rdi=0
保存旧栈指针并切换到跳板栈。
1
2movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp复制 IRET 帧到跳板栈。
1
2
3
4
5pushq 6*8(%rdi) /* SS */
pushq 5*8(%rdi) /* RSP */
pushq 4*8(%rdi) /* EFLAGS */
pushq 3*8(%rdi) /* CS */
pushq 2*8(%rdi) /* RIP */切换到用户页表。在这之前需要先将
rdi
保存到跳板栈中,因为这rdi
寄存器在切换页表时会用到。1
2
3
4
5
6
7/* Push user RDI on the trampoline stack. */
pushq (%rdi)
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
/* Restore RDI. */
popq %rdi对应的汇编代码为:
1
2
3
4push rdi
mov rdi, cr3
or rdi,0x1000
pop rdi提示
关闭 KPTI 后由于内核代码会被动态补丁(patch)修改,导致页表切换代码(
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
)被跳过。恢复 gs 寄存器并返回。
1
2SWAPGS ; swapgs
INTERRUPT_RETURN ; iretq
kernel pwn 如何返回用户态
iretq + SIGSEGV
这种方式需要我们寻找一个 swapgs; iretq;
的 gadget 来返回用户空间。另外还要根据 iretq;
指令的需求设置栈顶为 trap_frame
结构来表示返回用户空间后的状态。
1 | struct trap_frame { |
对于开启 KPTI 保护的内核来说,这种方式缺少页表切换操作,导致返回到用户态后用户态代码没有执行权限造成异常。
一种方法是把 get_shell
函数注册为 SIGSEGV
信号处理函数,然后再用 swapgs + iretq 的方式返回。当出现异常时会跳转到 get_shell
函数继续执行,这样就完成了用户态的返回。
1 | signal(SIGSEGV, (void (*)(int)) get_shell); |
上面这段代码本质就是在程序的段错误信号注册了一个 get_shell
回调函数。开启 KPIT 后 swapgs + iretq 的方式返回位切换页表,执行用户空间代码触发段错误回调 get_shell
函数。而回调 get_shell
函数前也会有系统态到用户态的切换,此时完成了页表和栈的正确切换(栈 0x10 对齐),确保执行 get_shell
函数不会出问题。
1 |
|
swapgs_restore_regs_and_return_to_usermode
根据我们对 swapgs_restore_regs_and_return_to_usermode
函数的分析,忽略掉前面恢复通用寄存器以及切换到跳板栈的操作后,该函数等价于如下代码:
1 | mov rdi, cr3 |
因此我们只需要跳转到该函数恢复通用寄存器之后的代码处,并且在栈上依次设置 rax
(如果有)、rdi
和 trap_frame
结构就可以实现页表切换+返回用户态。
并且对于关闭 KPTI 的情况,内核代码会被动态修改,也就是说该函数中切换页表的操作会被跳过,因此这个方法也可以完美兼容 KPTI 关闭的情况。
进程核心结构体 task_struct
在 Linux 操作系统中,task_struct
是一个关键的数据结构,用于表示进程的状态。每个进程在 Linux 内核中都有一个 task_struct
结构体实例,它包含了关于该进程的所有信息,如进程状态、堆栈、调度信息、打开的文件、信号状态等。task_struct
结构体定义在 <linux/sched.h>
头文件中。
其中在 kernel pwn 中用到的关键字段如下图所示:
void* stack
:这个字段指向进程的内核栈的栈顶。在 kernel pwn 中我们可以通过这个字段获取到内核栈地址,从而向内核栈喷射 ROP 实现提权。struct list_head tasks
:该字段是一个链表节点,用于将当前进程结构体链接到全局的进程链表中。这使得内核能够遍历所有进程。在 kernel pwn 中我们从全局变量
init_task
开始通过tasks.prev
遍历task_struct
来寻找 exp 进程对应的task_struct
。由于新创建的进程是从
init_task.tasks.prev
插入的,因此按照prev
方向遍历可以更快的找到我们的进程。在 Linux 操作系统中,
init_task
是一个非常关键的结构,它是所有进程的祖先。init_task
定义了系统中第一个进程,即进程号为 0 的swapper
或idle
进程。这个进程在系统启动时被创建,并作为所有后续进程的根基。它不执行任何实际的应用级任务,而是主要负责系统的调度和管理任务。init_task
在 Linux 内核源代码中通常是以宏的形式静态定义( 位于linux/init_task.h
)的。struct mm_struct mm
:指向mm_struct
结构的指针,该结构包含进程的内存管理信息。pgd_t pgd
:指向该进程页全局目录(Page Global Directory)的指针(注意这是线性映射区的地址而不是物理地址),是虚拟内存地址转换中最顶层的页表。在 kernel pwn 中我们借助这个找到 PGD,进而可以解析页表实现虚拟地址到物理地址的转换。
pid_t pid
:进程的唯一标识符,即进程ID。在 kernel pwn 中我们可以通过这个字段判断task_struct
是否对应自身进程。struct list_head ptraced
:一个链表头,用于链接所有跟踪(或被该进程跟踪)的进程。在没有进程附加的时候这个字段是是空链表指向自己(注意不是指向task_struct
起始地址)。因此在 kernel pwn 中如果我们有物理地址上的任意地址读,那么如果我们扫描物理内存找到自身进程对应的task_struct
,就可以根据这个字段计算出task_struct
的地址(线性映射区的地址),结合物理地址上的偏移进而可以得到线性映射区的起始地址page_offset_base
。const struct cred __rcu *cred
:指向进程的凭证结构的指针,包括 UID、GID 和其他安全相关信息。在 kernel pwn 中我们的目的就是想办法让该字段字段指向的cred
对应的权限变成 root 权限。直接针对这个字段的操作有两种:修改
cred
指针,使其指向init_cred
。init_cred
主要用于定义系统启动时进程的默认安全属性。在 Linux 操作系统中,init_cred
为系统初始化进程(如 init 或 systemd)提供初始的用户和组标识符,通常是 root 用户(UID 0)和 root 组(GID 0)。这允许系统初始化进程以最高权限运行,从而完成系统启动和配置任务。修改
cred
指针指向的cred
结构体的内容,使得其中的uid
、gid
等字段变为 0。
char comm[TASK_COMM_LEN]
:进程的命令名,通常是启动进程的可执行文件名,这个字段的最大长度为 16 字节。在 kernel pwn 中,如果我们有无限次任意地址读的能力,那么我们通常使用如下代码修改这个字段,然后在内存中搜索这个字符串来定位自身进程对应的task_struct
。1
2
3
prctl(PR_SET_NAME, "sky123123123123");struct fs_struct *fs
:指向文件系统相关结构的指针,包括文件描述符表、根目录、当前工作目录等信息。在 kernel pwn 中,我们会将这个字段修改指向init_fs
来实现 docker 逃逸。因为init_fs
是系统初始化时使用的全局fs_struct
实例,如果某进程的fs_struct
被修改为init_fs
,理论上这个进程将会获得对整个宿主机文件系统的访问能力,而不再受到容器文件系统命名空间的限制,即导致容器逃逸。
Linux 权限管理
用户权限
注意
为了方便记录,用户权限和文件权限分开写了,但是用户权限部分内容依赖文件权限 SUID (Set User ID) 相关内容,需要先在文件权限部分作了解。
用户权限表示
- UID(User ID):每个用户在 Linux 系统中的唯一标识符。超级用户(root)的 UID 为 0,普通用户的 UID 通常大于 1000。
- GID(Group ID):用户主组 ID,GID 指定了用户默认属于哪个组。
- Groups:
- 主组:每个用户都有一个主组,通常在创建用户时分配,与用户同名。主组的 GID 用于标记用户创建的文件和目录。
- 辅助组:用户可以属于多个辅助组,辅助组提供额外的权限,允许用户访问和修改多个资源。通过
id
命令可以查看用户所属的所有组。
例如下面的示例:
1 | / ~ id |
- 用户
sky123
的 UID 为 1000,主组 GID 为 1000(sky123
),另外还属于sudo
和developers
两个组。其中辅助组sudo
赋予了该用户执行sudo
命令的权限。 - 切换为
root
用户后仅属于root
组。
提示
- Linux 系统中的 root 用户的用户 ID(UID)是 0。这个 UID 是为系统的超级用户或管理员保留的,它赋予用户对系统的完全控制权,可以无视任何权限限制(因为本身就可以修改权限配置)。
- 如果一个用户不是 root 用户但是在 root 组(GID 为 0)那么虽然这个用户没有管理员权限,但是对于文件有额外的 root 所属组的权限。这时候就有可能通过修改属于管理员组的文件实现提权。
注意
一些定制的操作系统会有一些内核层面的安全保护可以限制 root 权限用户的行为。
相关配置文件
账户信息(/etc/passwd)
/etc/passwd
存储用户账号信息,包含用户名、UID、GID、主目录和登录 Shell。
例如:
1 | sky123:x:1000:1000:Sky User,,,:/home/sky123:/bin/bash |
sky123
:用户名。x
:加密密码的占位符(实际存储在/etc/shadow
中)。1000
:用户的 UID。1000
:用户的主组 GID。Sky User,,,
:用户的描述信息,可以存储用户的全名、联系信息等。/home/sky123
:用户的主目录。/bin/bash
:用户登录后使用的 Shell。
提示
如果我们有编辑 /etc/passwd
的权限,那么可以通过修改 uid
为 0 来使自身变为 root 权限。不过这个文件通常只有 root 权限的用户才能编辑。
组信息(/etc/group)
/etc/group
存储系统中所有组的信息,包括组名、GID 和组内用户列表。
例如:
1 | sudo:x:27:sky123 |
sudo
:组名。x
:密码占位符。27
:GID。sky123
:该组内的用户。
密码相关(/etc/shadow)
/etc/shadow
存储用户的加密密码及密码相关的安全信息,只有 root
用户可以访问。
通常该文件中的条目格式如下:
1 | username:$id$salt$hash:lastchg:min:max:warn:inactive:expire:reserved |
username
(用户名):表示与此密码条目关联的用户名。必须与/etc/passwd
中的用户名一致。$id$salt$hash
(加密密码信息):存储加密后的密码。这个字段分为几个部分,通过$
分隔:id
:标识使用的密码哈希算法。salt
:随机生成的盐值,用于增加密码的随机性。hash
:最终的加密密码,基于用户输入密码和盐值计算得出。
特殊情况:
- 如果是
x
,表示加密密码存储在/etc/passwd
(历史遗留方式)。 - 如果是
*
,表示用户密码被禁用。 - 如果是
!
,表示用户账户被禁用。 - 如果是
!!
,表示尚未设置密码。 - 如果为空(
:
),表示无需密码即可登录(危险,尽量避免)。
lastchg
(上次修改密码的日期):用户密码上次修改的日期。表示自 1970 年 1 月 1 日以来的天数。min
(最小修改间隔):两次密码修改之间的最小天数,防止用户过于频繁地更改密码。max
(最大密码有效期)密码的最大有效期,超过这个期限,用户将被要求修改密码。warn
(密码过期警告时间):密码过期前,系统开始向用户发出警告的天数。inactive
(密码过期后的宽限时间):密码过期后,用户仍然可以登录的宽限天数。超过宽限时间后,账户将被锁定,无法登录。expire
(账户过期时间):如果设置了这个字段,到期后用户将无法登录。reserved
(保留字段):当前未使用,通常为空。
注意
现代 Linux 系统中 root 用户对应在 /etc/shadow
中的典型设置为:
1 | root:*:19683:0:99999:7::: |
这种配置表示 root 用户的密码被禁用,无法通过密码直接登录。
工具相关(/etc/sudoers,/etc/sudoers.d/)
sudo
命令,以及如何使用 sudo
。建议使用 visudo
工具进行编辑,以确保文件的正确性。
- 示例条目:
sky123 ALL=(ALL) ALL
- 解释:允许
sky123
用户在所有主机上以所有用户身份执行所有命令。
相关命令
查看用户和组信息
- 查看用户ID和组:
id username
- 显示指定用户的 UID、主组GID 及其所属的所有组。
- 查看用户组:
groups username
- 列出用户所属的所有组,包括主组和辅助组。
- 显示当前用户名:
whoami
- 显示当前登录用户的用户名。
更改权限
临时更改权限:
sudo command
sudo
(superuser do)允许普通用户以其他用户的安全权限,通常是超级用户(root),执行命令。sudo
为系统管理员提供了一种给予普通用户部分管理员权限的方法。注意
我们使用
sudo command
执行 root 权限命令时要求输入的是当前用户的密码,而不是 root 用户的密码。并不是所有用户都可以使用
sudo command
执行 root 权限命令,只有在/etc/sudoers
文件或相关配置中明确允许的用户才能使用sudo
。可以通过sudo -l
检查当前用户是否有sudo
权限1
2
3
4
5
6
7
8/ ~ sudo -l
[sudo] password for sky123:
Matching Defaults entries for sky123 on sky123:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
use_pty
User sky123 may run the following commands on sky123:
(ALL : ALL) ALLsudo
命令具有密码缓存机制,- 当你在一个新的终端或 Shell 会话中首次使用
sudo
命令时,sudo
会提示你输入密码。它通过密码验证来确认你是当前用户,并且你有权限使用sudo
。 - 输入密码后,
sudo
会将这个验证状态缓存一段时间,默认情况下是 15 分钟。如果你在缓存有效期内再次运行sudo
命令,无需再次输入密码。 sudo
使用 时间戳文件 来记录密码验证状态。默认情况下,这些文件存储在/run/sudo/ts
目录中。可以使用sudo -k
清除缓存状态。- linux 中每个 shell 会话会有独立的
sudo
会话状态。新开的 shell 或终端没有继承之前终端的sudo
缓存,因此即使是同一用户也需要重新输入密码来进行验证。
- 当你在一个新的终端或 Shell 会话中首次使用
切换用户:
su [- username]
su
(substitute user或switch user)命令允许用户切换当前登录会话的用户身份。默认情况下,没有参数的su
会尝试切换到超级用户(root)。注意
su
如果切换的目标用户需要输入目标用户的密码,除非当前用户是 root 用户。su
不带参数的话默认切换到 root 用户,而 root 用户通常禁用密码,也就是说直接运行 su 命令是切换不到 root 权限的。sudo su
的意思是以 root 权限运行su
命令,只需要在sudo
的时候输入当前用户密码。之后执行su
命令的时候由于没有切换用户所以不需要输入 root 用户密码。(同理sudo bash
也可以切换到 root 用户)
提示
su
和 sudo
命令之所以能够改变权限是因为这两个文件具有 SUID(Set User ID upon execution)权限。
1 | / ~ ls -l $(which su) |
用户信息管理
创建用户:
sudo useradd -m username
-m
:为用户创建主目录(如/home/username
)。-s
:指定用户的登录Shell,例如-s /bin/bash
。-G
:将用户添加到一个或多个组,例如-G sudo,developers
。
修改用户密码:
passwd username
- 修改用户:
username
的登录密码,提示输入新密码。
- 修改用户:
修改用户:
sudo usermod -aG groupname username
-aG
:将用户添加到指定组,-a
选项确保用户保留其当前的组成员身份。-l newname
:修改用户的用户名为newname
。-d new_home_directory
:更改用户的主目录为new_home_directory
。
删除用户:
sudo userdel -r username
-r
:删除用户并同时删除其主目录及关联的邮件目录。
创建组:
sudo groupadd groupname
- 无其他参数时,默认创建一个新组。
删除组:
sudo groupdel groupname
- 无其他参数时,删除指定的用户组。
管理组成员:
sudo gpasswd -a username groupname
-a
:将用户username
添加到组groupname
。
用户权限的内核表示
用户权限管理结构
注意到 task_struct
的源码中有如下代码:
1 | /* 进程的权限凭据信息 */ |
这些是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred
结构体进行标识,对于一个进程而言应当有三个 cred
:
ptracer_cred
: 这个字段存储了 跟踪者(Tracer) 的凭证信息。当一个进程附加到另一个进程(如调试器附加到目标进程时),跟踪者的凭证信息被保存到这个字段。
内核在
ptrace
调用时,会对ptracer_cred
进行权限验证。提示
某些反调试方法就是利用提前占用
ptracer_cred
的原理来阻止调试器(例如gdb
)附加到进程上,从而实现反调试效果。real_cred
:即客体凭证(objective cred),通常是一个进程最初启动时所具有的权限。通常这个字段不会发生变化,用来记录进程原本的权限。cred
:即主体凭证(subjective cred),该进程的有效cred
,linux 以此作为进程权限的凭证。
cred
结构体定义于内核源码 include/linux/cred.h
中,具体如下:
1 | /* |
一个 cred
结构体中记载了一个进程四种不同的用户 ID:
- 有效用户 ID(
uid
,Effective User ID):标识进程当前正在运行时的有效用户身份,决定进程在访问资源时的权限(例如访问文件、执行系统调用等)。 - 保存的用户 ID (
suid
,Saved User ID):当进程通过某些系统调用(如setuid()
)切换到新的有效用户 ID 时,suid
会保存进程切换之前的uid
,用于恢复到原来的权限状态。 - 实际用户 ID(
euid
,Real User ID):euid
记录了进程的“真实身份”,即启动该进程的用户。 - 文件系统用户 ID(
fsuid
,File System User ID):用于文件系统权限检查的用户 ID,进程访问文件系统资源时,会根据fsuid
执行权限检查。设置这个字段是为了允许进程在文件系统操作中使用不同的身份而不影响其他权限相关操作。
通常情况下这四个值都是相同的。
用户组 ID 同样分为四个:有效组(gid
)、保存组(sgid
)、真实组(egid
)、文件系统组(fsgid
)与上面类似。
相关系统调用
Linux 内核提供了一系列的系统调用供我们修改进程的 4 个用户 ID 和 4 个用户组 ID:
设置 uid 相关系统调用
- **
setuid(uid_t uid)
**:设置进程的实际用户 ID和有效用户 ID。 - **
setgid(gid_t gid)
**:设置进程的实际组 ID和有效组 ID。 - **
seteuid(uid_t euid)
**:设置进程的有效用户 ID。 - **
setegid(gid_t egid)
**:设置进程的有效组 ID。 - **
setresuid(uid_t ruid, uid_t euid, uid_t suid)
**:同时设置实际、有效和保存的用户 ID。 - **
setresgid(gid_t rgid, gid_t egid, gid_t sgid)
**:同时设置实际、有效和保存的组 ID。
获取 uid 相关系统调用
- **
getuid()
**:获取当前进程的实际用户 ID。 - **
geteuid()
**:获取当前进程的有效用户 ID。 - **
getgid()
**:获取当前进程的实际组 ID。 - **
getegid()
**:获取当前进程的有效组 ID。 - **
getresuid(uid_t *ruid, uid_t *euid, uid_t *suid)
**:同时获取实际、有效和保存的用户 ID。 - **
getresgid(gid_t *rgid, gid_t *egid, gid_t *sgid)
**:同时获取实际、有效和保存的组 ID。
注意
fsuid
是一个内核空间的概念,它属于进程的内核数据结构(task_struct
),并用于内核内部的权限控制。因此用户空间程序无法获取或修改 fsuid
。
权限修改规则
这里仅介绍用户 ID 的修改规则,转换关系如下图所示。用户组 ID 与用户 ID 的修改规则类似。
- 当
uid
和euid
有一个为 0 时都可以转换成uid = 0
的状态,此时权限为 root 权限。 - 当
uid
和euid
都非 0 时,则此时权限为非 root 权限。
提示
从上面的规则可以看出,suid
这个字段存在感不强。个人理解这个变量是 linux 内核给我们提供的一个保存之前权限的地方。当然这里的保存需要手动 setsuid
保存。由于这个变量由内核维护,因此可以实现父子进程的传递,具有用户程序内部变量保存无法比拟的优势。
相关提权思路
commit_creds 提权
只要我们改变一个进程的 cred
结构体,就能改变其执行权限。
内核空间下面有两个函数,都位于 kernel/cred.c
中:
struct cred* prepare_kernel_cred(struct task_struct* daemon)
:该函数用以拷贝一个进程的cred
结构体,并返回一个新的cred
结构体,需要注意的是daemon
参数应为有效的进程描述符地址或者 NULL 。int commit_creds(struct cred *new)
:该函数用以将一个新的cred
结构体应用到进程。
查看prepare_kernel_cred()
函数源码,观察到如下逻辑:
1 | struct cred *prepare_kernel_cred(struct task_struct *daemon) |
在 prepare_kernel_cred()
函数中,若传入的参数为 NULL ,则会缺省使用 init
进程的 cred
作为模板进行拷贝,即可以直接获得一个标识着 root 权限的 cred 结构体。那么我们不难想到,只要我们能够在内核空间执行 commit_creds(prepare_kernel_cred(NULL))
,那么就能够将进程的权限提升到 root。
如果进行 ROP 提权有一个难点就是寻找将 rax 赋值给 rdi 的 gadget 。可以尝试搜索 xchg rax, rdi
,push rax; pop rdi
,mov rdi, rax
等 gadget 。
另外 init_cred
是在内核当中有一个特殊的 cred
,它是 init
进程的 cred
,因此其权限为 root ,且该 cred
并非是动态分配的,因此当我们泄露出内核基址之后我们也便能够获得 init_cred
的地址,因此我们就只需要执行一次 commit_creds(&init_cred)
便能完成提权。
不过有些内核中没有 init_cred
(实际上多数情况是由于缺少符号找不到 init_cred
,因此需要逆向分析 prepare_kernel_cred
函数来定位 init_cred
)。
覆写 cred 提权
覆写 cred
有两种方式,一种是覆盖 task_struct
的 cred
指针指向 init_cred
;另一种是直接覆盖 cred
结构体。
对于覆盖覆盖 cred
结构体的方式,通常来说只要我们能够把 cred
结构体的 uid
字段覆盖为 0 就可以实现提权。
文件权限
文件权限表示
普通权限
每个文件或目录的权限由三部分组成,每部分三个字符,分别代表用户、组和其他用户的权限。权限表示如下:
- r: 读取权限(Read),可以查看文件内容或列出目录内容。
- w: 写入权限(Write),可以修改文件内容或在目录中添加、删除文件。
- x: 执行权限(Execute),可以执行文件(如脚本或程序)或进入目录。
使用 ls -l
命令查看文件权限,如下示例:
1 | sky123@ubuntu:~$ ls -l example.txt |
解释:
-
: 普通文件(d
表示目录)。rwx
: 文件所有者权限,具备读、写、执行权限。r-x
: 所属组权限,具备读和执行权限。r--
: 其他用户权限,具备只读权限。- 该文件的所有者是
sky123
用户,该文件的所属组是sky123
。
特殊权限
SUID、SGID 和 Sticky Bit,是 Linux 系统中用于增强文件和目录权限管理的特殊权限。
- SUID (Set User ID):
s
替代用户的执行位,任何用户在执行该文件时,都会以文件所有者的权限运行,而不是执行者的权限。这就是su
命令从低权限用户切换到高权限用户的原理(设置 SUID 权限,使用密码保护,且非特权用户无法修改)。- 示例:
rwsr-xr-x
- 示例:
- SGID (Set Group ID):
s
替代组的执行位,文件执行时将以文件的所属组权限运行。对于目录,新创建的文件会继承目录的组。- 示例:
rwxr-sr-x
- 示例:
- Sticky Bit:
t
替代其他用户的执行位,通常用于公共目录,如/tmp
,限制删除操作。只有文件的所有者或目录的所有者可以删除文件,即使其他用户对目录有写权限。- 示例:
rwxrwxrwt
- 示例:
SUID 权限的可执行文件执行后产生的进程的 euid
等于文件所属的用户,uid
等于运行可执行文件的用户的 uid
。
如果可执行文件的所属用户是 root 用户,那么创建的进程可以通过 setuid(0)
把权限提升至 root 权限。
这就是 sudo
、su
等权限管理工具的原理,只不过提权那一步需要密码验证。有一些提权漏洞就是针对这一类 SUID 文件的。
相关管理命令
查看文件权限
ls -l
: 列出文件或目录的详细信息,包括权限。stat
: 显示文件的详细状态,包括权限和特殊位设置。stat filename
查看filename
的权限和其他详细信息。
管理文件权限
chmod
: 修改文件或目录的权限。chmod 755 filename
设置文件所有者为读、写、执行权限,组和其他用户为读、执行权限。chmod u+x filename
为文件所有者添加执行权限。chmod u+s /path/to/file
为文件添加 SUID 权限。chmod g+s /path/to/directory
为目录设置 SGID 权限,使新文件继承目录组。chmod +t /path/to/directory
为目录设置 Sticky Bit。
chown
: 更改文件或目录的所有者。sudo chown user filename
将filename
的所有者更改为user
。sudo chown user:group filename
将filename
的所有者更改为user
,组更改为group
。
chgrp
: 更改文件或目录的组。sudo chgrp groupname filename
更改filename
的组为groupname
。
modprobe_path 提权
一种经典的提权技术是覆盖内核中的 modprobe_path
变量。该变量的值在编译时设置为 CONFIG_MODPROBE_PATH
,并使用空字节填充至 KMOD_PATH_LEN
长度。通常情况下,CONFIG_MODPROBE_PATH
被设置为 /sbin/modprobe
,因为这是 modprobe 二进制文件的常见路径。
1 | ➜ ~ cat /proc/sys/kernel/modprobe |
过程分析
当用户执行一个文件时,系统调用 execve
来执行程序:
1 | SYSCALL_DEFINE3(execve, |
这会调用 do_execveat_common()
> bprm_execve()
> exec_binprm()
,然后调用 search_binary_handler()
。这个函数会查找 formats
链表中合适的加载器函数来处理指定的二进制文件。formats
只是一个包含 struct linux_binfmt
的链表。
pwndbg> delist formats $1 = { next = 0xffffffff82a12d20 <script_format>, prev = 0xffffffff829fba70 <formats> } $2 = { next = 0xffffffff82a12d60 <elf_format>, prev = 0xffffffff82a12c80 <misc_format> } $3 = { next = 0xffffffff82a12da0 <compat_elf_format>, prev = 0xffffffff82a12d20 <script_format> } $4 = { next = 0xffffffff829fba70 <formats>, prev = 0xffffffff82a12d60 <elf_format> } $5 = { next = 0xffffffff82a12c80 <misc_format>, prev = 0xffffffff82a12da0 <compat_elf_format> }
此列表中包含四种格式:elf_format
是常见的 ELF 二进制格式,compat_elf_format
与 elf_format
相同,script_format
是用于脚本文件(以 #!
开头),misc_format
是用于其他二进制文件。每种格式都有如下结构,其中最重要的成员是 load_binary
:
pwndbg> p script_format $9 = { lh = { next = 0xffffffff82a12d60 <elf_format>, prev = 0xffffffff82a12c80 <misc_format> }, module = 0x0 <fixed_percpu_data>, load_binary = 0xffffffff813d20a0 <load_script>, load_shlib = 0x0 <fixed_percpu_data>, core_dump = 0x0 <fixed_percpu_data>, min_coredump = 0 }
在 search_binary_handler()
中,formats
列表会通过循环遍历并调用每个 load_binary()
来处理二进制文件
1 | retry: |
例如,elf_format
的 load_binary
是 load_elf_binary()
,它会检查二进制的 ELF 头部是否以 \x7FELF
开头:
1 | if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) |
script_format
的 load_binary
是 load_script()
,它会检查脚本文件的 shebang。如果 shebang 有效,bprm->interpreter
会被设置为指定的解释器,原始的二进制文件名会作为 argv
的一个参数传递,然后重新搜索合适的处理程序。
1 | if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!')) |
如果没有找到适当的二进制格式处理程序,程序会执行以下路径:
1 | if (need_retry) { |
这会检查二进制的前四个字节是否为可打印字符(非 ASCII 字符除外)。然后,它调用 request_module()
来加载与二进制文件头部相符的模块,模块名为 binfmt-<前四个字节>
。它最终会调用 __request_module()
,并且会调用 call_modprobe()
。
1 | static int call_modprobe(char *module_name, int wait) |
这个函数尝试加载指定的二进制文件作为模块。默认的辅助程序路径定义在 kernel/kmod.c
文件中,为 /sbin/modprobe
,并且它会以 root 权限执行。注意这个路径并不是 const
类型的。
1 | char modprobe_path[KMOD_PATH_LEN] = "/sbin/modprobe"; |
利用过程
为了利用这一点,我们可以将 modprobe_path
的值覆盖为提权脚本(例如赋予 /bin/sh
root SUID 权限)的路径 /tmp/privesc_script.sh
,然后通过尝试执行一个无效格式的文件(如 ffff ffff
)来调用 modprobe
。内核将以 root 权限运行 /tmp/privesc_script.sh -q -- binfmt-ffff
,这样我们就可以以 root 权限运行任何代码。这使得我们不必自己运行内核函数,而是可以通过覆盖一个字符串轻松提权。
在 privesc_script.sh
脚本中我们通常会写一些需要 root 权限执行的命令。在 ctf 中我们一般的做法是给 flag 赋予普通用户可读的权限。
1 |
|
不过在实际环境中我们一般会针对一个恶意程序依次做如下操作:
- 将恶意程序的所属用户设置为 root。
- 为恶意程序添加 SUID 权限。
对应的脚本内容如下:
1 |
|
如果 /tmp/evil
是如下代码,那么由于 /tmp/evil
已经具有 SUID 权限且所属用户为 root,因此我们执行 /tmp/evil
会返回一个 root 权限的 shell。
1 |
|
因此通过 modprobe_path 提权的 exp 模板如下:
1 |
|
STATIC_USERMODEHELPER 防护与绕过
在某个时间点,CONFIG_STATIC_USERMODEHELPER_PATH
缓解措施被引入,使得覆盖 modprobe_path
变得无用。
该缓解措施的作用主要体现在 call_usermodehelper_setup
函数。在该函数中,sub_info->path
被设置如下:
1 |
|
通过将每个被执行二进制文件的路径设置为类似于 busybox
的二进制文件来工作,而该二进制文件的行为取决于传递的 argv[0]
文件名。因此,即使我们覆盖了 modprobe_path
,也只有 argv[0]
的值发生了变化,busybox
类似的二进制文件无法识别这个值,因此不会执行。
不过我们仍然可以覆盖 CONFIG_STATIC_USERMODEHELPER_PATH
本身来实现同样的效果。
无文件方式
前面的提权方式需要创建至少 1 个 trigger
文件。而对于 chroot
到某个目录的情况下,我们无法获取创建的文件的真实路径,也无法在根目录下创建文件,而 modprobe_path
执行的应当是真实完整的路径下的文件。因此传统的基于文件的 modprobe_path
提权方式失效。
然而在这种情况下,我们创建的匿名内存文件 /proc/<pid>/fd/<fd>
。也就是说我们可以知道创建的文件的真实路径,其中路径中的 <pid>
是进程的真实 pid,fd
是 memfd_create
返回的句柄。
memfd_create
系统调用允许进程创建一个匿名内存文件,该文件可以被映射到进程的地址空间。该文件存在于内存中,因此读写该文件时会直接操作内存,而不是磁盘。这在某些情况下非常有用,尤其是在需要临时存储数据,但不希望数据在磁盘上留下任何痕迹时。
1
2
3
4
int memfd_create(const char *name, unsigned int flags);参数
name
:指定内存文件的名称。虽然这个文件不会在文件系统中创建,但是它有一个名字,主要用于调试和日志记录。传入NULL
也可以。flags
:标志,控制文件的属性。例如,MFD_CLOEXEC
表示在执行exec
系统调用时,关闭文件描述符;MFD_ALLOW_SEALING
允许文件进行“封印”,即锁定文件的状态,阻止修改。返回值
如果成功,返回一个文件描述符(
fd
),该文件描述符指向创建的内存文件。如果失败,返回
-1
,并设置errno
以指示错误。
由于匿名内存文件的效果和真实文件一样,我们不妨将 modprobe_path
修改指向自己创建的匿名内存文件中,并且在该文件中写入如下内容:
1 |
|
这个文件的内容主要是创建一个 /bin/sh
进程,并将输入输出重定向到 exp 的标准输入输出。
除此之外,触发 modprobe
的 trigger
文件也可以是内存文件。因此漏洞利用模板如下:
1 |
|
最后借助 perl
,我们可以将远程的 exp 下载为内存文件并执行,从而真正实现无文件提权。
1 | perl -e ' |
前面提到 getpid
系统调用获取到的 pid
不一定是进程真正的 pid
。比如如果这个进程处在 PID 命名空间中,那么 getpid
获取到的进程号是命名空间中的进程号。而报暴露 /proc
目录下的匿名内存文件的路径上的 pid
是真实的 pid
。
真实的 pid
可以在进入命名空间之前通过 getpid
获取,然而如果这个进程本身就是在命名空间中启动的,那么就无法通过 getpid
获取真实的 pid
。
一种方法是直接暴力枚举 pid
测试。此时需要通过一个 status
文件来检测是否枚举到了正确的 pid
从而跳出循环。我们在 modprobe
脚本中添加一句 echo -n 1 1>/proc/<exploit_pid>/fd/<status_fd>
,然后在 exp 中判断 read(status_fd, &status_cnt, 1)
的读入长度是否为 1 来确定是否枚举到了正确的 pid
。
1 |
|
CONFIG_USERMODEHELPER
CONFIG_USERMODEHELPER
是 Linux 内核的一个配置选项,控制着内核是否允许通过用户空间辅助程序来处理特定的内核事件。
通常内核会通过执行用户空间的程序来处理一些特定的事务,例如执行 /sbin/modprobe
来处理用户执行无效文件的情况。然而这种行为非常危险,因为一些内核漏洞可能会篡改内核中记录的这些用户程序的路径指向自己的恶意程序从而实现提权的目的。针对这一情况就有了 CONFIG_USERMODEHELPER
这一保护措施。
通常 CONFIG_USERMODEHELPER
选项会做如下配置:
1 | CONFIG_USERMODEHELPER=y |
类似于 busybox
,内核会通过第一个参数传递要执行的程序路径到 usermode-helper
,而该 usermode-helper
的行为取决于传递的 argv[0]
文件名。因此,即使我们覆盖了 modprobe_path
,也只有 argv[0]
的值发生了变化, usermode-helper
无法识别这个值,因此不会执行。
不过我们可以通过直接覆盖内核中的 /sbin/usermode-helper
字符串来实现这一保护的绕过。
命名空间
Linux 命名空间(Namespace)是 Linux 内核提供的一种机制,它可以将系统资源隔离为不同的命名空间,使得多个进程可以共享同一系统上的资源,同时又互相隔离。命名空间是 Linux 容器(如 Docker、LXC)和虚拟化技术的核心概念之一。
常见命名空间
- PID 命名空间:每个命名空间中的进程有自己独立的进程 ID(PID)。不同命名空间中的进程可以有相同的 PID,不会发生冲突。
- 网络命名空间(Net Namespace):不同的网络命名空间可以拥有自己的网络设备、路由表、iptables 配置等。每个网络命名空间中的进程看不到其他命名空间中的网络资源。
- 挂载命名空间(Mount Namespace):挂载命名空间为每个命名空间提供独立的文件系统视图。每个命名空间中的进程对挂载点和文件系统的变化都是独立的。
- UTS 命名空间:每个命名空间中的进程可以拥有自己独立的主机名(hostname)和域名(domainname),从而实现不同命名空间中进程的名称隔离。
- IPC 命名空间:提供独立的进程间通信(IPC)机制,包括信号量、消息队列和共享内存。不同的命名空间中的进程无法相互访问这些 IPC 资源。
- 用户命名空间(User Namespace):用户命名空间可以为每个命名空间中的进程提供不同的用户和组 ID(UID/GID)映射。这样,进程可以在命名空间中以 root 权限运行,但在宿主机上却没有实际的 root 权限。
- 时间命名空间(Time Namespace):提供独立的系统时间视图。每个命名空间中的进程可以有自己的系统时间,与其他命名空间中的进程独立。
- Cgroup 命名空间(Cgroup Namespace):Cgroup 命名空间用于隔离资源的限制,例如 CPU、内存和 I/O。它提供了进程之间对资源的独立管理视图。
提示
kernel pwn 中最常用的命名空间是网络命名空间(Net Namespace)和用户命名空间(User Namespace)。这两个命名空间的主要作用是让我们可以完成一些 root 权限用户才能完成的操作。虽然这些操作被隔离在命名空间中,但是这些操作对内核空间的“影响”却是真实存在的。
我们可以通过如下命令检查是否启用了非特权用户命名空间。
1 | $ sysctl kernel.unprivileged_userns_clone |
命名空间切换
unshare 系统调用
unshare
是一个用于 Linux 系统中进程命名空间(Namespace)管理的系统调用,它允许进程在不创建新进程的情况下分离或隔离其资源。通过 unshare
,进程可以断开与当前进程空间的关联,创建新的命名空间,从而在该命名空间内独立运行。
unshare
系统调用的函数原型如下:
1 |
|
参数
flags
:这是一个位掩码(bitmask),用来指定进程要分离哪些资源。unshare
将把当前进程从指定的命名空间中分离出来并将其移入一个新的命名空间。不同的标志位对应不同类型的命名空间。CLONE_NEWUSER
: 创建一个新的用户命名空间,允许设置 UID/GID 映射。CLONE_NEWNET
: 创建一个新的网络命名空间,允许网络配置和网络设备的隔离。
返回值
成功: 返回 0。
失败: 返回 -1,并且设置
errno
来指示错误原因。
通常我们会借助 unshare
系统调用创建并进入网络和用户命名空间,从而能够完成一些 root 权限用户才能完成的操作。
1 | static void do_unshare() |
然而这样进入命名空间之后权限是 65534
(nobody
用户)。这是因为进程进入命名空间之后没有在 /proc/self/uid_map
和 /proc/self/gid_map
文件配置权限的映射关系,导致系统不知道原本的权限在新的命名空间中对应什么权限,因此设置为默认的 nobody 权限。
1 | uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) |
/proc/self/uid_map
、/proc/self/setgroups
和 /proc/self/gid_map
是 Linux 系统中与用户命名空间(User Namespace)相关的虚拟文件,用于控制和配置进程的用户(UID)和组(GID)映射。
文件路径 | 功能 | 格式及作用 |
---|---|---|
/proc/self/setgroups |
控制是否允许修改组成员信息 | deny (禁止修改组信息)或 allow (允许修改组信息) |
/proc/self/uid_map |
配置 UID 映射 | <new-uid> <old-uid> <range> :将宿主系统 UID 映射到命名空间 UID |
/proc/self/gid_map |
配置 GID 映射 | <new-gid> <old-gid> <range> :将宿主系统 GID 映射到命名空间 GID |
因此我们只需要把原本的 pid
和 uid
映射到 0 即可在命名空间中拥有“root 权限”。
注意
需要先将 /proc/self/setgroups
设置为 deny
(禁止修改组信息)否则后续对 /proc/self/gid_map
的修改会失败。
这是因为当进行 UID/GID 映射时,如果没有禁用附加组修改(即不将 setgroups
设置为 "deny"
),内核可能会试图修改附加组列表。这会与 UID/GID 映射操作冲突,导致 GID 映射失败。
1 | static void configure_uid_map(uid_t old_uid, gid_t old_gid) { |
为了方便起见,这两部分代码可以整合在一起。这样直接在一开始调用这个函数即可。
1 | void setup_unshare() { |
unshare 命令
unshare
是一个用于在 Linux 系统中创建新的命名空间的命令。unshare
命令会创建一个新的命名空间,并将创建的进程移入该命名空间。这样,进程在新的命名空间内执行时,所使用的资源将与其他进程隔离。
在 kernel pwn 中常用如下命令让 exp
进入网络和用户命名空间,并且在命名空间中权限为 root。
1 | unshare -Urn ./exp |
**-U
**:创建一个新的 用户命名空间。在新的用户命名空间中,进程的 UID 和 GID 是独立的,它们与主机系统的 UID 和 GID 映射是隔离的。
**-r
:--map-root-user
**,在新的用户命名空间中将当前用户映射为 root 用户(UID 0)。
**-n
**:创建一个新的 网络命名空间。进程将在新的网络栈中运行,这样它将有自己的网络接口、路由表和网络设备,和主机系统或者其他进程的网络配置隔离开来。
提示
unshare
在创建和让进程进入新的命名空间之外顺便还完成了权限的映射,因此这里不需要再手动进行权限映射。
命名空间与提权
处于命名空间中的程序在提权后可能会出现 uid
显示为 0 但是实际权限不是 root 权限的现象。
实际上这与 cred
结构体中除了 uid
、gid
等描述权限的字段之外还有 struct user_namespace *user_ns
这样跟命名空间相关的字段,因此:
- 如果我们仅修改了
cred
中的uid
、gid
等字段可能只是在命名空间中完成了“提权”,实际表现并不是 root 权限。 - 另外像 SUID 文件提权本质上还是修改了
uid
和gid
,同样只是在命名空间中完成“提权”。
因此要想真正提权,关键还是看有没有整体换掉 cred
而不是仅仅修改了其中的 uid
和 gid
。
明确了这一点我们可以确定下面几种提权方式在命名空间中可以真正的提权到 root 权限:
覆盖
task_struct->cred
指向init_cred
。commit_creds
提权:本质上也是把task_struct
换成了init_cred
modprobe_path
无文件方式:因为是建立管道与 root 权限进程启的 shell 通信,用的是 shell 的 cred,因此是 root 权限。
而对于那些在命名空间中不能真正提权的方法,可以通过 fork
系统调用创建子进程,然后子进程进命名空间完成一部分需要特权的操作。剩余部分以及最后的提权需要在父进程中完成(或者改父进程的 cred
)。父子进程直接可以通过共享内存或者 socket 进行通信。
例如下面的基于 modprobe_path
的提权模板,可以以通过 fork
子进程进入命名空间,然后父进程负责获取 root shell。
1 |
|
内存管理
内存映射
在 Linux 内核源码中的文档 Documentation/x86/x86_64/mm.rst
中有对 Linux 内核的内存映射的详细描述。
完整的虚拟内存映射(4 级页表)
诸如 “-23 TB” 这样的负地址是以字节为单位的绝对地址,计算方式是从 64 位地址空间的顶部开始向下计算。通过绝对地址和距离顶部的表示方式结合来看,内存布局会更容易理解。例如,
0xffffe90000000000
就等于 -23 TB,它比 64 位地址空间的顶部(ffffffffffffffff
)低 23 TB。请注意,当我们接近地址空间的顶部时,表示法会从 TB 变为 GB,接着变为 MB/KB。“16M TB” 可能一开始看起来很奇怪,但它比 “16 EB” 更容易理解为大小表示,因为 “16 EB” 可能让人难以第一时间意识到是 16 埃克萨字节(Exabyte)。它还很好地展示了 64 位地址空间是多么巨大。
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 ========================================================================================================================
Start addr | Offset | End addr | Size | VM area description
========================================================================================================================
| | | |
0000000000000000 | 0 | 00007fffffffffff | 128 TB | user-space virtual memory, different per mm
__________________|____________|__________________|_________|___________________________________________________________
| | | |
0000800000000000 | +128 TB | ffff7fffffffffff | ~16M TB | ... huge, almost 64 bits wide hole of non-canonical
| | | | virtual memory addresses up to the -128 TB
| | | | starting offset of kernel mappings.
__________________|____________|__________________|_________|___________________________________________________________
|
| Kernel-space virtual memory, shared between all processes:
____________________________________________________________|___________________________________________________________
| | | |
ffff800000000000 | -128 TB | ffff87ffffffffff | 8 TB | ... guard hole, also reserved for hypervisor
ffff880000000000 | -120 TB | ffff887fffffffff | 0.5 TB | LDT remap for PTI
ffff888000000000 | -119.5 TB | ffffc87fffffffff | 64 TB | direct mapping of all physical memory (page_offset_base)
ffffc88000000000 | -55.5 TB | ffffc8ffffffffff | 0.5 TB | ... unused hole
ffffc90000000000 | -55 TB | ffffe8ffffffffff | 32 TB | vmalloc/ioremap space (vmalloc_base)
ffffe90000000000 | -23 TB | ffffe9ffffffffff | 1 TB | ... unused hole
ffffea0000000000 | -22 TB | ffffeaffffffffff | 1 TB | virtual memory map (vmemmap_base)
ffffeb0000000000 | -21 TB | ffffebffffffffff | 1 TB | ... unused hole
ffffec0000000000 | -20 TB | fffffbffffffffff | 16 TB | KASAN shadow memory
__________________|____________|__________________|_________|____________________________________________________________
|
| Identical layout to the 56-bit one from here on:
____________________________________________________________|____________________________________________________________
| | | |
fffffc0000000000 | -4 TB | fffffdffffffffff | 2 TB | ... unused hole
| | | | vaddr_end for KASLR
fffffe0000000000 | -2 TB | fffffe7fffffffff | 0.5 TB | cpu_entry_area mapping
fffffe8000000000 | -1.5 TB | fffffeffffffffff | 0.5 TB | ... unused hole
ffffff0000000000 | -1 TB | ffffff7fffffffff | 0.5 TB | %esp fixup stacks
ffffff8000000000 | -512 GB | ffffffeeffffffff | 444 GB | ... unused hole
ffffffef00000000 | -68 GB | fffffffeffffffff | 64 GB | EFI region mapping space
ffffffff00000000 | -4 GB | ffffffff7fffffff | 2 GB | ... unused hole
ffffffff80000000 | -2 GB | ffffffff9fffffff | 512 MB | kernel text mapping, mapped to physical address 0
ffffffff80000000 |-2048 MB | | |
ffffffffa0000000 |-1536 MB | fffffffffeffffff | 1520 MB | module mapping space
ffffffffff000000 | -16 MB | | |
FIXADDR_START | ~-11 MB | ffffffffff5fffff | ~0.5 MB | kernel-internal fixmap range, variable size and offset
ffffffffff600000 | -10 MB | ffffffffff600fff | 4 kB | legacy vsyscall ABI
ffffffffffe00000 | -2 MB | ffffffffffffffff | 2 MB | ... unused hole
__________________|____________|__________________|_________|___________________________________________________________完整的虚拟内存映射(5 级页表)
- 使用 56 位地址时,用户空间的内存扩展了 512 倍,从 0.125 PB 增加到 64 PB。所有内核映射会向下移动至 -64 PB 起始偏移位置,并且许多内存区域会扩展以支持更大容量的物理内存。
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 ========================================================================================================================
Start addr | Offset | End addr | Size | VM area description
========================================================================================================================
| | | |
0000000000000000 | 0 | 00ffffffffffffff | 64 PB | user-space virtual memory, different per mm
__________________|____________|__________________|_________|___________________________________________________________
| | | |
0100000000000000 | +64 PB | feffffffffffffff | ~16K PB | ... huge, still almost 64 bits wide hole of non-canonical
| | | | virtual memory addresses up to the -64 PB
| | | | starting offset of kernel mappings.
__________________|____________|__________________|_________|___________________________________________________________
|
| Kernel-space virtual memory, shared between all processes:
____________________________________________________________|___________________________________________________________
| | | |
ff00000000000000 | -64 PB | ff0fffffffffffff | 4 PB | ... guard hole, also reserved for hypervisor
ff10000000000000 | -60 PB | ff10ffffffffffff | 0.25 PB | LDT remap for PTI
ff11000000000000 | -59.75 PB | ff90ffffffffffff | 32 PB | direct mapping of all physical memory (page_offset_base)
ff91000000000000 | -27.75 PB | ff9fffffffffffff | 3.75 PB | ... unused hole
ffa0000000000000 | -24 PB | ffd1ffffffffffff | 12.5 PB | vmalloc/ioremap space (vmalloc_base)
ffd2000000000000 | -11.5 PB | ffd3ffffffffffff | 0.5 PB | ... unused hole
ffd4000000000000 | -11 PB | ffd5ffffffffffff | 0.5 PB | virtual memory map (vmemmap_base)
ffd6000000000000 | -10.5 PB | ffdeffffffffffff | 2.25 PB | ... unused hole
ffdf000000000000 | -8.25 PB | fffffbffffffffff | ~8 PB | KASAN shadow memory
__________________|____________|__________________|_________|____________________________________________________________
|
| Identical layout to the 47-bit one from here on:
____________________________________________________________|____________________________________________________________
| | | |
fffffc0000000000 | -4 TB | fffffdffffffffff | 2 TB | ... unused hole
| | | | vaddr_end for KASLR
fffffe0000000000 | -2 TB | fffffe7fffffffff | 0.5 TB | cpu_entry_area mapping
fffffe8000000000 | -1.5 TB | fffffeffffffffff | 0.5 TB | ... unused hole
ffffff0000000000 | -1 TB | ffffff7fffffffff | 0.5 TB | %esp fixup stacks
ffffff8000000000 | -512 GB | ffffffeeffffffff | 444 GB | ... unused hole
ffffffef00000000 | -68 GB | fffffffeffffffff | 64 GB | EFI region mapping space
ffffffff00000000 | -4 GB | ffffffff7fffffff | 2 GB | ... unused hole
ffffffff80000000 | -2 GB | ffffffff9fffffff | 512 MB | kernel text mapping, mapped to physical address 0
ffffffff80000000 |-2048 MB | | |
ffffffffa0000000 |-1536 MB | fffffffffeffffff | 1520 MB | module mapping space
ffffffffff000000 | -16 MB | | |
FIXADDR_START | ~-11 MB | ffffffffff5fffff | ~0.5 MB | kernel-internal fixmap range, variable size and offset
ffffffffff600000 | -10 MB | ffffffffff600fff | 4 kB | legacy vsyscall ABI
ffffffffffe00000 | -2 MB | ffffffffffffffff | 2 MB | ... unused hole
__________________|____________|__________________|_________|___________________________________________________________架构定义了 64 位虚拟地址。实现可以支持更少的位数。目前支持的虚拟地址有 48 位和 57 位。位 63 到实现的最高有效位会进行符号扩展。这会导致如果将用户空间和内核地址视为无符号数时,它们之间会出现间隙。
直接映射覆盖系统中的所有内存,直到最高内存地址(这意味着在某些情况下,它还可以包括 PCI 内存孔)。
我们将 EFI 运行时服务映射到
efi_pgd
PGD 中的一个 64GB 大小的虚拟内存窗口(这个大小是任意的,如果需要,稍后可以增大)。这些映射不属于任何其他内核 PGD,并且仅在 EFI 运行时调用期间可用。请注意,如果启用了
CONFIG_RANDOMIZE_MEMORY
,所有物理内存的直接映射、vmalloc/ioremap
空间和虚拟内存映射都会被随机化。它们的顺序会保持不变,但它们的基址将在启动时被提前偏移。在更改此处内容时,请非常小心 KASLR。KASLR 地址范围不能与任何其他区域重叠,除了 KASAN 阴影区域,因为 KASAN 会禁用 KASLR。
对于 4 级和 5 级布局,最后 2MB 空洞中的
STACKLEAK_POISON
值:ffffffffffffff4111
目前我们常见的都是 4 级页表的内存映射,此时的内存布局如下:
1 | #define MODULES_VADDR (__START_KERNEL_map + KERNEL_IMAGE_SIZE) |
常见内存映射区
线性映射区
在Linux内核中,线性映射区(Linear Mapping Area)是指一个虚拟内存区域,在该区域内,虚拟地址和物理地址之间建立了直接的、一对一的映射关系。
具体来说,内核将物理内存从地址 0x00000000
开始,线性地映射到内核虚拟地址空间中的一个高端固定地址区间,而这个地址区间的起始位置由内核中的 page_offset_base
变量存储。
在 64 位 x86 架构上,不考虑地址随机化的情况下,内核将物理内存从 0x00000000
映射到虚拟地址 0xffff888000000000
开始的一段区域。
1 |
|
虚拟内存映射区域
在 Linux 内核中,虚拟内存映射区域(Virtual Memory Map Area)是指内核虚拟地址空间中专门用于管理和跟踪物理内存页面的一个特定区域。这个区域主要用于存放与每个物理页面对应的 struct page
结构体。通过这种映射,内核能够高效地管理物理内存,包括分配、回收、页面状态跟踪等操作。
vmemmap_base
是虚拟内存映射区域的起始地址,通常该地址为 0xffffea0000000000
。
1 |
|
其中 page
结构体定义如下(不同版本内核可能有区别)。
1 | struct page { |
struct page
结构体是 Linux 内核中用于管理和跟踪物理内存页面的核心数据结构。它在内核的内存管理系统中扮演着关键角色,负责记录每个物理页面的状态、引用计数、映射关系及其他相关信息:
- 页面状态标志管理
- **
flags
**:存储页面的各种状态标志,如是否被分配、是否可写、是否被锁定等。这些标志通过原子操作更新,确保在多核处理器环境下的线程安全。
- **
- 引用计数与使用管理
- **
_refcount
**:跟踪页面的引用计数,决定页面何时可以被回收或重新分配。通过引用计数机制,内核能够有效管理页面的生命周期,防止内存泄漏或重复释放。 - **
_mapcount
**:记录页面在页表中被引用的次数(如果页面可映射到用户空间)。用于管理页面在不同虚拟地址空间中的映射关系,确保页面在被多个进程共享时的正确性。
- **
- 页面映射与地址空间管理
mapping
和 **index
**:指向页面所属的地址空间(如文件系统的地址空间)以及页面在映射中的偏移量。用于定位页面在文件或匿名内存中的具体位置。- **
virtual
**(可选):在某些架构上,存储页面在内核虚拟地址空间中的地址,特别是对于 highmem 页面。
- 内存分配器支持
- Slab、Slob 和 Slub 分配器:通过
slab_list
、freelist
、slab_cache
等字段,管理内核内存分配器使用的页面。支持高效的内存分配与回收,优化内存使用和性能。
- Slab、Slob 和 Slub 分配器:通过
- 复合页面管理
- 复合页面相关字段:如
compound_head
、compound_dtor
、compound_order
和compound_mapcount
,用于管理大页(如 2MB 或 1GB 页)的复合页面。支持页面的聚合与分离,提高大内存页的管理效率。
- 复合页面相关字段:如
- 页表页面管理
- 页表页面字段:通过
pmd_huge_pte
、pt_mm
和pt_frag_refcount
等字段,管理页表自身的页面。确保页表页面的并发访问和一致性,支持多级页表系统的高效运行。 - 页表锁(
ptl
):用于保护页表的访问,防止并发修改引发的数据竞争。
- 页表页面字段:通过
- 设备页面管理
- ZONE_DEVICE 页面字段:通过
pgmap
和hmm_data
,管理设备内存映射页面。支持设备驱动程序的高效内存使用和访问。
- ZONE_DEVICE 页面字段:通过
- RCU 机制支持
- **
rcu_head
**:允许通过 RCU(Read-Copy-Update)机制安全地延迟释放页面,确保在所有读者完成访问后再进行释放操作,防止数据竞争和内存错误。
- **
- 内存控制组支持
- **
mem_cgroup
**:指向页面所属的内存控制组,用于实现内存资源的限制和分配。支持对不同进程组的内存使用进行精细化管理,提高系统资源利用率。
- **
- 其他辅助功能
- **
page_type
**:如果页面既不是 PageSlab 也不可映射到用户空间,用于存储页面的类型,辅助确定页面的用途。 active
和 **units
**:用于 SLAB 和 SLOB 分配器,记录页面的活动状态和分配单位数。
- **
vmalloc/ioremap space
vmalloc/ioremap space 是 Linux 内核中的一个专用虚拟地址空间,主要用于内核通过 vmalloc
和 ioremap
函数进行内存映射时,映射非连续的物理内存块。这一地址空间为内核提供了一个连续的虚拟地址区域,便于访问那些在物理内存中不连续的内存区域或设备内存。
- **
vmalloc space
**:用于vmalloc
动态分配大块内存,尤其是在物理内存碎片较多时,无法找到连续的物理页面。 - **
ioremap space
**:用于将 I/O 设备(如网卡、显卡等)的物理内存地址映射到内核的虚拟地址空间,便于内核直接操作设备内存。
vmalloc_base
是 vmalloc/ioremap space
的起始地址,通常该地址为 0xffffc90000000000
。
1 |
|
值得一提是,如启用了 CONFIG_VMAP_STACK
(默认启用),内核栈通过 vmalloc
动态分配,存放在 vmalloc/ioremap space
中。
1 | static unsigned long *alloc_thread_stack_node(struct task_struct *tsk, int node) |
注意
不是所有内核线程的栈都在 vmalloc/ioremap space
范围,比如在内核启动时,内核的第一个线程(通常是 swapper
或 init
线程)会使用 init_stack
来执行一些初始化工作。
1 | extern unsigned long init_stack[THREAD_SIZE / sizeof(unsigned long)]; |
内核模块区
内核模块区是 Linux 内核中用于映射内核模块的区域,起始地址为 0xffffffff80000000 + 1024 * 1024 * 1024 = 0xffffffffc0000000
,结束地址为 0xffffffffff000000
。
1 | /* |
KASLR
内核镜像地址随机化
对于 bzImage
这种压缩过的内核镜像,在启动内核前需要执行 extract_kernel
函数进行解压。其中调用的 choose_random_location
函数会选择一个随机的物理地址 output
和虚拟地址 virt_addr
作为内核镜像的存放地址。
提示
从 extract_kernel
函数中我们得出以下结论:
只有压缩的内核镜像才会在随机地址加载,
vmlinux
镜像加载地址不随机。内核镜像加载的物理地址和虚拟地址都要关于
MIN_KERNEL_ALIGN (0x200000)
对齐。1
2
3如果没有地址随机化,则内核加载的物理地址为
LOAD_PHYSICAL_ADDR(0x1000000)
。1
2
3
4
5
1 | /* |
在 choose_random_location
函数中会调用 find_random_phys_addr
和 find_random_virt_addr
函数分别选择一个随机的物理地址和虚拟地址。
1 |
|
内存映射区随机化
由于在 choose_random_location
函数中中设置了 boot_params.hdr.loadflags
的 KASLR_FLAG
标志位,因此 kernel_randomize_memory
函数会对线性映射区、虚拟内存映射区域、**vmalloc/iomemmap space
** 的进行随机化。
提示
通过分析代码可知,对于四级页表,随机化的偏移关于 PUD_MASK(0x40000000)
对齐。
1 | /* |
内核模块地址随机化
如果 boot_params.hdr.loadflags
的 KASLR_FLAG
标志位置位,则 get_module_load_offset
函数返回值为 0x1000
的整数倍,范围为 0x1000
到 0x400000
。另外需要注意 module_load_offset
只被初始化一次,也就是说 get_module_load_offset
返回的结果总是相同的。
1 | /* |
buddy system
buddy system 中内存管理的一个例子:
这个例子中,分配的最小单位是64K,初始时的最大块order=4. 依次进行下面的操作
- 初始状态
- 分配块A 34K, order=0.
- 没有order为0的块,切分order=4的块为2个order=3的块.
- 仍然没有order=0的块,再切分order=3的块.
- 仍然没有order=0的块,再切分order=2的块.
- 仍然没有order=0的块,再切分order=1的块.
- 将order=0的块返回.
- 分配块B 66K, order=1. 已经有了,直接返回.
- 分配块C 35K, order=0. 也已经有了,直接返回.
- 分配块D 67K, order=1. 切分一个order=2的块,返回.
- 块B释放.
- 块D释放,因为与其后面的order=1的块是第5步分裂得来的,再将其合并为order=2的块.
- 块A释放.
- 块C释放,依次合并.
slub
关于 object
slab 以页为基本单位切割,然后用单向链表(fd指针)串起来,类似用户态堆的 fastbin,每一个小块我们叫它 object 。
注意:object 的 freelist 指针偏移是 kmem_cache.offset 而不是 0,虽然大多数情况 kmem_cache.offset 默认为 0 。
1 | static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp) |
object 结构如下图所示:
kmem_cache 创建
slub 分配器把伙伴系统提供的内存内存切割成特定大小的块,进行内核的小内存分配。
具体来说,内核会预先定义一些 kmem_cache
结构体,它保存着要如何分割使用内存页的信息,可以通过 cat /proc/slabinfo
查看系统当前可用的 kmem_cache
。
内核很多的结构体会频繁的申请和释放内存,用 kmem_cache
来管理特定的结构体所需要申请的内存效率上就会比较高,也比较节省内存。默认会创建 kmalloc-8k
,kmalloc-4k
,… ,kmalloc-16
,kmalloc-8
这样的 cache ,kmem_cache
的名称以及大小使用 struct kmalloc_info_struct
管理。
1 | const struct kmalloc_info_struct kmalloc_info[] __initconst = { |
这样内核调用 kmalloc
函数时就可以根据申请的内存大小找到对应的 kmalloc-xx
,然后在里面找可可用的内存块。
1 | static __always_inline int kmalloc_index(size_t size) |
创建默认的 kmem_cache
过程存在如下调用链:
1 | x86_64_start_kernel() |
在 new_kmalloc_cache
中根据 kmalloc_info
的信息创建对应的 kmalloc_cache
。
1 | static void __init |
这里可以看到默认创建的 kmem_cache
的地址被保存在 kmalloc_caches
因此可以通过该结构获得 kmem_cache
的地址,从而获取到重要调试信息,比如 freelist
在 object
中的偏移 offset
。
create_kmalloc_cache
函数调用了核心函数 create_boot_cache
,之后 list_add
将创建的 kmem_cache
加入到 slab_caches
链表中。内核全局有一个 slab_caches
变量,它是一个链表,系统所有的 kmem_cache
都接在这个链表上。
1 | struct kmem_cache *__init create_kmalloc_cache(const char *name, |
create_boot_cache
初始化了相关信息,之后调用 __kmem_cache_create
。
1 | void __init create_boot_cache(struct kmem_cache *s, const char *name, |
__kmem_cache_create
调用了 kmem_cache_open
函数,该函数做了很多重要的初始化操作。
1 | /* |
slub 分配
kmem_cache 刚刚建立,还没有任何对象可供分配,此时只能从伙伴系统分配一个 slab ,如下图所示。
如果正在使用的 slab 有 free obj,那么就直接分配即可,这种是最简单快捷的。如下图所示。
随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表中有可用 slab 用于分配,那么就会从 per cpu partial 链表中取下一个 slab 用于分配 obj。如下图所示。
随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表也为空,此时发现 per node partial 链表中有可用 slab 用于分配,那么就会从 per node partial 链表中取下一个 slab 用于分配 obj。如下图所示。
slub 释放
- 假设下图左边的情况下释放 obj,如果满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
- 假设下图左边的情况下释放 obj,如果不满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
- 假设下图从 full slab 释放 obj 的话,如果满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,将 per cpu partial 链表管理的所有 slab 移动到 per node partial 链表管理,释放情况如下图所示。
- 假设下图从 full slab 释放 obj 的话,如果不满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,释放情况如下图所示。
内核堆利用与绑核
slub allocator 会优先从当前核心的 kmem_cache_cpu
中进行内存分配,在多核架构下存在多个 kmem_cache_cpu
,由于进程调度算法会保持核心间的负载均衡,因此我们的 exp 进程可能会被在不同的核心上运行,这也就导致了利用过程中 kernel object 的分配有可能会来自不同的 kmem_cache_cpu
,这使得利用模型变得复杂,也降低了漏洞利用的成功率。
因此为了保证漏洞利用的稳定,我们需要将我们的进程绑定到特定的某个 CPU 核心上,这样 slub allocator 的模型对我们而言便简化成了 kmem_cache_node + kmem_cache_cpu
,我们也能更加方便地进行漏洞利用
1 |
|
通用 kmalloc flag
GFP_KERNEL
与 GFP_KERNEL_ACCOUNT
是内核中最为常见与通用的分配 flag,常规情况下他们的分配都来自同一个 kmem_cache
——即通用的 kmalloc-xx
这两种 flag 的区别主要在于 GFP_KERNEL_ACCOUNT
比 GFP_KERNEL
多了一个属性——表示该对象与来自用户空间的数据相关联,因此我们可以看到诸如 msg_msg
、pipe_buffer
、sk_buff的数据包
的分配使用的都是 GFP_KERNEL_ACCOUNT
,而 ldt_struct
、packet_socket
等与用户空间数据没有直接关联的结构体则使用 GFP_KERNEL
在5.9 版本之前GFP_KERNEL
与 GFP_KERNEL_ACCOUNT
存在隔离机制,在 这个 commit 中取消了隔离机制,自内核版本 5.14 起,在 这个 commit 当中又重新引入:
- 对于开启了
CONFIG_MEMCG_KMEM
编译选项的 kernel 而言(通常都是默认开启),其会为使用GFP_KERNEL_ACCOUNT
进行分配的通用对象创建一组独立的kmem_cache
——名为kmalloc-cg-\*
,从而导致使用这两种 flag 的 object 之间的隔离:
slab alias
slab alias 机制是一种对同等/相近大小 object 的 kmem_cache
进行复用的一种机制:当一个 kmem_cache
在创建时,若已经存在能分配相等/近似大小的 object 的 kmem_cache
,则不会创建新的 kmem_cache,而是为原有的 kmem_cache 起一个 alias,作为“新的” kmem_cache 返回。
例如 cred_jar
是专门用以分配 cred
结构体的 kmem_cache
,在 Linux 4.4 之前的版本中,其为 kmalloc-192
的 alias,即 cred 结构体与其他的 192 大小的 object 都会从同一个 kmem_cache
——kmalloc-192
中分配。
对于初始化时设置了 SLAB_ACCOUNT
这一 flag 的 kmem_cache
而言,则会新建一个新的 kmem_cache
而非为原有的建立 alias,例如在新版的内核当中 cred_jar
与 kmalloc-192
便是两个独立的 kmem_cache
,彼此之间互不干扰。
1 | /* Account to memcg */ |
内核保护机制
地址相关
KASLR
内核地址空间布局随机化(Kernel Address Space Layout Randomization),开启后,允许kernel image
加载到VMALLOC
区域的任何位置。
间在未开启KASLR保护机制时,内核代码段的基址为 0xffffffff81000000 ,direct mapping area 的基址为 0xffff888000000000 。
FG_KASLR
FG-KASLR (Function Granular Kernel Address Space Layout Randomization):细粒度的kaslr,函数级别上的 KASLR 优化。
注意,该保护只是在代码段打乱顺序,在数据段偏移不变,例如 commit_creds
函数的偏移改变但是 init_cred
的偏移不变。
CONFIG_MMAP_MIN_ADDR
内核空间和用户空间共享虚拟内存地址,因此需要防止用户空间 mmap 的内存从 0 开始,从而缓解空指针引用攻击。windows 系统从 win8 开始禁止在零页分配内存。从 linux 内核 2.6.22 开始可以使用 sysctl 设置 mmap_min_addr
来实现这一保护。
CONFIG_RANDOMIZE_KSTACK_OFFSET
1 | diff --git a/arch/x86/Kconfig b/arch/x86/Kconfig |
数据相关
CONFIG_HARDENED_USERCOPY
hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界:
读取的数据长度是否超出源 object 范围
写入的数据长度是否超出目的 object 范围
不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段
这一保护被用于在使用 copy_to_user()
与 copy_from_user()
等数据交换 API 时用 __check_object_size
检查是否越界。
1 |
|
CONFIG_STACK PROTECTOR
类似于用户态程序的canary,通常又被称作是 stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生 kernel panic
。
开启: 在编译内核时,我们可以设置 CONFIG_CC_STACKPROTECTOR 选项,来开启该保护。
关闭: 我们需要重新编译内核,并关闭编译选项才可以关闭 Canary 保护。
内核中的canary
的值通常取自gs
段寄存器某个固定偏移处的值,可以直接绕过。
堆相关
CONFIG_SLAB_FREELIST_HARDENED
CONFIG_SLAB_FREELIST_HARDENED=y
编译选项开启 Hardened freelist 保护。
在这个配置下,kmem_cache
增加了一个变量 random
。在 mm/slub.c
文件, kmem_cache_open
的时候给 random
字段一个随机数。
1 |
|
set_freepointer
函数中加了一个 BUG_ON
的检查,这里是检查 double free 的,当前 free 的 object 的内存地址和 freelist 指向的第一个 object 的地址不能一样,这和 glibc 类似。
1 | static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp) |
接着是 freelist_ptr
,它会返回当前 object 的下一个 free object 的地址, 加上 hardened 之后会和之前初始化的 random 值做异或。
1 | static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr, |
CONFIG_SLAB_FREELIST_RANDOM
CONFIG_SLAB_FREELIST_RANDOM=y
编译选项开启 Random freelist 保护。
这种保护主要发生在 slub allocator 向 buddy system 申请到页框之后的处理过程中,对于未开启这种保护的一张完整的 slub,其上的 object 的连接顺序是线性连续的,但在开启了这种保护之后其上的 object 之间的连接顺序是随机的,这让攻击者无法直接预测下一个分配的 object 的地址
需要注意的是这种保护发生在slub allocator 刚从 buddy system 拿到新 slub 的时候,运行时 freelist 的构成仍遵循 LIFO
CONFIG_INIT_ON_ALLOC_DEFAULT_ON
当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况。
CONFIG_MEMCG_KMEM
GFP_KERNEL
与 GFP_KERNEL_ACCOUNT
隔离。
- Title: linux kernel pwn 基础知识
- Author: sky123
- Created at : 2024-11-08 20:23:30
- Updated at : 2024-12-27 01:17:10
- Link: https://skyi23.github.io/2024/11/08/linux-kernel-pwn-basic-knowlege/
- License: This work is licensed under CC BY-NC-SA 4.0.