Looking at the lifecycle of tcx_entry, we can see that it's first allocated when initializing a new ingress/clsact qdisc in ingress_init and similar functions:
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;
}At [1], tcx_entry_fetch_or_create is called to either allocate or fetch an existing tcx_entry (wrapped by 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;
}At [2], tcx_miniq_set_active is called to set tcx_entry->miniq_active to true, then at [3], &tcx_entry(entry)->miniq (basically a pointer to the first field of tcx_entry) is stored at &q->miniqp, which is then copied to q->block_info.chain_head_change_priv at [4].
If we now also look into the process of releasing an ingress qdisc we can notice something quite interesting:
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();
}In ingress_destroy, if there is an existing tcx_entry bound to the net device, then it changes the tcx_entry->miniq_active bool to false, then calls tcx_entry_is_active(entry) to determine if tcx_entry is still in use, and if it's not, we remove the reference from the net device and free tcx_entry. Let's have a look at tcx_entry_is_active now:
static inline bool tcx_entry_is_active(struct bpf_mprog_entry *entry)
{
ASSERT_RTNL();
return bpf_mprog_total(entry) || tcx_entry(entry)->miniq_active;
}It checks if the entry either has any bpf programs currently attached to it since they will have a view of the entry or if the miniq_active bool is true, which would mean the qdisc view still exists, but since destroying the qdisc sets this to false, if we don't attach any programs, this will always return false and enter the free path.
This raises the question of what could happen if another qdisc fetched the same entry and referenced it and then we deleted the first qdisc, causing the entry to be deleted as well. At this point it's probably worth noting that, in my understanding, you can't really have 2 ingress qdiscs co-exist in the same net device and creating a new one will make the old qdisc be deleted. However, interestingly enough, creating a new ingress qdisc will call ingress_init() for the new qdisc before ever calling ingress_destroy() for the old one, so the reference can get duplicated right before freeing tcx_entry by simply creating a ingress qdisc twice in a row. After the first qdisc gets deleted, the second one still has a pointer to tcx_entry in q->block_info.chain_head_change_priv which later can get dereferenced in the call to mini_qdisc_pair_swap() in the following call chain:
[...]
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);
};Which we can reach by adding or removing traffic control filters from this qdisc.
- Create a new ingress/clsact qdisc
- Create a second ingress/clasct qdisc, which will cause the following to happen:
- reuse tcx_entry reference
- delete tcx_entry
- delete first qdisc
- add a filter to second qdisc which will dereference the dangling pointer and overwrite the first qword of the UAF object with a pointer to the qdisc struct or NULL
The dangling pointer is dereferenced in the mini_qdisc_pair_swap function:
void mini_qdisc_pair_swap(struct mini_Qdisc_pair *miniqp,
struct tcf_proto *tp_head)
{
/* Protected with chain0->filter_chain_lock.
* Can't access chain directly because tp_head can be 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;
/* We need to make sure that readers won't see the miniq
* we are about to modify. So ensure that at least one RCU
* grace period has elapsed since the miniq was made
* inactive.
*/
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)
/* This is counterpart of the rcu sync above. We need to
* block potential new user of miniq_old until all readers
* are not seeing it.
*/
miniq_old->rcu_state = start_poll_synchronize_rcu(); // [4]
}Keep in mind that miniqp is the first field of struct tcx_entry and miniq is a field of struct Qdisc, so miniqp->p_miniq is a pointer to somewhere in the middle of a qdisc that gets stored at the start of a struct tcx_entry. Also, tcx_entry lives in kmalloc-2k.
Notice that, at [2], if tp_head is NULL, it writes NULL to miniqp->p_miniq, which is at the start of tcx_entry and if tp_head is not NULL, it writes a pointer to miniq at [3], writing the pointer to the middle of a qdisc at the start of tcx_entry. We can control whether or not tp_head is NULL, so we could try to write NULL. An idea that might come to mind is targeting refcounts with the NULL write, however, there is a catch: at [1] it reads the original value at the first qword of the UAF object before being overwritten, and later at [4], if it's not NULL, it will get dereferenced as a pointer. So our options are limited to victim objects that either have NULL in the first qword or that already have a pointer in the first qword and would do something interesting if we overwrote it with either NULL or a different pointer.
My approach was cross-caching from kmalloc-2k to kmalloc-cg-2k to get access to msg_msgseg objects and replace the msg_msgseg->next pointer with the miniq pointer. This is particularly interesting in our case given that the first field of struct mini_Qdisc is filter_list, which is a linked list of struct tcf_proto objects bound to filters we can allocate and free at will. Combine this with the fact that reading a msg_msgseg will free whatever if at msg_msgseg->next until it finds NULL and we have a very strong arbitrary free instrument.
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;
}
}This allow us to do the following:
- make
tcx_entrydangling - replace it with
msg_msgseg - add a filter to qdisc (this will both add a
tcf_prototo the linked list with head atminiqand write theminiqpointer atmsg_msgseg->next) - read
msg_msgseg, which will cause it to get freed and also interate overfilter_list, freeing everything until it finds NULL, so it will free ourtcf_proto, which lives at kmalloc-128.
The end goal of my exploit was to reach get a double free in kmalloc-cg-1k to overlap pipe_buffer with skbuf->data and win. In order to do that, I had to go through some intermediate steps in other caches though.
Once I had arbitrary free in kmalloc-128, I decided to once again cross-cache, this time from kmalloc-128 to kmalloc-cg-128 by spraying msg_msgseg and reusing our arbitrary free primitive to free a msg_msgseg object in kmalloc-cg-128. At this point my goal was to get some object that could give me a pointer to kmalloc-cg-1k so I could overlap our new, fully controllable, msg_msgseg->next with it. My first idea was to overlap it with a msg_msg object, however, this would end up linking the msg_msgseg into the circular msg_msg list, and cause a hang while freeing msg_msgseg because it would iterate endlessly. After going through some structs, I came across 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];
/* In seconds, relative to tstamp. Expiry is at tstamp + HZ * lft. */
__u32 ifa_valid_lft;
__u32 ifa_preferred_lft;
unsigned long ifa_cstamp; /* created timestamp */
unsigned long ifa_tstamp; /* updated timestamp */
};This is particularly interesting for mainly 2 reasons:
- I can read the
ifa_devpointer, which will allow me to leak a pointer to astruct in_deviceobject in kmalloc-512 - When spraying, I can use the
ifa_addressfield as an identifier, so I can know which sprayed object overlappedmsg_msgsegA kmalloc-512 pointer is not what I wanted initially, but good enough. It's relatively easy to allocated kmalloc-cg-512 pages adjacent to kmalloc-512 pages because they belong to the same page order. With this in mind, I allocated a huge padding of kmalloc-cg-512skbuf->databefore allocatingin_device, so when I receive the leak I simply subtract an arbitrary offset that should dislocate the pointer to somewhere in the middle of theskbuf->dataspray.
Now that we have a pointer to a skbuf->data object in kmalloc-cfg-512, we need to somehow write it to our controllable msg_msgseg in order to free it. While many handy data spray techniques seem to be dead by now, I ended up coming across a (seemly novel) object that could do the job.
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;
}The alt_ifname object is a temporary buffer where user data is stored with an arbitrary size, allocated with GFP_KERNEL_ACCOUNT. Keep in mind that rtnl_alt_ifname is only called from contexts where the rtnl lock is acquired, so spawning multiple threads to race and spray the temporary buffer won't work, so we have to make sure the object we want to overwrite is first on the freelist.
One way of doing this is incrementally allocating other msg_msgseg objects and checking our corrupted msg_msgseg using MSG_COPY to read it without freeing, allowing me to known which of the the new msg_msgseg objects just overlapped the target one, and then replace it with the alt_ifname buffer, finally allowing me to point msg_msgseg->next to a skbuf->data object in kmalloc-cg-512 and free it.
kmalloc-cg-512 is already much better than kmalloc-cg-128. We can now overlap a msg_msg with our skbuf->data object. If we send another message in the same queue as this msg_msg object, we'll get a pointer to the second message in the first qword of the first message, so by sending a message that gets allocated on kmalloc-cg-1k we can leak a pointer by reading our skbuf->data. If we now slightly increment the pointer, it will point to a different msg_msg allocated by our spray, which is owned by a different queue, so we now have a duplicated reference to a msg_msg in kmalloc-cg-1k that we can use to get double-free. Manipulating msg_msg in that fashion has been covered in detail here: https://google.github.io/security-research/pocs/linux/cve-2021-22555/writeup.html
Once we get to kmalloc-cg-1k we can overlap skbuf->data with pipe_buffer to control the pipe_buffer->page pointer to achieve physical read and write, this technique is thoroughly discussed in https://www.interruptlabs.co.uk/articles/pipe-buffer.
Using this primitive, I overwrite the modprobe_path string to point it to a memory file created with memfd_create, under /proc/<pid>/fd/<n>. I also bruteforce the pid of the exploit process outside of the sandboxed namespace. This has all been very well described in https://pwning.tech/nftables/#4-techniques