前言 沉寂许久,终于挖了个0day,这篇文章就是提交Google的文章,但是感觉这是一个比较简单的洞所以没必要重新写一个文章(懒狗实锤)。
后续呢会分析其他子系统了,不想局限于这个nftables(实在是谷歌给钱太少了!),广撒网了属于是。
希望三角洲不再误我,明年争取月更!
Vulnerability Linux 内核的 Netfilter 是一个强大的框架,它提供了多种网络相关操作,包括数据包过滤、网络地址转换(NAT)和端口转发,这些操作都在 Linux 内核中实现。它被设计为与 Linux 网络栈无缝协作,为监控、修改或拒绝网络数据包提供灵活且高效的机制。
Linux 内核 Netfilter 的架构是模块化和高度灵活的,旨在高效处理网络数据包在网络栈不同阶段的处理。Netfilter 的核心结构围绕着 Linux 网络栈中的一系列钩子,这些钩子可以在数据包在网络栈中传输的不同点注册函数以拦截和操作数据包。这些钩子被策略性地放置在关键点,例如当数据包首次进入网络接口时、当它们即将被路由时,以及就在它们离开系统之前。
Linux 内核中的 Netfilter 框架包含多种钩子,用于在不同处理阶段拦截网络数据包。其中一个关键钩子是入站钩子。位于数据包处理路径的起始位置,入站钩子允许 Netfilter 框架在数据包到达网络栈进行进一步处理之前进行检查和决定其命运。
让我们看看用户通过 Netfilter 套接字更新chain的源代码(以下的chain均为netdev的basechain)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 static int nf_tables_updchain (struct nft_ctx *ctx, u8 genmask, u8 policy, u32 flags, const struct nlattr *attr, struct netlink_ext_ack *extack) { if (nla[NFTA_CHAIN_HOOK]) { if (!nft_is_base_chain(chain)) { NL_SET_BAD_ATTR(extack, attr); return -EEXIST; } basechain = nft_base_chain(chain); err = nft_chain_parse_hook(ctx->net, basechain, nla, &hook, ctx->family, flags, extack); if (err < 0 ) return err; if (basechain->type != hook.type) { nft_chain_release_hook(&hook); NL_SET_BAD_ATTR(extack, attr); return -EEXIST; } if (nft_base_chain_netdev(ctx->family, basechain->ops.hooknum)) { list_for_each_entry_safe(h, next, &hook.list , list ) { h->ops.pf = basechain->ops.pf; h->ops.hooknum = basechain->ops.hooknum; h->ops.priority = basechain->ops.priority; h->ops.priv = basechain->ops.priv; h->ops.hook = basechain->ops.hook; if (nft_hook_list_find(&basechain->hook_list, h)) { list_del(&h->list ); kfree(h); } } } else { ops = &basechain->ops; if (ops->hooknum != hook.num || ops->priority != hook.priority) { nft_chain_release_hook(&hook); NL_SET_BAD_ATTR(extack, attr); return -EEXIST; } } } if (!(table->flags & NFT_TABLE_F_DORMANT) && nft_is_base_chain(chain) && !list_empty(&hook.list )) { basechain = nft_base_chain(chain); ops = &basechain->ops; if (nft_base_chain_netdev(table->family, basechain->ops.hooknum)) { err = nft_netdev_register_hooks(ctx->net, &hook.list ); if (err < 0 ) goto err_hooks; unregister = true ; } } err = -ENOMEM; trans = nft_trans_alloc(ctx, NFT_MSG_NEWCHAIN, sizeof (struct nft_trans_chain)); if (trans == NULL ) goto err_trans; nft_trans_chain_stats(trans) = stats; nft_trans_chain_update(trans) = true ; if (nla[NFTA_CHAIN_POLICY]) nft_trans_chain_policy(trans) = policy; else nft_trans_chain_policy(trans) = -1 ; if (nla[NFTA_CHAIN_HANDLE] && nla[NFTA_CHAIN_NAME]) { struct nftables_pernet *nft_net = nft_pernet(ctx->net); struct nft_trans *tmp ; char *name; err = -ENOMEM; name = nla_strdup(nla[NFTA_CHAIN_NAME], GFP_KERNEL_ACCOUNT); if (!name) goto err_trans; err = -EEXIST; list_for_each_entry(tmp, &nft_net->commit_list, list ) { if (tmp->msg_type == NFT_MSG_NEWCHAIN && tmp->ctx.table == table && nft_trans_chain_update(tmp) && nft_trans_chain_name(tmp) && strcmp (name, nft_trans_chain_name(tmp)) == 0 ) { NL_SET_BAD_ATTR(extack, nla[NFTA_CHAIN_NAME]); kfree(name); goto err_trans; } } nft_trans_chain_name(trans) = name; } nft_trans_basechain(trans) = basechain; INIT_LIST_HEAD(&nft_trans_chain_hooks(trans)); list_splice(&hook.list , &nft_trans_chain_hooks(trans)); if (nla[NFTA_CHAIN_HOOK]) module_put(hook.type->owner); nft_trans_commit_list_add_tail(ctx->net, trans); return 0 ; err_trans: free_percpu(stats); kfree(trans); err_hooks: if (nla[NFTA_CHAIN_HOOK]) { list_for_each_entry_safe(h, next, &hook.list , list ) { if (unregister) nf_unregister_net_hook(ctx->net, &h->ops); list_del(&h->list ); kfree_rcu(h, rcu); } module_put(hook.type->owner); } return err; }
在[1]处,会检查当前环节解析的hook是否有与basechain中已存在的hook所指向的设备名字一致。
在[2]处,会将新注册的hook注册到设备中。
在[3]处,会将新注册的hook移到trans->hook链表中。
再让我们看看提交函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 static int nf_tables_commit (struct net *net, struct sk_buff *skb) { list_for_each_entry_safe(trans, next, &nft_net->commit_list, list ) { nf_tables_commit_audit_collect(&adl, trans->ctx.table, trans->msg_type); switch (trans->msg_type) { case NFT_MSG_NEWCHAIN: if (nft_trans_chain_update(trans)) { nft_chain_commit_update(trans); nf_tables_chain_notify(&trans->ctx, NFT_MSG_NEWCHAIN, &nft_trans_chain_hooks(trans)); list_splice(&nft_trans_chain_hooks(trans), &nft_trans_basechain(trans)->hook_list); } else { nft_chain_commit_drop_policy(trans); nft_clear(net, trans->ctx.chain); nf_tables_chain_notify(&trans->ctx, NFT_MSG_NEWCHAIN, NULL ); nft_trans_destroy(trans); } break ; } } return 0 ; }
在[4]处,会在此处将trans->hooks链表移到basechain中。
可以看到的是,在创建新的hook时只是与basechain的hook链表中的hook进行比较并未对提交链表中的trans中的hook进行比较,导致可以在同一条chain中存在两个指向同一个dev的hook。
另外一方面,当用户通过 netlink 套接字从系统中删除网络设备时,内核将调用函数 rtnl_dellink
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static int rtnl_dellink (struct sk_buff *skb, struct nlmsghdr *nlh, struct netlink_ext_ack *extack) { ... err = rtnl_delete_link(dev, portid, nlh); ... } int rtnl_delete_link (struct net_device *dev, u32 portid, const struct nlmsghdr *nlh) { const struct rtnl_link_ops *ops ; LIST_HEAD(list_kill); ops = dev->rtnl_link_ops; if (!ops || !ops->dellink) return -EOPNOTSUPP; ops->dellink(dev, &list_kill); unregister_netdevice_many_notify(&list_kill, portid, nlh); return 0 ; }
根据网络设备的类型,删除操作将进入不同的回调。有一些默认的网络设备:ppp、tun、bridge、…,但它们的所有回调最终都会调用unregister_netdevice_queue 函数,这会导致函数 unregister_netdevice_many_notify 被执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void unregister_netdevice_many_notify (struct list_head *head, u32 portid, const struct nlmsghdr *nlh) { ... list_for_each_entry(dev, head, unreg_list) { ... call_netdevice_notifiers(NETDEV_UNREGISTER, dev); ... netdev_unregister_kobject(dev); } list_del(head); }
这个函数主要关注于设备未初始化并从内核堆内存中释放该对象。此外,它还调用了 call_netdevice_notifiers ,这会通知所有订阅网络设备的处理器。这些处理器实际上是来自 netfilter 子系统的过滤器链,我们可以在 nft_chain_filter.c 中找到它们的定义。让我们看看处理器 nf_tables_netdev_notifier 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 static int nf_tables_netdev_event (struct notifier_block *this , unsigned long event, void *ptr) { struct net_device *dev = netdev_notifier_info_to_dev(ptr); struct nft_base_chain *basechain ; struct nftables_pernet *nft_net ; struct nft_chain *chain , *nr ; struct nft_table *table ; struct nft_ctx ctx = { .net = dev_net(dev), }; if (event != NETDEV_UNREGISTER && event != NETDEV_CHANGENAME) return NOTIFY_DONE; nft_net = nft_pernet(ctx.net); mutex_lock(&nft_net->commit_mutex); list_for_each_entry(table, &nft_net->tables, list ) { if (table->family != NFPROTO_NETDEV && table->family != NFPROTO_INET) continue ; ctx.family = table->family; ctx.table = table; list_for_each_entry_safe(chain, nr, &table->chains, list ) { if (!nft_is_base_chain(chain)) continue ; basechain = nft_base_chain(chain); if (table->family == NFPROTO_INET && basechain->ops.hooknum != NF_INET_INGRESS) continue ; ctx.chain = chain; nft_netdev_event(event, dev, &ctx); } } mutex_unlock(&nft_net->commit_mutex); return NOTIFY_DONE; }
所以,总结来说,当我们移除一个网络设备时, netdev 事件的处理器会遍历当前网络中的所有table,如果表不属于 NFPROTO_NETDEV或NFPROTO_INET 协议族则直接跳过;否则会遍历table中的所有chain,如果不是basechain则直接跳过,如果是basechain则最终会调用nft_netdev_event函数移除hook。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 static void nft_netdev_event (unsigned long event, struct net_device *dev, struct nft_ctx *ctx) { struct nft_base_chain *basechain = nft_base_chain(ctx->chain); struct nft_hook *hook , *found = NULL ; int n = 0 ; if (event != NETDEV_UNREGISTER) return ; list_for_each_entry(hook, &basechain->hook_list, list ) { if (hook->ops.dev == dev) found = hook; n++; } if (!found) return ; if (n > 1 ) { if (!(ctx->chain->table->flags & NFT_TABLE_F_DORMANT)) nf_unregister_net_hook(ctx->net, &found->ops); list_del_rcu(&found->list ); kfree_rcu(found, rcu); return ; } __nft_release_basechain(ctx); }
然而,在移除hook的nft_netdev_event函数中只会寻找到当前chain中的第一个指向该设备的hook并进行移除,所以即便是删除了设备仍有一个hook指向该设备。
现在我们需要一个地方来使用这个释放的网络设备。当通过Netfilter套接字删除一条链时会调用__nf_unregister_net_hook函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 static void __nf_unregister_net_hook(struct net *net, int pf, const struct nf_hook_ops *reg) { struct nf_hook_entries __rcu **pp ; struct nf_hook_entries *p ; pp = nf_hook_entry_head(net, pf, reg->hooknum, reg->dev); if (!pp) return ; mutex_lock(&nf_hook_mutex); p = nf_entry_dereference(*pp); if (WARN_ON_ONCE(!p)) { mutex_unlock(&nf_hook_mutex); return ; } if (nf_remove_net_hook(p, reg)) { #ifdef CONFIG_NETFILTER_INGRESS if (nf_ingress_hook(reg, pf)) net_dec_ingress_queue(); #endif #ifdef CONFIG_NETFILTER_EGRESS if (nf_egress_hook(reg, pf)) net_dec_egress_queue(); #endif nf_static_key_dec(reg, pf); } else { WARN_ONCE(1 , "hook not found, pf %d num %d" , pf, reg->hooknum); } p = __nf_hook_entries_try_shrink(p, pp); mutex_unlock(&nf_hook_mutex); if (!p) return ; nf_queue_nf_hook_drop(net); nf_hook_entries_free(p); }
在该函数中会访问已经被释放掉的dev设备
1 2 3 4 5 6 7 8 case NFPROTO_INET: if (WARN_ON_ONCE(hooknum != NF_INET_INGRESS)) return NULL ; if (!dev || dev_net(dev) != net) { WARN_ON_ONCE(1 ); return NULL ; } return &dev->nf_hooks_ingress;
综上所述,我们得到了一个内核中的Use-After-Free漏洞。
Triggering Vulnerabilty 首先我们通过向 netlink 套接字发送创建请求来创建一个虚拟设备
1 rt_newlink(nl_route, "y4g7" , 1337 );
创建一个table和一个chain,并在同一环节中更新两次chain使两个hook指向同一设备
1 2 3 create_basechain(batch, seq++, table1_name, chain1_name, 0 , 0xff ); update_basechain(batch, seq++, table1_name, chain1_name, "y4g7" ); update_basechain(batch, seq++, table1_name, chain1_name, "y4g7" );
移除设备
1 rt_dellink(nl_route, 1337 );
现在我们可以通过dump这一条chain来拿到已经移除设备的名称从而触发UAF。
Exploitation UAF Leak 当我们dump前面触发UAF的chain的信息时,可以在nft_dump_basechain_hook函数中获取到已经释放的旧设备的设备名,这导致我们可以以字符串的形式泄露出结构体的前八个字节的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 static int nft_dump_basechain_hook (struct sk_buff *skb, int family, const struct nft_base_chain *basechain, const struct list_head *hook_list) { list_for_each_entry_rcu(hook, hook_list, list ) { if (!first) first = hook; if (nla_put_string(skb, NFTA_DEVICE_NAME, hook->ops.dev->name)) goto nla_put_failure; n++; } }
Arbitrary free 当我们删除chain时,会调用 __nf_unregister_net_hook 来通过已释放的 net_device 查找 struct nf_hook_entries ,随后调用 nf_hook_entries_free 来释放我们可控的值。但存在一个限制,我们需要使我们构造的 net_device 的net与当前命名空间下的net相同。否则它将返回 NULL 并提前返回,而不会进入 nf_hook_entries_free
1 2 3 4 5 6 7 8 case NFPROTO_INET: if (WARN_ON_ONCE(hooknum != NF_INET_INGRESS)) return NULL ; if (!dev || dev_net(dev) != net) { WARN_ON_ONCE(1 ); return NULL ; } return &dev->nf_hooks_ingress;
Overwrite core_pattern 获取特权执行的一种方法是覆盖 core_pattern ,这样如果用户程序触发崩溃,我们的漏洞利用将以 root 身份执行。通过在之前使用任意释放,漏洞利用会释放一些 pipe_buffer 对象,并用我们自己的值覆盖 pipe_buffer 。
在 pipe_buffer 对象中有一个 page 成员,它代表内存中已分配的任何物理页。为了知道 core_pattern 的页对象的位置,我们首先将某个内核文本的 struct page 溢出到 pipe_buffer ,我们使用的技巧是通过 vmsplice vDSO 地址将数据写入管道。然后,通过从受害者 pipe_buffer 读取 page ,我们可以计算出 core_pattern 的位置。我们还用 PIPE_BUF_FLAG_CAN_MERGE 覆盖了 flags ,所以通过向管道写入数据,我们可以覆盖内存页的内容,即 core_pattern 。
Root shell 覆盖 core_pattern 后,我们可以使程序崩溃, core_pattern 中的任何内容都将以 root 身份执行,这就是我们的 root shell。
Detailed summary of step by steps exploitation
跨缓存覆盖 kmalloc-cg-4k 中的 net_device 对象到 kmalloc-4k 中的 packet_fanout 对象。
packet_fanout 对象在第一个 8 字节中有 net 地址,我们利用 UAF 漏洞利用原始操作泄露 net,为我们后续的任意释放做准备。
使用另一个 UAF 对象,这次我们用 msg_msg 对象覆盖 net_device ,不需要跨缓存,我们使用相同的 kmalloc-cg-4k。
我们将 msg_msg.next (前 8 字节偏移)填充为 kmalloc-cg-192,通过发送 msg_msg ,大小为 0x90,对每个已经放置在 kmalloc-cg-4k 中的 msg_msg 进行操作。
kmalloc-cg-192 保证第一个字节不包含空字节,因此我们可以利用 UAF 漏洞泄露 kmalloc-cg-192 的 msg_msg 地址。
使用任意释放功能释放我们在 kmalloc-cg-192 中获取的块。
通过spraypipe_buffer拿到前一步骤中释放的堆块并将其别名为pipe_buffer A
向管道 A 写入 0x1000 字节,这将增加 pipe->head ,使得下一个 vmspliced 页面发生在 &pipe->bufs[1] ,并且不会触及需要置零的重要字段 msg_msgseg.next ,该字段位于前八个字节。
再次使用任意空闲来释放 pipe_buffer A。
回收 pipe_buffer 作为 msg_msgseg ,因为 msg_msgseg 只有前八个字节不受用户控制,所以在这种情况下比 msg_msg 更好。
调用系统调用 vmsplice 将 vDSO 内存页映射到 pipe_buffer A 的偏移 0x28(因为在第 8 步增加了 pipe->head ),这种方法将 struct page 内核文本的地址提供给 pipe_buffer 。
从 msg_msgseg 读取内容以读取 pipe_buffer A 的内容。
准备一个fake_pipe_buffer,使用 page 和 offset 计算出的值指向 core_pattern page 的地址,并设置 PIPE_BUF_FLAG_CAN_MERGE 作为管道标志。
接收 msg 对象, msg_msgseg 对象将被释放,然后我们可以通过再次用fake_pipe_bufferspray msg_msgseg 来修改 pipe_buffer 。
写入 pipe_buffer A 以覆盖 core_pattern 值。
通过触发崩溃来获取 root shell。
这次就不放exp了。