Skip to content

codec, client: bound decode and reject pre-INIT monitor data - #180

Open
physwkim wants to merge 3 commits into
epics-base:masterfrom
physwkim:fix/remote-triggerable-crashes
Open

physwkim wants to merge 3 commits into
epics-base:masterfrom
physwkim:fix/remote-triggerable-crashes

Conversation

@physwkim

Copy link
Copy Markdown
Contributor

Thanks for merging the earlier fixes. Three more I ran into while reading the
decode and client monitor paths, all remote-triggerable crashes. Numbers 1 and
2 are reachable during CONNECTION_VALIDATION, before auth.

1. codec: bound value-decode recursion
The depth guard in from_wire() is reset every time an Any value is decoded
(the inner type is read with a fresh descriptor), so it never bounds the
value-decode chain. A peer value that is a run of nested Any (one 0x82 byte
each) recurses one level per byte and overflows the stack. The SIGSEGV is not a
std::exception, so the dispatch try/catch misses it and the whole process
goes down. Fix threads a single maxNestingDepth across the Any boundary so
the bound holds for both type and value decode.

2. codec: bound decode allocations to the remaining body
An array length / struct child count is a peer-supplied 32-bit value, used to
allocate (POD) or default-construct every Value (StructA/UnionA/AnyA) before
the element bytes are read. A short frame claiming ~4.29e9 elements commits the
memory first, then faults on the short read. Fix caps the count against the
bytes still available (new Buffer::maxAvail()), since each element needs at
least one wire byte.

3. client: reject a MONITOR data message before the INIT reply
handle_MONITOR() decodes the payload before checking operation state, but
info->fl stays null until the INIT reply. A peer that has acked
CREATE_CHANNEL can send a data frame before INIT and the client dereferences
the null free-list. Fix moves the state check ahead of the decode.

Each has a regression test (testxcode, testclientconn). Built and run on
macOS/arm64.

physwkim added 3 commits May 25, 2026 19:33
The type-description depth guard in from_wire(.., depth) was reset to 0
each time an Any *value* was decoded, because the inner type is read with
a fresh descriptor.  It therefore did not bound the value-decode call
chain from_wire_field -> from_wire_full -> from_wire_field.  A peer value
that is a chain of nested Any (each encoded as a single 0x82 byte) drove
one recursion level per byte and exhausted the thread stack.  The
resulting stack overflow raises SIGSEGV, which is not a std::exception
and so is not caught by the message-dispatch try/catch, crashing the
whole process.  This is reachable before authentication via
CONNECTION_VALIDATION, and on the client via any reply value.

Make the nesting bound a single global invariant shared by both decoders:
a named maxNestingDepth constant, threaded through from_wire_field /
from_wire_full / from_wire_valid and checked where each recursive descent
re-enters (from_wire for a nested type, from_wire_field for a nested
value), with depth flowing across the Any/AnyA boundary so the type and
value chains share one count.  "Total nesting <= maxNestingDepth" then
holds by construction and bounds both the recursion stack and the
per-message node allocation; plain (flat) struct nesting is unaffected.

Adds a testxcode regression that decodes a long run of 0x82 and asserts
the decode faults instead of recursing without bound.
A peer-supplied wire count (array length or struct child count) is a
32-bit value decoded straight into a shared_array (or reserve) before any
of the matching element bytes are read.  A short frame can therefore
claim ~4.29e9 elements: the POD array path allocates the storage (lazily
reserved on some hosts, bad_alloc on others) and the StructA/UnionA/AnyA
path default-constructs every Value -- committing real memory -- before
the short read finally faults.  This is reachable during the connection
validation handshake, before authentication, so it is an unauthenticated
remote memory-exhaustion vector independent of any frame-size cap.

Bound every such count to the bytes that could still be decoded.  size()
reports only the current pulled-up window, so add Buffer::maxAvail() (the
full remaining body, including bytes not yet pulled from the backing
evbuffer) and fault when the count exceeds it -- every element costs at
least one wire byte, sizeof(C) for a fixed-size POD.  This ties each
allocation to bytes actually received without false-rejecting a large but
legitimate array delivered in multiple segments.

testxcode: a value claiming 1000 elements over a near-empty body must
leave the field empty rather than pre-sized to the claimed count.
handle_MONITOR() decoded the message payload before validating it against
the operation state.  The data branch takes Guard G(info->fl->lock), but
info->fl stays null until the INIT reply has been processed, so a peer
that has acknowledged CREATE_CHANNEL -- the op then sits in opByIOID with
state==Creating and fl==nullptr -- could send a MONITOR data message
before the INIT reply and dereference the null free-list, a
remote-triggerable client crash.

Move the operation-state validation (handle.lock(), op==CMD_MONITOR, and
the subcmd-vs-state check) ahead of the payload-decode block.  A data
message in the Creating state now faults the buffer and the connection is
dropped before any access to info->fl, so "payload is decoded only after
a successful INIT" holds by construction rather than by a trailing check.

The regression test drives a real client against a hand-rolled peer that
completes the handshake through MONITOR INIT and then injects a pre-INIT
data frame; a correct client rejects it and closes the connection.
@physwkim
physwkim force-pushed the fix/remote-triggerable-crashes branch from 54c4965 to c933e23 Compare May 25, 2026 11:22
@mdavidsaver

Copy link
Copy Markdown
Member

I have applied parts 1 and 3 as 1679799 and 5c18e10.

I am still thinking about part 2. PVA was not designed with untrusted peers in mind. So all manner of remote resource exhaustion is possible. I am not sure if mitigating this particular possibility is worth the added complexity.

@physwkim

physwkim commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking 1 and 3.

For 2, I see it a bit more like the same class of issue as 1 than a general resource-exhaustion problem. PVA's support for arbitrary nesting and flexible data structures naturally creates these kinds of edge cases where a very small input can drive disproportionately large work.

I don't think this patch meaningfully changes the overall threat model, but it does close off one of the more obvious and easily reachable cases. My impression from the TLS work is that there is some interest in improving robustness against less-trusted peers, and incrementally addressing these low-hanging cases seems consistent with that goal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants