Somnia Disclosed Report

Audit report Somnia Audit Contest

LeaderViewChangeMessage is not properly checked, and malicious request payloads can be inserted, leading to total network shutdown with fork

Company
Created date
Aug 24 2025

Target

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

Vulnerability Details

Description

When the current leader of a view in PBFT does not respond within a given timeout window, the nodes advance to the next view to get a new leader and continue the consensus process. In this case, all nodes send a LeaderViewChangeMessage to the new leader so that he can use the nodes votes to move to the next view: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L590-L595

The new consensus leader then builds a NewViewMessage, including all the LeaderViewChangeMessages, so that the nodes can verify that the view change has been agreed upon by a 2f+1 majority of nodes. This message is then broadcast to all nodes, allowing them to update their view: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L748-L755

All nodes then attempt to switch to the view in TryEnterNewView: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L765

In this function, GetLockedRequestBatchesForNewView is used to obtain the request batches that should be carried over into the next round. This is determined based on the request batches provided in the LeaderViewChangeMessages that are included in the NewViewMessage. To decide which request batches must be taken in the next view a LeaderViewChangeMessage has three fields:

  • latest_observed_checkpoint (https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_types.h#L472): This is the latest checkpoint of the node that sent the message. It is needed to determine the first sequence number that has not been checkpointed and will probably need to be included in the next view.
  • certificate_rounds_above_checkpointed (https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_types.h#L477): These are the sequence numbers that already have a certificate and probably need to be committed or re-proposed in the next view.
  • preprepared_rounds_above_checkpointed (https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_types.h#L489): These are sequence numbers with views that are currently in the pre-prepare round and are only re-proposed when they have f+1 votes.

In GetLockedRequestBatchesForNewView, the latest_observed_checkpoint field is first used to determine the maximum checkpointed sequence number. At this point, the certificate is validated if necessary: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L983-L986

Then certificate_rounds_above_checkpointed is used to determine the highest view that has a certificate for a given sequence number. At this stage, both the certificate and the underlying request batch are verified: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L1085-L1087

As a last step, preprepared_rounds_above_checkpointed is used to determine the views in the sequence numbers that have more than f+1 votes and should be re-proposed. In this step, the data is not validated, and a malicious validator could include a request batch with arbitrary payloads because the hashes are not checked. Normally, a validator would need f+1 votes for a request batch to be re-proposed, but since the hashes are not verified, the validator could set the request batch hash to a batch that already has more than f+1 votes but with malicious request payloads: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L1111-L1150

One thing an attacker must pay attention to is that their votes must be the ones crossing the f+1 threshold; otherwise, another request batch without a malicious payload will already have been included in proposal_majority_rounds_for_new_view and the attacker’s request batch will not be set: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_view_manager.cc#L1125-L1135

This can be done most efficiently by an attacker who waits to become the consensus leader, because then they can simply order the LeaderViewChangeMessages in NewViewMessage and set their request batch so that it is included in proposal_majority_rounds_for_new_view in GetLockedRequestBatchesForNewView.

After this, the request batch is set for the new round. An attacker could exploit this when they are the consensus leader by sending one half of the nodes a request batch and the other half a request batch with different payloads but the same hash. Since the hashes are not verified until execution, one half of the network will execute different transactions than the other half. This results in divergent blocks and causes the chain to fork. After about 16 blocks, the chain will halt because the sequence commitments included in the blocks no longer match, and no transactions can be executed anymore: https://github.com/hackenproof-public/somnia/blob/5931d305dd6fb02b637efd2fca13e77731d05804/somnia/pbft/pbft_round_manager.cc#L219-L236

Recommendation

The hashes of the request batch should be verified in GetLockedRequestBatchesForNewView when adding a round to proposal_majority_rounds_for_new_view

Validation steps

The following modifications must be applied to the code to run the POC. The reasons for some of the modifications are explained in the comments accompanying the changes: https://gist.github.com/TheSchnilch/a618c01e1b7913e3697495a57396aa13

The POC can be executed with NETWORK_PRESET=mainnet-small ./ci/run-local-deployment.sh

How the POC works

  1. In epoch 3, the first consensus leader proposes 5 request batches and then simulates a timeout.
  2. Sequence number 5 has only one prepare vote because the other nodes have not sent theirs, so it is included in preprepared_rounds_above_checkpointed in the LeaderViewChangeMessage.
  3. The new leader is malicious and adds an additional vote for sequence number 5 in preprepared_rounds_above_checkpointed.
  4. The malicious leader creates two different NewViewMessages: one containing the correct request payloads for sequence number 5, and another with no payloads.
  5. Half of the network receives the first NewViewMessage, while the other half receives the second message.
  6. Due to the different state changes in the two payloads, the resulting blocks differ, causing the chain to fork and finally halting the production of new blocks.

After this the output of the nodes should look like the one in screenshot.

To verify that the chain actually forked this curl request can be used on node http://127.0.0.1:6500 and http://127.0.0.1:6501 to see that the block hashes are different at block 400 (block 400 because we are in epoch 3 and 3*128 + 5 = 389. This means block 400 is one of the forked):

curl -X POST -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x190", false],"id":1}' \
  http://127.0.0.1:6500

Attachments

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