
受影响代码源自官方 Linux 内核(来自 https://kernel.org/),是 Netfilter nf_tables 组件的一部分(net/netfilter/nf_tables_api.c)。
Netfilter nf_tables 允许以原子操作方式更新其配置。使用此功能时,用户态客户端发送包含一系列基本操作的批量请求。Netfilter nf_tables 将批量中的所有操作作为单个事务处理。在处理批量时,Netfilter nf_tables 会检查配置状态更新,以确保每个后续基本操作有效,并且同时考虑批量中之前所有操作的状态更新。然而,当前实现的检查不够充分。
在我们的具体场景中,首先有一个 Netfilter nf_tables 配置,其中包含一个带有对匿名 nft_set 进行 lookup 表达式的 nft_rule,并且该匿名 nft_set 包含一些元素。然后,我们发送一个包含以下两个基本操作的批量请求:
NFT_MSG_DELRULE 操作用于删除 nft_rule。lookup 表达式和匿名 nft_set。NFT_MSG_DELSETELEM 操作用于删除已删除匿名 nft_set 中的任意元素。当前版本的 Netfilter nf_tables 接受上述批量请求。然后调用 nf_tables_commit_release(),将释放的资源追加到 nf_tables_destroy_list。nf_tables_destroy_list 随后由 nf_tables_trans_destroy_work() 处理,该函数首先通过以下调用释放与 NFT_MSG_DELRULE 操作相关的资源:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() 指向 nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() 释放 `nft_set` 使用的内存
然后在处理 NFT_MSG_DELSETELEM 操作之前,通过以下调用中的 nft_trans_elem_set() 访问已释放 nft_set 的引用:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
在上述 nft_set_elem_ext() 中,访问已释放 nft_set 的内存位置以确定 nft_set_ext 的位置:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
用于后续操作。因此,一旦 set->ops->elemsize 的值被破坏,某些意外的内存位置可能被解释为待销毁的 nft_expr 列表:
static void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
const struct nft_set *set, void *elem)
{
struct nft_set_ext *ext = nft_set_elem_ext(set, elem);
if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));
利用上述漏洞需要与从 Linux 内核后台工作线程执行的 nf_tables_trans_destroy_work() 进行竞争。这似乎在考虑现有缓解措施(如内核 slab 分配器加固、内核地址空间布局随机化(KASLR)以及尤其是控制流完整性)之前就使实际利用复杂化。然而,附带的 PoC 证明在实践中仍然可以实现相当可靠的利用。
为了利用漏洞,我们需要在 nf_tables_rule_destroy() 释放 nft_set 之后,但在 nf_tables_set_elem_destroy() 使用它之前修改其内存内容。nf_tables_rule_destroy() 和 nf_tables_set_elem_destroy() 都是在 nf_tables_trans_destroy_work() 的单次调用中执行的,该函数从 Linux 内核的后台工作线程运行。此外,释放的内存块通常只能从同一 CPU 核心重用。
当与 nf_tables_trans_destroy_work() 竞争时,我们通过在后调用 nf_tables_rule_destroy() 和 nf_tables_set_elem_destroy() 之间为后台工作线程添加受控延迟来提高成功率。为此,我们插入一个额外的操作来销毁包含大量元素的另一个 nft_set。此外,我们让所有其他 CPU 核心保持忙碌,这样后台工作线程很可能在特定 CPU 核心上调度,从而我们可以在它刚刚在 nf_tables_rule_destroy() 下释放 nft_set 后尝试从同一 CPU 核心分配新结构。我们的目标是分配一个不同类型的 nft_set 来重用 nf_tables_rule_destroy() 下释放的 nft_set 的内存位置。
选择新的 nft_set 类型,使其具有不同的 set->ops->elemsize 值。因此,当后台工作线程最终调用 nf_tables_set_elem_destroy() 处理 NFT_MSG_DELSETELEM 操作时,它会错误地解释其 elem 参数,导致损坏的 nft_set_ext *ext 比正确位置偏移几个字节。这意味着原始 nft_set_ext 的某些用户可控数据字段现在被解释为头部,从而导致类型混淆。
一种滥用此类型混淆的方法是通过构造损坏的 nft_set_ext 头部,其偏移值使得 nf_tables_set_elem_destroy() 将任何相邻内存块的内容解释为要销毁的 nft_expr 列表,通过以下调用:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
此时,我们尚未了解内核内存布局的细节。因此无法构造绝对指针地址。然而,在构造损坏的 nft_set_ext 头部时,我们仍然可以使用超出范围的偏移量,使得 expr->ops->destroy() 在相邻内存块中的某些有效 nft_expr 上被调用。
为此,我们喷洒带有可控 NFTA_LOG_PREFIX 的 nft_log 表达式。一旦 expr->ops->destroy() 被调用,该 nft_log->prefix 会由 nft_log_destroy() 释放:
static void nft_log_destroy(const struct nft_ctx *ctx,
const struct nft_expr *expr)
{
struct nft_log *priv = nft_expr_priv(expr);
struct nf_loginfo *li = &priv->loginfo;
if (priv->prefix != nft_log_null_prefix)
kfree(priv->prefix);
注意,我们仍然可以通过喷洒的 nft_log 表达式的其他引用访问甚至再次释放此内存。
此外,我们还可以控制 nft_log->prefix 的大小,使其可以从任何 slab(kmalloc-{8, ..., 192})中分配。最后,被引用的内存被内核解释为字符串,因此当我们在其上重叠不同对象时无需担心损坏。这基本上意味着游戏结束。
一个不便之处是任何 NULL 字符都会终止 nft_log->prefix,因此我们无法在泄漏内存内容时读取 NULL 字节之后的内容。在下一步中,我们分配 nft_object->udata 以重用 nft_log->prefix 内存块并销毁 nft_log 表达式。这会释放 nft_object->udata 内存,但我们现在仍然可以使用 nft_object->udata 悬空指针来泄漏内存内容,不受 NULL 字节限制。
在寻找后续步骤的合适结构时,我们决定使用从 nft_dynset_new() 分配的 nft_expr。这些结构位于与 nft_log->prefix 和 nft_object->udata 相同的 slab 中。此外,我们可以合理控制分配大小,以便后续在需要时轻松切换不同大小的 slab。
为了使用这些结构,我们创建带有 nft_dynset 表达式的包过滤器。当通过回环接口发送任何包时,nft_dynset 表达式调用 nft_dynset_new() 为关联的 nft_set 创建新元素。创建的元素是以下类型的有状态表达式:
nft_counter:用于获取内核内存中 nf_tables.ko 的位置。
该结构包含一个指向 nf_tables.ko 内核模块中 nft_counter_ops 的指针。我们通过读取 nft_object->udata 泄漏此指针。
nft_quota:用于任意内存读写。
我们可以重复释放和重新分配 nft_object->udata 来修改 nft_quota->consumed 指针。然后,执行 NFT_MSG_GETSETELEM 操作,该操作调用 nft_quota_do_dump() 读取引用的内存内容,并将结果作为 NFTA_QUOTA_CONSUMED 属性返回。至于写入,我们只需通过回环接口发送包,其中 nft_quota_do_eval() 调用:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
来修改 nft_quota->consumed。
我们使用上述任意内存读取来获取内核核心基地址。然后修改 "/sbin/modprobe" 路径名中的 "sbin" 子串,将其替换为 "/tmp"。结果路径 "//tmp/modprobe" 随后被内核用来启动一个具有 root 权限的进程,而我们控制文件内容。
注意,我们没有刻意尝试绕过控制流完整性。然而,对于每个利用步骤,我们有意识地选择了最灵活和最健壮的基元。事实证明,我们的选择不知何故避免了任何可能被控制流完整性阻止的基元。我们现在很好奇,通过测试验证生成的漏洞是否真的在启用了控制流完整性缓解的系统上有效。