-
-
[原创]KernelGPT让大模型给 Linux 内核写 fuzz 规则:一条从源码到 QEMU 的流水线,和它踩过的坑
-
发表于: 2026-10-19 10:17 521
-
让大模型给 Linux 内核写 fuzz 规则:一条从源码到 QEMU 的流水线,和它踩过的坑
用一个 7 分钟、1 台虚拟机的冒烟测试,换来一条完整闭环的证明:
内核源码 → Clang AST → LLM 推理 → Syzkaller 描述 → 常量提取 → 编译器校验 → QEMU 里真的执行了那条 ioctl。
一、问题的起点:ioctl 是 fuzzer 的盲区
用 Syzkaller fuzz 内核,大多数系统调用的参数是"有名有姓"的:read(fd, buf, count) 的结构从签名就能读出来。
但 ioctl(fd, cmd, arg) 不行。arg 只是一个 unsigned long,它到底是 int、是 struct foo *、还是一个位域打包的结构体,只有驱动源码知道。而 syzkaller 的模糊测试效果,几乎完全取决于这份"类型描述"写得对不对:
ioctl$KGPT_RNDRESEEDCRNG(fd fd_random, cmd const[RNDRESEEDCRNG], arg ptr[in, array[int8]])
这一行里,const[RNDRESEEDCRNG] 的常量值、arg 的真实类型,都不是猜出来的,是从内核源码里读出来的。
人工写规则不现实——Linux 有几万个 ioctl。所以很自然的一个想法是:
让大模型读源码,自动生成这些描述。
KernelGPT 做的就是这件事。但真正把它跑通,从"能生成 JSON"到"能真的在 QEMU 里执行",中间隔着一整个工程问题。
二、流水线长什么样
内核源码 + .config
│
├─ compile_commands.json ──► Clang AST 分析器
│ │
│ ▼
│ func/struct/enum/ioctl/usage.jsonl
│ │
│ ▼
│ processed_*.json(按名字索引)
│ │
▼ ▼
按名字回查源码 ◄──────────── gen_spec.py(LLM 多轮推理)
│
▼
KernelGPT JSON 规则
│
▼
Syzkaller .txt 描述
│
┌──────────────┴──────────────┐
▼ ▼
syz-env 提取常量 make descriptions
│ │
└──────────────┬──────────────┘
▼
syz-manager + QEMU
看起来是条直线,但每一段都有让整条链静默失效的细节。
三、三个关键设计
1. 让模型"只指名",不让它"背源码"
内核源码几百 MB,塞不进上下文。所以第一步不是问模型,而是先把源码变成可查询的库:分析器用 Clang AST 把「函数 / 结构体 / 枚举 / 引用点」全抽出来,按名字索引。
然后模型只需要输出符号名:
{"function": ["pde_ioctl"], "type": [], "usage": ["return pde_ioctl(pde, file, cmd, arg);"]}
Python 拿着这些名字去库里取回原文,拼进下一轮 prompt。
这一步把不确定性压到了最小:模型不需要复述任何代码,也就不可能编造代码。上下文长度天然可控(每次几 KB),而且每一步取到的源码都落盘,可以复盘。
2. 把"不知道"变成可迭代的状态
最糟糕的做法是让模型硬猜——猜出来的类型会变成编译错误,改起来比重新生成还贵。
所以协议里显式定义了 UNKNOWN_ARG、UNKNOWN_VAL、UNKNOWN_LEN、types 里的 UNKNOWN、以及 ioctl 列表旁的 unknown[]。每一轮把这些"未知"摘出来,连同新的取码指令进下一轮:
step0 open → step1 cmd → step1-CMD(缺失函数)→ step2 arg → step3 type → step4 返回值
每层都有 MAX_DEPTH 封顶。宁可标记未知,也不让模型编一个看起来合理的答案。
3. 用编译器当裁判
"生成得好不好"不靠另一个模型打分,而靠 Syzkaller 自己编译:
eval_spec.py ──► make extract / make descriptions / make
▲ │
└────── 失败:把「文件:行:列」诊断回灌 ─────────┘
从诊断里正则抽出出错行号 → 反查是哪条 syscall / 哪个 type → 只把那一小块和错误信息重新问模型 → 局部替换。局部修复而不是整体重生成,既省 token,也保住了已经对的部分。
四、踩坑实录
下面这些坑,每一个都能让流水线"看起来在跑,实际什么都没产出"。按发现顺序记录。
坑 1:编译数据库被覆盖,覆盖率悄悄掉到 0.4%
分析器要靠 compile_commands.json 重放编译。我按文档跑了 bear -- make,然后发现分析器只处理了 10 个文件。
查下来:bear 并不会解析 Makefile,它是在进程层拦截真正被 spawn 出来的编译器(LD_PRELOAD 一个 libexec.so,hook execve/posix_spawn 家族,外加一组 cc/gcc/clang/ld 的 wrapper)。在已经构建完成的树上再跑一次,Make 认为所有目标都是最新的,一个编译进程都不会启动——于是它把已有的数据库整体覆盖成 10 条。
而 linux/ 下有 4404 个 .cmd 文件、约 2900 个 .o,说明这棵树本来是完整构建过的。
正确做法是让内核自己从 .cmd 重建,不需要重新编译:
cd linux && make compile_commands.json
bear 只有在全新构建时才有意义;真要用就加 --append,别覆盖。
教训:一行自检命令就能提前发现——
python3 -c "import json;print(len(json.load(open('linux/compile_commands.json'))))"
# 应该是几千,不是几十
坑 2:gcc 构建的数据库,会让 libclang 全量报错
数据库修好后,分析器开始疯狂输出:
error: unknown warning option '-Wno-alloc-size-larger-than'; did you mean '-Wno-frame-larger-than'? [-Werror,-Wunknown-warning-option]
...
11 errors generated.
Error while processing /data/.../sound/hda/hdac_stream.c.
根因不是源码,而是编译器不匹配:编译数据库里存的是构建内核时那个编译器的原始命令行。这棵树是用 gcc 构建的,于是每条命令都带着 -Wno-alloc-size-larger-than、-Wimplicit-fallthrough=5、-mpreferred-stack-boundary=3 这类 gcc 专属参数。
而 libclang 的 driver 把不认识的选项当硬错误。再叠加内核命令行里本来就有的 -Werror,连"未知的 warning 选项"都会被提升成错误——每个翻译单元在解析前就挂了,一条 AST 都出不来。
修法不是瞎试,而是把库里每条命令都丢给 clang 跑一遍、收集它拒绝的参数:
$ python3 sweep.py # 一次性脚本:把 2789 条命令逐条丢给 clang-14
rejected args (name, #files):
2757 -mpreferred-stack-boundary=3
2757 -mindirect-branch=thunk-extern
2757 -mindirect-branch-register
2757 -mindirect-branch-cs-prefix
2757 -mfunction-return=thunk-extern
2757 -fno-allow-store-data-races
2757 -fconserve-stack
1 -fno-code-hoisting
全库只有这 8 个。把它们(以及所有 -W*)在重放前丢掉,analyze 立刻从"全红"变成只剩 warning。
教训:编译器报错先问"这是谁编的",而不是"我的代码哪错了"。
坑 3:PATH 里的 clang++ 是个 riscv 交叉工具链
修完上面那个坑,编译分析器本身又失败:
error: no type named 'mutex' in namespace 'std'
error: implicit instantiation of undefined template 'std::future<void>'
note: template is declared here
/home/test/code/gcc/bin/../lib/gcc/riscv64-unknown-elf/14.2.1/.../future:137
报错里混进了一个 riscv64-unknown-elf 的头文件路径,这不是巧合:PATH 第一位是个 riscv 交叉工具链,里面的 clang++ 是 Target: riscv64-unknown-unknown-elf 的 clang 19。它的默认 C++ 标准库来自 bare-metal 的 libstdc++,没有启用 gthreads——所以 std::mutex 根本不存在,std::future 只有一个前置声明。
一行就能验证:
clang++ -std=c++17 -pthread -fsyntax-only /tmp/t.cc # 报错
/usr/bin/clang++-14 -std=c++17 -pthread -fsyntax-only /tmp/t.cc # 通过
而 Makefile 里把 clang++ 写死了(只有辅助对象用 $(CXX)),所以光传 CXX=/usr/bin/g++ 没用:
tmpbin=$(mktemp -d); ln -s /usr/bin/clang++-14 "$tmpbin/clang++"
PATH="$tmpbin:/usr/bin:/bin" make all CXX=/usr/bin/g++
教训:which -a clang++ 比报错本身更有信息量。
坑 4:分析数据里残留着上一棵树的绝对路径
生成规则时,同一个 handler 跑了两遍:
[proc_reg_file_ops] Generating fs/virt spec ← /home/test/code/KernelGPT/...
[proc_reg_file_ops] Generating fs/virt spec ← /data/KernelGPT/...
原因是 *.jsonl 是追加写入的,里面同时留着旧树(/home/test/code/...,目录早已不存在)和新树的记录。规整后 processed_handlers.json 里 177 个 handler 有 88 个带死路径,每个都得白跑一半。
更隐蔽的是 -n 1 的语义:它按 (handler, 路径) 计数,不是按 handler。所以"只跑一个"其实跑了两条。
清掉旧根记录、重新规整后:177 → 89 个 handler,每个恰好一条路径。
教训:中间产物永远要带"来源版本",否则它会和新数据混在一起,而且很难看出来。
坑 5:有些 fops 本来就不该生成出 ioctl
第一个跑到的 handler 是 /proc 的通用 fops,结果 failed no spec:
proc_reg_unlocked_ioctl(file, cmd, arg)
└─ pde_ioctl(pde, ...)
└─ ioctl = pde->proc_ops->proc_ioctl; if (ioctl) return ioctl(...);
两级函数指针,源码里根本没有具体 cmd(没有 proc_ioctl 就返回 -ENOTTY)。模型的表现其实完全正确:第一轮答 unknown 并指出 pde_ioctl,喂给它源码后仍然答 unknown,于是被 already_missing 去重、判定失败。
真正有 ioctl 的是注册到 /proc 的那些驱动自己的 proc_ops——属于驱动,不属于 /proc。
教训:"失败"分两种:一种该修,一种该接受。分不清就会去优化一个本来就没有答案的问题。
坑 6:make -j 64 把 Go 编译器送进 OOM-killer
第一次跑完整的 fuzz 流程,卡在构建 Syzkaller:
github.com/google/syzkaller/sys/linux/gen: .../compile: signal: killed
make: *** [Makefile:184: mutate] Error 1
dmesg 给了答案:
Out of memory: Killed process (compile) ... anon-rss:1763244kB
Out of memory: Killed process (compile) ... anon-rss:2547316kB
Out of memory: Killed process (compile) ... anon-rss:3091208kB
三个 Go 编译进程各吃 1.7 / 2.5 / 3.1 GB,同时被杀。原因是两层并发相乘:
- syzkaller 的生成描述包
sys/linux/gen单个compile就要 2–3 GB,而每个二进制都依赖它; make -j 8会同时起 8 个go build(每个对应一个二进制),go build内部还会再并发。
改成"recipe 串行、Go 内部封顶 2"之后,同样的构建 1 分钟通过:
concurrency = max(1, min(jobs, 2))
env["GOMAXPROCS"] = str(concurrency)
env["GOFLAGS"] = f"-p={concurrency}"
# make -j 1
还有一半责任在编辑器:ps 显示 8 个 cpptools-srv2 各占 1.0–1.25 GB,合计约 9 GB。16 GB 的机器只剩 2–5 GB 给构建,怎么调参数都不够。
教训:OOM-killer 是静默的(构建工具只报 signal: killed)。在构建前打印可用内存,能省掉几小时的误判。
坑 7:--merge 分支的退出码不可信
第一次跑 merge,脚本正常退出了,但看日志才发现构建阶段早就失败了——eval_spec.py 的 merge 分支只接收子进程的 CompletedProcess,不检查也不传递返回码。只有单文件模式用返回码。
教训:判断成功与否,要回到"最终产物"和"日志里的阶段标记",不要迷信退出码。
五、第一次真正的闭环
修完这一串之后,终于跑通了。规模很小:7 分钟、1 台虚拟机(2 vCPU / 2 GB)、4 个 fuzz 进程。
VMs 1, executed 30052, cover 5764, signal 6520/9217, crashes 0, repro 0, triageQLen 0
没有崩溃——这在预期之内,这个规模只够冒烟。但真正的收获是另一件事:怎么证明生成的规则真的执行到了内核?
manager 日志里只有 "enabling syscall ...",那只说明规则被注册了。要证明执行,得看语料:
./bin/syz-db unpack workdir-*/corpus.db /tmp/corpus-dump
grep -rl 'KGPT_RNDRESEEDCRNG' /tmp/corpus-dump | wc -l
(直接 grep corpus.db 是查不到的——语料里存的是数字 syscall ID,不是名字。)
结果:
语料 174 条
含 openat$KGPT_random: 104 条
含 ioctl$KGPT_RNDRESEEDCRNG: 7 条
r0 = openat$KGPT_random(0xffffffffffffff9c, &(0x7f0000000100), 0x0, 0x0)
ioctl$KGPT_RNDRESEEDCRNG(r0, 0x5207, 0x0)
0x5207 是 RNDRESEEDCRNG——syz-extract 从内核头文件里提取出来的常量。
看到这三行,整条链路才算被证明:源码 → AST → LLM → 描述 → 常量提取 → 编译 → 生成 program → 在 guest 内核里执行。
六、如果要让它真的挖到 bug
7 分钟 3 万次执行、5764 个基本块,这个数字说明两件事:
- 时间远不够。覆盖率结束时仍在以约 500 块/分钟增长,根本没饱和。
- 规则集太小。这次
enable_syscalls只有 7 项,其中真正来自 KernelGPT 的只有 2 条(openat$KGPT_random+ 一个cmd const[0]的 ioctl),其余是自动补的read*/write*/close*/mount*。
所以优先级很明确:
| 优先级 | 动作 | 说明 |
|---|---|---|
| 1 | 把规则集做厚 | 现在 89 个 handler 只生成了 2 条规则 |
| 2 | 加 VM 数和时长 | 每 7 分钟 1 台 VM 覆盖 5.7k 块,离"能挖到 bug"差两三个数量级 |
| 3 | 用覆盖反馈筛规则 | 保留带来新代码路径的,剔除纯空转 |
| 4 | 关注"失败"的分类 | 通用 fops 没有可推导的 ioctl,这是正确结果 |
七、几条可以带走的经验
- 编译器报错先问"这是谁编的"。同一份源码,换驱动就是另一种命运。
- 中间产物要能追溯版本。追加写 + 绝对路径 = 新旧数据混在一起,且极难发现。
- 把"不知道"显式化。让模型说
unknown比让它猜一个"看起来对"的答案有价值得多。 - OOM 是静默的。构建前打印可用内存,能省下几小时。
- 判断成功要看产物,不要只看退出码。
- 闭环的证据在语料里,不在日志的
enabling syscall里。
最后一条也许最重要:
这条流水线最难的部分,从来不是"让大模型写出 JSON",
而是让每一步的失败都变得可观测、可归因。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。