Cross-Tenant Session Kill in BoxCompute Sandboxes
Disclosure: This advisory was generated by using AI assistance for better clarity
Overview
BoxCompute is a cloud service that gives AI agents their own computers: full Linux VMs (“sandboxes”) that stay on, drivable through a chat agent or the API, with live previews and a browser, VMs hosted in Cyberjaya, Malaysia, with more Southeast Asia regions on the way.
Under the hood each tenant VM talks to the virtualization host over virtio (AF_VSOCK) sockets. During an evaluation period, I found that the sandbox-to-host vsock channel can be attacked from a neighboring tenant’s VM: a guest that shares the virtualization host with a victim can forge vsock packets that get delivered into the victim’s live host-side sessions, killing them.
The impact is availability - cross-tenant denial of service against in-flight work, including API-issued commands. It is not a data exposure issue: the sessions involved are TLS-wrapped end to end, which is exactly why the primitive is a kill, not a read or an inject.
Notes
The defect is in the receiving kernel’s vsock lookup (the host node’s, in the platform’s case - Linux 7.0 class, with older branches behaviorally equivalent), so the review was done in two stages:
- a local QEMU lab with two guests and a forged-packet kernel module, where the delivery path could be instrumented end to end; and
- a live proof on the platform itself, using only two sandboxes I owned - one as the “victim”, one as the “attacker” - with co-location confirmed before firing, single-shot tests, and teardown after each run.
No other tenant’s sandbox was touched at any point. The single-shot evidence below is from the live run; controls were fired on four neighboring ports to rule out coincidence.
Vulnerabilities
1. vsock bound-table fallback delivers foreign packets into established sessions
AF_VSOCK delivery on the receiving side is a two-table lookup. When a packet arrives from the transport, the kernel first tries the connected table, keyed by the full 4-tuple (source cid:port + destination cid:port). On a miss it falls back to the bound table, keyed by destination only:
// net/vmw_vsock/virtio_transport_common.c - virtio_transport_recv_pkt()
/* The socket must be in connected or bound table
* otherwise send reset back
*/
sk = vsock_find_connected_socket_net(&src, &dst, net);
if (!sk) {
sk = vsock_find_bound_socket_net(&dst, net); // <-- destination only!
if (!sk) {
(void)virtio_transport_reset_no_sock(t, skb, net);
goto free_pkt;
}
}
The fallback exists for listening and connecting sockets - but sockets stay
in the bound table after connect() succeeds, so it can also return a
non-listening socket. And the bound-table matcher only ever looks at the
local address:
// net/vmw_vsock/af_vsock.c - __vsock_find_bound_socket_net()
list_for_each_entry(vsk, vsock_bound_sockets(addr), bound_table) {
struct sock *sk = sk_vsock(vsk);
if (vsock_addr_equals_addr(addr, &vsk->local_addr) && ...
return sk; // matches (dst_cid, dst_port) - src is never checked
Nothing in this path compares the packet’s source against the peer the
socket actually connected to. The upstream commit that fixed this
(dee44f41f206, “vsock/virtio: validate packet source for connected
sockets”) describes the same hole from the reporter’s angle: a RESPONSE from
an unrelated source can complete a victim socket’s handshake, and subsequent
data from that source keeps flowing through the destination-only fallback.
Put together, the full kill path from a neighboring guest looks like this:
attacker guest (root, kernel module)
forge virtqueue packet: OP_RW, src=(attacker_cid, any_port), dst=(2, P_victim)
↓ virtqueue
vhost_vsock: src_cid == guest_cid ✓ (only check) → pass
↓
host vsock core: connected-lookup miss (foreign src)
↓ [BUG] dst-only bound-table fallback
match: victim tenant's ESTABLISHED platform socket on (2, P_victim)
↓
13 attacker bytes enter the victim session's stream
↓
host TLS record layer: garbage record → fatal unexpected_message alert
↓
victim sandbox's platform control channel: torn down / wedged 60s+
Aiming the shot: co-location and port discovery, entirely from inside the VM
Before any packet could be fired, two things had to be established: that the two sandboxes shared a virtualization host, and the port of a live host↔victim session. Both fall out of one observation made purely from guest userspace - no platform API, no control-plane access, nothing beyond the rented VM itself.
The host dials into every sandbox on a known vsock port for routine
polling (health, file stat, configure). A guest that simply listens there
and calls accept() sees each inbound connection’s peer address - and the
peer port is a host-side auto-bind port. Running that listener on both
sandboxes at the same time makes the co-location test self-evident:
SB1 accepted dials from host ports: ...728, 730, 738, 748
SB2 accepted dials from host ports: ...743, 744, 751, 760
Both streams interleave on one sequential counter with the same ~2 s cadence - same node, proven. That counter is the node’s single port allocator: every host-initiated vsock connection for every tenant on the node draws its port from it. Observed advancing ~370–650 per 2 s, so live session ports sit inside a ~600-wide moving window a guest can track in real time just by watching its own dials. Probing a dead port costs an RST with no penalty and no alarm - at the time of the report the platform had no vsock-level telemetry on its nodes at all.
So the entire targeting step is: listen, accept, read the peer port, diff two streams for co-location, track the window. The single-shot kill below was aimed with exactly this - and the shared counter is also why the proof was single-shot by necessity: a sprayed port would land in some other tenant’s channel.
Here is what the forged send looks like from the attacking guest (kernel
module, one sendmsg on a raw vsock socket):
forge_bc: single port=2716070736 ret=13
ret=13 is the byte count returned by the forged sendmsg - the 13-byte
marker payload was accepted and delivered. On the victim side, the op that
was mid-flight when the shot landed never completed: it wedged in the TLS
layer for ~60 seconds and then died:
[+0000.12] TARGET port=2716070736 op=configure - holding 25s
[+0085.16] TARGET port=2716070736 REPLY-FAIL age=85.0
[+0085.17] DEATH port=2716070736 age=85.0 SSLError
Four control sessions on neighbouring ports (…744, …754, …762, …770), held through the same window and not fired at, each completed their ops cleanly
- the kill is specific to the port carrying the live session.
2. Blast radius: the platform’s persistent exec channel dies with it
The interesting part is what the platform rides on these sessions. Reverse
engineering the guest-side daemons (a stripped Go supervisor binary) showed
that every sandbox keeps one persistent, boot-established TLS connection
to the host that carries the start_process / wait_process /
read_process_output operations - i.e. the API’s “run this command” path.
Short-lived dials on the same transport carry health, stat and log polling.
When a forged packet kills a session of that family, the tenant-visible symptom is their API commands failing wholesale (gateway 524s) until the platform re-establishes the channel. So the primitive is not “reset one connection” - it is “take the victim’s command-execution path down” from a VM the attacker rents on the same host.
Co-location is the only precondition: the attacker needs to be on the same virtualization host as the victim, which the platform’s bin-packing makes routine. The forged source tuple is fully controlled by the attacker; the victim does not need to initiate anything.
3. Not just this platform: any host with untrusted virtio-vsock guests
The root cause is not BoxCompute’s - it shipped in the kernel when virtio
vsock itself did: commit 06a8fc78367d (“VSOCK: Introduce
virtio_vsock_common.ko”, 2016). Every kernel from that point until
dee44f41f206 delivers packets to connected sockets through a
destination-only fallback with no source validation. That is a decade of
releases, and every stable branch at the time of our report was affected.
The upstream fix itself proves the primitive is not guest-module-only. Its
commit message describes an unprivileged, capability-empty repro under
different UIDs on the same host: the attacker finds the target tuple via
AF_VSOCK sock_diag, and a forged RESPONSE moves the victim socket from
SYN_SENT to ESTABLISHED - after which further forged RW packets keep
arriving through the same fallback. Same bug, different (and easier) entry.
So the exposure set is: any QEMU/KVM deployment using vhost-vsock with guests that are not fully trusted - sandbox platforms obviously, but also Kata Containers-style isolation, Firecracker with vsock devices, CI runners that execute untrusted code in VMs. If your host-initiated vsock sessions carry anything the guest should not be able to kill, check whether your kernel carries the fix before checking anything else.
Fix
The upstream fix adds a source validation helper, called after lock_sock()
in the virtio receive path - a non-listening socket now only accepts packets
whose source matches its stored peer:
// net/vmw_vsock/af_vsock.c - added by dee44f41f206
bool vsock_check_source(const struct vsock_sock *vsk,
const struct vsock_transport *transport,
const struct sockaddr_vm *src)
{
if (vsk->transport != transport)
return false;
if (src->svm_port != vsk->remote_addr.svm_port)
return false;
if (src->svm_cid == vsk->remote_addr.svm_cid)
return true;
return transport->get_local_cid() == VMADDR_CID_LOCAL;
}
BoxCompute deployed the fix after our report - the source-tuple check is now enforced on their nodes, and the forged-packet delivery path is closed. Platform-side hardening worth keeping regardless of kernel version: randomizing the host auto-bind port allocator (the port space was previously guessable, which is what made live session ports cheap to find), and alerting on the RST/timeout differential that a forged-packet kill produces on the host side. Credit to the dev team for the fast turnaround.
Timeline
- 2026-08-26: Daehyeon Ko lands the upstream fix
dee44f41f206(“vsock/virtio: validate packet source for connected sockets”), including an unprivileged same-host repro - independent of, and prior to, our work; full credit for the kernel-side discovery and fix - 2026-10-11: independent discovery during the review; local QEMU lab reproduces the delivery path (two guests, forged-packet module, kernel 7.0)
- 2026-10-12: live single-shot proof on BoxCompute (own sandboxes only, co-location confirmed, four clean controls)
- 2026-10-12: reported to the developer with full technical record and a replication script
- 2026-10-14: fix confirmed deployed; public release authorized
Appendix: reproducing locally
The full campaign kit is not published with this post, but the core of the bug reproduces in a two-VM lab with no access to any cloud platform:
-
Guests: two QEMU instances with
vhost-vsock-pcidevices (guest-cid=10,guest-cid=11), an unpatched kernel (we used a 7.0 tinyconfig build; any pre-dee44f41f206kernel works), and serial consoles for visibility:qemu-system-x86_64 -kernel bzImage -initrd initramfs-A.gz \ -append "console=ttyS0 panic=-1" -m 512 -smp 1 \ -device vhost-vsock-pci,guest-cid=10 \ -nographic -no-reboot -monitor none # second instance: guest-cid=11, initramfs-B.gz - Host: a small listener standing in for the platform’s session endpoint - bind AF_VSOCK CID 2, accept, and watch what arrives.
- Attacker guest: the forge driver - a kernel module that emits a
virtqueue OP_RW packet with a chosen source tuple toward
(2, <listener port>). On an unpatched host kernel the bytes are delivered into the established listener socket; on a fixed kernel they are not. - What to observe: on the victim connection, garbage bytes mid-stream
(TLS listeners die with a fatal
unexpected_messagealert, as on the platform); on a patched host, the forged packet is dropped and nothing changes.