Skip to content

fix: Clamp the depth used to index selectBranch's key byte - #7941

Draft
bthomee wants to merge 1 commit into
bthomee/shamap-stack-03-reject-inner-node-leaf-depthfrom
bthomee/shamap-stack-04-clamp-selectbranch-depth
Draft

fix: Clamp the depth used to index selectBranch's key byte#7941
bthomee wants to merge 1 commit into
bthomee/shamap-stack-03-reject-inner-node-leaf-depthfrom
bthomee/shamap-stack-04-clamp-selectbranch-depth

Conversation

@bthomee

@bthomee bthomee commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Part 4/8 of a stack. Base: part 3 (bthomee/shamap-stack-03-reject-inner-node-leaf-depth).

selectBranch reads the key byte at depth / 2, which is out of bounds for a
32-byte key once the depth reaches 64. Every branch selection in the map
funnels through here, so this is the one place a stray depth can turn into a
bad read. Assert the precondition for callers, then clamp anyway: a wrong
answer for an input that should never occur is better than reading past the
buffer. Verified under ASan that the unclamped form reads one byte past a
32-byte allocation while the clamped form does not.

deserializeSHAMapNodeID gets the same mask check isPrefixOf already
performs, pulled into a shared isPrefixOfAtDepth helper.

Tests cover the depth-sensitivity of isPrefixOf, the guards that must hold
with asserts stripped, that deserializeSHAMapNodeID rejects an out-of-range
depth, and the clamp itself under both build configurations (EXPECT_DEATH
in a forked process when the assert is live, and the clamped result compared
against depth 63 when it is not). The clamp test also gates on
ENABLE_VOIDSTAR, not just NDEBUG: under Antithesis instrumentation
XRPL_ASSERT routes to a handler that records the hit but never aborts, so a
Debug build with voidstar enabled has NDEBUG undefined yet still hits the
same non-fatal assert as a release build, and without this gate would send
that configuration into the EXPECT_DEATH arm, where the forked child never
dies and the test fails, breaking the CI job that runs this suite under
-Dvoidstar=ON.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens SHAMap node branch selection and node-ID deserialization against out-of-range depths, preventing potential out-of-bounds reads on 32-byte keys and centralizing prefix/mask validation logic.

Changes:

  • Add an assertion + runtime clamp in selectBranch to prevent indexing past the end of the key when depth reaches (or exceeds) leaf depth.
  • Factor prefix/mask checking into a shared isPrefixOfAtDepth helper and reuse it in isPrefixOf and deserializeSHAMapNodeID.
  • Add focused unit tests covering prefix depth-sensitivity, deserialization depth rejection, leaf-depth child guards, and the selectBranch clamp behavior under different build configurations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/libxrpl/shamap/SHAMapNodeID.cpp Introduces isPrefixOfAtDepth, tightens deserialization validation, and clamps selectBranch depth to avoid OOB reads.
src/tests/libxrpl/shamap/SHAMapNodeID.cpp Adds new test coverage for SHAMapNodeID prefix behavior, deserialization validation, and selectBranch depth clamping/assert behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

selectBranch(SHAMapNodeID const& id, uint256 const& hash)
{
auto const depth = id.getDepth();
XRPL_ASSERT(id.getDepth() < SHAMap::kLeafDepth, "xrpl::selectBranch : depth below leaf depth");
Comment on lines +124 to +128
#else
// In a debug build the assert is live and must reject this call outright, in a forked
// process so a failure here cannot take down the rest of the suite.
EXPECT_DEATH((void)selectBranch(leafDepthID, kTestKey), "depth below leaf depth");
#endif
@bthomee
bthomee force-pushed the bthomee/shamap-stack-04-clamp-selectbranch-depth branch from 2bed752 to ed28138 Compare August 3, 2026 23:25
@bthomee
bthomee force-pushed the bthomee/shamap-stack-03-reject-inner-node-leaf-depth branch from 24baeb7 to da20f92 Compare August 4, 2026 00:12
@bthomee
bthomee force-pushed the bthomee/shamap-stack-04-clamp-selectbranch-depth branch from ed28138 to 7b7d835 Compare August 4, 2026 00:12
`selectBranch` reads the key byte at `depth / 2`, which is out of bounds for a
32-byte key once the depth reaches 64. Every branch selection in the map
funnels through here, so this is the one place a stray depth can turn into a
bad read. Assert the precondition for callers, then clamp anyway: a wrong
answer for an input that should never occur is better than reading past the
buffer. Verified under ASan that the unclamped form reads one byte past a
32-byte allocation while the clamped form does not.

`depthMask`'s own 65-entry table had the same exposure one level up, reachable
through the public `createID` factory rather than only from inside the map. A
depth past `kLeafDepth` indexed that table out of bounds, confirmed under ASan
as a 4-byte `global-buffer-overflow` immediately after `kMasks`. Both places
that can set a depth now clamp it: the constructor, which is the single point
every `SHAMapNodeID`'s `depth_` passes through, and `createID`, which needs its
own bound because it picks the mask while evaluating the constructor's
argument, before the constructor body could correct anything.

Clamping rather than throwing, which is what `getChildNodeID` does for the
analogous case: `createID` is reached from `getSHAMapNodeID` with a
peer-supplied depth, and two of that function's three callers
(`InboundTransactions::gotData`, `PeerImp::onMessage`) sit on paths with no
handler between them and a thread boundary, so a throw there would end the
process rather than the message. Clamping also has to fix up `id_` alongside
`depth_`, since a node ID whose id and depth disagree fails the invariant every
read of `id_` relies on. Leaving the depth unclamped would additionally let
`getRawString` narrow it to a byte, turning depth 256 into a node claiming to
be the root.

`deserializeSHAMapNodeID` gets the same mask check `isPrefixOf` already
performs, pulled into a shared `isPrefixOfAtDepth` helper, and the masking both
it and `createID` perform is now a named `maskedToDepth` rather than a repeated
bitwise-and.

Tests cover the depth-sensitivity of `isPrefixOf`, the guards that must hold
with asserts stripped, that `deserializeSHAMapNodeID` rejects an out-of-range
depth, and the clamp itself under both build configurations (`EXPECT_DEATH`
in a forked process when the assert is live, and the clamped result compared
against depth 63 when it is not). The clamp test also gates on
`ENABLE_VOIDSTAR`, not just `NDEBUG`: under Antithesis instrumentation
`XRPL_ASSERT` routes to a handler that records the hit but never aborts, so a
Debug build with voidstar enabled has `NDEBUG` undefined yet still hits the
same non-fatal assert as a release build, and without this gate would send
that configuration into the `EXPECT_DEATH` arm, where the forked child never
dies and the test fails, breaking the CI job that runs this suite under
`-Dvoidstar=ON`.
@bthomee
bthomee force-pushed the bthomee/shamap-stack-03-reject-inner-node-leaf-depth branch from da20f92 to 8af43fd Compare August 4, 2026 00:49
@bthomee
bthomee force-pushed the bthomee/shamap-stack-04-clamp-selectbranch-depth branch from 7b7d835 to a09fec0 Compare August 4, 2026 00:49
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