让规则跟着子系统走:四类 Linux 子系统的 Syzkaller 规则演进记录
一条 Syzkaller 规则从"能编译"到"真的在跑",中间不是一个步骤,而是四级台阶。
每级台阶卡住的地方,由子系统的接口形态决定 ------ 不是由你的耐心决定。
本文按子系统组织:Bluetooth、内存管理(userfaultfd)、进程文件系统(pidfs),
以及还没动手的设备类。每次尝试都放在对应子系统的演进链里当证据。
内核:
7.3.0-rc7-00032-g0fcb05b72344(2026-10-14)。
零、先立一把尺子:规则的四级台阶
后面的所有讨论都需要这把尺子,否则"规则跑通了"这句话毫无信息量。
| 级别 | 含义 | 怎么验证 | 常见的假通过 |
|---|---|---|---|
| L0 | 描述编译通过 | make descriptions 不报错 |
规则里没有符号常量时,include 不会被真正编译 |
| L1 | 通过机器探测,调用被允许生成 | syscalls : N/M 里 N 合理 |
N=1 也是"已启用" |
| L2 | 真实命令值真的被执行到 | corpus 里搜得到 0xc058ff0b 这类具体值 |
看到 0x0 指针就以为没执行 |
| L3 | 到达目标状态 | fd 有效、对象已注册、前置协商完成 | 调用返回 0 但对象没进状态机 |
| L4 | 覆盖饱和 | 覆盖率曲线走平 | 跑太短就以为饱和 |
L1 和 L2 最容易混,下面单独展开------它们是整篇文章里出现频率最高的两个卡点。
L1 具体是什么
一句话:Syzkaller 认定"这个调用可以出现在生成出来的程序里"。
syzkaller.sh 启动 syz-manager 后,manager 会先做一次 machine check :把
enabled_calls 里的每个调用用零参数 跑一遍,看内核是否认它。返回"不支持"类错误的
(ENOSYS、EPROTONOSUPPORT、EAFNOSUPPORT、ENODEV 等)会被标记禁用;
然后对资源做传递闭包------如果某个资源的创建者全被禁用了,依赖它的调用也被禁用。
所以日志里会出现两段,含义完全不同:
# 段一:自己跑不通
disabled the following syscalls:
syz_init_net_socket$KGPT_bt_iso : syz_init_net_socket$KGPT_bt_iso(0x1f, 0x5, 0x8)
failed: protocol not supported
# 段二:自己被连累(方括号里是"能创建这个资源的调用",它们都被禁了)
transitively disabled the following syscalls (missing resource [creating syscalls]):
ioctl$KGPT_PIDFD_GET_INFO : fd_pidfd [openat$pidfd openat$thread_pidfd
syz_clone3 syz_pidfd_open]
最后一行 syscalls : N/M 是结果:N = 还剩多少调用可用,M = 这棵树里描述了多少调用。
读这一行有几个要点:
- M 会变 。注入的规则不同,M 就不同(我们三次运行分别是
1/8376、14/8367、24/8375)。
只有 N 在同一套规则下才有可比性。 - N=1 也是"已启用" 。ISO 那轮 N=1(只剩
readlink),日志看起来一切正常,
实际上我们的 16 个调用全没了。 - N 少了不一定是规则写错。可能是资源链断了(段二),这时要看方括号里列出的创建者。
L1 通过 ≠ 调用会成功。 探测用的是零参数:ioctl(fd, cmd, 0)、socket(0,0,0)。
它只证明"内核对这个调用号/命令字有反应",不证明"参数对了就能进状态机"。
这正是 L1 和 L2 必须分开的原因。
另外,L1 失败有两种完全不同性质的原因,处理方式也不同:
| 性质 | 例子 | 能不能靠改规则解决 |
|---|---|---|
| 参数写错 | proto const[0](L2CAP)配 SOCK_STREAM |
能,改成 BTPROTO_RFCOMM |
| 内核有门 | ISO 未注册(要 mgmt 命令先开) | 不能,得改 guest 启动流程 |
L2 具体是什么
一句话:一个真实携带目标命令值的程序,真的在 guest 里执行过了。
L1 只保证"调用可以被生成",但从"可以被生成"到"真的打到目标 handler"之间,还有三道坎:
坎一:const 参数也会被变异。
Syzkaller 会变异常量参数。所以会出现这种程序:
ioctl$KGPT_PIDFD_GET_INFO(r0, 0x5452, 0x0)
0x5452 不是 PIDFD_GET_INFO(0xc058ff0b),早就落到 default: 分支返回
ENOIOCTLCMD 了。调用"执行了",但 pidfs_ioctl 里我们关心的代码一行都没进。
坎二:fd 可能是无效值。
ioctl$KGPT_PIDFD_GET_NET_NAMESPACE(0xffffffffffffffff, 0xff04)
命令字完全正确,但 fd 是 -1 → EBADF。同样是"执行了"。
坎三:前置状态没到位。
uffd 的 SET_MODE 前面没有 UFFDIO_API,或者 enable=RWP_ASYNC 但 ctx 没协商过
UFFD_FEATURE_RWP ------ 都在第一个 return -EINVAL 就结束了。
所以 L2 的验证方法是搜真实数值,而不是搜符号名(程序里存的是数字):
bash
default-tmp/syzkaller/bin/syz-db unpack workdir-default-*/corpus.db /tmp/out
grep -l '0xff04' /tmp/out/* # PIDFD_GET_NET_NAMESPACE,18 个程序
grep -l '0xc058ff0b' /tmp/out/* # PIDFD_GET_INFO,9 个程序
grep -l '0x4010aa0a' /tmp/out/* # UFFDIO_SET_MODE
这里有个必须记住的不对称:
搜到 = 确定发生过;搜不到 ≠ 没发生过。
因为 corpus 是最小化 后的结果,最小化器会把不影响覆盖率的参数换掉、甚至丢掉整个程序。
我就在这上面判错过一次:看到 ioctl$KGPT_PIDFD_GET_INFO(r0, 0xc058ff0b, 0x0) 里指针是
0x0,判断"没跑起来"------实际上指针是被最小化成 0 的,参数在 fuzzing 时是真实地址。
所以 0x0 指针既不能证明没执行,也不能证明执行了。 判断 L2 只能看两样东西:
真实命令值是否出现、覆盖率是否在引入这条规则后增长。最严格的做法是差分 :
同一套配置跑一遍有规则、一遍没规则,比较覆盖率------这一步我们没系统做,是这一轮的欠缺。
L3 / L4 为什么也要分开
L2 只说明"命令字打到了 handler",L3 要求对象进入目标状态:
| 子系统 | L2 的样子 | L3 的样子 |
|---|---|---|
| uffd | UFFDIO_REGISTER 被调用 |
区间真的注册成功,且 SET_MODE 打的是同一个 ctx |
| pidfs | PIDFD_GET_* 被调用 |
fd 是从 openat("/proc/self") 拿到的有效 pidfs fd |
L4 则是饱和判定。没有 L4,就会把"跑了 15 分钟还在涨"和"3 分钟就平了"当成同一件事。
这把尺子到底有什么用
它把"失败了"这句话变成可定位的结论:
- 卡在 L0 → 规则本身写错了(常量、尺寸、重名)
- 卡在 L1 → 资源或门的问题(要么补
--extra-calls,要么改 guest) - 卡在 L2 → 调用在跑但没打中(const 变异、fd 无效、缺前置状态)
- 卡在 L3 → 状态进不去(seed/状态机的问题)
- 到 L4 → 这个面打透了,该换目标
这次三个做过的子系统分别停在 L1 之前 (Bluetooth)、L4 (uffd)、L4 (pidfs),
每一个卡点都对应到一个具体的接口特性。
一、Bluetooth(net/bluetooth):被"门"封顶的子系统
1.1 这个子系统的接口形态
AF_BLUETOOTH 是单一家族 + 多个协议号的结构:
c
#define BTPROTO_L2CAP 0
#define BTPROTO_HCI 1
#define BTPROTO_SCO 2
#define BTPROTO_RFCOMM 3
...
#define BTPROTO_ISO 8
入口都是 socket(AF_BLUETOOTH, type, proto),但三个参数互相约束 :
SOCK_STREAM 只能配 RFCOMM,SOCK_SEQPACKET 才能配 ISO。而且有两道额外的门。
1.2 规则演进链条
| 版本 | 规则写法 | 到了哪级 | 证据 |
|---|---|---|---|
| v1 | socket$KGPT_rfcomm(domain const[AF_BLUETOOTH], type const[SOCK_STREAM], proto const[0]) |
L0 | 编译通过,机器探测失败 |
| v2 | proto const[BTPROTO_RFCOMM](3) |
L0 | 同上,错误码从 EPROTONOSUPPORT 不变 |
| v3 | syz_init_net_socket$KGPT_rfcomm(...) |
L0→卡在 L1 | 那次运行里所有 BT 族仍报 EAFNOSUPPORT |
| v4(ISO) | syz_init_net_socket$KGPT_bt_iso(..., proto const[BTPROTO_ISO]) + 正确类型 |
L0,L1 前失败 | syscalls : 1/8376 |
1.3 三道门,三个不同的错误码
这不是一个"参数调错了"的故事。v1 到 v3 的每次修改都在对付不同的 一道门,
而错误码是唯一的线索。
门一:协议号与 socket 类型必须匹配。
v1 里 proto const[0] 是 L2CAP,配 SOCK_STREAM 必然 EPROTONOSUPPORT。
这个错误看起来像"协议族没注册",实际上只是参数组合非法。
门二:网络命名空间。
c
/* net/bluetooth/af_bluetooth.c:118 */
static int bt_sock_create(struct net *net, struct socket *sock, int proto, int kern)
{
if (net != &init_net)
return -EAFNOSUPPORT;
错误码变成 EAFNOSUPPORT(address family not supported)------ 注意它不是 family 不存在,
而是"这个 netns 里不许"。这就是 syz_init_net_socket$ 存在的原因。
这里有个值得记下来的教训:v3 那次改名其实从来没被验证过 。那次运行里所有 BT 族报的
还是 EAFNOSUPPORT(因为规则集里用的是未改名的那批),直到 ISO 才第一次真正过了 netns
检查 ------ 然后撞上第三道门。
门三:实验特性开关。
ISO 修好所有参数、编译通过、16 个调用全进了 enabled_calls 之后:
syscalls : 1/8376
syz_init_net_socket$KGPT_bt_iso(0x1f, 0x5, 0x8) failed: protocol not supported
参数完全正确:0x1f=AF_BLUETOOTH、0x5=SOCK_SEQPACKET、0x8=BTPROTO_ISO。
但 EPROTONOSUPPORT 在这里的含义是 bt_proto[8] == NULL ------ 协议族从来没被注册。
iso_init() 全树只有两个调用点:
net/bluetooth/af_bluetooth.c:992 iso_exit()
net/bluetooth/mgmt.c:5099 iso_init() ← MGMT_OP_SET_EXP_FEATURE 处理里
ISO socket 是实验特性,要先发一条 HCI 管理命令才会注册:
socket(AF_BLUETOOTH, SOCK_RAW, BTPROTO_HCI)
bind(hci_dev = HCI_DEV_NONE 0xffff, hci_channel = HCI_CHANNEL_CONTROL 3)
sendmsg: MGMT_HDR{opcode = 0x004a, index = 0xffff, len = 17}
+ {uuid = 3ee0b4fd-ddd6-8598-6a49-e00588f1ba6f, param = 0x01}
而且这个门从 2022-07-22 (ccf74f2390d6 Bluetooth: Add BTPROTO_ISO socket type)就有,
不是 7.3 的新设计。iso.c 在 2026 年被改了 36 次,改的是 BIG/BIS 广播之类的功能,
跟可达性无关。
这里的教训是选目标层面的 :按 churn 排序会觉得 iso.c 很香(36 次改动 + 上游零覆盖),
但 churn 度量的是"代码在动",不是"代码能被跑到"。
1.4 为什么 seed 救不了
Syzkaller 的机器探测发生在 seed 执行之前 。探测失败 → 调用被禁用 → 依赖它的调用被
传递性禁用:
transitively disabled the following syscalls:
bind$KGPT_iso_sock_ops : sock_bt_iso [syz_init_net_socket$KGPT_bt_iso]
connect$KGPT_iso_sock_ops : sock_bt_iso [syz_init_net_socket$KGPT_bt_iso]
setsockopt$KGPT_BT_ISO_QOS : sock_bt_iso [syz_init_net_socket$KGPT_bt_iso]
...
seed 里写什么都一样,因为这些调用已经不在程序生成器的候选集里了。
可达性问题是 seed 层解决不了的,必须在规则层或 guest 层解决。
1.5 这个子系统教会规则写作的三件事
proto常量必须显式写对,且要和type匹配bt_*一族一律用syz_init_net_socket$- 类型不能重定义(
bdaddr_t、bdaddr_type上游已有),但bind()要求
bdaddr_type_is_le(),所以iso_bdaddr_type必须建成只含0x01/0x02的枚举,
不能用裸int8
另外还逼出了一组尺寸,必须用编译器量:
sizeof(sockaddr_iso) = 10 ← 不是 9:u16+6+u8=9,对齐 2 补到 10
sizeof(sockaddr_iso_bc) = 40
bind bc 要求 addr_len = 50 ← 10 + 40
sizeof(bt_iso_qos) = 60 ← union,取最大成员(ucast 32 / bcast 60)
差一字节的后果很具体:传 9 → EINVAL;传 11 及以上 → 静默 走进 broadcast 分支,
然后被 addr_len != 50 拒掉。两种都是"看起来跑了、其实什么都没发生"。
1.6 结论
这个子系统的演进被门封顶在 L1 之前 。规则留在
spec-gen/eval-input/iso-7.3/,将来若在 guest 启动脚本里补上那条 mgmt 命令,
可以立刻复用。
二、内存管理(mm/userfaultfd):把 fd 从系统调用手里接过来
2.1 这个子系统的接口形态
和其它子系统最大的不同:没有设备节点、没有 socket 族 ,fd 来自 userfaultfd() 系统调用。
三个特性叠在一起,使它成为最难写的规则:
- fd 由系统调用创建,而那个系统调用上游已经描述过了 → 规则里不能重新声明
- 强状态机 :
UFFDIO_API协商 →UFFDIO_REGISTER绑 VMA →UFFDIO_SET_MODE才有效 - 参数里有
vma64:指向一个真实 mmap 出来的区域,不是普通指针
2.2 为什么选它
7.3 的 UFFDIO_SET_MODE 和 UFFDIO_RWPROTECT 是 2026-08-04 新增的,
而且上游 uffd.txt 里恰好缺这两条(其余 UFFDIO_* 全有)。
userfaultfd_set_mode() 的逻辑正好是边界密集的:
c
if (mode.enable & mode.disable) return -EINVAL; // 互斥
if ((mode.enable | mode.disable) & ~toggleable) return -EINVAL; // 掩码
if ((mode.enable & RWP_ASYNC) && !(features & RWP)) return -EINVAL; // 前置协商
...
for_each_vma(vmi, vma)
if (vma->vm_userfaultfd_ctx.ctx == ctx)
vma_start_write(vma); // 排空在飞的 page fault
WRITE_ONCE(ctx->features, (ctx->features | enable) & ~disable);
2.3 规则演进链条
| 版本 | 变化 | 级别 | 证据 |
|---|---|---|---|
| v1 | 只加 UFFDIO_SET_MODE + UFFDIO_RWPROTECT |
L0 | --- |
| v1 | 补 "resources": {} |
L0 | 否则 output_spec() 抛 KeyError: 'socket' |
| v2 | 加 --extra-calls 'userfaultfd,mmap' |
L1(勉强) | syscalls : 14/8367,但程序里全是空指针 |
| v3 | 再加 ioctl$UFFDIO_API,ioctl$UFFDIO_REGISTER,ioctl$UFFDIO_UNREGISTER |
L2→L3 | API+REGISTER 同程序 0 → 104 |
| v4 | 热启动 30 min | L4 | coverage 10145,末段走平 |
2.4 卡点一:L1 通过不等于跑到了(--extra-calls 的由来)
v1/v2 的第一次 10 分钟跑,数字看起来正常:
coverage 8569 corpus 254 crashes 0
但日志里有一行被我第一遍忽略的:
WARNING generate_seeds:345 - seed generation failed for gpt4_userfaultfd_fops...:
no programs generated
看程序形态才明白发生了什么:
ioctl$KGPT_UFFDIO_REGISTER(r0, 0xc020aa00, 0x0)
ioctl$KGPT_UFFDIO_SET_MODE(r1, 0x4010aa0a, 0x0) ← 前面也没有 UFFDIO_API
ioctl$KGPT_UFFDIO_API(r2, 0xc018aa3f, &(0x7f0000000180)) ← 空结构体,api != 0xAA
根因:fd_uffd 来自上游 userfaultfd(),规则里不能重新声明(跨文件重名是硬错误),
而 --merge 的默认 enabled_calls 只有 read*/write*/close*。fuzzer 拿不到可用的 fd。
补上之后立刻变化:
| 指标 | v2 | v3 |
|---|---|---|
| coverage | 8569 | 9210 |
含 UFFDIO_API 的程序 |
少 | 152 |
含 UFFDIO_REGISTER 的程序 |
15 | 119 |
| API + REGISTER 同一程序 | 0 | 104 |
程序里开始出现真实的 mmap 区域:
r1 = userfaultfd(0x80801)
ioctl$UFFDIO_API(r1, 0xc018aa3f, &(0x7f0000000140)={0xaa, 0x140})
ioctl$UFFDIO_REGISTER(r1, 0xc020aa00,
&(0x7f0000000180)={{&(0x7f0000004000/0x2000)=nil, 0x2000}, 0x1})
&(0x7f0000004000/0x2000)=nil 是 Syzkaller 自己建的 mmap 区域,0x1 是
UFFDIO_REGISTER_MODE_MISSING。这才叫 L2。
2.5 卡点二:手写 seed 想直接跳到 L3,失败了
gen_seeds 对这类规则产不出程序(没有 open/socket 前驱),所以手写了一条链:
userfaultfd → API(RWP|RWP_ASYNC) → mmap → REGISTER(RWP)
→ SET_MODE(enable=RWP_ASYNC) → RWPROTECT(RWP)
syz-db merge 接受,syz-db print 能看到完整程序。跑完却被最小化器丢了 ------
用 0x60000(RWP|RWP_ASYNC 的特征值)搜索 corpus,0 个程序。
第二版改成复制一个已存活程序的地址布局(避免 MAP_FIXED 撞车),API 阶段只协商 RWP,
RWP_ASYNC 留给 SET_MODE。同样被丢(0xaa, 0x20000 签名 0 个)。
结论:这类程序没带来新覆盖,最小化器有权丢弃。
seed 只值一次验证的代价 ------ 跑完先搜特征值确认它活着,再谈它有没有用。
2.6 卡点三:一次看起来像 bug 的 crash
改造前的一轮出现过唯一一次 crash:
17:20:59 VM 1: crash: no output from test machine
report0 : 空
诱惑很大 ------ 我们刚打的正是 mmap_write_lock + vma_start_write 排空逻辑,
VM DIAGNOSIS 的寄存器快照还正好落在:
RIP=ffffffff819c06d0 → lock_release kernel/locking/lockdep.c:5951
但证据不支持:
| 检查 | 结果 |
|---|---|
BUG: / WARNING: / Kernel panic |
0 |
oom-kill / Killed process |
0 |
blocked for more than(hung task) |
0 |
FAULT_INJECTION: forcing a failure |
大量(末段全是 failslab 的 dump_stack) |
ERROR: Out of memory at tomoyo_realpath_from_path |
144 |
那些 Call Trace 是 fail-injection 自己的 dump_stack(),不是崩溃栈。
单变量对照(同 corpus、同 6144 MiB、只关故障注入):
| 开故障注入 | 关故障注入 | |
|---|---|---|
| 结果 | VM 失联 | 15 min 正常结束 |
FAULT_INJECTION |
大量 | 0 |
| crash | 1 | 0 |
| coverage | 9717(被截断,不可比) | 9818 |
根因:--merge 基础集里的 write* 会顺带启用 write$fault_inject,而它能往
/sys/kernel/debug/failslab/probability 写 "100" ------ 每次 slab 分配都失败。
判据一句话 :没有任何内核自诊断输出 + 大量 FAULT_INJECTION = 基础设施故障。
两轮的 coverage 不可比 (崩溃那轮 VM 5 分钟就死了,剩下 9 分钟靠单台机器跑)。
能比的只有「有没有 crash」------1 比 0。
2.7 这个子系统教会的
- fd 来自上游系统调用时,必须用
--extra-calls显式启用那个系统调用 - 状态机顺序(API→REGISTER→SET_MODE)不能指望 fuzzer 自己凑,
gen_seeds也凑不出 vma64参数意味着必须让mmap参与- 故障注入和长跑不可混在一起
三、进程文件系统(fs/pidfs):目录 fd 上一组"无参数"ioctl
3.1 这个子系统的接口形态
pidfs 是 procfs 里进程目录被拆出来的新文件系统。它的 fd 是目录 fd ,
ioctl 分两类:
c
#define PIDFS_IOCTL_MAGIC 0xFF
#define PIDFD_GET_CGROUP_NAMESPACE _IO(0xFF, 1)
...
#define PIDFD_GET_UTS_NAMESPACE _IO(0xFF, 10)
#define PIDFD_GET_INFO _IOWR(0xFF, 11, struct pidfd_info)
10 个 namespace ioctl 是 _IO(无参数 ),作用是返回一个新的 nsfs fd ;
PIDFD_GET_INFO 是 _IOWR,带一个 size-versioned 的 88 字节结构体。
选它的理由很直接:13 条命令上游一条都没有,而且不需要 mmap,避开了 uffd 那套内存压力。
3.2 规则演进链条
| 版本 | 变化 | 级别 | 证据 |
|---|---|---|---|
| v1 | 12 条 ioctl(10 个 namespace + INFO + FS_IOC_GETVERSION) |
L0 | 编译通过 |
| v1 | 只加 --extra-calls 'syz_pidfd_open' |
卡在 L1 | syscalls : 1/8375 |
| v2 | 改成 --extra-calls 'openat$pidfd,...' |
L2 | syscalls : 24/8375,corpus : 24 (24 seeds) |
| v3 | 跑 15 min | L4 | coverage 6030,约 3 分钟饱和 |
3.3 卡点:fd_pidfd 的所有创建者都没启用
v1 的冒烟测试里,12 个调用全被传递性禁用:
ioctl$KGPT_PIDFD_GET_INFO : fd_pidfd [openat$pidfd openat$thread_pidfd
syz_clone3 syz_pidfd_open]
方括号里是"能创建这个资源的系统调用",而它们全都没被启用。
syz_pidfd_open 需要 pid 资源,pid 又来自 syz_clone*;链条太长。
正确解法是找到最短路径:openat$pidfd 直接从 /proc/self 拿 pidfs fd:
c
openat$pidfd(fd const[AT_FDCWD], file ptr[in, string["/proc/self"]],
flags flags[open_flags], mode const[0]) fd_pidfd
这是这类子系统的通用技巧:找资源的最短创建路径,不要顺着依赖链往上追。
3.4 到达 L2 之后
openat$pidfd 建出 fd 的程序 43 / 167
PIDFD_GET_NET_NAMESPACE 真实 cmd 0xff04 18 个程序
PIDFD_GET_INFO 真实 cmd 0xc058ff0b 9 个程序
0xc058ff0b = _IOWR(0xFF, 11, 88),说明 88 字节的结构体布局算对了
(PIDFD_INFO_SIZE_VER3)。
3.5 为什么 3 分钟就饱和
覆盖率轨迹:0 → 4205 → 5015 → 5769 → ... → 6021 → 6030。
pidfs_ioctl 的分支本来就少:10 个 namespace ioctl 共用一套逻辑
(get_pid_task → ptrace_may_access → get_nsproxy → ns_ref_get → 返回 fd),
只有 switch 的 case 不同;GET_INFO 就是一次拷贝。
分支数决定覆盖率上限,而覆盖率几分钟就能摸到那个上限。 加时长买不到新东西。
3.6 一个误判
corpus 里看到 ioctl$KGPT_PIDFD_GET_INFO(r0, 0xc058ff0b, 0x0),我判断"指针是 NULL,
所以没跑起来"。
错的。 corpus 最小化会在覆盖率不变时把指针参数换成 0。判断有没有执行要看覆盖率增长
和真实命令值,不能看保留下来的程序长什么样。
四、还没动手的设备类子系统
按 churn × 上游盲区排序,剩下的候选集中在一类形态上:字符设备 / virt 设备。
| 目标 | 命令数 | 上游覆盖 | 最新的命令 | fd 来源 |
|---|---|---|---|---|
vduse_ctrl_fops |
6 | 零 | VDUSE_SET/GET_FEATURES(2026-08-19) |
/dev/vduse/control |
eventpoll_fops |
2 | 零 | EPIOCSPARAMS(2024-02) |
epoll_create1() |
这类子系统的特征是 L1 容易过(设备节点存在就能 open),难点转移到"设备需要什么样的前置状态":
vduse 需要能创建虚拟 vDPA 设备,fuse 需要 mount + 用户态服务端。
它们的规则演进大概率会停在 L2/L3 之间 ------ 调用能执行,但设备状态进不去。这是下一阶段要验证的。
五、四个子系统放在一起看
| 维度 | Bluetooth | userfaultfd | pidfs | 设备类(待做) |
|---|---|---|---|---|
| fd 来源 | socket() |
userfaultfd() |
openat("/proc/self") |
openat("/dev/xxx") |
| 需要 init_net | 是 | 否 | 否 | 否 |
| 需要 mmap | 否 | 是(vma64) |
否 | 否 |
| 前置状态机 | 无 | 三级 | 无 | 设备初始化 |
| 尺寸敏感 | 强 (addr_len 精确值) |
中 | 中(size-version) | 未知 |
| 有"门" | 是(实验特性) | 否 | 否 | 未知 |
| 上游覆盖度 | 部分(缺 ISO) | 高(缺 2 条) | 零 | 零 |
| 本次到达的级别 | L0(L1 前失败) | L4 | L4 | 未做 |
从这张表能读出一件事:"上游覆盖度低"和"能跑到"是两回事。
pidfs 上游覆盖为零,但 3 分钟就饱和;Bluetooth ISO 上游覆盖也为零,连 L1 都过不了。
真正的决定因素是「fd 好不好拿 + 状态好不好进」。
六、演进的通用阶梯(沉淀)
把三个子系统的经验抽象一下,规则演进是被迫按顺序做四件事:
第一步:让规则编译(L0)
- LLM 给的常量逐条对头文件(
BTPROTO_ISO被写成 SCO 的 2) - 尺寸用编译器量,别推算(
sockaddr_iso是 10 不是 9) - union 不是 struct(
bt_iso_qos是 60 字节,不是 92) - 跨文件重名是硬错误,上游已有类型只能复用
- 纯 ioctl 增量规则要补顶层
"resources": {}
第二步:让调用通过探测(L1)
先看 syscalls : N/M 的 N:
| N 的表现 | 含义 | 下一步 |
|---|---|---|
| 只有 1 | fd 造不出来 | 查被传递性禁用的列表,找资源的最短创建路径 |
| 有一半 | 部分规则参数非法 | 查 disabled the following syscalls,逐条看错误码 |
| 全部 | 通过 | 进第三步 |
EAFNOSUPPORT(netns 不对)和 EPROTONOSUPPORT(协议族没注册)要分清,
后者往往是"门",seed 救不了。
第三步:让调用真的执行(L2)
- fd 来自上游系统调用 → 必须
--extra-calls - 通配带进来的
write$fault_inject→ 必须--disable-calls - 判断执行看覆盖率增长 + 真实命令值,不看 corpus 快照
第四步:让状态到位(L3)与饱和判定(L4)
- 状态前置写进
syscall_specs,别指望 fuzzer 凑 gen_seeds只支持"建对象 → 调一次"的形状,链式状态得自己补(而且补了要验证存活)- 覆盖率走平就换目标,别加时长
七、回头看:KernelGPT 在这条链里发挥了什么
一个必须诚实回答的问题:这套为 KernelGPT 建起来的流水线,在这三个子系统的演进里到底起了什么作用?
结论先说:它的核心能力(LLM 生成规则)这次基本没发挥;真正承重的是它的分析器和执行框架。
7.1 实际分工
| 组件 | 这次具体做了什么 | 是否关键路径 |
|---|---|---|
analyzer(Clang AST → *.jsonl) |
340k 函数索引、371 个带 ioctl 的 fops | 是 ------ 目标排序工具直接跑在 ioctl.jsonl 上 |
func.jsonl |
跨文件 handler 查找、一层转发追踪 | 是 ------ find_recent_surface.py 和 backfill_usage.py 都依赖它 |
gen_spec.py(LLM) |
只对 iso_sock_ops 跑过 1 次,产出 4 处硬伤 |
否 |
output_spec.py |
三套规则都走它渲染;include 顺序修复是关键 |
是 |
eval_spec.py / run-specs.py |
整个测试床;这次扩展了 --extra-calls / --disable-calls |
是 |
gen_seeds.py |
socket 类规则有效(bind 前驱、双 socket 配对);uffd/pidfs 产出 0 | 部分 |
三个目标里,跑到 L4 的两个(uffd、pidfs)规则是手写的,LLM 完全没参与。
7.2 为什么它不适合站 C 位
不是模型不行,是任务形态不匹配 。gen_spec.py 有两个隐含假设,这次都被打破了。
假设一:每个 fops 都需要从零生成一条完整规则。
这次三个目标里有两个是"给上游已描述的对象补几条命令" ------ uffd 上游已有 8 条 UFFDIO_*,
我们只补 2 条;pidfs 则完全不涉及已有对象。全量重生成会立刻撞两堵墙:
- 重名 :重新定义
bdaddr_t、bdaddr_type,sys/linux/*.txt是一起编译的,直接炸 - 丢 fd 来源 :
fd_uffd来自userfaultfd()、fd_pidfd来自openat("/proc/self"),
都是上游系统调用。规则里不能重新声明,生成器也不会替你把它们塞进enabled_calls
这是一个结构性尴尬:生成器擅长"从无到有",而这类任务需要的是"增量补丁"。
假设二:规则的正确性可以用编译器判定。
这条对 L0 成立,对后面三级不成立:
| 级别 | 谁能判定 | pipeline 有反馈吗 |
|---|---|---|
| L0 编译 | syz-extract + make descriptions |
有(规则能否生成) |
| L1 通过探测 | guest 里跑一次 socket() / open() |
没有 |
| L2 真实执行 | corpus 里搜真实命令值 | 没有 |
| L3 到达状态 | 只有覆盖率/日志能看出来 | 没有 |
evaluate() 目前只返回 success / fail,不返回"卡在哪一级、被哪道门挡住"。
而 machine check 打出的 disabled the following syscalls 列表已经是最现成的信号,
只是没人把它接回 pipeline。
7.3 LLM 最弱和最擅长的地方(这次实测)
ISO 规则里 4 处硬伤,全部集中在可以被机械校验的那一类:
| 硬伤 | 正确值 | 性质 |
|---|---|---|
BTPROTO_ISO = 2 |
8 | 常量查表 |
bdaddr_t 重新定义 |
上游已有 | 命名空间冲突 |
bt_iso_qos 当 struct(92 字节) |
union,60 字节 | 布局计算 |
socket$ |
syz_init_net_socket$ |
运行时约束 |
没有一处是"语义理解错了"。它把 sockaddr_iso { sa_family_t; bdaddr_t; __u8; } 正确翻译成了
类型树,setsockopt 的 5 个选项也枚举对了。
所以分工应该是 :LLM 只做语义推断(这段源码在干什么、哪些字段是一组、哪个参数是输出),
凡能手算或机算的(常量值、结构体尺寸、名字冲突、netns 约束)一律交给工具。
7.4 但要公平地说:它在"广度"上是有效的
不能因为这次没发挥就否定它。在更早的阶段,gen_spec.py 批处理生成过 19 条规则 ,
那轮 fuzz 的实测结果是:
executed 90461 cover 12242 crashes 9(5 个唯一标题)
虽然那 5 个都是已知的 syzbot 问题,但说明当目标是"一批没人描述过的 handler"时,
这条流水线是能出货的。
差别在于两种任务要的东西完全相反:
| 广度模式(KernelGPT 的本职) | 深度模式(这次) | |
|---|---|---|
| 目标数量 | 几十~几百 | 3 |
| 每个目标的上游覆盖 | 零 | 大部分已有 |
| 主要成本 | 生成 + 批量校验 | 可达性分析 + 状态构造 |
| LLM 的价值 | 高(规模化的唯一手段) | 低(每个都要人工判断) |
这次是拿广度工具去做深度活,所以看起来"没发挥"。
7.5 让它真正发挥,需要补四件事
- 增量模式。 只生成"上游缺的那几条命令 + 它们需要的类型",输出
syscall_specs
片段而不是完整规则。判据很机械:拿ioctl.jsonl的 case 常量减去上游*.txt里的常量,
差集就是该生成的。 - 把四级台阶变成 pipeline 的一等公民。
evaluate()返回
{level, blocked_by, evidence}。L1 的disabled列表、L2 的命令值、L4 的覆盖率曲线
都是现成的。 - 资源可达性求解。 给定一个 resource(
fd_pidfd),自动从sys/linux/*.txt里找
最短创建路径(这次是openat$pidfd而不是syz_pidfd_open),生成--extra-calls。
纯机械工作,不需要 LLM。 - 常量与尺寸前置校验。 生成完立刻用
syz-extract求值 + 编译一段sizeof打印,
把 LLM 的常量错误挡在 L0 之前。
第 3 点这次是手工做的,而且做错过一次(先试了 syz_pidfd_open,它需要 pid 资源,
链条比 openat$pidfd 长得多)。这类"最短路径"问题完全应该工具化。
八、结论
累计约 2 小时、四类子系统、0 个内核 bug。
不是工具链的问题 ------ 每条规则都验证过"真实命令值真的在执行"。原因有三层:
- 补的是边角。 uffd 补 2 条、pidfs 补 13 条,上游主体覆盖早就存在。
- 边角路径很薄。 一次
copy_struct_from_user、一次 refcount 增减。分支数决定覆盖上限。 - 真正 bug 密集的是并发和生命周期 ,需要状态持续存在 + 多线程竞争 ------
而现在的 seed 都是"建对象 → 调一次 → 退出",这在演进阶梯的 L3 之后就断了。
再往上追一层,是任务形态和工具形态的错位 :这一阶段做的是"深度"(3 个目标,
每个都要做可达性分析和状态构造),而 KernelGPT 的强项是"广度"(一批没人描述过的 handler,
批处理生成 + 批量校验)。同样的流水线在广度模式下出货过 19 条规则、9 个 crash;
在深度模式下只能当分析器和测试床用。
所以下一阶段有两个方向,选哪个取决于目标是"补覆盖面"还是"找 bug":
- 要补覆盖面 :给
gen_spec.py加增量模式(只生成上游缺的命令),回到广度模式,
让 LLM 重新承重。 - 要找 bug :把 L3 做扎实 ------ 让对象活到另一个线程去动它。
gen_seeds的链式能力是
真正的瓶颈(build_pair()目前只做了两个 socket 的配对)。