https://github.com/hackenproof-public/somnia
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:
DataChainBlockReceived messages using always a different epoch (simply increment it in a loop)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
data_chains map grows without bound → OOM.Assets:
somnia\data_chains\data_chain_store.cc
Impact Rate: 3/5
Likelihood Rate: 4/5
Severity: High
Remediation:
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.
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.
simple_data_client.cc and copy it inside somnia\node folder.cc_binary(
name = "simple_data_client",
srcs = ["simple_data_client.cc"],
deps = [
":node",
"//somnia/test:in_process_deployment",
],
)
NETWORK_PRESET=mainnet-small ./ci/run-local-deployment.shbazel run //somnia/node:simple_data_clientResults:
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