Skip to content

nimble/transport: Fix double free in hci_h4_sm_completed()#2266

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

nimble/transport: Fix double free in hci_h4_sm_completed()#2266
94xhn wants to merge 1 commit into
apache:masterfrom
94xhn:fix/hci-h4-acl-rx-double-free

Conversation

@94xhn

@94xhn 94xhn commented Jul 11, 2026

Copy link
Copy Markdown

What

hci_h4_sm_completed() in nimble/transport/common/hci_h4/src/hci_h4.c
double-frees the received os_mbuf on the ACL/ISO path whenever the
transport's frame_cb() forwards the packet to the host stack and the
host fails to accept it.

Where the bug is

case HCI_H4_ACL:
case HCI_H4_ISO:
    if (h4sm->om) {
        assert(h4sm->frame_cb);
        rc = h4sm->frame_cb(h4sm->pkt_type, h4sm->om);
        if (rc != 0) {
            os_mbuf_free_chain(h4sm->om);
        }
        h4sm->om = NULL;
    }
    break;

hci_h4_sm_completed() is the shared HCI-H4 framing state machine used
by several transports: apollo3, dialog_cmac (host role), uart_ll,
and cdc. For the LL->HS direction, frame_cb() is e.g.
apollo3_ble_hci_frame_cb() / hci_cmac_hs_frame_cb() /
uart_ll's hci_uart_frame_cb(), all of which do for HCI_H4_ACL:

rc = ble_transport_to_hs_acl(data);
return rc;

which reaches ble_transport_to_hs_acl_impl() -> ble_hs_rx_data() in
nimble/host/src/ble_hs.c. That function's own doc comment says:

Called when a data packet is received from the controller. This
function consumes the supplied mbuf, regardless of the outcome.

and its body is:

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;

ble_mqueue_put() (nimble/host/src/ble_hs_mqueue.c) fails whenever the
mbuf is not a proper packet-header mbuf:

if (!OS_MBUF_IS_PKTHDR(om)) {
    rc = OS_EINVAL;
    goto err;
}

So on that failure branch om has already been freed by the time
ble_transport_to_hs_acl() returns non-zero to hci_h4_sm_completed(),
which then frees the same pointer again via os_mbuf_free_chain(h4sm->om).

This is the same double-free pattern already reported/fixed in
#2260 / #2265 for ble_hci_emspi_rx_acl(), but living in the shared
hci_h4.c state machine instead, and reachable from three different
transports (apollo3, dialog_cmac host role, uart_ll) rather than a
single one. It is a distinct bug location/fix from #2265 (different
file, different function, no overlap in the diff), so this is filed as
a separate PR rather than folded into #2265.

Why the HS->LL branch is unaffected, and why CMD/EVT is untouched

For the reverse (HS->LL) direction used by uart, cdc, and
dialog_cmac's controller role, frame_cb() for ACL forwards to
ble_transport_to_ll_acl() -> ble_ll_hci_acl_rx()
(nimble/controller/src/ble_ll_hci.c), which always returns 0 and
always takes ownership of om itself (either handing it to
ble_ll_acl_data_in() or freeing it directly). So removing the
conditional free is safe for every currently-existing frame_cb()
implementation reached through this switch case, not just the buggy
ones.

The HCI_H4_CMD/HCI_H4_EVT branch directly above is intentionally
left untouched, because it follows a genuinely different, correct
ownership contract: ble_ll_hci_cmd_rx() has a legitimate path where it
returns non-zero without taking ownership of the buffer (the "command
busy" case, if (ble_npl_event_is_queued(ev)) return BLE_ERR_MEM_CAPACITY;),
so that branch's "free on non-zero return" cleanup is correct as-is.

The fix

Drop the conditional free for the HCI_H4_ACL/HCI_H4_ISO case and
just let frame_cb()'s return value be ignored (ownership was already
transferred, regardless of outcome), mirroring the same "ownership
transferred, do not free again" fix already applied to
ble_hci_emspi_rx_acl() in #2265.

Verification

Wrote a standalone host-side C program
(not included in the PR diff) that re-creates the real struct layouts
(struct os_mbuf / struct os_mbuf_pkthdr, OS_MBUF_IS_PKTHDR) and the
exact control flow of hci_h4_sm_completed() (ACL branch),
ble_hs_rx_data(), and ble_mqueue_put(), using the host's real
malloc()/free():

  • Built an om with om_pkthdr_len = 0 (not a pkthdr mbuf), the exact
    condition ble_mqueue_put() checks and rejects.
  • Current code (buggy): ble_mqueue_put() fails -> ble_hs_rx_data()
    frees om and returns non-zero -> hci_h4_sm_completed() frees the
    same om again. Two free() calls on the same pointer; process
    aborts with STATUS_HEAP_CORRUPTION (0xC0000374).
  • With this fix: exactly one free() call, process exits cleanly
    (exit code 0).
=== hci_h4_sm_completed() double-free repro (USE_BUGGY=1) ===
Allocated om = 00000000007E91B0 (om_pkthdr_len=0 -> OS_MBUF_IS_PKTHDR=0)
  os_mbuf_free_chain(00000000007E91B0) -> free()
  os_mbuf_free_chain(00000000007E91B0) -> free()
LASTEXITCODE = -1073740940 (0xC0000374, STATUS_HEAP_CORRUPTION)

=== hci_h4_sm_completed() double-free repro (USE_BUGGY=0) ===
Allocated om = 0000000000709850 (om_pkthdr_len=0 -> OS_MBUF_IS_PKTHDR=0)
  os_mbuf_free_chain(0000000000709850) -> free()
hci_h4_sm_completed() returned, total free() calls = 1
OK: om was freed exactly once.
EXIT CODE: 0

Disclosure

This fix, its root-cause analysis, and the accompanying reproduction
program were produced with the assistance of an AI coding agent
(Claude). The analysis was independently verified against the sources
in this repository and the change was reviewed and submitted by the
human author.

hci_h4_sm_completed() (nimble/transport/common/hci_h4/src/hci_h4.c)
is the shared HCI-H4 framing state machine used by several
transports (apollo3, dialog_cmac host role, uart_ll, cdc). Once an
ACL/ISO frame is fully received it does:

  rc = h4sm->frame_cb(h4sm->pkt_type, h4sm->om);
  if (rc != 0) {
      os_mbuf_free_chain(h4sm->om);
  }
  h4sm->om = NULL;

For the LL->HS direction (apollo3_ble_hci_frame_cb,
hci_cmac_hs_frame_cb, uart_ll's hci_uart_frame_cb) frame_cb()
forwards ACL data to ble_transport_to_hs_acl(), which reaches
ble_hs_rx_data() in nimble/host/src/ble_hs.c. That function is
documented to "consume the supplied mbuf, regardless of the
outcome": on ble_mqueue_put() failure it already frees om via
os_mbuf_free_chain() before returning a non-zero status.

hci_h4_sm_completed() then frees the same h4sm->om a second time,
corrupting the mbuf pool. This is the same double-free pattern
already fixed for ble_hci_emspi_rx_acl() in PR apache#2265 / issue apache#2260,
but here it lives in the shared state machine and is reachable from
apollo3, dialog_cmac (host role) and uart_ll instead of a single
transport.

The other frame_cb() implementations reached from this same switch
case (ble_ll_hci_acl_rx()/ble_ll_hci_iso_rx() in the controller, for
the HS->LL direction used by uart/cdc/dialog_cmac controller role)
always return 0 and always take ownership of om themselves, so
removing the conditional free does not change behavior for them.
The HCI_H4_CMD/HCI_H4_EVT branch right above is intentionally left
untouched: ble_ll_hci_cmd_rx() can legitimately return non-zero
*without* freeing the buffer (the "command busy" path), so that
branch's cleanup-on-error is correct as-is and follows a different
ownership contract than the ACL/ISO one.

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

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.

1 participant