nftables Adventures: Bug Hunting and N-day Exploitation (CVE-2023-31248)
nftables简介
nftables是一个现代的包过滤框架,旨在取代传统的{ip,ip6,arp,eb}_tables(xtables)基础设施。它重用了现有的netfilter钩子,这些钩子作为处理程序的入口点,对数据包执行各种操作。nftables表对象包含链对象列表,链对象包含规则对象列表,规则最终包含表达式,这些表达式执行伪状态机的操作。
表
表是顶级对象,包含链、集合、对象和流表。在内部,表由struct nft_table表示。
|
|
表可以有多个不同的标志。用户可以在创建表时设置标志NFT_TABLE_F_DORMANT和/或NFT_TABLE_F_OWNER。休眠状态标志(NFT_TABLE_F_DORMANT)可以在nf_tables_updtable中更新。如果设置了NFT_TABLE_F_DORMANT(0x1),表将被置为休眠状态,其所有基础链钩子将被取消注册,但表不会被删除。
链
链可以是基础链,它们注册了netfilter钩子且不能被跳转至;或者是普通链,它们没有注册钩子但可以被跳转至。在内部,链由struct nft_chain表示。
基础链由struct nft_base_chain表示。
规则
规则包含nftables表达式。在内部,规则由struct nft_rule表示。
表达式
表达式充当状态机的操作。有许多表达式,例如:
- Bitwise:执行位操作
- Immediate:将数据加载到寄存器中,也允许跳转/转到另一个普通链
- Byteorder:更改主机/网络字节序
- Compare:比较两个寄存器中的值
- Counter:在规则中启用计数器
在内部,表达式由struct nft_expr表示。
每个表达式还有一个struct nft_expr_ops表示各种操作。
生成掩码系统
许多nftables对象有一个2位的生成掩码,指定对象在当前和/或下一代中是否处于活动状态。如果某位被设置,对象在该代中不活动。有一个整体的生成游标定义代表当前代的位。对象可以有以下状态:
- 在当前和下一代都处于活动状态(例如未更改的对象)
- 在当前代处于活动状态,在下一代不活动(例如标记为删除的对象)
- 在当前代不活动,在下一代处于活动状态(例如新创建的对象)
控制平面、事务系统和事务工作器
在nftables中,用户空间请求的操作(通过netlink消息)在控制平面中执行,包括诸如nf_tables_newtable、nf_tables_updtable、nf_tables_newchain等函数。控制平面负责对象的创建和分配、在下一代中激活/停用对象、链接对象以及修改对象的"use"引用计数。
然而,新创建的对象在创建后不会立即激活;它们只在提交阶段新代开始时激活。控制平面中涉及对象创建或更新的所有操作都会向事务列表添加新事务。
当netlink批处理事务被认为是有效的时(即控制平面中的所有操作不返回错误),进入提交阶段并调用nf_tables_commit。将启动新代,导致所有新创建的对象变为活动状态,并执行事务列表中的操作。提交阶段还负责取消链接要删除的对象,并排队负责销毁对象的异步事务工作器(nf_tables_trans_destroy_work)。
异步事务工作器在运行时将调用nft_commit_release,最终将调用销毁和释放标记为删除的对象的函数。
nftables休眠状态链钩子解除激活错误
在研究nftables时,通过手动源代码审查,我能够识别一个导致警告的错误。错误报告可以在这里看到。
当新创建的表通过nf_tables_updtable从活动更新为休眠时,表标志设置为NFT_TABLE_F_DORMANT,并且由于没有设置任何__NFT_TABLE_F_UPDATE标志,将设置__NFT_TABLE_F_WAS_AWAKEN标志。当表从活动更新为休眠时,链钩子直到调用nf_tables_commit时才会解除激活。然而,当表从休眠更新为活动时,NFT_TABLE_F_DORMANT标志被取消设置。然后检查是否设置了任何__NFT_TABLE_F_UPDATE标志,如果没有设置,链钩子立即通过nf_tables_table_enable激活(即在调用nf_tables_commit之前)。
可能以某种方式激活/停用表,使得在某个时间点,一些链被注册而一些链没有被注册。这可以通过将活动表更新为休眠来实现,以便设置__NFT_TABLE_F_WAS_AWAKEN标志(这是__NFT_TABLE_F_UPDATE标志之一),然后将休眠表更新为活动。由于设置了__NFT_TABLE_F_UPDATE标志之一,跳过nf_tables_table_enable,留下一些链未注册。
当删除活动表时,nf_tables_unregister_hook仅检查NFT_TABLE_F_DORMANT标志是否为零。如果标志未设置,所有基础链都被假定为活动,因此所有链钩子将被解除激活,即使它们最初没有被注册。这导致显示以下警告:
|
|
触发错误
要触发错误,应采取以下步骤(在同一批处理事务中):
- 创建表"test_table" - 此表处于活动状态 [1]
- 将表"test_table"从活动更新为休眠 [2]
- 设置NFT_TABLE_F_DORMANT和__NFT_TABLE_F_WAS_AWAKEN表标志
- 添加基础链"chain1" - 此基础链被添加到休眠表中,因此未注册 [3]
- 将表"test_table"从休眠更新为活动 [4]
- NFT_TABLE_F_DORMANT标志被清零,但__NFT_TABLE_F_WAS_AWAKEN标志仍然设置,导致跳过nf_tables_enable_table
- 使用nft实用程序删除活动表"test_table":nft delete table test_table [5]
表在删除时处于活动状态,因此当表被刷新时,所有基础链都被视为已注册并将被取消注册。然而,由于基础链"chain1"从未被注册,内核将尝试取消注册未注册的链,导致警告。
补丁分析
为了修补这个错误,开发人员决定防止在单个批处理事务中多次切换休眠状态。
|
|
如果更新标志先前已设置(通过在同一批处理事务中先前切换休眠状态),nf_tables_updtable将简单地失败。
CVE-2023-31248
除了尝试寻找新错误外,我还对Mingi Cho发现的CVE-2023-31248进行了n-day研究。错误报告和补丁可以在这里找到。
Linux内核版本6.2.0-26 generic之前容易受到此错误的影响。漏洞利用已在Ubuntu 23.04(Lunar Lobster)上测试,内核版本为6.2.0-20 generic。
漏洞分析
nft_chain_lookup_byid在查找链时不检查链是否处于活动状态(通过检查生成掩码),如下面的代码所示:
|
|
当向引用其ID的链添加规则时,如果该链已在同一批处理中被删除,则可能引用非活动链。由于chain->use的值不为0,规则添加将立即失败,导致显示警告。
在单个批处理事务中触发错误
要触发错误,可以发送包含以下步骤的批处理事务:
- 创建新表"table1"(NFT_MSG_NEWTABLE)
- 创建新链"chain1"(NFT_MSG_NEWCHAIN)
- 删除"chain1"(NFT_MSG_DELCHAIN)
- 创建新链"chain2"(NFT_MSG_NEWCHAIN)
- 在chain2内创建引用chain1的规则。这可以通过跳转或转到表达式完成,目标链设置为chain1的链ID。(NFT_MSG_NEWRULE)
当创建新规则时,采取以下代码路径,使得目标链(chain1)的chain->use值从0增加到1。这是因为创建了对chain1的新引用。
由于批处理事务中的所有操作都被确定为有效,批处理事务成功。当有效的批处理事务成功时,nfnetlink_rcv_batch调用nf_tables_subsys的提交操作,即nf_tables_commit。
请注意,当收到NFT_MSG_DELCHAIN时,struct nft_chain chain1对象不会立即删除。对于每个操作,事务被添加到列表,所有事务在调用提交时处理。然后调度已删除对象的销毁,并由工作线程异步执行。
然而,在这种情况下,当达到nf_tables_chain_destroy时,chain1不会被释放并显示警告。这是因为chain1的chain->use是1而不是0([6])。
|
|
漏洞利用
获取释放后使用
编写成功权限提升漏洞利用的第一步是获取释放后使用原语。本质上,我们需要找到一种方法将已删除链的chain->use减少到0,以便在调用nf_tables_chain_destroy时释放链对象。这可以通过利用控制平面(nf_tables_delrule)和事务工作器(nf_tables_trans_destroy_work)之间的竞争条件来完成。
为此,发送了2个批处理事务。在第一个批处理事务中,执行以下操作:
- 创建新表"test_table"(NFT_MSG_NEWTABLE)
- 创建新链"chain1",名称为"AAAAAAAAAAAAAAAAAAAA"(NFT_MSG_NEWCHAIN)。链的名称长度为20个字符。这是要删除的链。
- 删除链1(NFT_MSG_DELCHAIN)
- 创建新链"chain2"(NFT_MSG_NEWCHAIN)
- 在"chain2"内创建引用链1的规则,名称为"AAAAAAAAAAAAAAAAAAAA"。在漏洞利用中,这是通过立即"goto"表达式完成的,目标链使用链ID设置。
由于第一个批处理事务中的所有操作都有效,调用提交,并调度销毁非活动对象的事务工作器。
第二个批处理事务包括以下操作:
- 删除引用目标链的规则(NFT_MSG_DELRULE)
- 创建无效规则。在这种情况下,audit_info->type可以取0到2(包括)的值,因此0xff是导致批处理失败的无效值。[7]
由于第二个批处理事务失败,不会调用提交。然而,nftables netlink消息仍然传递给nftables,控制平面中的操作仍然执行(它们将在批处理事务失败时最后中止)。
由于NFT_MSG_DELRULE传递给nftables,采取以下代码路径:
|
|
具体来说,在nft_verdict_uninit中,引用链(在这种情况下是我们的目标链"AAAAAAAAAAAAAAAAAAAA")的chain->use将从1减少到0。
本质上,目标链的chain->use必须在事务工作器nf_tables_trans_destroy_work运行之前减少到0,并且事务工作器必须在失败的批处理事务中止之前运行。
如果在调用nf_tables_chain_destroy之前规则被标记为删除,目标链的chain->use在链被销毁时为0,允许链被释放。如先前在函数代码中看到的,链按chain->name、chain->udata和chain的顺序释放。struct nft_chain对象已被释放,但我们仍然通过规则(由于第二个事务失败而实际上未删除)引用已释放的链,导致释放后使用。最初是链、chain->name和chain->udata的空间现在可以被另一个对象回收以帮助我们的漏洞利用。
获取内核文本泄漏
在了解如何获取泄漏之前,了解链、chain->udata和chain->name对象如何以及在哪里分配很重要。
struct nft_chain对象在nftables收到NFT_MSG_NEWCHAIN消息时分配。在控制平面中,nf_tables_newchain调用nf_tables_addchain,它在kmalloc-cg-128缓存中分配新链对象。chain->udata和chain->name分别由nla_memdup和nla_strdup在各自的kmalloc-cg缓存中分配。
可以通过从chain->name读取数据来泄漏数据。然而,由于chain->name被视为字符串,只能打印到空字节的数据。
为了获取内核文本泄漏,选择struct seq_operations作为喷射对象。在内核版本6.2.0中,struct seq_operations由fs/seq_file.c中的single_open函数在kmalloc-cg-32缓存中分配。这个对象非常适合泄漏,因为它包含指向内核文本指针的指针(single_start函数)。
喷射struct seq_operations以回收最初由chain->name占用的释放空间[8]。然后读取chain->name以获取文本泄漏,可用于计算内核基址[9]。
获取堆泄漏
理想情况下,为了有足够的空间用于我们的伪struct nft_rule、struct nft_expr和struct nft_expr_ops,我们希望有kmalloc-cg-1024堆泄漏(我们可以在其中分配包含所有伪对象的struct msg_msg)。然而,kmalloc-cg-1024地址总是以空字节结尾,因此阻止我们直接通过chain->name打印地址。
为了规避这个限制,我们将以如下所示的方式喷射struct msg_msg:
在单个消息队列中,将有:
- 主消息(大小64)- 这将回收最初是chain->name的空闲空间
- 辅助消息(大小96)
- 第三条消息(大小1024)
我们将首先尝试通过从释放的链进行UAF读取来泄漏kmalloc-cg-96指针。chain->name将指向主消息的下一个指针,这将是辅助消息的地址。选择96字节的大小是因为kmalloc-cg-96缓存对象很小,地址最后字节为0x0并导致泄漏截断和失败的概率要低得多。
获取有效的kmalloc-cg-96堆指针后,我们现在想要泄漏kmalloc-cg-1024堆指针。辅助消息的下一个指针指向第三条消息,该消息在kmalloc-cg-1024中分配。我们还知道struct nft_chain对象(现在已释放)在kmalloc-cg-128中分配。为了获取泄漏,我们将大小为128的第四条消息喷射到释放的链对象的空间中,并将伪chain->name设置为kmalloc-cg-96指针的地址+1以绕过空字节。
我们现在可以从chain->name读取以获取kmalloc-cg-1024指针。
控制RIP
当向基础链添加新规则时,调用以下函数以确保规则集不会导致任何循环:
|
|
当调用nft_chain_validate时,将验证链中规则的表达式。nftables将使用nft_chain结构中的struct list_head rules来确定哪些规则属于该链。然而,我们能够控制先前由释放的目标链占用的空间。这意味着如果我们创建伪规则、伪表达式和指向ROP链的伪表达式操作,然后喷射伪链以回收释放的目标链的空间,最后向基础链添加新规则,我们能够启动这一系列函数,使我们能够控制RIP。
我们首先释放用于泄漏堆指针的第三条消息(大小1024)和第四条消息(大小128)。然后我们在struct msg_msg的数据部分构建伪规则、伪表达式、伪表达式操作和ROP链,并将其作为我们的第三条消息喷射。伪结构和ROP链如下所示:
|
|
然后我们喷射第四条struct msg_msg,它将作为我们的伪链。
要启动ROP链,只需向先前创建的基础链"chain2"添加新规则,并享受root shell!
补丁分析
要修补错误,只需在通过ID查找链时检查生成掩码。
|
|
致谢
我要感谢我的导师Billy教我这么多酷技术并指导我,Jacob给我这个实习机会,以及STAR Labs的其他人!:D
参考资料和致谢
- Theori的Mingi Cho报告CVE-2023-31248
- David Bouman关于nftables的文章和辅助库函数
- Bien Pham用于使用audit稳定竞争条件和验证操作想法
- Elixir Bootlin用于内核源代码
- Andy Nguyen用于msg_msg技巧