Somnia Disclosed Report

Audit report Somnia Audit Contest

Somnia - Node Crash via Bitset Capacity Violation

Company
Created date
Sep 18 2025

Target

https://github.com/hackenproof-public/somnia

Vulnerability Details

Somnia - Node Crash via Bitset Capacity Violation

Description

A vulnerability exists in the Bitset deserialization mechanism that allows an attacker to crash any Somnia node through a malicious quorum signature with manipulated participant data. This attack exploits the absence of bounds validation during network deserialization, enabling the creation of bitsets that contain valid bit indexes exceeding their declared capacity, ultimately triggering a fatal assertion in the epoch management system.

Technical Analysis

Bitset Structure and Design

The Bitset implementation uses a capacity-based design where:

struct Bitset {
  std::size_t capacity = 0;           // Maximum number of elements
  std::size_t num_cells = 0;          // Number of 64-bit cells
  std::vector<std::uint64_t> data;    // Actual bit storage
};

Each std::uint64_t cell can represent 64 individual bits, meaning a single cell can store indexes from 0 to 63. The capacity field defines the logical upper bound for valid bit indexes.

Vulnerability Root Cause

The critical flaw exists in the network deserialization path where the Decode implementation fails to validate the relationship between declared capacity and actual bit data. (Another way to look at it is that the ForEachTrue function does not check if the index is within the capacity.)

Vulnerable Deserialization Code:

  FORCE_INLINE static bool Decode(WireDecoder& decoder, WireDecoderCursor& cursor, Bitset& value) {
    // Decode the capacity.
    std::uint32_t capacity;
    if (!SmashTypeTrait<std::uint32_t>::Decode<AssumeCompileTimeSizeSpace,
                                               MustDecodeCompileTimeSize>(decoder, cursor,
                                                                          capacity)) {
      return false;
    }

    // Deserialise the cells before making the bitset.
    std::size_t num_cells = Bitset::NumCellsForCapacity(capacity);
    if (!decoder.HasEnoughBytes<AssumeCompileTimeSizeSpace>(cursor,
                                                            num_cells * sizeof(std::uint64_t))) {
      return false;
    }

    // We have enough data. Allocate the bitset.
    value = Bitset{capacity};
    DEBUG_ASSERT(value.GetNumCells() == num_cells);

    // And copy the data.
    PotentiallyZeroMemcpy(value.GetCellData().data(),
                          decoder.ReadBytes(cursor, num_cells * sizeof(std::uint64_t)),
                          num_cells * sizeof(std::uint64_t));

    // <- NO VALIDATION OF BIT CONTENTS
    return true;
  }

Missing Validation: The decoder trusts the raw bit data without verifying that no bits are set beyond the declared capacity boundary.

Attack Methodology

Step-by-Step Attack Flow

  1. Message Construction

    • Attacker crafts a malicious RequestMessage containing AdvanceDataChainTransaction
    • Transaction includes QuorumSignature with manipulated participants Bitset
    • Bitset declares capacity equal to the committee size (e.g., 10) but contains bits set beyond this limit
  2. Network Transmission

    • Malicious message is broadcasted to the entire network
    • Node receives and deserializes the Bitset through Decode()
    • No validation occurs during deserialization process
    • The message is processed by the ConsensusManagerPbftInterface::AddRequestToProposalPool function and added to received_advancement_transactions (The QuorumSignature is not validated yet in this stage)
  3. Signature Verification Trigger

    • When PbftNode::Tick is called and TryProposeRequestBatch is called, the node will attempt to verify the QuorumSignature
    • Verification process calls participants.ForEachTrue()
    • ForEachTrue() iterates through all bits in all cells via ProcessCell()
  4. Bounds Violation

    • ProcessCell() finds set bits beyond the declared capacity
    • Calls verification functor with replica_index > capacity
    • Verification attempts epoch_data.GetNumVotes(replica_index)
  5. Fatal Assertion

    • GetNumVotes() calls RELEASE_ASSERT(replica_index < committee_nodes.size())
    • Since replica_index exceeds valid committee bounds, assertion fails
    • Result: Node terminates immediately via abort()

Attack Vector Diagram

Malicious Bitset: capacity=10, but bit 63 is set
   ┌─────────────────────────────────────────────────────────────────┐
   │  Cell 0: [bits 0-63] - bit 63 is maliciously set to 1           │
   └─────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
ForEachTrue() → ProcessCell() → functor(replica_index=63)
                                    │
                                    ▼
GetNumVotes(63) → RELEASE_ASSERT(63 < committee_size) → CRASH

Impact

This vulnerability enables remote denial of service attacks against Somnia nodes. This can halt the entire network as sending this message to the entire validator set is possible and not hard.

For a network that:

capable of processing over 1,000,000 transactions per second

I believe that even though there is no funds at stake, such long period of down time will result in economic damage to the value of the network itself which will translate to loss of funds. For that reason, I think it should be considered Critical

Recommendation

Bitset Bounds Validation

Implement comprehensive validation during Bitset deserialization to ensure no bits are set beyond declared capacity.

Iterator Bounds Checking

Modify ProcessCell() to enforce capacity limits during iteration:

  void ProcessCell(const F& functor, size_t cell_index, uint64_t cell_value) const {
    static_assert(std::is_invocable_r_v<void, F, std::size_t>);
    while (cell_value != 0) {
      uint64_t t = KeepLeastSignificantBit(cell_value);
      auto trailing_zeroes = NumTrailingZeros(cell_value);
      std::size_t index = (Bitset::kBitsPerCell * cell_index) + trailing_zeroes;
      if (index < capacity) {
        functor(index);
      }
      cell_value ^= t;
    }
  }

Validation steps

Proof of Concept

Test Case Implementation

Add the following test to the smash_test.cc file:

TEST(SmashTest, BitsetDecodeTest) {
  std::vector<std::uint8_t> data = {
    0x0a, 0x00, 0x00, 0x00, // capacity = 10
    0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff // first cell
  };
  Bitset bitset;
  smash::DeserialiseFromBuffer(data, bitset);

  std::uint64_t counter = 0;
  bitset.ForEachTrue([&](https://dashboard.hackenproof.com/redirect?url=std::size_t index) {
    counter++;
  });

  std::cout << "Counter:" << counter << std::endl;

  ASSERT_GT(counter, bitset.GetNumElements());
}

Execution Instructions

bazel test --config no-avx -s //somnia/smash:smash_test --test_output=all --test_filter="SmashTest.BitsetDecodeTest"

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
High
Bounty$8,544
Visibilitypartially
VulnerabilityBlockchain
Participants (3)
company admin
author
triage team