4. 驱动安全威胁模型

驱动开发里,输入验证这块儿,我见过太多人翻车了。说白了,驱动是内核和用户态之间的守门员。你想想看,用户态传过来的数据,什么妖魔鬼怪都有。你不拦着,内核就崩给你看。

我个人习惯,把输入验证分成三个层次:用户态数据本身、ioctl 命令字、还有缓冲区长度。这三层都守住了,驱动才算有了基本的防护。

4.1 用户态输入校验:别信任何人

用户态传进来的指针、数据块,你都得当敌人看待。内核里不能直接解引用用户态指针,这是铁律。为什么?因为用户态指针可能是伪造的,指向内核空间,或者干脆就是个空指针。

正确的做法,就是用 copy_from_user() 和 copy_to_user() 这套 API。我见过有人图省事,直接用 memcpy() 去拷用户态数据,结果系统直接 panic。嗯,这里要注意,copy_from_user() 会做地址合法性检查,还会处理缺页异常,这是内核给你的安全网。

核心原则:所有从用户态接收的数据,必须经过拷贝和校验,才能在内核中使用。

光拷贝还不够,你还得校验数据内容。比如用户传了个结构体进来,里面的字段值合不合理?长度对不对?我曾在项目中遇到过,一个网卡驱动的配置结构体,用户把 MTU 值设成了负数,结果驱动没检查,直接拿去算缓冲区大小,内存分配就炸了。

// 错误示范:直接使用用户态数据
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    struct my_config __user *user_cfg = (struct my_config __user *)arg;
    // 危险!直接解引用用户态指针
    if (user_cfg->size > MAX_SIZE) {
        // ...
    }
}

// 正确做法:先拷贝到内核空间,再校验
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    struct my_config cfg;
    
    if (copy_from_user(&cfg, (void __user *)arg, sizeof(cfg)))
        return -EFAULT;
    
    // 校验字段值
    if (cfg.size <= 0 || cfg.size > MAX_SIZE)
        return -EINVAL;
    
    if (cfg.type != TYPE_A && cfg.type != TYPE_B)
        return -EINVAL;
    
    // 通过校验,才能使用
    // ...
}

4.2 ioctl 命令字验证:别让野命令进来

ioctl 是用户态和驱动交互的主要通道。命令字如果没验证好,攻击者就能用非法命令搞破坏。我记得 Linux 内核里,ioctl 命令字是用 _IO()、_IOR()、_IOW() 这些宏生成的,里面编码了方向、大小、魔数等信息。

你写驱动时,一定要用 switch 或者 if-else 把合法命令字全列出来。不在列表里的,直接返回 -ENOTTY 或 -EINVAL。别搞什么默认处理,更别把未知命令当成合法命令去执行。

警告:不要在 ioctl 处理函数中使用 default: 分支执行任何实质性操作。未知命令必须拒绝。

我曾经接手过一个遗留的 USB 驱动,它的 ioctl 处理函数里有个 default: 分支,居然直接调用了 memcpy() 把用户数据拷到某个全局缓冲区里。这等于给攻击者开了个后门,想写哪就写哪。我修复时,把所有合法命令字都枚举出来,非法的全部挡掉。

// 命令字定义
#define MY_IOCTL_MAGIC  'M'
#define MY_IOCTL_GET   _IOR(MY_IOCTL_MAGIC, 1, struct my_data)
#define MY_IOCTL_SET   _IOW(MY_IOCTL_MAGIC, 2, struct my_data)
#define MY_IOCTL_RESET _IO(MY_IOCTL_MAGIC, 3)

static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    // 先检查魔数
    if (_IOC_TYPE(cmd) != MY_IOCTL_MAGIC)
        return -ENOTTY;
    
    // 再检查命令号是否在合法范围内
    if (_IOC_NR(cmd) < 1 || _IOC_NR(cmd) > 3)
        return -ENOTTY;
    
    // 检查数据方向和数据大小
    switch (cmd) {
    case MY_IOCTL_GET:
        // 读操作
        break;
    case MY_IOCTL_SET:
        // 写操作,需要校验 arg 指向的数据
        break;
    case MY_IOCTL_RESET:
        // 无数据操作
        break;
    default:
        return -ENOTTY;  // 未知命令,直接拒绝
    }
    
    // ...
}

4.3 缓冲区长度检查:别让数据溢出

缓冲区溢出是老生常谈的问题了,但在驱动里,后果更严重。用户态溢出可能只是段错误,内核里溢出就是系统崩溃,甚至权限提升。

你想想看,用户传了个长度值进来,你拿它去分配内存或者拷贝数据。如果这个长度值比实际缓冲区大,或者是个负数,那后果不堪设想。

我建议,所有从用户态接收的长度值,都要做三件事:

  1. 检查是否为正数:长度值不能是 0 或负数。
  2. 检查是否超过上限:不能超过驱动内部定义的最大值。
  3. 检查是否与实际数据匹配:比如用户说数据有 100 字节,但实际只传了 50 字节,你得能发现。

小技巧:用 size_t 类型来存储长度值,它天然是无符号的,能避免负数问题。但别忘了检查 0 值和溢出。

我记得有一次,一个同事写的音频驱动,用户传了个采样率值,驱动直接用这个值去算 DMA 缓冲区大小。结果用户传了个 0,驱动算出来的缓冲区大小也是 0,然后 kmalloc(0, GFP_KERNEL) 返回了个非空指针,驱动以为成功了,往里面写数据,直接写到了别的内存区域。嗯,这就是典型的长度检查没做好。

// 缓冲区长度检查示例
#define MAX_BUF_SIZE  (1024 * 1024)  // 1MB 上限

static int my_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) {
    struct my_device *dev = filp->private_data;
    
    // 1. 检查长度是否合法
    if (count == 0 || count > MAX_BUF_SIZE)
        return -EINVAL;
    
    // 2. 检查驱动内部缓冲区是否足够
    if (count > dev->buf_size - dev->buf_used)
        return -ENOSPC;
    
    // 3. 安全拷贝
    if (copy_from_user(dev->buf + dev->buf_used, buf, count))
        return -EFAULT;
    
    dev->buf_used += count;
    return count;
}

4.4 三层验证体系总览

这三层验证,不是孤立的。它们应该形成一个完整的防护链。我画了张图,你看一眼就明白了。

驱动输入验证三层防护体系 第一层:用户态输入校验 使用 copy_from_user / copy_to_user 安全拷贝 校验数据结构体字段的合法性(范围、类型、标志位) 拒绝空指针、非法地址、畸形数据 第二层:ioctl命令字验证 检查魔数(_IOC_TYPE)是否匹配驱动定义的魔数 检查命令号(_IOC_NR)是否在合法范围内 检查数据方向(读/写/无数据)是否与操作匹配 第三层:缓冲区长度检查 检查长度是否为正数且不超过上限 检查长度是否与驱动内部缓冲区容量匹配 防止整数溢出、零长度分配、越界写入

这三层,每一层都不可或缺。用户态数据校验是第一道关,ioctl 命令字验证是第二道关,缓冲区长度检查是第三道关。攻击者想突破,得连过三关。

4.5 常见陷阱与避坑指南

说了这么多,我总结几个实际开发中容易踩的坑:

陷阱 错误表现 正确做法
直接解引用用户态指针 内核 panic 或权限提升 始终使用 copy_from_user / copy_to_user
ioctl 命令字未校验魔数 其他驱动的命令字也能进来 用 _IOC_TYPE(cmd) 检查魔数
长度值未检查负数 整数溢出,分配过小缓冲区 使用 size_t 类型,检查 count == 0
未校验数据字段合理性 驱动内部状态被破坏 每个字段都做范围/类型检查
ioctl 默认分支处理数据 攻击者用未知命令执行操作 default 分支只返回 -ENOTTY

我曾经踩过的坑:有一次,我在一个字符驱动里,用 copy_from_user 拷完数据后,忘了校验结构体里的一个枚举字段。结果用户传了个非法枚举值,驱动用这个值去查表,查到了错误的分支,把不该写的寄存器给写了。从那以后,我养成了习惯:拷完数据后,逐字段校验,一个都不放过。

嗯,输入验证这块儿,说白了就是「不相信任何人」。用户态的数据、命令字、长度,你都得当成恶意数据来处理。只有这样,你的驱动才能在真实环境中扛住攻击。