Somnia Disclosed Report

Audit report Somnia Audit Contest

Unbounded data chain creation lead to memory exhaustion and chain halt

Company
Created date
Sep 08 2025

Target

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

Vulnerability Details

Description: Data network is used to receive blocks (DataChainBlockReceived) previously requested throught the protocol network (DataChainBlockRequest), but the protocol allow to send those message directly without having sent any request. Additionally, Somnia lacks epoch upperbound verification which overall open the door for memory exhaustion attack which is in-scope for this audit probably falling under Peer-to-peer network flaws.

This design allows an attacker (any clients, not even need to be an official node or in-committee, just to be able to connect to the data network port) to execute a memory exhaustion attack against any Somnia node. Essentially, the attack goes as follow:

  • Attacker connect to the node on the data network port
  • Send DataChainBlockReceived messages using always a different epoch (simply increment it in a loop)
  • The honest node will accept this block and create a new data chain locally for each message sent
  • Since data_chains map is unbounded, this can be grown to completelly exhaust the node RAM and make it crash. Be aware that data_chains struct is not reset at epoch change, this is permanent until the process is alive.

As indicated a key weakness in the code is the fact that there is no upperbound verification on the epoch that can be passed in the DataChainBlockReceived message, which I'm fully exploiting in this report. Be aware that this report is not a duplicate of my other report (SOMNIAAC-110), the lack of throttling is not mandatory for this attack, if that would be fixed, it would just take more time to complete the attack and exhaust the memory as the message rate that could be sent would be lower; the root cause, mitigation and the attack are different.

Affected Code

void DataChainStore::ReceivedBlock(DataChain::BlockView block) {
  DEBUG_ASSERT(block.Validate());
  DataChain* data_chain = TryGetDataChain(block.data_chain_id);
  if (!data_chain) {
    // We have not created this data chain yet.
    if (block.data_chain_id.data_chain_epoch_number < minimum_epoch_number_to_receive) { //<<<<<<<<<<<<<<<<<<<<<<<---------------------- NO upperbound verification
      // We do not require blocks from this epoch number any more.
      return;
    }

    // Create the data chain.
    CreateDataChain(block.data_chain_id);
    data_chain = &GetDataChain(block.data_chain_id);
  }

  data_chain->ReceivedBlock(std::move(block));
}

void DataChainStore::CreateDataChain(const DataChainId& data_chain_id) {
  RELEASE_ASSERT(!data_chains.count(data_chain_id));
  spdlog::info("Node {} creating data chain {}", AddressToHexString(our_private_keys.GetAddress()),
               data_chain_id);
  data_chains.emplace(data_chain_id,
                      std::make_unique<DataChain>(chain_parameters, storage_database,
                                                  protocol_outgoing_router, epoch_manager,
                                                  data_chain_id, our_private_keys));
}

Impact

  • Memory Exhaustion: data_chains map grows without bound → OOM.
  • Chain halt: Attack orchestrated properly could kill multiple in-commitee nodes with the clear potential of chain halt.
  • Performance Degradation: Wasted CPU cycles and memory by processing valid DataChainBlockReceived messages and fake block which create new data chain and also store it in storage.

Assets: somnia\data_chains\data_chain_store.cc


Classification

Impact Rate: 3/5

Likelihood Rate: 4/5

Severity: High


Recommendations

Remediation:

  • Implement an epoch upper bound verification, such that node reject message having an epoch which is too high.
  • Implement a restriction such that a DataChainBlockReceived is only processed if the node previously requested it, otherwise reject right away.

Be aware that implementing an epoch upper bound verification is not enough, as the attacker could go around this by varying the data_chain_owner instead of the data_chain_epoch_number (this would also make the data chain unique so the node would create a new data chain also), it would basically simply generate a new private key at every loop and reconnect to the peer etc, that would be a bit more tedious, but that would still work, so you need to also think of such scenario in your mitigation.

Validation steps


Evidences

Reproduce:

First, I added some traces into Somnia to get more visibility on the behavior.

void DataChainStore::CreateDataChain(const DataChainId& data_chain_id) {
  RELEASE_ASSERT(!data_chains.count(data_chain_id));
  spdlog::info("Node {} creating data chain {}", AddressToHexString(our_private_keys.GetAddress()),
               data_chain_id);
  data_chains.emplace(data_chain_id,
                      std::make_unique<DataChain>(chain_parameters, storage_database,
                                                  protocol_outgoing_router, epoch_manager,
                                                  data_chain_id, our_private_keys));

+  size_t data_chains_RAM_size_kB = data_chains.size() * (sizeof(DataChainId) + sizeof(DataChain)) / 1024;
+  spdlog::info("EL*****(): CreateDataChain: data_chain_id: {} data_chains size: {}, RAM (kB): {}", data_chain_id, data_chains.size(), data_chains_RAM_size_kB);
}
void PeerDataChainActorShard::ProcessInput(PeerDataChainActorInput&& input) {
  Match(
      std::move(input),
      [&](https://dashboard.hackenproof.com/redirect?url=DataChain::DataChainHeartbeat&& message) {
+        spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --PP-- ");
        data_chain_store.ReceiveDataChainHeartbeat(message);
      },
      [&](https://dashboard.hackenproof.com/redirect?url=DataChain::DataChainBlockRequest&& message) {
+       spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --RR-- ");        	  
        data_chain_store.ReceivedDataChainBlockRequest(
            message, node_actor_manager.data_network.GetAsyncDataNetwork());
      },
      [&](https://dashboard.hackenproof.com/redirect?url=DataChainBlockReceived&& message) {
+        spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --SS-- ");        

        // Deserialise the block.
        DataChain::BlockView block;
        if (!smash::DeserialiseFromBuffer(message.serialised_block, block)) {

+         spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --SS-- INVALID Deserialise ");        
          // This was an invalid block. Drop it.
          return;
        }

        // Validate the block.
        if (!block.Validate()) {

+         spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --SS-- INVALID Validate ");        
          return;
        }

+       spdlog::info("EL*****():PeerDataChainActorShard::ProcessInput() --SS-- GOOD BLOCK! ");        
        // Store the block ourselves.
        data_chain_store.ReceivedBlock(block);
      },
	  ...

We need also the Smash desarilization fixed otherwise block would get rejected. See report SOMNIAAC-111 to understand why.

struct DataChain {
  // This is the canonical block data for a data chain block.
  struct BlockView {
    DataChainId data_chain_id;
    DataChainBlockNumber block_number = 0;
    Hash parent_hash;
    Hash block_hash;
    FrontendResources data_chain_block_resources;
    // This is the total resources from all data chain blocks including this one.
    FrontendResources total_resources_committed;
    ecdsa::Signature signature;
-    ByteSpanConst data;
+    std::vector<std::uint8_t> data;
    ...

Then I used the following program in order to connect to the peer (node 0) and send it DataChainBlockReceived message once the localchain is running.

  • Rename the attached file to simple_data_client.cc and copy it inside somnia\node folder.
  • Add the following target in the corresponding BUILD file
cc_binary(
    name = "simple_data_client",
    srcs = ["simple_data_client.cc"],
    deps = [
        ":node",
        "//somnia/test:in_process_deployment",
    ],
)
  • First run the localnet --> NETWORK_PRESET=mainnet-small ./ci/run-local-deployment.sh
  • After, build and run the attack --> bazel run //somnia/node:simple_data_client

Results: Looking at the screenshot uploaded, we can see the message getting processed fully and accepted (in storage too) creating a new data chain confirming the issue mention in the report. We can also observe another consequence is that other nodes then seems are receiving DataChainBlockRequest message (shown by the trace PeerDataChainActorShard::ProcessInput() --RR-- in the screenshot), which is usually not happening and cause resource waste for nothing.

Here one sample from the screenshot create a new data chain for epoch 89, while the current epoch of the main chain is still at 0 at that specific moment.

[N0] [2025-09-08 17:40:38.244] [peer_dc_1      ] [info] EL*****():PeerDataChainActorShard::ProcessInput() --SS--
[N0] [2025-09-08 17:40:38.244] [peer_dc_1      ] [info] EL*****():PeerDataChainActorShard::ProcessInput() --SS-- GOOD BLOCK!
[N0] [2025-09-08 17:40:38.245] [peer_dc_1      ] [info] Node 0xd1d8a091d3644d1a8ee6b995939bf85c41215b6f creating data chain DataChain(owner: 0xe5970658bd1bd8061eaf20604379da73a9eea80c, epoch: 89)
[N0] [2025-09-08 17:40:38.245] [peer_dc_1      ] [info] EL*****(): CreateDataChain: data_chain_id: DataChain(owner: 0xe5970658bd1bd8061eaf20604379da73a9eea80c, epoch: 89) data_chains size: 91, RAM (kB): 1     113

Attachments

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