☰
CVE-2020-27194:eBPF JIT常量盲化整数溢出漏洞深度解析
2026/10/20 9:45:29 网站建设 项目流程

BPF 这个子系统,这几年可以说已经成了内核漏洞研究圈里的“流量担当”。会看内核的人大都绕不开它:它让普通用户态进程可以往内核里“注入”一段经过校验的程序,校验不通过就拒绝运行,校验通过就交给 JIT 编译成原生指令。把自己代码写进别人的地盘,还带了个看似严格的“安检员”,这套设计几乎是天然的攻防试验场。CVE-2020-27194 就是其中一个典型到不能再典型的样本——它是一枚位于 eBPF JIT 常量盲化(constant blinding)逻辑中的整数溢出漏洞,公开于 2020 年 9 月,允许攻击者构造恶意 BPF 程序绕过 verifier 的边界检查,最终实现内核内存越界读写并完成本地提权。这篇文章会用我实际踩坑后的视角,把这个 CVE 从代码定位、根因、挖掘方法到 PoC 构造完整拆一遍。适合想入门内核漏洞研究、打算把 BPF 当作入手点的同学,也适合对 verifier/JIT 边界逻辑感兴趣、想了解“整数溢出究竟怎么变成内存破坏”的读者。

1. 为什么说 BPF 是目前内核漏洞挖掘的“头号考点”

1.1 BPF 子系统架构里藏了多少攻击面

先简单对齐一下基础。现在说的 BPF 基本都是 eBPF,它不是一个简单的包过滤玩笑了,而是内核里一套完整的虚拟机加工具链。用户态可以通过syscall(SYS_bpf, BPF_PROG_LOAD, ...)把一段所谓的“字节码”提交给内核,内核里的 verifier 会对这段字节码做静态分析,确认它不会干坏事——比如不能越界读写、不能死循环、不能访问任意内核地址。分析通过之后,如果开了 JIT,这段字节码会被翻译成当前架构的真实指令,后续执行就直接走原生代码,不再逐条解释。

问题在于,这个“安全检查”和“执行翻译”的链条实在太长。verifier 是一套寄存器状态机,要模拟每条指令对寄存器、栈、指针的影响;JIT 则是另一套完全不同的实现,负责把 eBPF 指令翻译成 x86 指令。两侧只要出现任何一点点语义不一致,就有可能出现“verifier 觉得你访问没问题,结果机器码真的跑起来越界了”的情况。这本身就是一个巨大的攻击面。再加上 BPF 在很多发行版里默认允许非特权用户加载,等于把一个可编程入口直接开放给所有人,自然成了研究者的靶场。

1.2 CVE-2020-27194 在 BPF 漏洞家族里的位置

BPF 相关的漏洞这些年公开了不少,大部分集中在 verifier 的边界计算错误,比如某个s32向s64扩展时符号位没处理好,导致寄存器区间被错误扩大,最后绕过检查。CVE-2020-27194 就属于另一个分支——JIT 后端的整数溢出。

这个漏洞发生的地方不是 verifier,而是 JIT 编译时的常量盲化逻辑。盲化是干嘛的,下一节细说。这里先给一个结论:JIT 端的漏洞往往比 verifier 端的漏洞更难发现,因为它需要读到生成出来的汇编代码才看得出问题,单纯做语义分析不容易覆盖到位。CVE-2020-27194 的巧劲恰恰在于,它把一个很小的 32 位变量宽度问题,放大成了校验器与机器码之间的“语义分歧”,最终变成了一个很实用的提权漏洞。

2. CVE-2020-27194:从一次 32 位截断到提权种子的根因拆解

2.1 JIT 常量盲化到底在防什么

要理解这个漏洞,就得先知道 JIT 为什么要做常量盲化。现代漏洞利用里有个很经典的手段叫 JIT spraying,攻击者通过反复加载合法程序,让 JIT 区域里堆满可预测的字节序列,然后劫持控制流跳到这些字节中间,把它们当机器码执行。内核 JIT 也一样有这个风险,所以后来引入了 hardening 机制,其中一项就是把立即数“盲化”。

盲化的原则很简单:编译时把指令里的立即数跟一个随机数做异或,然后在运行时先加载随机数,再做一次异或,把真实立即数恢复出来。比如原本指令里要写mov eax, 0x12345678,盲化后变成mov eax, 0xdeadbeef; xor eax, random这类形式(实际 JIT 会走寄存器操作)。这样攻击者看到的内存里不再直接出现可预测的0x12345678,喷射的可用性就大打折扣。

这套机制本身没问题,问题出在某个实现细节上。也就是本节标题提到的:整数溢出。

2.2 一个 32 位临时变量是如何毁掉整个安全证明的

我来用一段逻辑示意代码还原这处漏洞的核心,不是为了贴原代码,是为了让场景更清晰。假设某版本的盲化处理长这样:

static void blind_imm(struct bpf_insn *insn) { u32 rnd = prandom_u32(); // 随机数,32位 u32 masked = insn->imm & 0xffffffff; // 问题点:先窄化到32位 insn->imm = masked ^ rnd; // 再把异或结果写回64位imm }

看起来都是位运算,哪里溢出?关键就在第三行。insn->imm本身是 64 位的,但masked是 32 位,那么当原始立即数的高 32 位不是零时,赋值回insn->imm的高 32 位其实已经被丢弃了。丢失的那部分就是溢出,或者说截断。

举一个具体数值。原始立即数是0x100000001,低 32 位是1,高 32 位是1。盲化之后,真正参与生成的 64 位立即数的低 32 位变成了某个异或后的值,而高 32 位被抹成了0。于是 verifier 在抽象执行时看到的可能是0x100000001,JIT 生成的机器码里实际放进去的却是1附近的小数字。校验器的证明体系建立在“立即数是 x”这个前提上,机器码却跑在“立即数是 y”的世界里,整个证明自然失效了。

这个过程可以类比成快递审核:安检单上写包裹重量 5kg,仓库实际贴的标签却是 50kg。审核员按 5kg 放行,叉车按 50kg 装卸,最后必然出事。这里的“重量”就是立即数,“叉车”就是 JIT 生成的机器码。

2.3 为什么 verifier 没能拦住这个“不一致”

可能有人会问:verifier 不是号称非常严格吗,怎么一个简单的截断都没发现?原因是 verifier 和 JIT 是两套代码。verifier 对 BPF 指令的语义理解,基于它自己维护的一套寄存器状态模型,它会反复推导每条指令执行后的区间、类型、引用计数。但经过盲化之后,指令里的立即数已经被改写,之后 verifier 不会重新跑一遍校验。也就是说,校验阶段看到的是原始值,JIT 编译阶段看到的可以是另一份值。这类“验证时语义”和“执行时语义”不一致的问题,恰恰是漏洞挖掘者最应该盯住的方向——而 CVE-2020-27194 把这句话变成了一个可以提权的实际案例。

3. 漏洞挖掘方法论:静态审计与动态 Fuzzing 双线并进

3.1 人工审计:盯着宽度、符号、掩码这三个危险词

如果让我给一个优先推荐的挖掘路径,我会说:先别一头扎进 verifier 那套复杂的状态机,而是从 JIT 后端代码开始找边界条件。arch/x86/net/bpf_jit_comp.c这个文件是 x86_64 的 JIT 实现,里面几乎所有代码都在处理“如何把指令变成机器码”,这里天然充满了类型转换、立即数扩展、掩码操作。

我自己的习惯是先做一轮关键词扫描。比如:

grep -n "imm" arch/x86/net/bpf_jit_comp.c | head -80 grep -n "0xffffffff\|u32\|s32" arch/x86/net/bpf_jit_comp.c git log --oneline -- arch/x86/net/bpf_jit_comp.c

扫描之后重点看三样东西。第一,有没有把 64 位立即数塞进 32 位变量的情况,尤其是用u32或int接收insn->imm的位置。第二,有没有手动做imm & 0xffffffff之类的操作,这种代码通常意味着作者潜意识里认定“这个立即数不会超过 32 位”。第三,有没有符号扩展的问题,比如s32隐式转成s64后高位全部变成符号位。

CVE-2020-27194 这类问题,基本就藏在这三个词附近。别指望一眼看到正确代码,而是要习惯先问自己:如果这里多一条“extend”语义错误,verifier 会不会察觉?如果生成出来的机器码和 eBPF 指令语义不一致,攻击者能利用这个差异做什么?

3.2 动态 Fuzzing:syzkaller 能撞到但效率看缘分

动态方向肯定绕不开 syzkaller。它对 BPF 的支持已经比较完善,内核需要开启CONFIG_BPF_SYSCALL、CONFIG_BPF_JIT。跑起来之后,sys/bpf.txt里描述了BPF_PROG_LOAD、BPF_MAP_CREATE等调用的参数结构,fuzzer 会随机生成 BPF 指令序列并交给内核执行。

但说实话,如果只靠纯 fuzz 去碰这个漏洞,效率很低。原因在于触发条件比较苛刻:立即数的构造必须同时满足“高 32 位非零”和“低 32 位是某个特定值”,而 fuzzer 生成的数据往往偏向小随机数,很难刚好覆盖到这种跨宽度边界。我的做法是动态和静态结合:先用静态审计锁定嫌疑函数,再针对性的写一段手工构造的 BPF 程序去喂给内核,或者把这段程序作为 syzkaller 的种子塞进语料库。真正高效的漏洞挖掘从来不是靠单纯跑一天 fuzz,而是把“代码里可疑的点”翻译成“可触发的输入”。

4. 触发条件与 PoC 构造:让校验器和 JIT 走出“分歧”

4.1 在线路上的三个关卡

要让这个漏洞真正被触发,绕不开三个环节:加载、校验、编译。

第一步是加载。进程调用bpf()系统调用,传入BPF_PROG_LOAD,同时把 BPF 指令数组交给内核。这一步需要确认当前环境允许 BPF,尤其是非特权 BPF 是否开启。老版本内核里kernel.unprivileged_bpf_disabled默认是 0,意味着普通用户也能加载。如果跑的是加固过的环境,这个值可能被设成 1,那就得先有CAP_BPF或者 root 权限。

第二步是过 verifier。verifier 会跑一遍安全分析,它会模拟指令执行,维护每个寄存器的数值范围。这里的核心诀窍是:要让 verifier 以为你看似安全的访问,实际上会因为盲化后的立即数变化而发生偏移。最好是制造一个“大偏移量”,verifier 认为访问目标仍在 map 范围内,但真实的偏移已经小到让指针落到了越界区域。

第三步才是 JIT 编译。内核在编译指令时,发现立即数需要盲化,于是进入漏洞函数,对 64 位立即数做 32 位处理,整数溢出发生。机器码里生成的地址计算和 verifier 证明的不一致,越界读写随之而来。

4.2 一个精简的 PoC 思路(逻辑示意)

下面这段不是能直接跑通的完整 exploit,但它是当时复现思路的核心骨架。关键在于构造一个 64 位立即数,它的低 32 位很小,高 32 位却不为零,这样 verifier 会按大数处理,JIT 却可能按小数处理。

// 伪代码,仅用于演示构造思路 struct bpf_insn prog[] = { // 先让 r0 指向一张 map 的 value 起始地址(略去 map 查找步骤) BPF_MOV64_REG(BPF_REG_1, BPF_REG_0), // 低32位是1,高32位是1:完整值是0x0000000100000001 // 盲化前 verifier 认为 r1 偏移非常大 // 盲化后 JIT 实际可能只加了接近1的偏移 BPF_ALU32_IMM(BPF_ADD, BPF_REG_1, 0x100000001ULL), // 然后向这个指针写入数据:verifier 以为是安全偏移,实际是越界写 BPF_STX_MEM(BPF_DW, BPF_REG_1, BPF_REG_2, 0), BPF_MOV64_IMM(BPF_REG_0, 0), BPF_EXIT_INSN(), };

真实利用时还需要配合 JIT 地址泄露、map 内部布局计算、目标对象定位等步骤。不过触发点就是这么薄薄一层:让 verifier 和 JIT 对同一个立即数产生两种理解。

4.3 实操验证时的环境细节

我在复现的时候踩过几个坑,顺便记一下。

第一个坑是盲化没生效。内核默认的 hardening 开关在/proc/sys/net/core/bpf_jit_harden,如果这个值是 0,JIT 根本不做盲化,漏洞代码路径完全不会进入。复现时务必确认bpf_jit_harden=1或2。bpf_jit_enable也要检查,如果是 0,等于整个 JIT 都没开,攻击面就换了。

第二个坑是看不到错误日志。可以打开/proc/sys/kernel/kptr_restrict=0,这样内核打印的指针地址不会被打码,配合dmesg能直接看到 JIT 生成区域的地址。如果发现 oops 信息里RIP指向某个bpf_prog_xxx区域,那说明崩溃点确实在 JIT 代码里,而不是 verifier 阶段。这一点本身就是漏洞触发的强信号。

第三个坑是在 qemu 环境里调试时最好把内核的kaslr关掉,用nokaslr启动参数,否则 gdb 里看到的符号地址和实际运行地址总是对不上,排查起来非常痛苦。

5. 利用方向与修复演进:从越界原语到防护基线

5.1 拿到越界读写之后怎么走

这个漏洞最终能撞出来的原语,一般是基于 BPF map 的越界读写。也就是攻击者可以通过一个看似在 map 范围内的偏移,实际读写到 map 内存边界之外的数据。有了这个原语,接下来就是经典提权流程。

常见目标有两个。一个是对当前进程的cred结构做修改,把 uid/gid 改成 0。要做这件事,需要首先知道当前进程的cred在内核里的地址。另一个是修改全局的modprobe_path,把它指向攻击者控制的脚本路径,然后触发一个未知文件格式的执行,让内核调用modprobe,脚本就以 root 身份跑了。相比之下,改cred更直接,但定位地址麻烦;改modprobe_path需要一个可写地址,但在某些环境下更通用。

实际利用时还要考虑 SMEP/SMAP、KASLR、kptr 隐藏等缓解措施。不过对本 CVE 来说,最核心的难点并不是绕过这些,而是稳定构造出一个越界读写窗口,以及算明白 map 对象和受害者对象之间的偏移。

5.2 内核修复到底改了什么

修复方案从根因角度讲其实不难:让盲化逻辑严格按照 64 位来处理立即数,不能先用 32 位变量承接再回写。同时,对超出s32范围的立即数,可能会直接拒绝,避免 verifier 与 JIT 在符号扩展上产生歧义。该修复在 2020 年 9 月合入主线,后续稳定分支也同步跟进。从漏洞披露到修复闭环的节奏来看,属于比较标准的处理流程。

这件事给内核开发者的教训也很有价值:安全校验代码的“证明”必须和被翻译代码的“实际行为”保持完全一致,哪怕中间只隔着一层 JIT 优化,任何窄化、掩码、截断都是破坏证明链路的隐患。对研究者来说,这恰恰是挖掘同类漏洞的绝佳观察窗口:每次 JIT 端新增一个优化逻辑,就意味着有一块“差异性可被操作”的新土地。

6. 实际调试中的经典报错与排查技巧

6.1 环境与触发类问题速查表

我把自己在复现和调试这个 CVE 过程中遇到过的典型问题整理成了表格,应该能在你复现时省掉不少时间。

现象可能原因排查建议
BPF_PROG_LOAD返回EACCES非特权 BPF 被禁用,或缺少CAP_BPF检查kernel.unprivileged_bpf_disabled,必要时用 root 登录测试
加载成功但没触发越界bpf_jit_harden=0,盲化路径没走设置sysctl net/core/bpf_jit_harden=1后重新加载
oops 发生但 RIP 指向匿名区域JIT 代码地址在运行时可变打开bpf_jit_enable=2并配合kptr_restrict=0观察
qemu + gdb 断点总是打不上KASLR 导致符号地址错位内核启动参数加nokaslr
JIT 后指令数据读不到kptr_restrict把地址都打码了/proc/sys/kernel/kptr_restrict=0,重跑
日志完全没输出内核日志级别太高,或 printk 被限制启动参数加loglevel=8,或检查dmesg -n 8

6.2 调试 JIT 类漏洞的几个独家习惯

调试这类 JIT 语义差异的漏洞,光看 oops 是不够的,还要能对着真正生成的机器码断点。我的习惯是这么做:

在内核里对bpf_jit_blinding_constants这个函数下断点(用 gdb 或者 ftrace 都行),然后单步看它执行前后的insn->imm变化。如果发现原始imm和盲化后的imm不再满足“高 32 位和低 32 位的异或关系”,那基本上就踩在漏洞函数里了。

进一步,我会把bpf_jit_enable=2打开,让内核直接把 JIT 生成的机器码 dump 到日志里。拿到这段机器码之后,再对照原 BPF 指令一条条看,重点检查立即数的编码区域。只要看到机器码里的位移和 verifier 日志里记录的边界对不上,漏洞的“证据”就非常直观。

还有一个辅助技巧:故意在 BPF 程序里放一个特征值,比如0xdeadbeefcafebabe,然后去机器码里搜这个特征。这样做的好处是能快速确认这组立即数是否正常落进了 JIT 区域,也方便在 panic 日志里快速定位到对应指令。

最后分享一点个人体会。做 BPF 漏洞研究,尤其是 JIT 方向的,最忌讳只盯着 verifier 那一层不断抠边界条件。把视野拉宽,多问一句“这里的立即数在生成机器码时会不会被窄化”,往往比盯着寄存器区间推导一整天更有效。这类“类型宽度不一致”的漏洞,在 JIT 后端的历史上反复出现,模式清楚、触发路径明确,是新手建立内核漏洞手感很好的起点。CVE-2020-27194 已经把这条思路完整演示了一遍,剩下的,就是拿它当模板,去代码里找下一个类似的歧义点了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询