4. 常见系统调用漏洞案例:缓冲区溢出、整数溢出、空指针解引用、格式字符串漏洞

系统调用是用户空间与内核空间交互的唯一入口,也是攻击者最常利用的攻击面。即使系统调用处理函数逻辑正确,若参数校验不严,仍可能引发严重的安全漏洞。本章通过四个经典漏洞案例,深入分析其原理、触发条件与防护策略。

核心观点: 系统调用参数校验的缺失或缺陷,是内核漏洞的主要成因。理解这些漏洞的底层机制,是编写安全内核模块的基础。

4.1 缓冲区溢出漏洞

缓冲区溢出是最经典的内存破坏漏洞。当系统调用从用户空间拷贝数据到内核缓冲区时,若未严格校验数据长度,超出缓冲区容量的数据会覆盖相邻内存区域,可能导致内核崩溃或权限提升。

4.1.1 漏洞原理

内核模块在处理 copy_from_user() 或 strncpy_from_user() 等操作时,如果目标缓冲区大小固定,而用户提供的长度参数大于缓冲区实际容量,就会发生溢出。

4.1.2 典型漏洞代码

// 漏洞示例:未校验长度的系统调用处理函数
static long vulnerable_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    char kernel_buf[64];  // 固定大小缓冲区
    char __user *user_buf = (char __user *)arg;

    // 危险:直接拷贝用户数据,未检查长度
    if (copy_from_user(kernel_buf, user_buf, sizeof(kernel_buf) + 64)) {
        return -EFAULT;
    }
    // 后续处理...
    return 0;
}

4.1.3 攻击效果

  • 覆盖栈返回地址: 攻击者可控制内核执行流,跳转到任意地址。
  • 覆盖关键数据结构: 如修改进程凭证结构体,实现提权。
  • 内核崩溃(DoS): 破坏内核栈或堆结构,导致系统重启。

4.1.4 防护策略

防护措施 说明
严格长度校验 在拷贝前比较用户提供的长度与缓冲区实际容量
使用安全函数 优先使用 strscpy()、memcpy_s() 等边界检查函数
栈保护机制 启用 CONFIG_STACKPROTECTOR 内核选项
地址空间布局随机化 KASLR 增加攻击者预测地址的难度
最佳实践: 始终使用 copy_from_user() 的返回值判断实际拷贝的字节数,并确保目标缓冲区大小由 sizeof() 或宏定义,而非硬编码。
缓冲区溢出攻击流程 用户空间 恶意数据 payload copy_from_user 内核缓冲区 64 字节 溢出数据 覆盖相邻内存 栈返回地址被覆盖 攻击者控制 RIP → 提权 shellcode 后果:内核崩溃、权限提升(从普通用户到 root) 防护:严格校验长度 + 使用安全拷贝函数

4.2 整数溢出漏洞

整数溢出发生在算术运算结果超出数据类型所能表示的范围时。在内核系统调用中,整数溢出常被用于绕过长度检查,进而触发缓冲区溢出或其他内存破坏。

4.2.1 漏洞原理

当系统调用参数中包含长度、大小或索引等整数值时,若内核模块对这些值进行加法、乘法或类型转换操作,可能产生意想不到的小值或负值,导致后续内存操作越界。

4.2.2 典型漏洞代码

// 漏洞示例:整数溢出导致缓冲区分配不足
static long vulnerable_write(struct file *filp, const char __user *buf, size_t count)
{
    unsigned int total_size;
    char *kernel_buf;

    // 漏洞:count 为 0xFFFFFFFF 时,+1 溢出为 0
    total_size = count + 1;
    kernel_buf = kmalloc(total_size, GFP_KERNEL);
    if (!kernel_buf)
        return -ENOMEM;

    // 实际拷贝 count 字节,但缓冲区只有 0 字节
    if (copy_from_user(kernel_buf, buf, count)) {
        kfree(kernel_buf);
        return -EFAULT;
    }
    // ...
}

4.2.3 常见整数溢出类型

溢出类型 示例 后果
加法溢出 size = a + b,a+b > UINT_MAX 分配过小缓冲区
乘法溢出 size = n * elem_size,乘积溢出 堆缓冲区溢出
符号转换溢出 int → unsigned int,负数变极大正数 绕过长度检查
截断溢出 size_t → unsigned short,高位被截断 索引越界

4.2.4 防护策略

  • 使用安全算术函数: 内核提供 check_add_overflow()、check_mul_overflow() 等宏。
  • 前置校验: 在算术操作前检查操作数是否会导致溢出。
  • 使用无符号类型: 避免符号转换带来的意外。
  • 静态分析工具: 使用 sparse 或 Coverity 检测整数溢出。
注意: 整数溢出漏洞往往不是独立存在,而是作为触发缓冲区溢出的“跳板”。攻击者通过整数溢出绕过长度检查,再通过缓冲区溢出实现代码执行。
整数溢出绕过长度检查流程 用户输入 count = 0xFFFFFFFF 整数运算 total = count + 1 = 0 内存分配 kmalloc(0) → 极小缓冲区 结果:分配 0 字节缓冲区,后续 copy_from_user 写入大量数据 → 堆溢出 防护:使用 check_add_overflow() 或前置校验 安全写法:if (check_add_overflow(count, 1, &total_size)) return -EOVERFLOW;

4.3 空指针解引用漏洞

空指针解引用是指程序试图访问地址为 0 的内存区域。在内核中,这通常会导致内核崩溃(Oops),但在某些架构或配置下,攻击者可利用此漏洞实现任意地址读写。

4.3.1 漏洞原理

系统调用参数中可能包含指针类型,如用户空间地址、内核对象指针等。若内核模块未检查指针是否为 NULL 就直接解引用,就会触发空指针漏洞。

4.3.2 典型漏洞代码

// 漏洞示例:未检查指针是否为 NULL
static long vulnerable_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct user_data __user *udata = (struct user_data __user *)arg;
    struct kernel_data *kdata;

    // 漏洞:未检查 udata 是否为 NULL
    if (copy_from_user(&kdata, &udata->ptr, sizeof(kdata))) {
        return -EFAULT;
    }

    // 如果 udata 为 NULL,udata->ptr 解引用会崩溃
    // 或者 kdata 为 NULL 时,后续操作也会崩溃
    kdata->value = 42;  // 空指针解引用
    return 0;
}

4.3.3 攻击场景

场景 触发方式 影响
用户传入 NULL 指针 ioctl(fd, CMD, NULL) 内核崩溃(DoS)
内核对象分配失败 kmalloc 返回 NULL 未检查 内核崩溃或信息泄露
释放后重用(UAF) 对象被释放后指针未置 NULL 任意代码执行
mmap 映射失败 映射地址为 0 的页面 低权限用户提权

4.3.4 防护策略

  • 显式 NULL 检查: 在解引用前使用 if (!ptr) return -EINVAL;。
  • 使用 IS_ERR() 宏: 检查内核函数返回的指针是否有效。
  • 禁用 mmap_min_addr: 防止用户空间映射零页(但需谨慎)。
  • 使用内核地址消毒剂(KASAN): 检测内存错误访问。
提示: 在 Linux 内核中,mmap_min_addr 默认值为 65536,防止用户空间映射低地址区域,从而降低空指针解引用的可利用性。
空指针解引用攻击路径 用户传入 NULL ioctl(fd, CMD, NULL) 内核模块处理 未检查 NULL 指针 解引用 NULL 访问地址 0x0 内核崩溃(Oops) 系统重启或拒绝服务 权限提升(提权) 映射零页 → 控制内核

4.4 格式字符串漏洞

格式字符串漏洞在用户空间已较为罕见,但在内核模块中仍时有发生。当系统调用参数中的字符串被直接传递给 printk()、sprintf() 等格式化函数时,攻击者可通过精心构造的格式说明符读取或写入内核内存。

4.4.1 漏洞原理

如果内核模块将用户提供的字符串作为格式化函数的 格式字符串参数(而非 格式化参数),攻击者可以使用 %s、%x、%n 等格式说明符来操纵栈内存。

4.4.2 典型漏洞代码

// 漏洞示例:将用户输入直接作为格式字符串
static long vulnerable_print(struct file *filp, const char __user *user_str, size_t len)
{
    char kernel_buf[256];

    if (copy_from_user(kernel_buf, user_str, min(len, sizeof(kernel_buf)))) {
        return -EFAULT;
    }
    kernel_buf[255] = '\0';

    // 漏洞:用户输入作为格式字符串
    printk(kernel_buf);  // 应使用 printk("%s", kernel_buf)
    return 0;
}

4.4.3 攻击向量

格式说明符 攻击效果
%s 读取栈上的指针指向的字符串,导致信息泄露
%x / %p 泄露栈上的十六进制值,如返回地址、canary 值
%n 将已输出的字符数写入指定地址,实现任意内存写入
%*n 更精细的内存写入控制

4.4.4 防护策略

  • 固定格式字符串: 永远不要将用户输入作为 printk() 的第一个参数。
  • 使用 %s 传递字符串: 正确写法为 printk("%s", user_data)。
  • 启用内核选项: CONFIG_DEBUG_FS 和 CONFIG_SECURITY_DMESG_RESTRICT 限制日志访问。
  • 代码审查: 重点关注所有 printk()、sprintf()、snprintf() 的调用。
严重性: 内核格式字符串漏洞通常可直接导致权限提升,因为 %n 允许攻击者向任意内核地址写入数据,覆盖进程凭证或函数指针。
格式字符串漏洞攻击原理 用户输入 "%x %x %x %n" 内核 printk printk(user_input) 栈内存 泄露/写入数据 %x 泄露栈上的 canary、返回地址 → 绕过 KASLR %n 向指定地址写入值 → 覆盖 cred 结构体 → 提权 安全写法:printk("%s", user_input); // 固定格式字符串 永远不要将用户输入作为 printk 的第一个参数

4.5 漏洞对比与总结

漏洞类型 根本原因 典型后果 核心防护
缓冲区溢出 未校验数据长度 代码执行、提权 边界检查 + 安全函数
整数溢出 算术运算结果超出范围 绕过检查、堆溢出 安全算术宏 + 前置校验
空指针解引用 未检查指针有效性 内核崩溃、提权 显式 NULL 检查
格式字符串漏洞 用户输入作为格式字符串 信息泄露、任意写入 固定格式字符串
核心原则: 所有来自用户空间的参数都是不可信的。系统调用处理函数必须对每个参数进行严格的类型、范围、长度和有效性校验。使用内核提供的安全辅助函数(如 check_add_overflow、strscpy、IS_ERR)可以显著降低漏洞风险。