Netfilter Tunnel 之殇:CVE-2025-22056分析

Netfilter Tunnel 之殇:CVE-2025-22056分析

原创 獬豸实验室 京东安全应急响应中心 2025-05-28 09:01

在一个多月前,Linux 内核中的 Netfilter 模块下针对 
nft_tunnel
 中存在的一处越界写漏洞补丁被提交到内核主线,该漏洞被分配为 CVE-2025-22056
,本篇文章旨在通过利用该漏洞对内核实现提权,同时该漏洞影响 Linux Kernel Version5.7-6.14
 所有版本,由于在 Ubuntu 中该模块被添加为默认配置,因此该漏洞对于未打补丁的 Ubuntu 系统仍然能够有效提权。

一、背景介绍

1.1 什么是 Netfilter?

Netfilter
 是 Linux 内核中的一个框架,主要用于对网络数据包进行处理。它提供了 hook 机制,使得数据包可以在被处理的过程中,在内核级别被“拦截”,进而决定对数据包的行为,包括检查、修改、接受或丢弃等。Netfilter 构成了 Linux 防火墙、NAT(网络地址转换)等功能的基础,是 Linux 网络安全和数据包控制的核心模块。

Netfilter 的核心功能:

1. 包过滤(Packet Filtering)
– 决定是否允许一个数据包通过

  • 用于实现防火墙逻辑

2. NAT(网络地址转换)
– 动态修改数据包中的 IP 地址或端口

  • 支持源地址转换(SNAT)和目的地址转换(DNAT)

3. 连接跟踪(Connection Tracking)
– 跟踪每个网络连接的状态

  • 可以判断数据包是否属于已建立的连接(如 
    ESTABLISHED

4. 状态防火墙(Stateful Firewall)
– 配合连接跟踪,实现有状态的包过滤规则(例如只允许建立连接的返回流量)

5. 数据包修改(Packet Mangling)
– 支持对包头或数据内容进行修改

1.2 为什么是 Netfilter?

Netfilter不仅是 Linux 内核强大功能的一部分,同时也是攻击者重点关注的攻击面,理由如下:

深度依赖用户输入(数据包):
Netfilter 模块直接处理外部网络数据,攻击者可以伪造恶意数据包进行模糊测试或构造边界条件。

内核态运行,特权高:
一旦触发漏洞,可能导致内核崩溃(DoS)或提权(LPE)。

模块复杂,状态追踪多:
如连接跟踪(conntrack)、NAT、匹配模块等涉及复杂状态机,容易产生边界处理错误、UAF、整数溢出等漏洞。

大量代码基于宏和结构体嵌套:
容易导致逻辑错误、缓冲区处理不当。

1.3 什么是 Netfilter Tunnel?

Netfilter Tunnel 是指利用 Linux 内核中的 Netfilter 框架对网络隧道数据进行处理和控制的技术体系。作为 Linux 网络栈的核心组件,Netfilter 为各种隧道协议提供了强大的数据包过滤、修改和转发能力,是构建现代虚拟化网络基础设施的关键技术。

二、漏洞分析

在本篇文章中测试版本为 Linux-6.12.6,commit 1b755d8eb1ace3870789d48fbd94f386ad6e30be 给出了针对该漏洞的 patch,内容如下:

通过上面的代码不难发现,该漏洞是一处类型混淆错误,原本的代码中
(struct geneve_opt *)opts->u.data + opts->len
的逻辑是先对
opts->u.data
进行类型转换,之后在对转换后的指针相加 
opts->len
 ,从而导致相加的长度实际是 
opts->len * 4
 (结构体 geneve_opt 的大小为 4 byte),间接导致在后面的 
memcpy
 代码处会发生越界写错误。

2.1 Basics

为了对漏洞有更好的理解,这里对部分关键知识进行说明

1. nlattr

该结构体是 Linux 内核网络子系统中 Netlink 协议使用的基本属性结构体 
struct nlattr
,常用于用户空间与内核空间之间通过 Netlink 消息交换附加数据(如 tunnel 配置、策略等)时的数据封装。

2. geneve_opt


每个 
GENEVE Option
 由固定的 4 字节头部 + 可变长度的数据组成,
struct geneve_opt
 就是对这 4 字节头部的结构体抽象。
opt_class
 表示该选项的“类”,类似于协议命名空间,
type
 表示选项类型, 
u8 length:5
 表示 
opt_data
 的长度,以 4 字节(即 32 位)为单位,实际长度 = 
length * 4
 字节。

3. nft_tunnel_obj/nft_tunnel_opts

这两个结构体 
struct nft_tunnel_opts
 和 
struct nft_tunnel_obj
 是 Linux 内核中 Netfilter 子系统的一部分,用于描述隧道封装元数据(如 VXLAN、ERSPAN、GENEVE 等)并与 Netfilter 的对象机制结合,从而在 
nftables
 中实现基于隧道元数据的匹配、处理或封装操作。层级关系如下:

4. nla_put

用于向 
struct sk_buff 
类型的 socket buffer 中添加一个 Netlink 属性(netlink attribute)
,这是 Linux 内核中 Netlink 通信的一部分,用户空间与内核空间的数据交换,
__nla_put() 
是实际将属性插入到 
skb
 中的内部函数。


__nla_put
本质是一个
memcpy
函数,其中
nla_data(nla) = (char *) nla + NLA_HDRLEN

2.2 OOB-Write

do_syscall_x64
–>
x64_sys_call
–>
__x64_sys_sendmsg
–>
__sys_sendmsg
–>
syssendmsg
–>
sys_sendmsg
–> 
sock_sendmsg
–>
sock_sendmsg_nosec
–>
netlink_sendmsg
–>
netlink_unicast
–>
netlink_unicast_kernel
–>
netlink_rcv
–>
nfnetlink_rcv
–>
nfnetlink_rcv_skb_batch
–>
nf_tables_newobj
–>
nft_obj_init
–>
nft_tunnel_obj_init
 –> 
nft_tunnel_obj_opts_init
–>
nft_tunnel_obj_geneve_init

漏洞触发时的调用函数堆栈如上所示,这里重点关注分析与漏洞利用相****
关的函数,具体如下:

1. nf_tables_newobj

该函数执行流大体含义为,首先确保消息中包含必须的属性:对象类型(NFTA_OBJ_TYPE)、对象名称(NFTA_OBJ_NAME)、以及对象数据(NFTA_OBJ_DATA),调用 
nft_table_lookup
 查找指定的 
nft_table
,调用 
nft_obj_lookup
 查找目标表中是否已存在具有相同名称和类型的对象,之后在经过相关检查和初始化之后进入到函数 
nft_obj_init
中。

2. nft_obj_init

该函数主要负责完成
nft_obj
的创建和初始化功能,其中第一个红框中的代码负责申请结构体内存,在测试版本中该结构体大小为 0x1d8,需要提前说明的是该结构体即为后续发生越界写时的结构体
,注意这里分配标志是
GFP_KERNEL_ACCOUNT
,这为后续选择利用的结构体作铺垫。第二个红框中的代码则是通过函数指针的方式将结构体的初始化交由 Netfilter Tunnel 中的
nft_tunnel_obj_init
 函数进行处理。
nft_object
 结构体内容如下,其中注释已对每个字段进行了解释。

  1. nft_tunnel_obj_init

该函数功能大致为初始化隧道信息结构,设置源端口和目标端口,解析用户提供的标志位,设置服务类型(TOS)和生存时间(TTL),初始化隧道选项(如扩展信息)存储到 priv->opts
,该部分内容对应为上图红框中的函数进行处理。

4. nft_tunnel_obj_opts_init

该函数首先通过执行
nla_validate_nested_deprecated
函数,参数
nft_tunnel_opts_policy
 中定义的规则确保传入的 Netlink 消息满足规定条件。进入到
nla_for_each_attr
循环遍历用户传递的每一个
nlattr
属性,通过设置
nla_type
 属性为
NFTA_TUNNEL_KEY_GENEVE_TYPE
,确保执行流可以走到漏洞触发函数
nft_tunnel_obj_geneve_init

5. nft_tunnel_obj_geneve_init

经过漫长的调用链跟踪,来到了开头提到的漏洞触发函数,该函数负责处理GENEVE隧道特有的选项。这里不妨重新思考如何将该类型混淆错误转换为越界写,上图中 
attr
 参数是用户可控的,参数 
opts
 由前面函数
nft_tunnel_obj_init
 中 
&priv->opts
传递而来的,而 
priv = nft_obj_data(obj)
 ,所以 
priv
 实际指向的是
nft_obj
的data字段, 初始
opts->u.data
指向数据区开头,且此时
opts->len
为0。

由于这里牵扯到多个结构体的内部类型转换,在理解上有一定困难,所以为了帮助理解,这里给出
nft_object

nft_tunnel_opts

geneve_opt
三个结构体层级关系:

从上图可以发现,三者实际是相互嵌套的关系,
opts->u.data
中会存储所有
geneve
 隧道类型的结构数据,且需要辨析的是
opts->len
 是
u32
类型,以 1byte 为单位长度;
opt->length
 是 
5bit
,以 4byte 为单位长度;
attr->nla_len
 是 
u16
类型且以 1byte 为单位长度,
data_len = nla_len(attr)
 实际返回的是
atrr->nla_len – NLA_HDRLEN = atrr->nla_len – 4
,该字段由用户控制。

再回头看漏洞函数,其中
nla_parse_nested
按照 
nft_tunnel_opts_geneve_policy
 的规范,从 Netlink 消息中嵌套的 Geneve 隧道选项属性中提取各字段到 
tb[]
,在该函数中会对传递的 
nlattr
 中的 
nla_len
 长度字段进行检查,在漏洞触发版本,针对 Geneve 隧道选项 DATA 的长度被设置为 128 byte,因此
nla_len
 的长度不能超过 132 byte(包括 4byte 头部长度),即 0x84 大小。

在实际利用过程中,我们至少需要发送两个
NFTA_TUNNEL_KEY_GENEVE_DATA
 类型的 attr 消息才能触发漏洞,理由是初始 
opts->len
 为0,在经过第一次 Geneve 消息处理时, opts->len 才会被设置为 
sizeof(*opt) + data_len
 ,在前面提到 attr 由用户可控,因此 data_len 长度也由用户控制,这样就会间接导致opts->len可控,而在第二次处理 Geneve 消息时,此时先前的类型混淆错误便会发生作用,导致 opt 实际指向的位置为 
opts->u.data + opts->len * 4
,而正常应该指向的位置是
opts->u.data + opts->len
,因此实际指向的偏移位置扩大了4倍,导致足够溢出到下一个结构体堆块。

实际溢出情况如上图所示,这里设置第一个 Gneneve 消息的 nla_len 长度为 0x58,计算得到的data_len 为0x54,从而 opts->len 为 0x58,该长度用来控制写的偏移位置,下一次写的位置则为 
opts->u.data + 0x584 + 4
,在测试版本中opts->u.data 指向的是 nft_object 相对偏移 0x90 的位置(则
opt->opt_data = 0x90 + 4 = 0x94
),因此下一次写的位置相对偏移为
0x90 + 0x58
4 + 4= 0x1f4
,该位置即为 Second geneve 写的位置,此时只要通过设置 Second geneve 的 nla_len 字段,即可设置溢出下一个堆块的字节长度,同时需要注意的是溢出的长度是 4byte 的整数倍。

上图为第一次处理 First Geneve 时的拷贝情况,如前面所述一致,第二次拷贝导致溢出时的情况如下图所示:

这里将第二次的 Second geneve 消息的 data_len 设置为了 0x10,导致最终的结果是可以溢出到下一个堆块的前4个字节,实际溢出的长度可以通过控制 data_len 来设置溢出到下一个堆块的长度。至此我们有了一个偏移溢出写。

2.3 OOB-Read

继续回到梦开始的地方,这里针对类型混淆添加了两处 patch,第一处即为我们先前在 OOB_write 中分析的函数,后面一处针对
nft_tunnel_opts_dump
 函数的 patch,同理该函数可以将 
opts->u.data
 中的数据 dump 下来传递到用户空间,由于类型混淆的存在因此可以想办法越界读下一个堆块的数据内容。

1. nft_tunnel_opts_dump

该函数支持处理 Vxlan、Erspan 或 Geneve 三种隧道的数据,这里只需要关注 Geneve 即可,为了说清楚如何触发越界读,还需要回到 
nft_tunnel_obj_geneve_init
 函数初始化结构体opts当中。

2. nft_tunnel_obj_geneve_init

在前面已经提过
data_len
 的最大长度可以被设置为 0x80,而先前在介绍 
geneve_opt
结构体时,我们关注到该结构体的
length
 字段为 5 bit,即 length 的最大值应为 0x1F,因为 
opt->length = data_len / 4
,而当 data_len 被设置为 0x80 时,有 0x80 / 4 = 0x20,此时会造成溢出导致 
opt->length
 被设置为了0(该整数溢出于同一天在 References[2] 中修复)。

而 
memcpy
 拷贝时候的长度又是按照 
data_len
 字段来拷贝的,也就是 0x80 个字节,由于前一个结构体 
geneve_opt
 的 
opt->length
 被设置为0,这会导致在函数
nft_tunnel_opts_dump
中 解析结构体 geneve_opt 数据时,误将用户传递的 
opt_data
 内容当做下一个 
geneve_opt
 结构体的 header 来进行处理,因此我们可以伪造一个 
fake geneve_opt
 结构体,从而配合类型混淆错误实现越界读。

具体过程如上图所示,通过设置 
fake_opt->length = 0x15
,刚好可以将相邻的下一个结构体内容继续当做 geneve_opt 结构体去解析,对应 geneve_opt:length 字段,位于第三字节偏移处的 5 bit,意味着只要下一个结构体的对应 length 字段处不为0,就可以读出下一个结构体的内容,同时只要满足
opts->len > offset
循环条件,在 length 为0时,依旧可以继续读后面的内容。由于内核堆地址的随机性,且通过堆喷特定类型的结构体(GFP_KERNEL_ACCOUNT),可以实现泄露出相邻堆块的数据。

通过gdb调试,也可以明显看到此时 opt->opt_data 已指向下一个堆块的内容,其中包括一些有明显特征的堆地址。

三、漏洞利用

我们现在有了一个越界写和越界读,但是越界读依赖下一个结构体中的内容。

3.1 Leak heap addr

首先看如何利用只能leak出不确定数据的越界读漏洞来稳定leak出下一个堆块的数据。

通过前面的分析如果下一个堆块的开头8字节是一个指针,第四个字节的5bit会被当作
geneve_opt->length
比如
0xffff888109412580
的第四个字节为0x09,那么就可以leak出下一个堆块的0x204 ~ 0x204 + 4 * 9 的数据,由于越界读不涉及指针破坏,因此可以多次尝试,直到 leak 出想要的数据。

回顾前面
nft_object
结构体的字段,可以发现该结构体开头有一个
list_head
字段,所有创建在同一个
table
的nft_object都会通过这个双向链表连接,那么有这样一个想法,连续分配两个能够leak的nft_object,它们如果相邻,那么就可以通过前一个nft_object来leak出下一个nft_object的
list->prev
指针,从而得到这两个连续nft_object的堆地址(是否相邻可以通过判断
list

rhlhead
里的几个指针字段的特征进行判断)。

比如说在上图中,就可以通过
nft_obj1
越界读取到
nft_obj2

list_head->prev
字段,由于每次只重复分配两个nft_object,那么nft_obj2中
list_head
字段的
prev
(图中为
0xffff8881023f5a00
)必定指向
nft_obj1
的堆地址,又因为两者相邻,则
nft_obj2
的堆地址也可知。

3.2 Root by io_uring table

在泄露完堆地址后,这里主要通过两种方式来实现提权,第一种是通过利用 
io_uring table
 实现任意地址读写。

申请的 table 数组大小也是可控的,分配标志也是
GFP_KERNEL_ACCOUNT
,不过需要注意的是这里需要通过
setrlimit(RLIMIT_NOFILE, &rl)
调整文件描述符数量限制,使得 table 数组可以从 
kmalloc-cg-512
 中分配缓存,原因如下:

在 
io_sqe_files_register

函数中会对 
nr_args
 的大小进行检查,使得正常情况下nr_args的大小最大支持 kmalloc-cg-256  大小的 table 数组的分配,由于 0x108 也会从 kmalloc-cg-512 中分配 ,这里以该值为例,计算nr_args 需为 
((0x108 / 8) * PAGE_SIZE) / 8 = 0x4200
,其中 0x108 表示 table 数组的大小(注意这种分配方式在 
commit 7029acd8a950393ee3a3d8e1a7ee1a9b77808a3b)
中已被去除)。关于通过修改table数组指针实现任意写原理,可参考 Reference[3]。

具体利用步骤如下:

任意地址写

Step.1

堆喷包含越界读的
nft_object
,通过越界读构造两组连续的
nft_object
,因为这一步只涉及越界读,不会破坏指针,因此可以不断重试,直到构造完成为止。 

Step.2

释放obj1,喷射tags_table A,释放obj4,喷射tags_table B,此时堆分布如下:

Step.3

释放obj3,在堆喷
nft_obj
触发越界写,使其仅覆盖下一个堆块开头的8字节,将 tags_table B的
table[0]
指向tags_table A。

Step.4

此时就可以通过控制tags_table B的
table[0]
向tags_table A的table[0]写入任意地址,最后再用tags_table A的table[0]向该地址写入任意值。

任意地址读

由于另外一个
nft_obj2
的地址也是已知的,注意到
nft_object
中存在
udlen

udata
两个字段,前者控制长度,后者控制地址,在
nf_tables_getobj
可以将
udata
指针指向的数据dump出来。

因此可以通过任意地址写将
udata
改为要读的地址,udlen改为要读取的长度(调试发现最好改为0xC00以下,否则可能会因为超过
sk_buff
的长度导致读取失败),从而完成任意地址读。

3.3 Root by pipe_buffer

由于 pipe_buffer 结构体也是从 
GFP_KERNEL_ACCOUNT
 缓存中分配,因此第二种方式尝试通过该结构体实现提权。

Step.1

在这一步中同时释放
nft_obj1
 和
nft_obj2
,然后堆喷
pipe_buffer
进行堆占位,又因为前面已经泄露了
nft_obj
的地址,自然而然也知道了pipe_buffer的地址,为方便后续说明,将这里堆喷的pipe_buffer 称作 
pipe_set_A
,简单来说就是看做一个集合A,这个集合里面的pipe会被用来后面构造
page uaf

Step.2

之后通过不断重复分配两个
nft_obj
,利用越界写其中一个nft_obj的
udlen

udata
字段,让指针指向
pipe_buffer
结构体从而可以泄露出
page
指针。

Step.3

在泄露出 page 之后,利用方式就变得比较多了,这里通过越界写构造一个 Page UAF,构建自写管道,实现任意物理地址读写,由于堆块分配的随机性,所以在这个过程中需要堆喷 
pipe_buffer
,同时去触发越界写,在检测是否成功溢出到 page 指针。在这个过程中堆喷的pipe_buffer称作
pipe_set_B
,完成该过程之后的堆内存分布图参考如下:

上图即为第一次构造
page uaf
时的场景,这里稍微提几点需要注意的地方,首先,不管是哪个集合,这些
spray_pipe
 不要求是连续的,也不要求在同一个slab之内,我们 leak 出的 page 字段也不一定是
 spray_pipe[0]
的,只需要确定在 
pipe_set_A
 和 
pipe_set_B
中存在两个page指向的是同一个页面即可,正常来说在这一步之继续堆喷 
pipe_buffer
 占用释放的 page 就可以实现任意读写了,但是在实机测试过程中发现成功命中该page的概率较小,导致利用成功率较低。

针对上述这种情况出现的问题,这里采用的思路是多次触发page uaf去增大page命中的概率,由于pipe_buffer结构体可能虽然在内存中是不连续的,但是需要注意的是,分配的
struct page
结构体却大概率是连续的,struct page结构体大小为0x40,在不开随机化的时候默认从
VMEMMAP_BASE 0xffffea0000000000
开始(对应x86-64架构)存放struct page结构体。具体原理参考下图:

事实证明,在
漏洞利用过
程中采用了上述思路之后,命中效果有较大幅提升,而且理论上来说,这种尝试次数可以叠加,从而对应的命中成功率也会上升,当然由于这个过程中也会增加越界写触发的次数可能导致内核崩溃的概率增加,但是这里由于只需要溢出page字段,因而控制每次溢出长度为 4 byte 即可,在实际利用过程中,因为越界写而导致的内核crash的概率很低。

Step.4

最后效果如上图所示,至此就可以通过修改 UAF page 中的 pipe_buffer 来实现任意地址读写。

四、实机演示

方式一:io_uring 提权

方式一:pipe_buffer 提权

References

  1. https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=1b755d8eb1ace3870789d48fbd94f386ad6e30be

  2. https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=b27055a08ad4b415dcf15b63034f9cb236f7fb40

  3. https://arttnba3.cn/2021/11/29/PWN-0X02-LINUX-KERNEL-PWN-PART-II/#IORING-REGISTER-BUFFERS2-%EF%BC%9A%E8%80%81%E7%89%88%E6%9C%AC%E5%86%85%E6%A0%B8%E4%B8%AD%E7%9A%84-4k-%E2%80%9C%E8%8F%9C%E5%8D%95%E5%A0%86%E2%80%9D

獬豸实验室

獬豸实验室 (Dawn Security Lab)是京东旗下专注前沿攻防技术研究和产品沉淀的安全研究实验室。重点关注移动端安全、系统安全、核心软件安全、机器人安全、IoT安全、广告流量反作弊等基础和业务技术研究。

实验室成员曾多次获得Pwn2Own冠军,在BlackHat、DEFCON、MOSEC、CanSecWest、GeekCon等顶级安全会议上发表演讲,发现Google、Apple、Samsung、小米、华为、Oppo等数百个CVE并获得致谢。曾获得2022年黑客奥斯卡-Pwnie Awards“最佳提权漏洞奖” ;同时也是华为漏洞奖励计划优秀合作伙伴,CNNVD一级支撑单位,GeekCon优秀合作伙伴。

加入我们

獬豸实验室正在招募各路英雄,欢迎加入崇尚技术创新、用技术守护互联网安全的我们。

简历发送:[email protected]

邮件主题和简历附件名称请备注

“岗位编号-岗位名称-姓名”

招聘岗位:

007-安全研究员

008-后端开发工程师

009-大数据开发/算法工程师

010-数据挖掘工程师

011-移动安全开发工程师

012-移动安全工程师

013-安全情报运营工程师

014-安全情报研发分析工程师

桌面端
安全
开发工程师

招聘详情请戳
👇