https://github.com/hackenproof-public/somnia
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.
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.
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.
Message Construction
RequestMessage containing AdvanceDataChainTransactionQuorumSignature with manipulated participants BitsetNetwork Transmission
Decode()ConsensusManagerPbftInterface::AddRequestToProposalPool function and added to received_advancement_transactions (The QuorumSignature is not validated yet in this stage)Signature Verification Trigger
PbftNode::Tick is called and TryProposeRequestBatch is called, the node will attempt to verify the QuorumSignatureparticipants.ForEachTrue()ForEachTrue() iterates through all bits in all cells via ProcessCell()Bounds Violation
ProcessCell() finds set bits beyond the declared capacityreplica_index > capacityepoch_data.GetNumVotes(replica_index)Fatal Assertion
GetNumVotes() calls RELEASE_ASSERT(replica_index < committee_nodes.size())replica_index exceeds valid committee bounds, assertion failsabort()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
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
Implement comprehensive validation during Bitset deserialization to ensure no bits are set beyond declared capacity.
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;
}
}
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());
}
bazel test --config no-avx -s //somnia/smash:smash_test --test_output=all --test_filter="SmashTest.BitsetDecodeTest"