Skip to content

nimble/transport: Fix double free in ble_hci_emspi_rx_acl()#2265

Open
94xhn wants to merge 1 commit into
apache:masterfrom
94xhn:fix/emspi-acl-rx-double-free
Open

nimble/transport: Fix double free in ble_hci_emspi_rx_acl()#2265
94xhn wants to merge 1 commit into
apache:masterfrom
94xhn:fix/emspi-acl-rx-double-free

Conversation

@94xhn

@94xhn 94xhn commented Jul 11, 2026

Copy link
Copy Markdown

Fixes #2260

Problem

ble_hci_emspi_rx_acl() (nimble/transport/emspi/src/ble_hci_emspi.c)
allocates an mbuf, fills it with the received ACL packet, and hands it
to the host with:

rc = ble_transport_to_hs_acl(om);
if (rc != 0) {
    goto err;
}

return 0;

err:
    os_mbuf_free_chain(om);
    return rc;

ble_transport_to_hs_acl() is a thin wrapper
(nimble/transport/src/monitor.c) around
ble_transport_to_hs_acl_impl(), which in the host build resolves to
ble_hs_rx_data() in nimble/host/src/ble_hs.c. That function is
explicitly documented to consume the mbuf regardless of outcome:

/* ... This function consumes the supplied mbuf, regardless of the
 * outcome. */
static int
ble_hs_rx_data(struct os_mbuf *om, void *arg)
{
    ...
    rc = ble_mqueue_put(&ble_hs_rx_q, ble_hs_evq, om);
    if (rc != 0) {
        os_mbuf_free_chain(om);
        return BLE_HS_EOS;
    }
    return 0;
}

So when ble_mqueue_put() fails (queue full / low memory), om is
already freed by the time control returns to
ble_hci_emspi_rx_acl(). The goto err there then calls
os_mbuf_free_chain(om) a second time on the same pointer — a double
free of the mbuf pool block.

I checked every other caller of ble_transport_to_hs_acl() /
ble_transport_to_ll_acl() in the tree (apollo3, uart, uart_ll, usb,
socket, nrf5340, dialog_cmac, hci_ipc,
nimble/controller/src/ble_ll_conn.c) — none of them free om again
after handing it off; they only propagate the return code (some with
a /* we dont free the mbuf since we handed it off! */ comment).
ble_hci_emspi is the only transport that violates this convention.

Fix

Once ownership of om has moved to the host via
ble_transport_to_hs_acl(), return its status directly instead of
falling through to the shared err: label. The earlier error paths
(SPI header read failure, oversized length, SPI payload read failure)
are unaffected — those still goto err and free om, since ownership
has not been transferred yet at that point.

Verification

This transport depends on the external Ambiq Apollo3 SDK
(am_mcu_apollo.h, not part of this repo or of mynewt-core) and has
no existing unit-test package, so it cannot be built with newt on a
plain host. To verify the bug and the fix without hardware, I wrote a
standalone, host-compilable C program (reproduced in full below) that
extracts the exact control flow of ble_hci_emspi_rx_acl() and
ble_hs_rx_data() verbatim and drives it against a real
malloc()/free()-backed mbuf, simulating ble_mqueue_put()
failing:

  • Before the fix (current master logic): os_mbuf_free_chain()
    is called twice on the same pointer; the process crashes with
    STATUS_HEAP_CORRUPTION (0xC0000374) on Windows (glibc would abort
    with "double free or corruption" the same way).
  • After the fix: exactly one free occurs and the program exits
    cleanly (status 0).
Reproduction program (compile with -DBUGGY to see the crash, without it to see the fix)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>

struct os_mbuf {
    uint8_t  om_data[64];
    uint16_t om_len;
    uint16_t om_pktlen;
};

#define OS_MBUF_PKTLEN(om)          ((om)->om_pktlen)
#define BLE_HCI_DATA_HDR_SZ         4
#define BLE_TRANSPORT_ACL_SIZE      251
#define BLE_ERR_UNSPECIFIED         0x1f
#define BLE_HS_EOS                  14

static int g_free_calls = 0;

static void
os_mbuf_free_chain(struct os_mbuf *om)
{
    g_free_calls++;
    printf("  -> os_mbuf_free_chain(om=%p)  [call #%d]\n", (void *)om, g_free_calls);
    free(om);
}

static int g_fail_mqueue_put = 0;
static int
ble_mqueue_put(struct os_mbuf *om)
{
    (void)om;
    return g_fail_mqueue_put ? -1 : 0;
}

/* Verbatim logic of nimble/host/src/ble_hs.c:ble_hs_rx_data() */
static int
ble_hs_rx_data(struct os_mbuf *om, void *arg)
{
    int rc;
    (void)arg;

    rc = ble_mqueue_put(om);
    if (rc != 0) {
        os_mbuf_free_chain(om);
        return BLE_HS_EOS;
    }
    return 0;
}

static int
ble_transport_to_hs_acl(struct os_mbuf *om)
{
    return ble_hs_rx_data(om, NULL);
}

static struct os_mbuf *
ble_transport_alloc_acl_from_ll(void)
{
    struct os_mbuf *om = calloc(1, sizeof(*om));
    assert(om != NULL);
    return om;
}

static uint16_t
get_le16(const uint8_t *buf)
{
    return (uint16_t)(buf[0] | (buf[1] << 8));
}

static int
ble_hci_emspi_rx(uint8_t *data, int max_len)
{
    (void)data;
    (void)max_len;
    return 0;
}

#if defined(BUGGY)
/* Verbatim copy of ble_hci_emspi_rx_acl() as it exists on master today. */
static int
ble_hci_emspi_rx_acl(void)
{
    struct os_mbuf *om;
    uint16_t len;
    int rc;

    om = ble_transport_alloc_acl_from_ll();
    assert(om != NULL);

    rc = ble_hci_emspi_rx(om->om_data, BLE_HCI_DATA_HDR_SZ);
    if (rc != 0) {
        goto err;
    }

    len = get_le16(om->om_data + 2);
    if (len > BLE_TRANSPORT_ACL_SIZE) {
        rc = BLE_ERR_UNSPECIFIED;
        goto err;
    }

    if (len > 0) {
        rc = ble_hci_emspi_rx(om->om_data + BLE_HCI_DATA_HDR_SZ, len);
        if (rc != 0) {
            goto err;
        }
    }

    OS_MBUF_PKTLEN(om) = BLE_HCI_DATA_HDR_SZ + len;
    om->om_len = BLE_HCI_DATA_HDR_SZ + len;

    rc = ble_transport_to_hs_acl(om);
    if (rc != 0) {
        goto err;               /* BUG: om already freed */
    }

    free(om);
    return 0;

err:
    os_mbuf_free_chain(om);
    return rc;
}
#else
/* Suggested fix. */
static int
ble_hci_emspi_rx_acl(void)
{
    struct os_mbuf *om;
    uint16_t len;
    int rc;

    om = ble_transport_alloc_acl_from_ll();
    assert(om != NULL);

    rc = ble_hci_emspi_rx(om->om_data, BLE_HCI_DATA_HDR_SZ);
    if (rc != 0) {
        goto err;
    }

    len = get_le16(om->om_data + 2);
    if (len > BLE_TRANSPORT_ACL_SIZE) {
        rc = BLE_ERR_UNSPECIFIED;
        goto err;
    }

    if (len > 0) {
        rc = ble_hci_emspi_rx(om->om_data + BLE_HCI_DATA_HDR_SZ, len);
        if (rc != 0) {
            goto err;
        }
    }

    OS_MBUF_PKTLEN(om) = BLE_HCI_DATA_HDR_SZ + len;
    om->om_len = BLE_HCI_DATA_HDR_SZ + len;

    return ble_transport_to_hs_acl(om); /* FIX: do not free again */

err:
    os_mbuf_free_chain(om);
    return rc;
}
#endif

int
main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);
    g_fail_mqueue_put = 1;

    int rc = ble_hci_emspi_rx_acl();

    printf("ble_hci_emspi_rx_acl() returned rc=%d\n", rc);
    printf("Total free() calls on the same om: %d\n", g_free_calls);
    return (g_free_calls > 1) ? 1 : 0;
}
$ gcc -DBUGGY -o repro_buggy repro.c && ./repro_buggy
  -> os_mbuf_free_chain(om=0x...)  [call #1]
  -> os_mbuf_free_chain(om=0x...)  [call #2]
<process aborts: STATUS_HEAP_CORRUPTION / glibc "double free or corruption">

$ gcc -o repro_fixed repro.c && ./repro_fixed
  -> os_mbuf_free_chain(om=0x...)  [call #1]
ble_hci_emspi_rx_acl() returned rc=14
Total free() calls on the same om: 1
$ echo $?
0

Disclosure

I want to be upfront: this fix was found and drafted with the
assistance of an AI coding agent (Claude), including the root-cause
analysis, the cross-transport comparison, and the standalone
reproduction program above. I (the human submitter) reviewed the
analysis against the current master source, independently compiled
and ran the reproduction on my own machine to confirm the crash/fix
behavior, and I stand behind the change. Happy to answer any
questions about it.

ble_hci_emspi_rx_acl() calls ble_transport_to_hs_acl(om) once the ACL
packet has been fully received, and jumps to the shared `err` label
(which calls os_mbuf_free_chain(om)) whenever that call returns a
non-zero status.

ble_transport_to_hs_acl() forwards to ble_hs_rx_data(), which is
documented to "consume the supplied mbuf, regardless of the outcome":
on failure (e.g. ble_mqueue_put() hitting capacity) it already frees
`om` via os_mbuf_free_chain() before returning an error. The `goto
err` in ble_hci_emspi_rx_acl() then frees the same `om` a second time,
corrupting the mbuf pool under low-memory or queue-full conditions.

Every other transport that calls ble_transport_to_hs_acl() /
ble_transport_to_ll_acl() (apollo3, uart, uart_ll, usb, socket,
nrf5340, dialog_cmac, hci_ipc, controller/ble_ll_conn.c) already
follows the "ownership transferred, do not free again" rule and
simply propagates the return code. Make ble_hci_emspi_rx_acl() do the
same by returning directly instead of falling through to the `err`
path once ownership has moved to the host.

Verified the double free with a standalone host-side program that
reproduces the exact control flow of ble_hci_emspi_rx_acl() and
ble_hs_rx_data() against a real malloc()/free()-backed mbuf: the
current code calls free() twice on the same pointer and the process
aborts with STATUS_HEAP_CORRUPTION (0xC0000374); with this change
applied only a single free() occurs and the process exits cleanly.

Disclosure: this fix, its analysis and the accompanying reproduction
program were produced with the assistance of an AI coding agent
(Claude), reviewed and submitted by the human author.

Signed-off-by: 94xhn <87560781+94xhn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Double free in ble_hci_emspi_rx_acl() on host ACL delivery failure

1 participant