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

Labels

size/S Small PR

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

2 participants