From: bugzilla@dpdk.org
To: dev@dpdk.org
Subject: [DPDK/vhost/virtio Bug 2003] vhost double-fetch leading to overflow in virtio_net_ctrl_pop
Date: Fri, 28 Aug 2026 13:09:34 +0000 [thread overview]
Message-ID: <bug-2003-3@https.bugs.dpdk.org/> (raw)
https://bugs.dpdk.org/show_bug.cgi?id=2003
Bug ID: 2003
Summary: vhost double-fetch leading to overflow in
virtio_net_ctrl_pop
Product: DPDK
Version: unspecified
Hardware: All
OS: All
Status: UNCONFIRMED
Severity: normal
Priority: Normal
Component: vhost/virtio
Assignee: dev@dpdk.org
Reporter: thomas@monjalon.net
Target Milestone: ---
Group: security
Report date: 2026-04-24
Reported by: Don Salvatore Nero <hemo40047@gmail.com>
Dear Thomas, Maxime, and the DPDK Security Team,
1. Executive Summary
A double-fetch vulnerability exists in virtio_net_ctrl_pop() in the DPDK
vhost library. The function reads descs[desc_idx].len twice from
guest-controlled shared memory — once to calculate allocation size
(data_len), and once during the actual memcpy() loop — with a malloc() call
in between. A malicious guest can modify the descriptor length between the
two reads, causing memcpy() to write far beyond the allocated buffer,
resulting in a heap buffer overflow with attacker-controlled length and
content.
2. Vulnerability Analysis
Step 1 — Guest-controlled shared memory
From vhost.c lines 482-488 (confirmed from real code):
vq->desc = (struct vring_desc *)(uintptr_t)vhost_iova_to_vva(dev, vq,
vq->ring_addrs.desc_user_addr, // ← address provided by guest &size,
VHOST_ACCESS_RO);
vq->desc is a direct pointer into guest-mapped shared memory. The guest
owns and can modify this memory at any time.
When VRING_DESC_F_INDIRECT is set, the indirect table also comes from guest
memory:
descs = (struct vring_desc *)(uintptr_t)vhost_iova_to_vva(dev, cvq,
desc_iova, &desc_len, VHOST_ACCESS_RO);
No local copy is made of the descriptor table. descs points directly to
guest memory throughout the entire function.
Step 2 — Phase 2: First read of len to compute data_len
From virtio_net_ctrl.c lines 70-105 (confirmed from real code):
while (1) { desc_len = descs[desc_idx].len; // ← READ #1 from guest memory
desc_iova = descs[desc_idx].addr; n_descs++; if (descs[desc_idx].flags &
VRING_DESC_F_WRITE) { // handle ack descriptor ... } else { if
(ctrl_elem->desc_ack) { goto err; } data_len += desc_len; // ← accumulate
size using READ #1 } if (!(descs[desc_idx].flags & VRING_DESC_F_NEXT))
break; desc_idx = descs[desc_idx].next; }
At this point, data_len = sum of all readable descriptor lengths from READ
#1.
Step 3 — malloc() using READ #1 value
From virtio_net_ctrl.c line 124 (confirmed from real code):
ctrl_elem->ctrl_req = malloc(data_len); // ← allocated based on READ #1 if
(!ctrl_elem->ctrl_req) { goto err; } ctrl_req = (uint8_t
*)ctrl_elem->ctrl_req;
The buffer is allocated with the size computed from READ #1.
malloc() is an external function call with side effects — the compiler
cannot cache descs[].len across this call. It must re-read from memory in
Phase 3.
Step 4 — Phase 3: Second read of len with NO bounds check
From virtio_net_ctrl.c lines 144-168 (confirmed from real code):
while (!(descs[desc_idx].flags & VRING_DESC_F_WRITE)) { desc_len =
descs[desc_idx].len; // ← READ #2 from guest memory desc_iova =
descs[desc_idx].addr; desc_addr = vhost_iova_to_vva(dev, cvq, desc_iova,
&desc_len, VHOST_ACCESS_RO); if (!desc_addr || desc_len <
descs[desc_idx].len) { goto free_err; } memcpy(ctrl_req, (void
*)(uintptr_t)desc_addr, desc_len); // ↑ copies desc_len bytes — from READ
#2, not READ #1 // NO check: copied_so_far + desc_len <= data_len ctrl_req
+= desc_len; // ↑ advances pointer by READ #2 value — no upper bound if
(!(descs[desc_idx].flags & VRING_DESC_F_NEXT)) break; desc_idx =
descs[desc_idx].next; }
There is NO check of total bytes copied against data_len.
The only check present is:
if (!desc_addr || desc_len < descs[desc_idx].len)
This only verifies that the IOVA translation succeeded — it does NOT check
that the copy stays within the allocated buffer.
Step 5 — The Race Window
[Thread: DPDK host] [Thread: Malicious Guest] Phase 2: READ #1:
descs[0].len = 4 data_len = 4 malloc(4) ← buffer of 4 bytes allocated
WRITE: descs[0].len = 65535 Phase 3: READ #2: descs[0].len = 65535
memcpy(ctrl_req, src, 65535) ← writes 65535 into 4-byte buffer ctrl_req +=
65535 ← pointer far past end of buffer
The race window is the time between data_len += desc_len (Phase 2) and
memcpy(..., desc_len) (Phase 3), which is widened by the malloc() call
between them.
4. Why the Existing Locks Do NOT Prevent This
From virtio_net_ctrl_handle():
rte_rwlock_read_lock(&dev->cvq->access_lock);
vhost_user_iotlb_rd_lock(dev->cvq);
These locks are intra-process locks — they only prevent concurrent access
between host threads. They provide zero protection against the guest
modifying the shared memory pages, because:
The guest runs in a separate address space (VM or VDUSE consumer process)
The shared memory is mapped via mmap(MAP_SHARED) — the guest has direct
write access
No lock can prevent the guest from writing to its own memory
5. Exploit Scenario
Prerequisites:
DPDK vhost enabled with VDUSE backend (rte_vduse_device_create())
VIRTIO_NET_F_CTRL_VQ enabled (default in VIRTIO_NET_SUPPORTED_FEATURES)
Guest has standard virtio-net driver
Steps:
1. Guest sets up descriptor chain with small len:
desc[0]: addr=X, len=4, flags=NEXT, next=1 desc[1]: addr=Y, len=1,
flags=WRITE (ack descriptor)
2. Host enters Phase 2:
Reads desc[0].len = 4 → data_len = 4
Calls malloc(4) → allocates 4-byte buffer
3. Guest immediately overwrites desc[0].len:
desc[0].len = 0x10000; // 65536 bytes
4. Host enters Phase 3:
Reads desc[0].len = 65536
vhost_iova_to_vva() succeeds (guest controls the memory mapping)
memcpy(ctrl_req, src, 65536) — writes 65536 bytes into 4-byte heap buffer
Heap corruption with attacker-controlled length and content
Impact of Heap Overflow:
Since the DPDK process typically runs with elevated privileges (direct NIC
access, hugepages, DMA), a successful heap overflow leads to:
ImpactDescriptionRCE in host processControl heap layout → overwrite
function pointers or vtablesHost escapeDPDK process has privileged access —
compromise extends to host OSMemory corruptionCorrupt adjacent heap objects
affecting other guest connectionsPrivilege escalationDPDK process
capabilities grant access to hardware resources
6. Code Proof Summary
LocationCodeSignificancevhost.c:482vq->desc = vhost_iova_to_vva(...)descs
points to guest shared memoryvirtio_net_ctrl.c:~75desc_len =
descs[desc_idx].lenREAD #1 from guest memoryvirtio_net_ctrl.c:~80data_len
+= desc_lenAllocation size from READ
#1virtio_net_ctrl.c:~124malloc(data_len)Buffer allocated from READ
#1virtio_net_ctrl.c:~148desc_len = descs[desc_idx].lenREAD #2 from guest
memoryvirtio_net_ctrl.c:~154memcpy(ctrl_req, ..., desc_len)Copy using READ
#2 — no bounds checkvirtio_net_ctrl.c:~156ctrl_req += desc_lenPointer
advance — no upper bound
7. Note on Proof of Concept
I was unable to produce a live PoC due to environment constraints — my
analysis VPS (Ubuntu 22.04) encountered build issues when attempting to
configure a VDUSE server environment for live exploitation testing.
However, the vulnerability is demonstrated entirely through source code
analysis:
The double-fetch pattern is unambiguous in the code
The absence of a cumulative bounds check in Phase 3 is confirmed
The guest-controlled nature of descs[] memory is confirmed via
vhost_iova_to_vva() chain
The race window is real and is widened by the malloc() call between phases
8. Recommended Fix
Option 1 — Copy descriptors locally before processing:
// Make a local copy of the entire descriptor chain ONCE struct vring_desc
local_descs[VIRTIO_MAX_INDIRECT_DESCS]; memcpy(local_descs, descs, n_descs
* sizeof(struct vring_desc)); // Use local_descs in BOTH Phase 2 and Phase
3
Option 2 — Add cumulative bounds check in Phase 3:
uint64_t copied = 0; while (!(descs[desc_idx].flags & VRING_DESC_F_WRITE))
{ desc_len = descs[desc_idx].len; // ADD THIS CHECK: if (copied + desc_len
> data_len) { VHOST_CONFIG_LOG(dev->ifname, ERR, "Descriptor overflow
detected"); goto free_err; } desc_addr = vhost_iova_to_vva(...);
memcpy(ctrl_req, desc_addr, desc_len); ctrl_req += desc_len; copied +=
desc_len; // track total ... }
Option 3 — Use atomic reads:
// Read len once atomically and reuse uint32_t cached_len =
__atomic_load_n(&descs[desc_idx].len, __ATOMIC_ACQUIRE);
--
You are receiving this mail because:
You are the assignee for the bug.
reply other threads:[~2026-08-28 13:09 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=bug-2003-3@https.bugs.dpdk.org/ \
--to=bugzilla@dpdk.org \
--cc=dev@dpdk.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.