受影响代码源自 https://kernel.org/ 的官方 Linux 内核,是 Netfilter nf_tables 组件(net/netfilter/nf_tables_api.c)的一部分。
Netfilter nf_tables 允许以原子操作的方式更新其配置。使用此功能时,用户态客户端会发送包含一系列基本操作的批量请求。然后 Netfilter nf_tables 将批量中的所有操作作为单个事务处理。在处理批量时,Netfilter nf_tables 会检查配置状态更新,以确保每个后续基本操作都有效,并且这也考虑了批处理中所有先前操作的状态更新。然而,当前实现的检查是不够的。
在我们的具体场景中,我们从具有带有 lookup 表达式的 nft_rule 和匿名 nft_set 的 Netfilter nf_tables 配置开始,该匿名 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() that points to nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() that deallocates memory used by `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 核心上,这样我们就可以在其释放 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 权限的进程,其中我们控制文件内容。
请注意,我们没有刻意努力去绕过控制流完整性。然而,对于每个利用步骤,我们有意识地选择了最灵活和最稳健的原语。结果证明,我们的选择 somehow 避免了任何可能被控制流完整性阻止的原语。我们现在很好奇,想通过测试确认最终的利用是否真的能在具有控制流完整性缓解措施的系统上工作。