深入分析 vsock 写空间唤醒机制

记一次 Kernel 5.10 中绕过 sk_buff 导致的 Use-After-Free 与流控死锁问题修复

Table of Contents

本文记录了一次在 Linux 内核(5.10 版本)中排查 vsock 网络栈深层 Bug 的全过程。起因是为 vsock 实现 FIONREAD ioctl 时,意外触发了底层 struct sock (sk) 的 Use-After-Free (UAF) 漏洞。经过源码分析发现,由于 vsock 绕过了标准的 sk_buff 机制,导致 sk_wmem_alloc 无法正确统计 in-flight 数据包,进而使 sk 提前被释放。然而,在尝试手动补全 sk_wmem_alloc 计数以修复 UAF 时,却无意中打破了标准网络栈判断写空间(Write Space)的低水位逻辑。这导致高吞吐场景下,底层状态更新与 EPOLLOUT 事件唤醒发生了致命的时序错位,最终引发发送端永久阻塞的死锁。本文将结合内核源码,带你从底层彻底理清 struct sock 的生命周期与事件唤醒机制。

sk 延迟释放

当用户进程调用 socket() 创建套接字时,内核会分配一个 struct socket(简称 sock),并在其初始化阶段调用 sk_alloc() 来创建底层的 struct sock(简称 sk)。这两者的核心区别在于:sock 是面向 VFS(虚拟文件系统)的接口层,负责向上对用户态提供统一的 Socket API 抽象;而 sk 则是面向协议栈的内部网络引擎,负责处理具体的协议逻辑与连接状态(例如维护 TCP 状态机、管理收发缓冲区等)。

在 Linux 内核网络栈中,struct sock (sk) 的生命周期由两个关键的计数器共同管理:

  • sk->sk_refcnt:表示 socket 的逻辑引用次数。sock_hold() 会使计数加 1,而 sock_put() 会使其减 1。
  • sk->sk_wmem_alloc:表示当前 socket 上已分配的向外发送数据包(通常是 sk_buff)占用的总内存大小。为了防止在初始化阶段或毫无数据时被意外释放,它在 sk 创建时会被默认置为 1(即带有一个基础的偏移量)。

当用户空间关闭 socket 时,vsock 会通过 vsock_release() 触发底层的清理逻辑,最终调用 sock_put() 递减引用计数:

static void __vsock_release(struct sock *sk, int level)
{
if (sk) {
// .. snipped
release_sock(sk);
sock_put(sk);
}
}
static int vsock_release(struct socket *sock)
{
__vsock_release(sock->sk, 0);
sock->sk = NULL;
sock->state = SS_FREE;
return 0;
}

sock_put()sk->sk_refcnt 减至 0 时(即逻辑上已无引用),它会调用 sk_free() 尝试释放 sk。但为了确保内存安全,sk_free() 会进一步检查 sk->sk_wmem_alloc。只有当 sk_wmem_alloc 减去初始的 1 之后等于 0 时,内核才会调用 __sk_free() 真正销毁这块内存。

这意味着,sk 想要被安全释放,必须同时满足两个条件:

  • 逻辑上未被引用(sk_refcnt 归零)。
  • 物理上没有在途中(in-flight)的数据包(sk_wmem_alloc 减去初始值 1 后归零)。
static inline void sock_put(struct sock *sk)
{
if (refcount_dec_and_test(&sk->sk_refcnt))
sk_free(sk);
}
void sk_free(struct sock *sk)
{
if (refcount_dec_and_test(&sk->sk_wmem_alloc))
__sk_free(sk);
}
static void __sk_free(struct sock *sk)
{
if (likely(sk->sk_net_refcnt))
sock_inuse_add(sock_net(sk), -1);
if (unlikely(sk->sk_net_refcnt && sock_diag_has_destroy_listeners(sk)))
sock_diag_broadcast_destroy(sk);
else
sk_destruct(sk);
}

然而,在 Kernel 5.10 版本的 vsock 实现中,存在一个致命的隐患:它的数据包管理使用的是自定义的 struct virtio_vsock_pkt *,完全绕过了标准网络栈的 sk_buff 机制。这导致了一个严重后果:sk_wmem_alloc 根本不会统计 vsock 的 in-flight 数据包。因此,当 sk_refcnt 归零触发 sk_free() 时,内核会误以为所有数据包都已处理完毕,直接调用 __sk_free() 立即释放 sk。

在过去,这个设计缺陷一直“相安无事”,因为底层在处理这些残留的 vsock packet 时,恰好不需要访问已经处于关闭状态的 sk 字段。直到我为 vsock 实现了 FIONREAD ioctl 功能——它要求在处理数据包时,必须实时读取和更新 sk 中的 unread_bytes 字段。正是这个新增的内存访问需求,无意中踩中了 sk 被提前释放的陷阱,从而彻底引爆了 Use-After-Free 问题。

wmem_alloc 与 polling

为了实现 sk 的延迟释放,我们在创建 pkt 时将 pkt 的总长度(包含 buffer 长度)累加到 sk->sk_wmem_alloc 中,并在 pkt 释放时扣除该数值。

该方案在多数场景下运行正常,但在进行从 Guest 向 Host 传输 10G 数据的压力测试时暴露了问题:当 vsock 发送缓冲区(buffer)耗尽,发送进程进入睡眠等待状态后,即使对端消费了数据,该进程也无法被再次唤醒(未能触发预期的 EPOLLOUT 事件)。

Vsock stream 实现了一套基于 credit(信用额度)的流控算法。在写入数据时,内核会调用 vsock_stream_has_space() 检查当前是否具备可写条件;若空间不足,发送进程将调用 wait_woken() 进入阻塞状态,等待后续被唤醒。

当前可写空间的计算由三个核心变量共同决定:对端分配的接收缓冲区总大小(peer_buf_alloc)、本地已发送的总数据量(tx_cnt),以及对端已接收的数据量(peer_fwd_cnt)。其中,(tx_cnt - peer_fwd_cnt) 也就是当前在途(in-flight)数据包所占用的长度。

static s64 virtio_transport_has_space(struct vsock_sock *vsk)
{
struct virtio_vsock_sock *vvs = vsk->trans;
s64 bytes;
bytes = vvs->peer_buf_alloc - (vvs->tx_cnt - vvs->peer_fwd_cnt);
if (bytes < 0)
bytes = 0;
return bytes;
}
s64 virtio_transport_stream_has_space(struct vsock_sock *vsk)
{
struct virtio_vsock_sock *vvs = vsk->trans;
s64 bytes;
spin_lock_bh(&vvs->tx_lock);
bytes = virtio_transport_has_space(vsk);
spin_unlock_bh(&vvs->tx_lock);
return bytes;
}
EXPORT_SYMBOL_GPL(virtio_transport_stream_has_space);

当接收端处理数据包时,会根据包头(header)携带的信息更新上述流控状态。如果计算后确认对端缓冲区释放了可用空间,系统将触发 sk->sk_write_space() 回调函数,尝试唤醒处于阻塞等待中的发送进程。

导致无法写入的根因在于状态更新与唤醒机制的时序错位。sk->sk_wmem_alloc 的扣除更新发生在数据包被释放的阶段(即调用 virtio_transport_free_pkt() 时),然而,sk_write_space() 回调函数的触发时机却早于数据包的释放。这导致回调函数在执行时,读取到的是尚未扣减的 sk_wmem_alloc 值。受此旧值影响,sock_def_write_space() 会误判当前发送缓冲区空间依然不足,从而放弃唤醒阻塞队列中的等待进程,最终造成永久性的写入阻塞。

void virtio_transport_recv_pkt(struct virtio_transport *t,
struct virtio_vsock_pkt *pkt)
{
// ... snipped
space_available = virtio_transport_space_update(sk, pkt);
if (space_available)
sk->sk_write_space(sk);
switch (sk->sk_state) {
case TCP_LISTEN:
virtio_transport_recv_listen(sk, pkt, t);
virtio_transport_free_pkt(pkt);
break;
case TCP_SYN_SENT:
virtio_transport_recv_connecting(sk, pkt);
virtio_transport_free_pkt(pkt);
break;
case TCP_ESTABLISHED:
virtio_transport_recv_connected(sk, pkt);
break;
case TCP_CLOSING:
virtio_transport_recv_disconnecting(sk, pkt);
virtio_transport_free_pkt(pkt);
break;
default:
(void)virtio_transport_reset_no_sock(t, pkt);
virtio_transport_free_pkt(pkt);
break;
}
// ... snipped
}

POLLOUT 事件的触发机制

在 socket 初始化阶段(参见 sock_init_data_uid()),sk->sk_write_space 回调函数被默认配置为 sock_def_write_space()。vsock 直接复用了标准网络协议栈的这一唤醒逻辑,并未进行额外的重写。

static void sock_def_write_space(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
if ((refcount_read(&sk->sk_wmem_alloc) << 1) <= READ_ONCE(sk->sk_sndbuf)) {
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait, EPOLLOUT |
EPOLLWRNORM | EPOLLWRBAND);
if (sock_writeable(sk))
sk_wake_async(sk, SOCK_WAKE_SPACE, POLL_OUT);
}
rcu_read_unlock();
}

观察 sock_def_write_space() 的实现可以发现,内核只有在满足特定条件时才会唤醒等待进程,即:当前已分配的写内存(sk_wmem_alloc)的两倍,必须小于或等于最大发送缓冲区(sndbuf)的大小。

内核采用这种类似“低水位(Low Watermark)”设计的核心原因在于性能优化:如果只要释放出极小的可写空间就立刻唤醒进程,应用层可能会迅速耗尽这点空间并再次陷入阻塞。这种设计有效避免了因频繁的短时唤醒而导致的大量上下文切换开销。

以 epoll 机制为例,当上述空间判定条件成立时,内核会调用 wake_up_interruptible_sync_poll() 触发唤醒流程。该函数会将当前 socket fd 对应的 epitem 结构体挂载到 epoll 的就绪队列(rdllist / rdllink)中,进而唤醒正阻塞在 epoll_wait() 上的用户进程,向其投递 EPOLLOUT 等可写事件。

All rights reserved
Except where otherwise noted, content on this page is copyrighted.