Skip to content

[Bug] Malicious virtio backend can trigger guest-kernel memory corruption and packet-borne data disclosure via unvalidated used-ring metadata #11327

@XueDugu

Description

@XueDugu

RT-Thread Version

master, commit 2b58dec87b , Also in released tags: - v5.0.0 - v5.0.2 - v5.1.0 - v5.2.0 - v5.2.1 - v5.2.2

Hardware Type/Architectures

Any RT-Thread guest running with virtio devices

Develop Toolchain

GCC

Describe the bug

RT-Thread contains a family of high-impact trust-boundary bugs in multiple virtio guest drivers. Several drivers directly trust device-controlled used->ring[].id and, in one case, used->ring[].len, and use them as local array indices or derived copy lengths without validating them first.

I confirmed the issue in at least the following functions:

  • components/drivers/virtio/virtio_input.c -> virtio_input_isr()
  • components/drivers/virtio/virtio_console.c -> virtio_console_isr()
  • components/drivers/virtio/virtio_gpu.c -> virtio_gpu_isr()
  • components/drivers/virtio/virtio_blk.c -> virtio_blk_isr()
  • components/drivers/virtio/virtio_net.c -> virtio_net_rx()

Representative examples

1. Unvalidated used-ring id leads to guest-kernel OOB accesses

In virtio_input_isr():

rt_uint16_t id = event_queue->used->ring[event_queue->used_idx % event_queue->num].id;
struct virtio_input_event *recv_events = &virtio_input_dev->recv_events[id];
struct virtio_input_event *bcst_events = &virtio_input_dev->bcst_events[id];

There is no check that id < event_queue->num, and no check that id stays within the fixed local arrays. The same trust pattern exists in:

  • virtio_console_isr() using virtio_console_dev->info[id]
  • virtio_gpu_isr() using virtio_gpu_dev->info[id]
  • virtio_blk_isr() using virtio_blk_dev->info[id]

This means a malicious backend can inject a forged used-ring completion entry and force the guest kernel to perform out-of-bounds reads or writes in interrupt context.

2. virtio_net_rx() also has a backend-controlled length underflow

In virtio_net_rx():

id = (queue_rx->used->ring[queue_rx->used_idx % queue_rx->num].id + 1) % queue_rx->num;
len = queue_rx->used->ring[queue_rx->used_idx % queue_rx->num].len - VIRTIO_NET_HDR_SIZE;

If the backend returns used.len < VIRTIO_NET_HDR_SIZE, len underflows as an unsigned integer. It is then only capped against VIRTIO_NET_PAYLOAD_MAX_SIZE, not against the actual RX data descriptor length:

rt_memcpy(p->payload, (void *)queue_rx->desc[id].addr - PV_OFFSET, len);

The RX queue is initialized so that the second descriptor only exposes VIRTIO_NET_MSS bytes of RX data payload. Therefore this copy can read beyond the advertised RX descriptor payload and over-copy adjacent guest kernel memory into the newly allocated pbuf.

This is not only a local crash bug. The resulting pbuf is forwarded into the normal RT-Thread/lwIP receive path via eth_device_ready() and netif->input(). In other words, the guest can be coerced into packaging adjacent guest-kernel bytes into a received network frame.

Impact

This crosses a real trust boundary. In many deployments, the virtio backend is not equivalent to an omnipotent guest debugger; it may be a separate backend process, a vhost-user service, a restricted device model, or a less-trusted co-processor. RT-Thread currently assumes that used-ring metadata is well-formed and turns backend-controlled ring entries into guest-kernel out-of-bounds reads and writes.

The impact includes:

  • guest-kernel memory corruption from backend-controlled used.id
  • guest-kernel out-of-bounds reads from backend-controlled used.len
  • interrupt-context corruption of driver state
  • denial of service / guest crash
  • guest-kernel memory disclosure into the guest network stack, potentially observable by unprivileged guest userspace

1. Steps to reproduce the behavior

PoC 1: Deterministic guest-kernel OOB write via forged used-ring id

Target:

  • virtio_gpu_isr() / virtio_blk_isr() / virtio_input_isr()

Steps:

  1. Build an RT-Thread guest with one of the affected virtio drivers enabled, for example virtio_gpu or virtio_input.
  2. Use a custom virtio backend, modified QEMU device model, or other backend instrumentation.
  3. Wait until the guest has initialized the virtqueue.
  4. Forge a used-ring entry for the affected queue with:
    • id >= queue->num (for example 64 for virtio_input, or any clearly out-of-range id)
    • a plausible len
  5. Trigger the queue interrupt.

Expected result:
The guest should reject the malformed used-ring entry.

Actual result:
The guest uses the forged id as a local array index and performs guest-kernel out-of-bounds access. In virtio_gpu_isr() and virtio_blk_isr(), this yields deterministic out-of-bounds writes to guest state (info[id].ctrl_valid = RT_FALSE, info[id].valid = RT_FALSE, etc.).

PoC 2: Guest-kernel memory disclosure into guest userspace via virtio_net_rx()

Target:

  • virtio_net_rx()

Steps:

  1. Build an RT-Thread guest with virtio_net and lwIP enabled.
  2. Start a simple UDP receiver inside the guest, listening on a known port.
  3. In the backend, return a forged RX used-ring completion with:
    • used.id = 0
    • used.len = 0 or any value < VIRTIO_NET_HDR_SIZE
  4. Fill the RX data descriptor with a valid Ethernet + IPv4 + UDP packet header and a controlled payload prefix. For IPv4, set UDP checksum to 0 so the stack will accept the packet without needing a checksum over the unknown leaked tail.
  5. Trigger the RX interrupt.

Expected result:
The guest should reject the malformed completion because the reported length is smaller than the virtio-net header.

Actual result:
used.len underflows, RT-Thread over-copies beyond the advertised RX descriptor payload, and the extra bytes are included in the resulting pbuf. Those bytes are then delivered into the guest network stack and can be observed by the guest UDP receiver as attacker-induced leakage of adjacent guest-kernel memory.

2. Expected behavior

All virtio guest drivers should treat used-ring metadata as untrusted backend input. They should validate:

  • used.id < queue->num
  • used.id is valid for the local driver state array being indexed
  • used.len >= minimum protocol header size
  • used.len does not exceed the actual descriptor payload range associated with that completion

Malformed completions should be rejected safely instead of being turned into guest-kernel memory access.

Fix suggestion

The fix should harden each affected virtio RX / completion path against untrusted used-ring metadata instead of assuming backend-provided ids and lengths are well-formed.

In components/drivers/virtio/virtio_input.c, update virtio_input_isr() so that it validates the used-ring entry before indexing any local arrays. Right after reading id and len, add checks that id < event_queue->num, id < RT_ARRAY_SIZE(virtio_input_dev->recv_events), and id < RT_ARRAY_SIZE(virtio_input_dev->bcst_events). If any check fails, log the malformed completion, advance used_idx, and drop the entry without dereferencing recv_events[id] or bcst_events[id]. A safe pattern is:

rt_uint16_t id = event_queue->used->ring[event_queue->used_idx % event_queue->num].id;
rt_uint32_t len = event_queue->used->ring[event_queue->used_idx % event_queue->num].len;

if (id >= event_queue->num ||
    id >= RT_ARRAY_SIZE(virtio_input_dev->recv_events) ||
    id >= RT_ARRAY_SIZE(virtio_input_dev->bcst_events))
{
    rt_kprintf("%s: invalid used ring id %u (queue size %u)\n",
               dev_name, id, event_queue->num);
    event_queue->used_idx++;
    continue;
}

if (len != sizeof(struct virtio_input_event))
{
    rt_kprintf("%s: invalid event length %u\n", dev_name, len);
    event_queue->used_idx++;
    continue;
}

In components/drivers/virtio/virtio_console.c, update virtio_console_isr() in both the control-RX path and the per-port RX path. Before accessing virtio_console_dev->info[id].rx_ctrl or port_dev->info[id].rx_char, validate that id < queue_rx->num and that id < RT_ARRAY_SIZE(virtio_console_dev->info) or id < RT_ARRAY_SIZE(port_dev->info) respectively. If invalid, consume the malformed used entry and drop it instead of touching guest memory. The same style of validation should be added to the queue handling around:

  • virtio_console_dev->info[id].rx_ctrl
  • port_dev->info[id].rx_char

In components/drivers/virtio/virtio_gpu.c, update virtio_gpu_isr() so that every id read from queue_ctrl->used->ring[...] or queue_cursor->used->ring[...] is validated before use. Specifically require id < queue_ctrl->num and id < RT_ARRAY_SIZE(virtio_gpu_dev->info) before writing virtio_gpu_dev->info[id].ctrl_valid = RT_FALSE, and require id < queue_cursor->num and id < RT_ARRAY_SIZE(virtio_gpu_dev->info) before writing cursor_valid. If invalid, log and discard the malformed completion entry.

In components/drivers/virtio/virtio_blk.c, update virtio_blk_isr() so that the backend-controlled completion id is validated before accessing virtio_blk_dev->info[id]. A minimal safe guard is:

id = queue->used->ring[queue->used_idx % queue->num].id;

if (id >= queue->num || id >= RT_ARRAY_SIZE(virtio_blk_dev->info))
{
    rt_kprintf("%s: invalid used ring id %u (queue size %u)\n",
               dev_name, id, queue->num);
    queue->used_idx++;
    continue;
}

In components/drivers/virtio/virtio_net.c, harden virtio_net_rx() against both forged ids and forged lengths. Do not derive the data descriptor id or payload length until the original used entry is validated. The code should validate:

  • head_id < queue_rx->num
  • head_id is the expected RX chain head (for the current layout this should be an even descriptor id)
  • data_id = head_id + 1 does not overflow and stays < queue_rx->num
  • used_len >= VIRTIO_NET_HDR_SIZE
  • payload_len = used_len - VIRTIO_NET_HDR_SIZE
  • payload_len <= queue_rx->desc[data_id].len
  • payload_len <= VIRTIO_NET_MSS

A concrete hardened version is:

rt_uint16_t head_id;
rt_uint16_t data_id;
rt_uint32_t used_len;
rt_uint32_t payload_len;

head_id = queue_rx->used->ring[queue_rx->used_idx % queue_rx->num].id;
used_len = queue_rx->used->ring[queue_rx->used_idx % queue_rx->num].len;

if (head_id >= queue_rx->num || (head_id & 0x1))
{
    rt_kprintf("%s: invalid RX head id %u (queue size %u)\n",
               virtio_net_dev->parent.parent.parent.name, head_id, queue_rx->num);
    queue_rx->used_idx++;
    return RT_NULL;
}

data_id = head_id + 1;
if (data_id >= queue_rx->num)
{
    rt_kprintf("%s: invalid RX data id %u\n",
               virtio_net_dev->parent.parent.parent.name, data_id);
    queue_rx->used_idx++;
    return RT_NULL;
}

if (used_len < VIRTIO_NET_HDR_SIZE)
{
    rt_kprintf("%s: invalid RX used length %u (< hdr %u)\n",
               virtio_net_dev->parent.parent.parent.name,
               used_len, VIRTIO_NET_HDR_SIZE);
    queue_rx->used_idx++;
    return RT_NULL;
}

payload_len = used_len - VIRTIO_NET_HDR_SIZE;
if (payload_len > queue_rx->desc[data_id].len || payload_len > VIRTIO_NET_MSS)
{
    rt_kprintf("%s: invalid RX payload length %u (desc %u)\n",
               virtio_net_dev->parent.parent.parent.name,
               payload_len, queue_rx->desc[data_id].len);
    queue_rx->used_idx++;
    return RT_NULL;
}

p = pbuf_alloc(PBUF_RAW, payload_len, PBUF_RAM);
if (p != RT_NULL)
{
    rt_memcpy(p->payload, (void *)queue_rx->desc[data_id].addr - PV_OFFSET, payload_len);
    queue_rx->used_idx++;

    /* Re-submit the validated RX chain head, not a derived unchecked id */
    virtio_submit_chain(virtio_dev, VIRTIO_NET_QUEUE_RX, head_id);
    virtio_queue_notify(virtio_dev, VIRTIO_NET_QUEUE_RX);
}

Finally, to avoid the same trust bug recurring in future virtio drivers, components/drivers/virtio/virtio.c or a shared virtio header should add a small common validation helper for used-ring ids and, where applicable, used lengths, and all virtio drivers should switch to that helper instead of open-coding raw used->ring[...] dereferences.

Kindly let me know if you intend to request a CVE ID upon confirmation of the vulnerability.

Other additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions