2024 年 1 月 20 日,Florian Westphal 提交了 f342de4e2f33,标题是 "netfilter: nf_tables: reject QUEUE/DROP verdict parameters"。提交信息第二行写着 This reverts commit e0abdadcc6e1.,被回退的提交来自 2014 年 2 月。在解释完问题之后,他补了一句:
Its not clear to me why this commit was made.
一位维护者在十年后回退一个提交,并且把自己没搞清原意这件事写进了提交信息。这句话通常被当作这段历史的注脚引用,但它指向一个更有用的问题:一个 2014 年被接受、在十年里没有被发现的改动,究竟把什么校验掉了。
一个值同时装两种东西
verdict 是钩子函数处理完一个包之后返回的处置决定,常见的取值是放行(NF_ACCEPT)、丢弃(NF_DROP)、交给用户态队列(NF_QUEUE)。netfilter 的 verdict 不是一个枚举,而是一个 32 位整数,字段被复用:
c
/* Responses from hook functions. */
#define NF_DROP 0
#define NF_ACCEPT 1
#define NF_STOLEN 2
#define NF_QUEUE 3
#define NF_REPEAT 4
#define NF_STOP 5 /* Deprecated, for userspace nf_queue compatibility. */
/* we overload the higher bits for encoding auxiliary data such as the queue
* number or errno values. Not nice, but better than additional function
* arguments. */
#define NF_VERDICT_MASK 0x000000ff
/* extra verdict flags have mask 0x0000ff00 */
#define NF_VERDICT_FLAG_QUEUE_BYPASS 0x00008000
/* queue number (NF_QUEUE) or errno (NF_DROP) */
#define NF_VERDICT_QMASK 0xffff0000
#define NF_VERDICT_QBITS 16
#define NF_QUEUE_NR(x) ((((x) << 16) & NF_VERDICT_QMASK) | NF_QUEUE)
#define NF_DROP_ERR(x) (((-x) << 16) | NF_DROP)
注释里那句 "Not nice, but better than additional function arguments"是设计者的自我评价,它承认了这次字段复用。低 8 位放分支,高 16 位放"queue number 或 errno",中间那 8 位留给 flag。两个编码宏把这件事写得更明白。NF_QUEUE_NR(x) 把队列号左移 16 位再与上 NF_QUEUE,NF_DROP_ERR(x) 把 errno 取负后左移 16 位再与上 NF_DROP。也就是说,同一个高 16 位区域的语义取决于低 8 位是谁:低 8 位是 NF_QUEUE 时它是队列号,是 NF_DROP 时它是 errno。这个约定有一个正当的使用者。nft_queue 是把包交给用户态队列的那个 nftables 表达式,它在求值时正是这么构造 verdict 的:
c
ret = NF_QUEUE_NR(queue);
if (priv->flags & NFT_QUEUE_FLAG_BYPASS)
ret |= NF_VERDICT_FLAG_QUEUE_BYPASS;
regs->verdict.code = ret;
队列号左移 16 位,与上 NF_QUEUE,需要时再把中间 flag 区的 NF_VERDICT_FLAG_QUEUE_BYPASS 或上去,最后写进 regs->verdict.code。高 16 位与 flag 区都有正当的生产者,而生产者是表达式本身。这一点在后面的分析里会用到。于是这次的 32 位里,一个值的不同部分有不同的含义:所谓位域,是把同一个整数的不同比特段当作不同字段来用:用掩码取出低位段、用右移取出高位段,读的是同一个值的不同部分。
位 31 . . . . . . . . . 16 15 14 . . . . . . 8 7 . . . . . . 0
┌───────────────────────┬──────────────┬───────────────┐
│ NF_VERDICT_QMASK │ FLAG 区 │ NF_VERDICT_ │
│ 0xffff0000 │ 0x0000ff00 │ MASK 0x000000ff│
│ queue number / errno │ │ NF_DROP = 0 │
│ │ │ NF_ACCEPT = 1│
│ │ │ NF_QUEUE = 3 │
└───────────────────────┴──────────────┴───────────────┘
▲ ▲
│ │
┌─────────┴──────────┐ ┌────────────┴────────────┐
│ 消费点读这里 │ │ 准入点校验这里 │
│ nf_hook_slow() │ │ nft_verdict_init() │
│ NF_DROP_GETERR(v) │ │ v & NF_VERDICT_MASK │
│ = -(v >> 16) │ │ 2014 年之后 │
│ 定义在内部头文件 │ │ │
└────────────────────┘ └────────────────────────┘
解码高 16 位的那个函数不在用户态头文件里。NF_DROP_GETERR 定义在 include/linux/netfilter.h,而不是 include/uapi/linux/netfilter.h:
c
static inline int NF_DROP_GETERR(int verdict)
{
return -(verdict >> NF_VERDICT_QBITS);
}
这一点在后面很重要。把用户态字节解码成内核信任值的解码器,本身不属于用户态 ABI。解析 netlink 属性的代码要按正确方式校验,得去调用一个定义在内部头文件里的辅助函数;它无法像 NF_VERDICT_MASK 那样直接在自己的文件里用手边的宏完成判断。
这个值从哪里进来
在讲消费点之前,先把这个值从用户态字节走到 skb verdict 的路径确认一遍。 这条路径上的四个词先交代:netfilter 是内核里的包处理框架;hook 是包在内核处理路径上经过的检查点;skb 是内核中代表一个正在被处理的网络包的数据结构;netlink 是用户态向内核下发网络配置(这里是 nftables 规则)的通道。nftables 规则里的"立即"动作由 nft_immediate 表达式承担,它的初始化函数调用 nft_data_init():
c
err = nft_data_init(ctx, &priv->data, &desc, tb[NFTA_IMMEDIATE_DATA]);
nft_data_init() 按数据类型分派,verdict 走这一支:
c
} else if (tb[NFTA_DATA_VERDICT] && ctx != NULL) {
if (desc->type != NFT_DATA_VERDICT)
return -EINVAL;
err = nft_verdict_init(ctx, data, desc, tb[NFTA_DATA_VERDICT]);
}
也就是说,nft_verdict_init() 是 netlink 属性进入内核 verdict 结构的唯一校验点,它前面只有一个类型判断。再看准入点内部的校验面。它做的第一件事是从嵌套属性里取出 NFTA_VERDICT_CODE,按大端 32 位读:
c
data->verdict.code = ntohl(nla_get_be32(tb[NFTA_VERDICT_CODE]));
用户态提供的就是一个完整的 32 位整数,内核这边不做拆分,直接放进 data->verdict.code。随后是那个 switch,校验面就是它。这个 switch 的完整改动在下一节以 diff 给出,它的判断依据只是 verdict 的低 8 位。函数最后一步记录长度:
c
desc->len = sizeof(data->verdict);
描述符记下的是整个 verdict 结构的长度,不是"有效位"的长度。这个细节与前面的分析一致:准入点把 verdict 当作整体来存储和传递,而在校验那一步只看了低 8 位。存储与传递以 32 位为单位,校验却是 8 位为单位。求值阶段则是简单搬运:
c
void nft_immediate_eval(const struct nft_expr *expr,
struct nft_regs *regs,
const struct nft_pktinfo *pkt)
{
const struct nft_immediate_expr *priv = nft_expr_priv(expr);
nft_data_copy(®s->data[priv->dreg], &priv->data, priv->dlen);
}
校验过的数据被原样复制进寄存器,链求值结束后作为 skb 的 verdict 返回给 hook。从 nft_verdict_init() 通过到 nf_hook_slow() 读到它,中间没有第二次检查。这个结构决定了准入点的分量:它不是一个可以出错的环节之一,而是唯一的那个环节。
消费点怎么用这个值
nf_hook_slow() 是决定怎么处理 skb 的地方。函数开头的注释写明了调用约定:
c
/* Returns 1 if okfn() needs to be executed by the caller,
* -EPERM for NF_DROP, 0 otherwise. Caller must hold rcu_read_lock. */
返回值 1 表示"继续执行调用方传入的 okfn",也就是这个包还要往下走;返回值 0 表示"已处理完,不需要调用方做事";负值表示丢弃。核心循环是这样:
c
for (; s < e->num_hook_entries; s++) {
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break;
case NF_DROP:
kfree_skb_reason(skb,
SKB_DROP_REASON_NETFILTER_DROP);
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;
return ret;
case NF_QUEUE:
ret = nf_queue(skb, state, s, verdict);
if (ret == 1)
continue;
return ret;
case NF_STOLEN:
return NF_DROP_GETERR(verdict);
default:
WARN_ON_ONCE(1);
return 0;
}
}
两条读取路径的差别是这段代码的关键。选分支时用的是 verdict & NF_VERDICT_MASK,只看低 8 位;进入 NF_DROP 分支之后,交给 NF_DROP_GETERR 的是未掩码的完整值,函数内部右移 16 位。还有一层:NF_VERDICT_FLAG_QUEUE_BYPASS 落在中间那 8 位的 flag 区(0x0000ff00),而这个区域在 2014 年之后同样不受准入校验约束。也就是说,改动放开的范围不止高 16 位,还包括一个本来留给标志位的区域,而 nft_queue 会往这个区域里写字。 这个标志位的用途由 nft_queue 按表达式配置写入:置位时表示该队列的包绕过正常的队列处理路径。公开材料没有说明用户态伪造这一位会产生什么具体后果,能确认的是它也属于"被读取但不被准入校验覆盖"的区域之一。
NF_DROP_GETERR 在这段代码里出现了两次。一次在 NF_DROP 分支,一次在 NF_STOLEN 分支。NF_STOLEN 的语义是"这个包不再由 netfilter 继续处理",函数直接把高 16 位的解码结果作为返回值交出去。也就是说,同一个解码器承担两个分支的返回值计算,而这两个分支都不再对高 16 位做任何检查。也就是说,一个值的两个部分在这里被分别消费:低 8 位决定走哪条路,高 16 位决定这条路的返回值。NF_DROP 分支里,kfree_skb_reason 已经把 skb 释放掉,随后 NF_DROP_GETERR 基于高 16 位算出一个返回值交给调用方。这个设计的前提是高 16 位受到约束。注释里那句 "-EPERM for NF_DROP, 0 otherwise"说明调用方预期拿到的只有负值或零;而返回 1 是"继续处理"的信号。NF_STOLEN 分支同样把返回值交给 NF_DROP_GETERR,同样依赖这个前提。关于这处约束缺失的具体后果,只按来源陈述,不做自行推演。CVE-2024-1086 的描述写的是:
The nft_verdict_init() function allows positive values as drop error within the hook verdict, and hence the nf_hook_slow() function can cause a double free vulnerability when NF_DROP is issued with a drop error which resembles NF_ACCEPT.
修复提交的描述是:
core.c:nf_hook_slow assumes that the upper 16 bits of NF_DROP verdicts contain a valid errno, i.e. -EPERM, -EHOSTUNREACH or similar, or 0.
Due to the reverted commit, its possible to provide a positive value, e.g. NF_ACCEPT (1), which results in use-after-free.
两段放在一起,可以得到一个不需要额外推演的结论:返回值由高 16 位决定,而取值区间不受约束时,"返回值恰好落在调用方视为继续处理的取值上"就成为一种可能。至于具体哪个输入常数值会走到那一步,这里不从源码反推,因为这一步的算术需要连同调用方对返回值的完整处理一起验证,而我没有做那一步核对。
这条链子是这样接起来的:包进入 NF_DROP 分支时已经被 kfree_skb_reason 释放;返回值 1 在 nf_hook_slow 的调用约定里表示"这个包还要继续往下走",于是调用方(协议栈里继续处理这个包的那一层)会接着使用这块内存。这就是 use-after-free,即释放后使用;如果同一块内存被再次释放,就是 double free。两者都属于内存破坏类缺陷,攻击者可以利用它们改写内核里的数据。
2014 年的那个改动
e0abdadcc6e1 由 Patrick McHardy 提交,作者日期 2014 年 2 月 18 日,Pablo Neira Ayuso 在 2 月 25 日合入。改动只落在 net/netfilter/nf_tables_api.c 一个文件,+10/−5。提交信息很短:
netfilter: nf_tables: accept QUEUE/DROP verdict parameters
Allow userspace to specify the queue number or the errno code for QUEUE and DROP verdicts.
意图写得很清楚:让用户态能为 QUEUE 指定队列号、为 DROP 指定 errno 码。这是一个功能提交,不是疏忽。需要说明的是,这一段的意图是从提交信息与改动本身读出来的。十年后的回退者写下的原话是"我不清楚这个提交当初为什么这么做",这里据此引用,不代他解释动机,也不主张当年的作者存在过失。相应的 hunk 是:(在当年的内核里这个值直接存放在 data->verdict 这个整数字段里,后来的版本把它改成带 code 成员的结构,所以下面这段与当前代码的写法不同。)
c
switch (data->verdict) {
- case NF_ACCEPT:
- case NF_DROP:
- case NF_QUEUE:
+ default:
+ switch (data->verdict & NF_VERDICT_MASK) {
+ case NF_ACCEPT:
+ case NF_DROP:
+ case NF_QUEUE:
+ break;
+ default:
+ return -EINVAL;
+ }
+ /* fall through */
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
改动前,switch 直接比较完整值,三个 case 分别对应 0、1、3。只有高 16 位全为零的那三个具体值能匹配上;任何带高位的值都落不到 case 里,走到后面的 default 被拒。改动之后,校验换成了 data->verdict & NF_VERDICT_MASK,只看低 8 位。带任意高位的值只要低 8 位是 0、1、3 就能通过,然后 /* fall through */ 落进原来的分支继续处理。准入点从"校验整个值"变成了"校验低 8 位"。而消费点读的是高 16 位。两个位域不相交,中间那 8 位的 flag 区也没有被约束。这个改动在当时看起来是加固。它不是把校验删掉,而是把校验重写成"先区分内部值和外部值,再对外部值校验低 8 位",还补了一个 return -EINVAL。从代码形状上看,它比原来更完整:原来只有一个扁平的 switch,改后有了嵌套结构和一个显式的拒绝分支。功能实现了,代码看起来更严谨了,而校验对象从"整个值"悄悄变成了"值的一部分"。
2024 年的修复:把功能删掉
f342de4e2f33 的选择是把 2014 年的改动整体回退,恢复完整值比较。提交信息的推理链条完整:
This reverts commit e0abdadcc6e1.
core.c:nf_hook_slow assumes that the upper 16 bits of NF_DROP verdicts contain a valid errno, i.e. -EPERM, -EHOSTUNREACH or similar, or 0.
Due to the reverted commit, its possible to provide a positive value, e.g. NF_ACCEPT (1), which results in use-after-free.
Its not clear to me why this commit was made.
NF_QUEUE is not used by nftables; "queue" rules in nftables will result in use of "nft_queue" expression.
紧跟着的那一段说明了取舍:
If we later need to allow specifiying errno values from userspace (do not know why), this has to call NF_DROP_GETERR and check that "err <= 0" holds true.
这句话描述的是"如果将来要实现这个功能,准入点应该怎么校验"。对照前面那张图:NF_DROP_GETERR 正是消费点使用的解码器,check that err <= 0 正是"返回值必须是负值或零"这个调用约定的准入版本。作者把正确做法写清楚了,而当时选择的方案是把功能整体删掉。这是一次明确的工程取舍。删掉功能让修复变小、风险变低,代价是 2014 年那个提交想加的能力不复存在。按提交信息里那句 "do not know why",作者判断这个能力没有实际需求。修复的第二处 hunk 把 default: return -EINVAL; 重新加回 switch 末尾,于是结构回到 2014 年之前的样子。恢复后的白名单是三个具体整数:NF_ACCEPT、NF_DROP、NF_QUEUE。
f342de4e2f33 的标签一并列出:Fixes: e0abdadcc6e1、Cc: stable@vger.kernel.org、Reported-by: Notselwyn。有明确的 Cc: stable,这是它进入多个 stable 分支的直接原因。
三次改动都没有带测试
在使用这些源码做结论之前,有一件容易漏掉的事:把三次提交的改动文件列出来。
e0abdadcc6e1 只动了一个文件,net/netfilter/nf_tables_api.c,+10/−5。
f342de4e2f33 只动了一个文件,同一个文件,+6/−10。
da107398cbd4 只动了一个文件,还是同一个文件,+5/−2。三次提交都没有改动测试文件,也没有新增测试。仓库里没有检索是否存在事后补的测试,因此不能断言"这套判断从未被测试覆盖";能确认的是这三次改动本身没有带来任何可执行的验证。这个观察在这条时间线上的位置很具体。2014 年的改动确立了"低 8 位校验"这条规则,2024 年的改动把它推翻,2026 年的改动又收窄了 2024 年留下的白名单------三次都是对同一处判断的修改,而三次的验证方式都只有人眼阅读。缺陷在十年里没被发现,与这一点是相关的。
保留下来的那个值
NF_QUEUE 留在了白名单里。同一份提交信息给出了理由:
NF_QUEUE is not used by nftables; "queue" rules in nftables will result in use of "nft_queue" expression.
nftables 里表达"把包交给队列"这件事,用的是 nft_queue 表达式,而不是一个立即的 NF_QUEUE verdict。既然没有合法生产者会发出这个值,作者把它留在白名单里。判断里其实包含两个都正确的观察。第一个:nft_queue 是 NF_QUEUE verdict 的合法生产者,它用 NF_QUEUE_NR(queue) 构造这个值。第二个:走 nft_queue 时,队列号来自表达式的配置,不是由用户态直接提供的 32 位整数。这两条都对。缺的是第三条:表达式是合法的生产者,不等于表达式是唯一可能的来源。通过 nft_immediate 提交一个立即 verdict,走的是同一个 nft_verdict_init(),只是那条路上没有任何东西保证高 16 位是被 NF_QUEUE_NR 那样算出来的。这句话在当时的语境下是合理的:它说明的是"不存在合法来源"。但准入点的职责不是判断"有没有合法来源",而是判断"这个值交到下游会不会出问题"。这两件事在大多数情况下结论一致,在 NF_QUEUE 这个具体值上不一致,而 2026 年的修复把不一致暴露了出来。这里有一处容易混淆的地方:NF_QUEUE 在消费点那边的处理是完整的:nf_hook_slow 有 case NF_QUEUE:,会调用 nf_queue()。所以它不是一个"下游不认识"的值,恰恰相反,下游对它有一整套处理。问题出在谁能触发这条路径:走 nft_queue 表达式时,队列号由表达式提供,低 8 位与高 16 位的组合是表达式自己算出来的;而通过 netlink 直接提交一个立即 verdict 时,这个组合由用户态提供,中间没有表达式的约束。
2026 年:同一个判据,相反的结论
da107398cbd4 由 Pablo Neira Ayuso 提交,作者日期 2026 年 3 月 31 日,提交信息带 Fixes: f342de4e2f33,也就是它修的是 2024 年那次修复本身:
nft_queue is always used from userspace nftables to deliver the NF_QUEUE verdict. Immediately emitting an NF_QUEUE verdict is never used by the userspace nft tools, so reject immediate NF_QUEUE verdicts.
The arp family does not provide queue support, but such an immediate verdict is still reachable. Globally reject NF_QUEUE immediate verdicts to address this issue.
前两句和 2024 年的理由几乎同构:立即的 NF_QUEUE 从不被用户态工具使用,所以拒绝它。差别落在第三句:arp family 不提供 queue 支持,但这个立即 verdict 仍然可达。
nft_queue 表达式在 arp family 下没有对应的处理路径,可是通过 netlink 提交一个立即 NF_QUEUE verdict 的路径仍然存在。没有合法生产者,路径却通着。补丁与 CVE 记录都没有说明在 arp family 下走到 nf_queue() 会产生什么具体后果,这次拒绝的依据是"路径可达"本身。2024 年把"没有被使用"读成了"不会出现";这一次把它读成了"没有正当来源,因此应当封掉"。修复本身很小,两个 hunk:
c
switch (data->verdict.code) {
case NF_ACCEPT:
case NF_DROP:
- case NF_QUEUE:
- break;
case NFT_CONTINUE:
c
data->verdict.chain = chain;
break;
+ case NF_QUEUE:
+ /* The nft_queue expression is used for this purpose, an
+ * immediate NF_QUEUE verdict should not ever be seen here.
+ */
+ fallthrough;
default:
return -EINVAL;
}
NF_QUEUE 从白名单移出,与 default 合流返回 -EINVAL,注释写明立即 NF_QUEUE 不应出现在这个位置。主线当前的 nft_verdict_init() 就停在这个状态,白名单是两个值:NF_ACCEPT 与 NF_DROP。两次修复的白名单变化可以并排看:
2014 之前 case NF_ACCEPT: case NF_DROP: case NF_QUEUE:(加 NFT_* 内部值)
2014 之后 default: switch (data->verdict & NF_VERDICT_MASK) ← 只校验低 8 位
case NF_ACCEPT: case NF_DROP: case NF_QUEUE: break;
default: return -EINVAL;
/* fall through */
2024 之后 case NF_ACCEPT: case NF_DROP: case NF_QUEUE: break; ← 恢复完整值比较
2026 之后 case NF_ACCEPT: case NF_DROP:
case NF_QUEUE: fallthrough; default: return -EINVAL;
顺带一个记录上的细节:da107398cbd4 的提交信息里没有 Cc: stable@vger.kernel.org,也没有 Reported-by。它仍然进了多个 stable 分支,但提交本身没有请求 backport。 stable 分支是仍在接收修复的旧版本维护线(例如 6.1.x);把主线的修复移植到这些分支上就叫 backport,通常靠提交里带 Cc: stable,或由维护者在主线落地后按 Fixes: 标签回溯。
回退而不是修正
2024 年的修复选择的是回退,而不是实现正确的校验。回退把 2014 年加的能力整体去掉。按提交信息,这个能力是"让用户态能为 QUEUE 指定队列号、为 DROP 指定 errno 码"。回退之后,nft_verdict_init() 只接受高 16 位为零的三个具体整数。实现正确校验的路线也在同一段提交信息里写了:调用 NF_DROP_GETERR,检查返回值不大于零。如果走这条路线,2014 年那个能力可以保留------用户态仍然能指定 errno,只是准入点会用消费点的解码器把值解一遍,确认它解出来的语义合法。两条路线的代价差别很清楚。回退改得更少、风险更低,不需要新逻辑,也便于 backport;实现正确校验则要在 nft_verdict_init() 里引入对内部解码器的调用,而这个函数(如前面所说)定义在内部头文件里,调用它会把这个解析层与 netfilter core 的内部实现绑得更紧。回退还有一个附带好处:它让状态回到"曾经正常工作过的那个版本"。代价同样清楚。回退之后,准入点的校验方式是"完整值白名单",这在当时是充分的------因为下游对高 16 位的处理只对全零的几个值有定义。但它没有把"准入点应当按消费方式校验"这件事变成一条显式的规则。留在这个状态下的代码仍然是正确的,只是它的正确性依赖于白名单恰好足够窄,而不是依赖于校验方式本身正确。
2026 年的第二次修复正是在这个基础上发生的。它没有改变校验方式,仍然是白名单;它只是把白名单再收窄一格,把 NF_QUEUE 排除掉。经过这次收窄,白名单是两个值,两个值的高 16 位都是零。校验方式依旧是"列举完整值",而这一次,列举的结果恰好覆盖了下游有定义的全部情况。
不变量
把三次提交并排看,能得到一个具体的判据:
准入点应当按"下游能否安全消费"来定义,而不是按"是否存在合法生产者"来定义。
三次提交分别是这个判据的三次应用,结论各不相同。
2014 年:准入校验的是分支位,消费读的是 errno 位,两个位域不相交。改动引入时并没有人认为自己在放松校验,相反,它看起来像是把校验写得更完整了。
2024 年:恢复了完整值比较,等于按消费方式校验了(因为下游只对高 16 位全零的那几个整数有定义好的行为)。但作者同时把"是否存在合法生产者"用在了另一个决定上,保留了 NF_QUEUE。
2026 年:同一个判据用在同一个值上,因为掌握了"路径可达"这一信息,结论反过来。2024 年那次提交里有一段自述:作者写下了正确的准入方式(调用消费点的解码器、检查返回值符号),却没有实现它,而是删掉了需要它的功能。这在当时是合理取舍。它的后果是"准入点的校验方式"这件事没有被真正建立起来,于是留下了一个没有被按消费方式校验的值。一次用删功能替代修校验的修复,会把同一个判断留给下一次。把这条判据说得更一般一点:当一个值的不同位域由不同代码路径读取时,生成这个值的那一方必须覆盖所有会被读取的位域,而不是覆盖"看起来需要校验"的那几个。这里"看起来需要校验"的直觉通常指向承载分类的那几位------因为分类决定了后续走哪条路,看起来最像安全关键的部分。而承载参数的高位看起来只是数值,容易被当作无害。这类错位都来自同一个直觉偏差:分类位与参数位分开存放,而安全影响可能落在参数位上。这条判据也有它的限度,需要说清楚。它要求先知道下游读了哪些位域,而这件事在大型代码库里并不总是容易回答------一个结构体的字段可能被多个子系统读取,读取方式也可能分布在不同的函数里。本篇能给出确定结论,是因为这个值只有一条消费路径(nf_hook_slow),而它的读取方式在同一段代码里就能看全。换成一个字段被十几个调用点各自解读的结构体,同样的检查会困难得多,也可能得不出确定的覆盖结论。还有一个次级的观察。三次改动里,真正起作用的都不是代码本身:2014 年的改动只有十行左右,2026 年的只有五行。"加校验"和"改校验"在 diff 尺度上很接近,区分它们需要看校验的对象是什么,而这一点在 diff 里看不出来,只能对照消费点才能确定。这也解释了为什么这类问题能通过 review:审查者看到的是一段结构更完整的校验代码,而"校验对象变了"这件事不在 diff 里。
两轮 backport 与版本核查
同一个判断留给下一次的代价,在 stable 分支上看得出来。两次修复分别进了两轮不同的版本。第一次(2024 年修复,主线 6.8)进入 4.19.307、5.4.269、5.10.210、5.15.149、6.1.76、6.6.15、6.7.3。第二次(2026 年修复)进入 5.10.253、5.15.203、6.1.168、6.6.134、6.12.81,另有 6.18.22 与 6.19.12 仅见于 CVE 记录。以 6.1 分支为例,两轮分别是 6.1.76 与 6.1.168;以 5.10 分支为例,是 5.10.210 与 5.10.253。第二次修复进入哪个位点,取决于改动落地时该分支还接受哪些补丁,因此没有任何一条"大于等于某个版本即安全"的规则可以同时覆盖两轮。做版本核查时,需要按两轮分别对照第一次修复的版本与第二次修复的版本。第二轮为什么进得晚,有一部分原因写在提交本身:这次提交没有 Cc: stable。补丁进入 stable 的主流路径是提交里带 Cc: stable,或者在主线落地后由 stable 维护者按 Fixes: 标签回溯。这次少了前一条,回溯就依赖后一条的流程。这就带来一个核对上的限制:文中的版本号并非全部来自逐份 ChangeLog 的独立核对。5.10.210 之后那批与 6.1.168 之后的版本可以逐份检索 ChangeLog 确认,而 4.19.307 与 5.4.269 这两个第一次修复的首发版本只由 CVE-2026-43024 记录给出的区间推得;6.18.22 与 6.19.12 同样只见于 CVE 记录。做资产核查时,这四个版本号需要另行对照 ChangeLog 才能作为依据。 这不是缺陷,但它解释了为什么同一个逻辑问题在两个时间点各进一轮。
CVE 记录在这里给了一个可用的形式。CVE-2026-43024 的记录把 introduced 与 fixed 配对列出,例如 f342de4e2f33 对应 da107398cbd4、f710691be163、42a47f4b1b76、17dc5d5a935c 四个修复提交,也就是"2024 年那个提交进入的分支,在 2026 年各自需要哪一个提交来补"。按这种配对关系做清单,比按版本号做清单可靠得多:它直接把"第一轮修过、第二轮还没修"这个状态表达出来了。还有一个元数据层面的差异会影响排序。CVE-2024-1086 的记录带 CVSS 3.1 评分 7.8,并被收录进 CISA 的已知被利用漏洞目录;(CVSS 是 0 到 10 的漏洞严重性评分体系,7.8 属于偏高的区间;CISA 是美国网络安全和基础设施安全局,它的"已知被利用漏洞"目录简称 KEV,收录的是有实际利用证据的漏洞)CVE-2026-43024 的记录里没有 CVSS 评分,也没有 problem type。也就是说,只看 CVE 元数据做严重性排序时,第二轮修复在榜单上几乎是隐形的,而它修的是一处已经被实际利用过的缺陷类型。对以 CVE 评分驱动修复排期的流程来说,这一类缺口不会自己浮现:第一轮的条目评分高、被收录、被反复引用,第二轮的条目只有一段提交信息。要发现它,需要按 Fixes: 链条把两轮的 CVE 关联起来,而不是各自独立打分。关于时间关系还有一处细节。2024 年的修复在 1 月 24 日进入主线,CVE-2024-1086 在 1 月 31 日公布,CISA 目录在 5 月 30 日收录。修复先于 CVE 公布,说明这轮修复走的是先修后公布的流程;从修复到被列入已知被利用目录之间约四个月。
为什么这处缺陷不会留下任何告警
CVE-2024-1086 的受影响范围写作 3.15 < 6.8,CVSS 3.1 评分 7.8,CISA 的 KEV 目录在 2024 年 5 月 30 日收录;CVE-2026-43024 的记录里没有 CVSS 评分,也没有 problem type。严重性只能按这两个数字来读,不再往下推。从提交时间看,缺陷引入到第一次修复之间约十年,但"存在十年"不等于"十年里都能被利用":可利用性还取决于非特权用户能否访问 nf_tables。CVE 记录描述的利用前提是本地权限提升,与这一点相符,再往下就超出记录范围了。观测这一层能说的有限,而这个限制本身也是结论的一部分。这处缺陷不发日志、不产生可见异常。nf_hook_slow() 里那句 WARN_ON_ONCE(1) 只覆盖 default 分支,也就是低 8 位不是任何已知分支的情况;本处的值是低 8 位合法、高 16 位不受约束,不会走到那个分支。因此依赖内核告警或内核日志发现这处问题的路径不存在。能发现它的入口是源码对照与产物核查,不是运行时观测。低 8 位承载分类这件事,在代码里还有一个副作用:只要分类正确,nf_hook_slow 就会选到看起来正确的那条分支,代码在功能上是正常的。NF_DROP 分支会释放 skb,NF_QUEUE 分支会去排队,行为都发生了。缺陷不会表现为"分支走错了",只会表现为"这条路返回的东西不对"。功能的表面正确性是这处问题能存活十年的一个具体原因,也是它不需要任何异常输入就能被触发的原因(触发所需的只是高 16 位取某个不受约束的值)。对一个以检测能力为目标的评估来说,这意味着这一类的补丁不能靠"有没有告警"来判断是否命中,也不能靠"行为是否异常"来判断。它只能从"准入点校验的对象与消费点读取的对象是否是同一个"这个结构问题上查出来。把这个结构问题写成可执行的检查,形态大致是这样:对每一个"解析用户输入并生成一个会被下游按位域解读的值"的函数,找出下游解读该值的全部位置,确认解析函数的校验覆盖了下游实际使用的每一个位域。以这个值本身为例,下游用了低 8 位选分支、用了高 16 位算返回值、flag 区被 nft_queue 写过;因此完整的准入校验必须对这三个区域都有交代。2014 年之后的校验只交代了第一个。这个检查不需要运行任何东西,也不需要构造输入。它的成本在于要同时读两侧的代码。而两侧相隔的调用距离正是这类问题容易存活的原因:从 nft_verdict_init() 到 nf_hook_slow() 之间隔着数据结构的存储、链求值、hook 调用好几层,只看其中一侧不会发现任何异常。如果反过来考虑运行时信号,需要的形态和现有的不一样。WARN_ON_ONCE(1) 出现在 nf_hook_slow 的 default 分支,判据是"低 8 位不是已知分支",而本处的值是低 8 位完全合法。要在这条路径上产生信号,判据必须改成"低 8 位是 NF_DROP 或 NF_QUEUE 时,高 16 位是否符合该分支的约定",也就是把准入点的检查在消费点复制一份,作为断言。这类断言在内核里通常以 WARN_ON_ONCE 的形式出现且只在开发配置下启用,因为它的判据本身就是准入点的职责。这构成一种常见的分工失效:当准入点与消费点相隔足够远时,消费点上的断言会被视为冗余(准入点应该已经保证了),而准入点的校验会被视为充分(消费点的约定看起来只是实现细节)。两侧各自都合理,合起来就留下了一个谁都不检查的区域。