在一个多月前,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 的核心功能:
-
包过滤(Packet Filtering)
- 决定是否允许一个数据包通过
- 用于实现防火墙逻辑
-
NAT(网络地址转换)
- 动态修改数据包中的 IP 地址或端口
- 支持源地址转换(SNAT)和目的地址转换(DNAT)
-
连接跟踪(Connection Tracking)
- 跟踪每个网络连接的状态
- 可以判断数据包是否属于已建立的连接(如
ESTABLISHED
)
-
状态防火墙(Stateful Firewall)
- 配合连接跟踪,实现有状态的包过滤规则(例如只允许建立连接的返回流量)
-
数据包修改(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-->___sys_sendmsg-->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_obj
结构体内容如下,其中注释已对每个字段进行了解释。
3. 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 + 0x58*4 + 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 来设置溢出到下一个堆块的长度。对应用户空间的部分payload内容如下:
至此我们有了一个偏移溢出写。
¶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,喷射stags_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
的长度导致读取失败),从而完成任意地址读。
具体在内存中的表现如下图所示:
有了任意读写之后,然后搜 cred
改uid
就可以拿到root权限了。
¶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 来实现任意地址读写。
¶四、实机演示
在 vedio 目录分别保存有两种利用方式最终实现的提权效果。
¶References
- https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=1b755d8eb1ace3870789d48fbd94f386ad6e30be
- https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=b27055a08ad4b415dcf15b63034f9cb236f7fb40
- https://arttnba3.cn/2021/11/29/PWN-0X02-LINUX-KERNEL-PWN-PART-II/#IORING-REGISTER-BUFFERS2-:老版本内核中的-4k-“菜单堆”