[BUG] [Crash Matter >=1.5] Process Abort on Unexpected Message During Chunked List Write

Author: Chapoly1305Created Sep 18, 2026Updated Sep 18, 2026
Labelsbugneeds triage

Summary of Issue

This issue is only affecting Matter 1.5 and later. It occurs when a Matter device is processing a chunked write to a list attribute (e.g., AttributeList, AcceptedCommandList, or GeneratedCommandList). The first request leaves the WriteHandler in an active list-write state. If a subsequent unexpected message arrives on the same exchange (i.e., a non-WriteRequest message), the exchange may be closed before the handler finishes cleaning up its state.

During cleanup, WriteHandler::Close() still attempts to retrieve the accessing FabricIndex from the already-closed exchange. This causes ExchangeHolder::operator->() to trigger VerifyOrDie, resulting in a process abort.

Earlier Matter versions were not affected because the list-write cleanup path did not need to access the ExchangeContext. Before Matter 1.5, DeliverListWriteEnd() only notified the data model that the list write had completed, without retrieving the accessing FabricIndex. Matter 1.5 added GetAccessingFabricIndex() as a new argument to ListAttributeWriteNotification(), which introduced a new dereference of mExchangeCtx during cleanup. The underlying exchange-closing behavior already existed, but it was harmless because the cleanup path did not touch the exchange after it had been closed. The 1.5 change therefore exposed a previously latent lifetime mismatch between WriteHandler and ExchangeContext.


The longer explaination. While the report is organized by AI, the issue is validated on a development board.

Call Flow and Root Cause Analysis

The failure requires two messages on the same Matter exchange. The first message establishes an active chunked list-write transaction. The second, unexpected message causes the underlying exchange to close before WriteHandler has finished cleaning up the first transaction.

The resulting call flow is described below.

Step 1: A session/exchange receives a chunked WriteRequest

A Matter session is already established, and an ExchangeContext receives a WriteRequest.

The request targets a list attribute and sets:

cpp
MoreChunkedMessages = true

Because additional chunks are expected, the WriteHandler remains active after processing this request instead of completing the transaction.

While processing the attribute, WriteHandler records that a list write is in progress:

cpp
mProcessingAttributePath = ...;
kProcessingAttributeIsList = true;

Importantly, this state is established before the actual write authorization check. Therefore, even if the write is later rejected by ACL or because the attribute is not writable, the handler can still remain in the list-write state.

At this point:

Session                alive
ExchangeContext        alive
WriteHandler           alive
List-write state       active
More chunks expected   yes

Step 2: Another message arrives on the same exchange

Because MoreChunkedMessages was set, the same WriteHandler is still associated with the in-progress transaction.

Normally, the next message should be another WriteRequest.

Instead, a non-WriteRequest message arrives on the same exchange.

WriteHandler::OnMessageReceived() detects the unexpected message type and enters its error-handling path:

cpp
StatusResponse::Send(
    Status::InvalidAction,
    apExchangeContext,
    false /* aExpectResponse */);

Close();

The important detail is:

cpp
aExpectResponse = false

This means that the generated StatusResponse does not expect another response from the peer.

Step 3: StatusResponse::Send() causes the exchange to close

Sending the StatusResponse eventually reaches the exchange-layer message handling logic.

Because:

no response is expected
+
no send remains outstanding

the exchange is considered complete.

As a result, the ExchangeContext is closed synchronously during StatusResponse::Send().

Conceptually, the call flow is:

WriteHandler::OnMessageReceived()
    |
    +-- StatusResponse::Send(..., false)
            |
            +-- ExchangeContext::SendMessage()
                    |
                    +-- MessageHandled()
                            |
                            +-- ExchangeContext::Close()

This is the critical lifecycle transition: although WriteHandler::OnMessageReceived() has not yet called its own Close(), the underlying exchange has already been closed.

Step 4: ExchangeHolder is notified that the exchange is closing

WriteHandler does not hold the ExchangeContext as an unconditional raw pointer. It accesses it through an ExchangeHolder.

When the exchange closes, ExchangeHolder::OnExchangeClosing() is invoked and the holder drops its reference to the exchange.

Conceptually:

cpp
ExchangeHolder::OnExchangeClosing(...)
{
    mpExchangeCtx = nullptr;
}

Therefore, after StatusResponse::Send() returns, the state has changed to:

Session                still exists
ExchangeContext        closed
WriteHandler           still alive
mExchangeCtx           empty
List-write state       still active

This is the inconsistent state that leads to the abort.

Step 5: WriteHandler::OnMessageReceived() continues and calls Close()

Control returns from StatusResponse::Send() to WriteHandler::OnMessageReceived().

The next statement is:

cpp
Close();

Therefore:

StatusResponse::Send(...)
        |
        +-- exchange closes
        |
        +-- mExchangeCtx becomes empty
        |
        v
WriteHandler::Close()

WriteHandler::Close() is written under the assumption that its exchange is still available until it explicitly releases it:

cpp
void WriteHandler::Close()
{
    VerifyOrReturn(mState != State::Uninitialized);

    DeliverFinalListWriteEnd(false /* wasSuccessful */);

    mExchangeCtx.Release();

    ...
}

Under normal call paths, this ordering is valid:

Deliver final notification
        ↓
Release exchange

However, on the unexpected-message path, the real ordering has already become:

Exchange closed externally
        ↓
WriteHandler::Close()
        ↓
Deliver final notification

Thus, DeliverFinalListWriteEnd() is executed after the exchange has already disappeared.

Step 6: Cleanup detects that a list write is still active

Because the first WriteRequest established an active list-write state, DeliverFinalListWriteEnd() does not return immediately.

The relevant condition is effectively:

cpp
if (mProcessingAttributePath.HasValue() &&
    kProcessingAttributeIsList)
{
    DeliverListWriteEnd(...);
}

Therefore, WriteHandler::Close() attempts to deliver the final list-write notification for the interrupted transaction.

The call chain becomes:

WriteHandler::Close()
    |
    +-- DeliverFinalListWriteEnd()
            |
            +-- DeliverListWriteEnd()

Step 7: DeliverListWriteEnd() requests the accessing FabricIndex

In affected versions, DeliverListWriteEnd() passes the accessing fabric index to ListAttributeWriteNotification().

Conceptually:

cpp
mDataModelProvider->ListAttributeWriteNotification(
    aPath,
    operation,
    GetAccessingFabricIndex());

This introduces another access to the exchange during cleanup.

The call chain now becomes:

WriteHandler::Close()
    |
    +-- DeliverFinalListWriteEnd()
            |
            +-- DeliverListWriteEnd()
                    |
                    +-- GetAccessingFabricIndex()

Step 8: GetAccessingFabricIndex() dereferences the already-empty ExchangeHolder

GetAccessingFabricIndex() retrieves the fabric information through mExchangeCtx:

cpp
FabricIndex WriteHandler::GetAccessingFabricIndex() const
{
    return mExchangeCtx->GetSessionHandle()->GetFabricIndex();
}

However, mExchangeCtx no longer contains a valid ExchangeContext, because the exchange was already closed in Step 3.

Dereferencing the holder invokes ExchangeHolder::operator->(), which contains a validity assertion equivalent to:

cpp
VerifyOrDie(mpExchangeCtx != nullptr);

At this point:

mpExchangeCtx == nullptr

and therefore:

VerifyOrDie
    ↓
process abort

The observed failure is:

VerifyOrDie failure at src/messaging/ExchangeHolder.h:
mpExchangeCtx != nullptr

exit 134

Complete Failure Sequence

The complete sequence can therefore be summarized as:

CASE Session / ExchangeContext
        |
        |  Chunked WriteRequest
        |  List attribute
        |  MoreChunkedMessages = true
        v
WriteHandler remains active
        |
        |  list-write state is recorded
        |
        v
Unexpected non-WriteRequest message
on the SAME Exchange
        |
        v
WriteHandler::OnMessageReceived()
        |
        v
StatusResponse::Send(InvalidAction, ..., false)
        |
        v
ExchangeContext::MessageHandled()
        |
        v
ExchangeContext::Close()
        |
        v
ExchangeHolder::OnExchangeClosing()
        |
        v
mExchangeCtx becomes empty
        |
        |  return to WriteHandler::OnMessageReceived()
        v
WriteHandler::Close()
        |
        v
DeliverFinalListWriteEnd()
        |
        v
DeliverListWriteEnd()
        |
        v
GetAccessingFabricIndex()
        |
        v
mExchangeCtx->...
        |
        v
ExchangeHolder::operator->()
        |
        v
VerifyOrDie(mpExchangeCtx != nullptr)
        |
        v
PROCESS ABORT

Bug prevalence

always

GitHub hash of the SDK that was being used

e054bb8ac6

Platform

linux

Platform Version(s)

1.5, 1.6

Anything else?

No response

Source: project-chip/connectedhomeip