boostorg / boostorg/mqtt5

heap-use-after-free on cancel()/destruction while the client is connecting — reconnect write_op holds a dangling reference to async_sender

Open
#55 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
207
Forks
25
PR merge metrics
No merged PRs in 30d

Description

### Summary

If an `mqtt_client` has a publish outstanding while it is still in its connect/reconnect loop
(broker unreachable, or a reconnect after a broker drop), then calling `cancel()` and destroying
the client produces a heap-use-after-free. A pending reconnect `write_op` holds a
`prepend_handler>>`; `cancel()` + destruction frees the
`async_sender`, and the subsequent teardown of that handler reads its associated allocator through
the now-dangling reference.

I've reduced it to a deterministic, single-threaded, ~20-line program with no broker and the stock
`noop_logger` — details below, including a teardown matrix that also implicates `cancel()` on its
own (independently of destruction).

### Environment

- Boost **1.90.0** (the relevant `impl/async_sender.hpp` `do_write()`/`cancel()` are identical on
`master` as of 2026-07-24).
- Linux, GCC 12.3, x86-64, AddressSanitizer (`-fsanitize=address`).
- Plaintext TCP (`asio::ip::tcp::socket`), stock `noop_logger`, **single-threaded**, `async_run`
called exactly once. No broker required.

### Minimal reproducer (deterministic, no broker, single thread)

```cpp
#include
#include
#include
#include
namespace asio = boost::asio;
namespace mqtt5 = boost::mqtt5;

int main() {
asio::io_context ioc;
auto client = std::make_unique>(ioc.get_executor());

client->brokers("127.0.0.1", 1).async_run(asio::detached); // nothing listening on :1

client->async_publish(
"t", "payload", mqtt5::retain_e::no, mqtt5::publish_props{}, [](mqtt5::error_code) {});

// Tear down on the io_context's own thread while still connecting.
asio::steady_timer timer(ioc, std::chrono::milliseconds(500));
timer.async_wait([&](boost::system::error_code) {
client->cancel();
client.reset();
ioc.stop();
});

ioc.run();
}
```

Build & run:

```
g++ -std=c++20 -fsanitize=address -g -I repro.cpp -o repro -lpthread
./repro # aborts with the ASan report below, every run
```

### AddressSanitizer output

Verbatim from the reproducer above (Boost 1.90, GCC 12.3, `-fsanitize=address`, `-O0`). Template
arguments are elided as `<…>` for readability and file:line anchors are exact; the full untrimmed
log is attached at the end.

```
==ERROR: AddressSanitizer: heap-use-after-free on address 0x507000000340 ... thread T0
READ of size 8 at 0x507000000340 thread T0
#0 asio::associator>>,
vector>>::get(...) boost/asio/impl/prepend.hpp:154
#2 mqtt5::detail::write_op,
prepend_handler>, vector>>::get_allocator() const
boost/mqtt5/impl/write_op.hpp:46
#4 asio::associator<…, prepend_handler::on_reconnect>>::get(...) boost/asio/impl/prepend.hpp:161
#6 asio::detail::any_completion_handler_impl<
prepend_handler::on_reconnect>>::deallocate(...) boost/asio/any_completion_handler.hpp:167
#12 asio::detail::any_completion_handler_impl<
async_mutex::tracked_op::on_locked, …>>::destroy() boost/asio/any_completion_handler.hpp:115
#16 mqtt5::detail::async_mutex::cancel()::{lambda()#1}::~cancel() boost/mqtt5/detail/async_mutex.hpp:164
...
#25 main repro.cpp (scope exit / ~io_context)

freed by thread T0 here:
#1 asio::aligned_delete(void*) boost/asio/detail/memory.hpp:143
#3 recycling of the write_op<…> region boost/asio/detail/recycling_allocator.hpp:82
...
#24 mqtt5::detail::async_mutex::cancel() boost/mqtt5/detail/async_mutex.hpp:164
#25 asio::detail::executor_function::complete
boost/asio/detail/executor_function.hpp:115

previously allocated by thread T0 here:
#1 asio (thread_info_base) boost/asio/detail/memory.hpp:107
... (write_op / async_sender / client_service region) boost/asio/detail/recycling_allocator.hpp:53

SUMMARY: AddressSanitizer: heap-use-after-free boost/asio/impl/prepend.hpp:154
in asio::associator>, vector>>::get
```

Note both the **use** (`#16`) and the **free** (`#24`) go through `async_mutex::cancel()`
(`async_mutex.hpp:164`): `cancel()` posts a completion that tears down the mutex's waiting
reconnect/write ops; freeing one op's region leaves the reconnect `write_op`'s
`reference_wrapper` dangling, and the read surfaces when `~io_context` destroys that
abandoned completion at scope exit. This is why the fault does not depend on the client destructor
running first — see the teardown matrix below.

### Root cause (code-level)

`impl/async_sender.hpp` `do_write()` binds the write completion with a **non-owning reference** to
the sender:

```cpp
_svc._stream.async_write(buffers, asio::prepend(std::ref(*this), std::move(write_queue)));
```

and `cancel()` only aborts the *queued* requests, never the in-flight/reconnect write op:

```cpp
void cancel() {
auto ops = std::move(_write_queue);
for (auto& op : ops)
op.complete_post(_svc.get_executor(), asio::error::operation_aborted);
}
```

When the `client_service` (which owns the `async_sender`) is destroyed after `cancel()` while a
reconnect `write_op` is still outstanding, that op's `prepend_handler` — carrying
`std::reference_wrapper` — is destroyed afterward, and querying its associated
allocator dereferences the freed sender.

### Trigger characterization (teardown matrix)

I swept teardown strategies against two states — **dead** (broker unreachable, client stuck
connecting; deterministic) and **drop** (broker accepts then closes, forcing reconnect churn;
timing-dependent). Single-threaded, all calls on the client's own executor:

| teardown | dead | drop (churn) |
|-----------------------------------------------------|---------|--------------|
| `cancel(); destroy` | **UAF** | UAF (race) |
| `io_context::stop(); destroy` | **UAF** | UAF (race) |
| `cancel(); pump io_context; destroy` | clean | **UAF** |
| `async_disconnect(); pump; destroy` | leak | **UAF** |
| `cancel(); pump; stop; DO NOT destroy` | clean | **UAF** |
| `stop(); DO NOT cancel, DO NOT destroy` | clean | clean |

Two observations:

1. **`cancel()` is implicated on its own** — under reconnect churn it faults even when the client
is never destroyed (row 5), so this isn't purely a destruction-order problem at the call site.
2. The **only** strategy clean in every state is to stop the `io_context` and neither `cancel()`
nor destroy the client — i.e. there is no way for a caller to safely tear the client down while
a connect/reconnect is in flight. That points at the reconnect `write_op` lifetime rather than
caller misuse.

### Related

- **#54** (BOOST_ASSERT in `assemble_op.hpp` on broker restart) and **#44** (hang after broker
restart over TLS) are adjacent reconnect-teardown issues; this is a distinct, memory-safety one.
- Same destruction-order class as CVE-2025-65503 in the sibling `async_mqtt` library.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by compiling and running the supplied single-threaded ASan reproducer against the MQTT5 headers. Inspect impl/async_sender.hpp, especially do_write() and cancel(), then follow the reconnect teardown through async_mutex.hpp:164 and write_op.hpp:46. Done means the cancel and destruction paths no longer produce the reported use-after-free, including the reconnect scenarios in the teardown matrix.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.