nvec: Use-After-Free in nvec_rx_completed() (CVE-2026-72489)

A use-after-free in the nvec staging driver's I2C receive completion handler. When an incomplete transfer is detected, nvec_rx_completed() calls nvec_msg_free() to mark the current RX message slot as available in the shared pool, then immediately reads nvec->rx->data[0] from that same slot to check whether to signal the waiter. A concurrent nvec_msg_alloc() caller can claim the slot the instant nvec_msg_free() clears the used flag, overwriting data[] before the read. The fix is to snapshot the message type byte before calling nvec_msg_free().

1. Background

I found the bug while reading through nvec_rx_completed() in nvec.c — the receive completion handler in the nvec staging driver (drivers/staging/nvec/), which speaks I2C to the NVIDIA Embedded Controller on Tegra platforms — looking at whether message pool operations were being done in the right order relative to any subsequent reads from the same slot.

They weren't.

2. The message pool

The nvec driver maintains a static pool of 64 nvec_msg structs inside the nvec_chip struct. Each slot has an atomic_t used field that acts as the sole allocator flag:

/* nvec.h */
struct nvec_msg {
    struct list_head node;
    unsigned char data[NVEC_MSG_SIZE];
    unsigned short size;
    unsigned short pos;
    atomic_t used;
};

nvec_msg_alloc() claims a free slot with a lockless CAS:

/* nvec.c */
static struct nvec_msg *nvec_msg_alloc(struct nvec_chip *nvec,
                                       enum nvec_msg_category category)
{
    int i = (category == NVEC_MSG_TX) ? (NVEC_POOL_SIZE / 4) : 0;

    for (; i < NVEC_POOL_SIZE; i++) {
        if (atomic_xchg(&nvec->msg_pool[i].used, 1) == 0)
            return &nvec->msg_pool[i];
    }
    return NULL;
}

nvec_msg_free() does not heap-free anything. It marks a slot available for immediate reuse:

/* nvec.c */
static void nvec_msg_free(struct nvec_chip *nvec, struct nvec_msg *msg)
{
    if (msg != &nvec->tx_scratch)
        atomic_set(&msg->used, 0);
}

Once atomic_set(&msg->used, 0) executes, any CPU running nvec_msg_alloc() can claim the slot and start writing new data into its data[] array.

3. The vulnerable code

nvec_rx_completed() is called from the I2C transfer completion path when a receive operation finishes. When the received data is shorter than the expected message size — nvec->rx->pos != nvec_msg_size(nvec->rx) — the driver handles an incomplete transfer. There is a documented quirk: battery subsystem messages (NVEC_BAT) are often incomplete, and skipping the completion signal there causes the EC round-trip to hang. So even for incomplete battery messages, the driver must call complete(&nvec->ec_transfer) to unblock the waiter.

Before the fix, the code did this:

/* nvec.c — nvec_rx_completed(), before the fix */
static void nvec_rx_completed(struct nvec_chip *nvec)
{
    if (nvec->rx->pos != nvec_msg_size(nvec->rx)) {
        dev_err(nvec->dev, "RX incomplete: ...\n");

        nvec_msg_free(nvec, nvec->rx);   /* atomic_set(&msg->used, 0) — slot reclaimable */
        nvec->state = 0;

        /* Battery quirk - Often incomplete, and likes to crash */
        if (nvec->rx->data[0] == NVEC_BAT)   /* ← UAF: slot may already be reallocated */
            complete(&nvec->ec_transfer);

        return;
    }
    ...
}

The check on the last line reads nvec->rx->data[0] from a slot that was freed two lines earlier. The pointer nvec->rx still points to the same pool location, but that location is now fair game for any concurrent allocator.

4. The race window

The race opens at the atomic_set(&msg->used, 0) inside nvec_msg_free() and closes at the nvec->rx->data[0] read two instructions later. Any concurrent path calling nvec_msg_alloc() can win this window — the typical trigger is another I2C receive completing on a different CPU and claiming the just-freed slot for its own incoming bytes.

If the slot is reallocated between the free and the read, data[0] holds whatever the new transfer wrote first — not the type byte of the original partial battery message. nvec_rx_completed() then decides whether to call complete(&nvec->ec_transfer) based on a byte that belongs to a different message. The EC command waiter either stalls until the 5-second timeout fires, or wakes up prematurely with the wrong transfer state.

  • Missed completion: the message was from the battery subsystem (NVEC_BAT), but the recycled byte is not NVEC_BAT. The complete() call is skipped. The EC command waiter times out after 5 seconds, leaving the driver's internal state out of sync with the EC.
  • Spurious completion: the message was not from the battery, but the recycled byte happens to equal NVEC_BAT. complete(&nvec->ec_transfer) is called for the wrong message, waking a waiter that will now process stale or corrupted data.

5. The fix

The fix snapshots data[0] into a local variable before calling nvec_msg_free(), while the slot is still owned by the current call:

 static void nvec_rx_completed(struct nvec_chip *nvec)
 {
     if (nvec->rx->pos != nvec_msg_size(nvec->rx)) {
+        unsigned char msg_type = nvec->rx->data[0];

         nvec_msg_free(nvec, nvec->rx);
         nvec->state = 0;

         /* Battery quirk - Often incomplete, and likes to crash */
-        if (nvec->rx->data[0] == NVEC_BAT)
+        if (msg_type == NVEC_BAT)
             complete(&nvec->ec_transfer);

         return;
     }
     ...
 }

Once msg_type is captured, the slot can be recycled by a concurrent allocator without affecting the check. The decision to call complete() is now based on data that was read while the slot was still exclusively owned.

6. Patch status

The patch was submitted to the staging tree on 2026-04-27 and merged as upstream commit 26813881181d.

The nvec driver has been in the kernel since the Tegra staging era, so the fix was backported to all active stable trees simultaneously on 2026-07-24:

CVE-2026-72489 was assigned by the kernel security team (NVD, CVSS 8.4 High).

The upstream commit did not carry a Cc: stable tag, so the backports were picked up separately by the stable kernel team. All seven active LTS and current stable branches received the fix in the same release cycle.

Other recent kernel work: a double-free in the ipu7 camera driver (separate post), a heap overflow in the iSCSI target CHAP code (separate post), patch series for the rtl8723bs WiFi driver (separate post), an OOB array access in the mac80211 WiFi stack (separate post), and a heap OOB read in the gdk-pixbuf ICNS loader (separate post).