两行代码干掉一个内核空指针:Scarlett2 USB 声卡驱动的 fuzzer 翻车现场

张开发
2026/4/6 15:58:57 15 分钟阅读

分享文章

两行代码干掉一个内核空指针:Scarlett2 USB 声卡驱动的 fuzzer 翻车现场
前几天在翻 Linux 内核的提交记录看到了一个特别典型的补丁——改动只有两行但背后的故事值得每个写驱动的人细品。提交者是 Takashi IwaiALSA 子系统的核心维护者搞 Linux 音频驱动的人对这个名字不会陌生。这次他修的是 Focusrite Scarlett2 系列 USB 声卡的 mixer 驱动bug 是 syzbotGoogle 的内核 fuzzer报出来的。先看补丁就两行ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line// sound/usb/mixer_scarlett2.c if (desc-bInterfaceClass ! 255) continue;if (desc-bNumEndpoints 1) continue; epd get_endpoint(intf-altsetting, 0);加了一个检查如果这个接口没有端点endpoint直接跳过别往下走了。就这就这。但如果没有这两行内核会 NULL dereference——空指针解引用直接 panic。翻车现场还原要理解这个 bug得先看看scarlett2_find_fc_interface()在干什么。Focusrite Scarlett 系列声卡有一个私有的控制接口Focusrite Control Interface驱动初始化时需要在 USB 配置描述符里找到它。找的方法很直接——遍历所有接口看哪个的bInterfaceClass等于 255Vendor Specificounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(linestatic 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;}找到 class255 的接口后代码直接调用get_endpoint()去取第 0 个端点描述符。看起来很合理对吧一个正常的 Scarlett 声卡这个接口肯定有端点。但问题是——谁说 USB 描述符一定是正常的Fuzzer我给你造一个没有端点的接口syzbot 干的事情就是构造各种畸形的 USB 描述符喂给内核。它造了一个接口bInterfaceClass 255✅ 看起来像 Focusrite 的控制接口bNumEndpoints 0❌ 但是里面一个端点都没有驱动一看 class255高兴了找到了然后去取第 0 个端点——get_endpoint的定义是什么ounter(line#define get_endpoint(alt, ep) ((alt)-endpoint[ep].desc)这就是个数组取下标的宏。endpoint数组的长度由bNumEndpoints决定如果bNumEndpoints是 0那endpoint[0]就是在访问一块根本不存在的内存。NULL dereference内核 boom。这就好比你去一个停车场找你的车保安告诉你A区有车位你直接冲进去——结果 A 区压根没修好地面都没铺你一脚踩进了坑里。修复思路门口加个保安修法简单到令人发指ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineif (desc-bInterfaceClass ! 255) continue;if (desc-bNumEndpoints 1) // 新增没有端点跳过 continue; epd get_endpoint(intf-altsetting, 0); // 现在安全了在取端点之前先看看bNumEndpoints是不是至少有 1 个。没有就continue连碰都不碰。两行代码一个if一个continue完事。这类 bug 为什么这么常见你翻翻内核的 git log类似的补丁多到令人麻木ounter(linegit log --oneline --all --grepbNumEndpoints | wc -l我敢说至少几十个。原因很简单驱动开发者在写代码的时候脑子里想的是我的设备长什么样而不是一个恶意构造的设备长什么样。正常的 Scarlett 声卡那个接口 100% 有端点。开发者在自己的硬件上测了一百遍都不会出问题。但内核是要跑在所有人的机器上的USB 口插进来的东西你根本控制不了——可能是一个 fuzzer 构造的虚拟设备可能是一个故障硬件甚至可能是一个恶意的 USB 攻击设备BadUSB。所以内核社区有一条不成文的规矩永远不要相信来自硬件/用户空间的数据。在用之前先验证它。从这个补丁学到什么1. 宏不是函数没有安全网get_endpoint只是一个数组取下标的宏它不检查越界ounter(line#define get_endpoint(alt, ep) ((alt)-endpoint[ep].desc)调用者必须自己保证ep在合法范围内。如果你在写驱动时用了类似的宏请在调用前加上边界检查。这不是可选项是必选项。2. 防御性编程三板斧在驱动里解析任何外部传入的描述符时ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line// 1. 检查数组长度再取下标if (desc-bNumEndpoints 1) return -EINVAL; // 2. 检查指针再解引用struct usb_endpoint_descriptor *epd get_endpoint(alt, 0);if (!epd) return -EINVAL; // 3. 检查值域再使用if (!usb_endpoint_xfer_bulk(epd) !usb_endpoint_xfer_int(epd)) return -EINVAL;多写三行检查少排查一周 crash。3. Fuzzer 是你最好的朋友syzbot 已经帮 Linux 内核找出了成千上万个 bug。如果你在写内核模块或者驱动强烈建议跑一跑 syzkaller。你以为自己的代码没问题fuzzer 分分钟给你上一课。最后这个补丁改了 2 行diff 加起来不到 50 个字符。但它堵住的是一个可以被任何人通过插入恶意 USB 设备触发的内核崩溃。写驱动的时候脑子里要时刻有一根弦你眼前的硬件是正常的但代码要为所有不正常的情况兜底。两行代码的修复很轻巧但背后的教训很重——在内核里少一个if就可能多一个 CVE。

更多文章