前几天浏览 Linux 内核的提交记录时,一个典型的补丁引起了我的注意——改动仅有两行,但其背后的教训值得每一位驱动开发者深思。
提交者是 ALSA 子系统的核心维护者 Takashi Iwai,对于从事 Linux 音频驱动开发的同行来说,这个名字再熟悉不过。这次他修复的是 Focusrite Scarlett2 系列 USB 声卡 mixer 驱动中的一个漏洞,而发现这个漏洞的,正是 Google 的内核 fuzzer:syzbot。
先看补丁,就两行
// sound/usb/mixer_scarlett2.c
if (desc->bInterfaceClass != 255)
continue;
+if (desc->bNumEndpoints < 1)
+ continue;
epd = get_endpoint(intf->altsetting, 0);
改动很简单:增加了一个条件判断。如果目标接口没有端点(endpoint),就跳过,不再继续处理。
就这么简单。但如果没有这两行代码,内核会因 NULL dereference(空指针解引用)而直接崩溃。
翻车现场还原
要理解这个 Bug,得先看看 scarlett2_find_fc_interface() 这个函数在做什么。
Focusrite Scarlett 系列声卡有一个私有的控制接口(Focusrite Control Interface)。驱动初始化时,需要在 USB 配置描述符中遍历并找到它。寻找的逻辑很直接:遍历所有接口,检查哪个接口的 bInterfaceClass 等于 255(Vendor Specific,即厂商自定义类):
static int scarlett2_find_fc_interface(struct usb_device *dev,
struct scarlett2_data *private)
{
struct usb_host_config *config = dev->actconfig;
int i;
for (i = 0; i < config->desc.bNumInterfaces; i++) {
struct usb_interface *intf = config->interface[i];
struct usb_interface_descriptor *desc =
&intf->altsetting[0].desc;
struct usb_endpoint_descriptor *epd;
if (desc->bInterfaceClass != 255)
continue;
// ★ 问题就出在这一行 ★
epd = get_endpoint(intf->altsetting, 0);
// ... 后续使用 epd 读取端点地址、包大小、轮询间隔等信息
}
return -EINVAL;
}
当找到 class=255 的接口后,代码直接调用 get_endpoint() 宏去获取第 0 个端点的描述符。
看起来非常合理,不是吗?对于一个正常的 Scarlett 声卡,这个接口必然包含端点。
但问题在于——谁规定 USB 描述符必须是“正常”的?
Fuzzer:让我给你造个没有端点的接口
syzbot 的工作就是构造各种畸形的 USB 描述符并“喂”给内核。这次,它构造了这样一个接口:
bInterfaceClass = 255 ✅ 看起来像是 Focusrite 的控制接口。
bNumEndpoints = 0 ❌ 但是内部一个端点都没有。
驱动看到 class=255,心中一喜:“找到了!”随即就去获取第 0 个端点。
那么,get_endpoint 的定义是什么呢?
#define get_endpoint(alt, ep) (&(alt)->endpoint[ep].desc)
这只是一个简单的数组下标访问宏。endpoint 数组的长度由 bNumEndpoints 决定。如果 bNumEndpoints 为 0,那么访问 endpoint[0] 就是在访问一块不存在的内存。
NULL dereference,内核崩溃。
这好比你去停车场找车,保安告诉你“A区有车位”,你直接冲进去——结果A区地面都没铺好,你一脚踩进了坑里。
修复思路:在入口加一道检查
修复方法简单得令人难以置信:
if (desc->bInterfaceClass != 255)
continue;
if (desc->bNumEndpoints < 1) // 新增:没有端点?跳过!
continue;
epd = get_endpoint(intf->altsetting, 0); // 现在安全了
在尝试获取端点之前,先检查 bNumEndpoints 是否至少为 1。如果不是,直接 continue,碰都不碰。
两行代码,一个 if,一个 continue,问题解决。这正是 C/C++ 编程中防御性思维的体现。
这类 Bug 为何如此常见?
如果你去翻看内核的 git 提交历史,会发现类似的补丁多到让人麻木:
git log --oneline --all --grep="bNumEndpoints" | wc -l
我敢打赌至少有几十个。原因很简单:
驱动开发者在编写代码时,脑子里想的是“我的设备长什么样”,而不是“一个恶意或畸形的设备长什么样”。
在开发者自己的、正常的 Scarlett 声卡上,那个接口 100% 有端点,测试一百遍都不会出错。但 Linux内核 要运行在无数用户的机器上,USB 端口可能接入任何设备——可能是 fuzzer 构造的虚拟设备,也可能是故障硬件,甚至是恶意的 USB 攻击设备(如 BadUSB)。
因此,内核社区有一条不成文的铁律:
永远不要信任来自硬件或用户空间的数据。在使用它们之前,务必先进行验证。
从这个补丁能学到什么
1. 宏不是函数,没有安全网
get_endpoint 只是一个数组访问宏,它不进行任何边界检查:
#define get_endpoint(alt, ep) (&(alt)->endpoint[ep].desc)
调用者必须自行确保 ep 索引在合法范围内。如果你在编写驱动时使用类似的宏,务必在调用前加入边界检查。这并非可选项,而是必选项。
2. 防御性编程的“三板斧”
在驱动中解析任何外部传入的描述符时,应遵循以下模式:
// 1. 检查数组长度再取下标
if (desc->bNumEndpoints < 1)
return -EINVAL;
// 2. 检查指针再解引用(虽然本例宏直接返回地址,但思路类似)
struct usb_endpoint_descriptor *epd = get_endpoint(alt, 0);
// 对于可能返回NULL的函数,需要检查
// if (!epd)
// return -EINVAL;
// 3. 检查值域再使用
if (!usb_endpoint_xfer_bulk(epd) && !usb_endpoint_xfer_int(epd))
return -EINVAL;
多写三行检查,可能为你省去排查一整周系统崩溃的时间。这涉及到扎实的 计算机基础 与编程原则。
3. Fuzzer 是你最好的朋友
syzbot 已经为 Linux 内核发现了成千上万个 Bug。如果你正在开发内核模块或驱动,强烈建议运行一下 syzkaller 之类的 Fuzzing 工具。你自认为坚固的代码,fuzzer 分分钟就能教你做人。
最后
这个补丁只修改了2行,diff 加起来不到50个字符。但它堵住的,是一个任何人都可能通过插入恶意或畸形 USB 设备来触发的内核崩溃漏洞。
编写驱动时,脑中必须时刻绷紧一根弦:你测试所用的硬件是正常的,但你的代码必须为所有不正常的情况做好兜底。
两行代码的修复看似轻巧,背后的教训却十分沉重——在内核的世界里,少一个 if 检查,就可能多一个安全漏洞(CVE)。这种对代码严谨性的追求,也是我们云栈社区 所倡导和分享的极客精神之一。