观察 tcx_entry
的生命周期,我们可以看到它首先在 ingress_init
等函数初始化新建的 ingress/clsact qdisc 时分配:
static int ingress_init(struct Qdisc *sch, struct nlattr *opt,
struct netlink_ext_ack *extack)
{
struct ingress_sched_data *q = qdisc_priv(sch);
struct net_device *dev = qdisc_dev(sch);
struct bpf_mprog_entry *entry;
bool created;
int err;
if (sch->parent != TC_H_INGRESS)
return -EOPNOTSUPP;
net_inc_ingress_queue();
entry = tcx_entry_fetch_or_create(dev, true, &created); // [1]
if (!entry)
return -ENOMEM;
tcx_miniq_set_active(entry, true); // [2]
mini_qdisc_pair_init(&q->miniqp, sch, &tcx_entry(entry)->miniq); // [3]
if (created)
tcx_entry_update(dev, entry, true);
q->block_info.binder_type = FLOW_BLOCK_BINDER_TYPE_CLSACT_INGRESS;
q->block_info.chain_head_change = clsact_chain_head_change;
q->block_info.chain_head_change_priv = &q->miniqp; // [4]
err = tcf_block_get_ext(&q->block, sch, &q->block_info, extack);
if (err)
return err;
mini_qdisc_pair_block_init(&q->miniqp, q->block);
return 0;
}
在 [1] 处,调用了 tcx_entry_fetch_or_create
来分配或获取已存在的 tcx_entry
(被 bpf_mprog_entry
包裹):
static inline struct bpf_mprog_entry *
tcx_entry_fetch_or_create(struct net_device *dev, bool ingress, bool *created)
{
struct bpf_mprog_entry *entry = tcx_entry_fetch(dev, ingress); // [5]
*created = false;
if (!entry) {
entry = tcx_entry_create();
if (!entry)
return NULL;
*created = true;
}
return entry;
}
在 [2] 处,调用 tcx_miniq_set_active
将 tcx_entry->miniq_active
设置为 true
,然后在 [3] 处,将 &tcx_entry(entry)->miniq
(基本上是指向 tcx_entry
第一个字段的指针)存储到 &q->miniqp
,然后在 [4] 处将其复制到 q->block_info.chain_head_change_priv
。
如果我们现在再看看释放 ingress qdisc 的过程,会发现一些有趣的地方:
static void ingress_destroy(struct Qdisc *sch)
{
struct ingress_sched_data *q = qdisc_priv(sch);
struct net_device *dev = qdisc_dev(sch);
struct bpf_mprog_entry *entry = rtnl_dereference(dev->tcx_ingress);
if (sch->parent != TC_H_INGRESS)
return;
tcf_block_put_ext(q->block, sch, &q->block_info);
if (entry) {
tcx_miniq_set_active(entry, false);
if (!tcx_entry_is_active(entry)) {
tcx_entry_update(dev, NULL, true);
tcx_entry_free(entry);
}
}
net_dec_ingress_queue();
}
在 ingress_destroy
中,如果存在与网络设备绑定的 tcx_entry
,它会将 tcx_entry->miniq_active
设置为 false
,然后调用 tcx_entry_is_active(entry)
来判断 tcx_entry
是否仍在使用。如果没有使用,则从网络设备中移除引用并释放 tcx_entry
。接下来看看 tcx_entry_is_active
的实现:
static inline bool tcx_entry_is_active(struct bpf_mprog_entry *entry)
{
ASSERT_RTNL();
return bpf_mprog_total(entry) || tcx_entry(entry)->miniq_active;
}
它会检查该条目是否有任何附加的 bpf 程序,因为这些程序会引用该条目,或者检查 miniq_active
是否为 true
,表示 qdisc 视图仍然存在。但是,销毁 qdisc 时会将其设置为 false
,如果没有附加任何程序,这个函数总是会返回 false
,从而进入释放路径。
这个问题引发了一个问题:如果另一个 qdisc 获取了相同的条目并引用了它,然后我们删除第一个 qdisc,导致该条目也被删除,会发生什么?值得注意的是,在我的理解中,不能在同一网络设备上共存两个 ingress qdisc,创建一个新的 ingress qdisc 会删除旧的 qdisc。然而,令人感兴趣的是,创建新 ingress qdisc 会在调用 ingress_destroy
删除旧的 qdisc 之前调用 ingress_init()
,因此可以通过连续创建两个 ingress qdisc 来复制引用。在第一个 qdisc 被删除之后,第二个 qdisc 仍然持有指向 tcx_entry
的指针,这个指针会在随后的调用链中的 mini_qdisc_pair_swap()
函数中被解引用:
[...]
static void tcf_chain0_head_change(struct tcf_chain *chain,
struct tcf_proto *tp_head)
{
struct tcf_filter_chain_list_item *item;
struct tcf_block *block = chain->block;
if (chain->index)
return;
mutex_lock(&block->lock);
list_for_each_entry(item, &block->chain0.filter_chain_list, list)
tcf_chain_head_change_item(item, tp_head);
mutex_unlock(&block->lock);
}
[...]
static void tcf_chain_head_change_item(struct tcf_filter_chain_list_item *item,
struct tcf_proto *tp_head)
{
if (item->chain_head_change)
item->chain_head_change(tp_head, item->chain_head_change_priv);
}
[...]
static void clsact_chain_head_change(struct tcf_proto *tp_head, void *priv)
{
struct mini_Qdisc_pair *miniqp = priv;
mini_qdisc_pair_swap(miniqp, tp_head);
};
这可以通过向该 qdisc 添加或删除流量控制过滤器来触发。
tcx_entry
引用tcx_entry
悬空指针在 mini_qdisc_pair_swap
函数中被解引用:
void mini_qdisc_pair_swap(struct mini_Qdisc_pair *miniqp,
struct tcf_proto *tp_head)
{
/* 被 chain0->filter_chain_lock 保护。
* 不能直接访问链表,因为 tp_head 可能为 NULL。
*/
struct mini_Qdisc *miniq_old =
rcu_dereference_protected(*miniqp->p_miniq, 1); // [1]
struct mini_Qdisc *miniq;
if (!tp_head) {
RCU_INIT_POINTER(*miniqp->p_miniq, NULL); // [2]
} else {
miniq = miniq_old != &miniqp->miniq1 ?
&miniqp->miniq1 : &miniqp->miniq2;
/* 确保读者在我们修改 miniq 时看不到它。
* 确保至少经过一个 RCPU 协调周期,确保 miniq 被标记为非活跃状态。
*/
if (IS_ENABLED(CONFIG_PREEMPT_RT))
cond_synchronize_rcu(miniq->rcu_state);
else if (!poll_state_synchronize_rcu(miniq->rcu_state))
synchronize_rcu_expedited();
miniq->filter_list = tp_head;
rcu_assign_pointer(*miniqp->p_miniq, miniq); // [3]
}
if (miniq_old)
/* 这是与上面的 rcu 同步的对等操作。我们需要阻止潜在的新用户
* 访问 miniq_old,直到所有的读者都不再看到它。
*/
miniq_old->rcu_state = start_poll_synchronize_rcu(); // [4]
}
需要注意的是,miniqp
是 struct tcx_entry
的第一个字段,miniq
是 struct Qdisc
的一个字段,因此 miniqp->p_miniq
是指向 qdisc 中部的一个指针,而 tcx_entry
位于 kmalloc-2k 中。
注意到,在 [2] 处,如果 tp_head
为 NULL,它会将 NULL 写入 miniqp->p_miniq
,这正好位于 tcx_entry
的开始位置。如果 tp_head
不为 NULL,则会在 [3]中写入 miniq
,即将实际的结构体写入悬空指针位置。
通过触发 UAF 攻击,可以绕过验证并修改内核内存的特定部分,进而对系统安全造成威胁。
我的方法是通过跨缓存(cross-caching)从 kmalloc-2k
到 kmalloc-cg-2k
,借此访问 msg_msgseg
对象,并将 msg_msgseg->next
指针替换为 miniq
指针。这在我们的案例中特别有趣,因为 struct mini_Qdisc
的第一个字段是 filter_list
,它是一个绑定到过滤器的 struct tcf_proto
对象的链表,而这些对象我们可以随意分配和释放。结合 msg_msgseg
的特性,读取 msg_msgseg
会释放 msg_msgseg->next
直到它找到 NULL,这使得我们有了一个非常强大的任意释放工具。
void free_msg(struct msg_msg *msg)
{
struct msg_msgseg *seg;
security_msg_msg_free(msg);
seg = msg->next;
kfree(msg);
while (seg != NULL) {
struct msg_msgseg *tmp = seg->next;
cond_resched();
kfree(seg);
seg = tmp;
}
}
通过这个方法,我们可以做以下操作:
tcx_entry
变为悬空指针msg_msgseg
tcf_proto
添加到以 miniq
为头的链表中,并将 miniq
指针写入 msg_msgseg->next
)msg_msgseg
,这会触发释放,并遍历 filter_list
,直到它找到 NULL,释放所有的对象,从而释放我们位于 kmalloc-128
区域的 tcf_proto
。我的漏洞利用的最终目标是获得一个在 kmalloc-cg-1k
中的双重释放(double free),从而使 pipe_buffer
与 skbuf->data
重叠,达到控制内存的目的。为此,我需要通过一些中间步骤,跨缓存,逐步接近 kmalloc-cg-1k
。
一旦我在 kmalloc-128
中获得了任意释放的能力,我决定再次跨缓存,这次从 kmalloc-128
到 kmalloc-cg-128
,通过喷洒 msg_msgseg
并重用我们的任意释放原语来释放 kmalloc-cg-128
中的一个 msg_msgseg
对象。在这一点上,我的目标是获得一个指向 kmalloc-cg-1k
的对象,以便将我们现在完全可控的 msg_msgseg->next
与其重叠。我的第一个想法是将其与一个 msg_msg
对象重叠,但这会导致 msg_msgseg
被链接到 msg_msg
的循环链表中,在释放时会无限迭代,导致死锁。后来,我通过分析结构体,发现了 struct in_ifaddr
:
struct hlist_node hash;
struct in_ifaddr __rcu *ifa_next;
struct in_device *ifa_dev;
struct rcu_head rcu_head;
__be32 ifa_local;
__be32 ifa_address;
__be32 ifa_mask;
__u32 ifa_rt_priority;
__be32 ifa_broadcast;
unsigned char ifa_scope;
unsigned char ifa_prefixlen;
unsigned char ifa_proto;
__u32 ifa_flags;
char ifa_label[IFNAMSIZ];
__u32 ifa_valid_lft;
__u32 ifa_preferred_lft;
unsigned long ifa_cstamp; /* created timestamp */
unsigned long ifa_tstamp; /* updated timestamp */
这个结构体特别有趣的原因主要有两个:
ifa_dev
指针,这将允许我泄露 kmalloc-512
区域中一个 struct in_device
对象的指针。ifa_address
字段作为标识符,从而知道哪个喷洒的对象与 msg_msgseg
重叠。虽然 kmalloc-512
指针并不是我最初的目标,但它已经足够好。因为 kmalloc-cg-512
和 kmalloc-512
属于同一个页面级别,所以它们通常在同一页面附近。基于这一点,我在分配 in_device
之前,先分配了大量的 kmalloc-cg-512
类型的 skbuf->data
,这样,当我收到泄露的指针时,可以通过减去一个任意的偏移量,将指针定位到 skbuf->data
喷洒区域的中间。
现在,我们有了 skbuf->data
对象在 kmalloc-cg-512
中的指针,我们需要通过某种方式将它写入我们可控的 msg_msgseg
,以便释放它。虽然许多方便的数据喷洒技术似乎已经失效,但我最终发现了一个(看起来是新颖的)对象,能够完成这个工作:
static int rtnl_alt_ifname(int cmd, struct net_device *dev, struct nlattr *attr,
bool *changed, struct netlink_ext_ack *extack)
{
char *alt_ifname;
size_t size;
int err;
err = nla_validate(attr, attr->nla_len, IFLA_MAX, ifla_policy, extack);
[...]
alt_ifname = nla_strdup(attr, GFP_KERNEL_ACCOUNT);
[...]
kfree(alt_ifname);
if (!err)
*changed = true;
return err;
}
alt_ifname
对象是一个临时缓冲区,用来存储用户数据,分配时使用了 GFP_KERNEL_ACCOUNT
。需要注意的是,rtnl_alt_ifname
只在获得 rtnl 锁的上下文中被调用,因此我们不能通过多线程来喷洒和竞争临时缓冲区。我们必须确保要覆盖的对象先于其他对象进入空闲链表。
一种方法是逐步分配其他 msg_msgseg
对象,并检查我们被篡改的 msg_msgseg
,使用 MSG_COPY
读取它,而不释放它,从而知道哪个新分配的 msg_msgseg
对象刚好覆盖了目标对象,然后用 alt_ifname
缓冲区替换它,最终使我们可以将 msg_msgseg->next
指向 kmalloc-cg-512
中的 skbuf->data
对象,并释放它。
kmalloc-cg-512
已经比 kmalloc-cg-128
更好。我们现在可以将 msg_msg
与我们的 skbuf->data
对象重叠。如果我们在与该 msg_msg
对象相同的队列中发送另一个消息,它会将第二个消息的指针放在第一个消息的第一个 qword 中。通过发送一个在 kmalloc-cg-1k
中分配的消息,我们可以泄露一个指针,并通过读取我们的 skbuf->data
来获取这个指针。如果我们稍微增加指针,它会指向另一个由我们的喷洒操作分配的 msg_msg
,该对象属于一个不同的队列,这样我们就得到了一个指向 kmalloc-cg-1k
中 msg_msg
的重复引用,可以用来进行双重释放。关于如何操作 msg_msg
来实现这个目标,已经在这里详细讨论过:Google CVE-2021-22555 writeup。
一旦我们到达 kmalloc-cg-1k
,我们可以将 skbuf->data
与 pipe_buffer
重叠,控制 pipe_buffer->page
指针,进而实现物理读写。这项技术已经在 Pipe Buffer 中详细讨论过。
使用这个原语,我可以覆盖 modprobe_path
字符串,将其指向一个通过 memfd_create
创建的内存文件,该文件位于 /proc/<pid>/fd/<n>
下。我还暴力破解了 exploit 进程的 pid,绕过了沙盒化命名空间的限制。有关更多细节,可以参考 Nftables techniques。
4 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!