Somnia Disclosed Report

Audit report Somnia Audit Contest

DoS attack due to memory leak in MempoolTransactionQueue::sender_accounts

Company
Created date
Sep 15 2025

Target

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

Vulnerability Details

Description: This is very similar report as SOMNIAAC-163, but reduced likelihood which drop the severity to Low. But not a duplicate as different root cause and mitigation.

MempoolTransactionQueue presents a memory leak which could be exploited by an attacker to degrade node performance (mainly memory pressure) and could potentially OOM and crash the node.

When a transaction is sent by a user and reaches a node RPC, after passing basic validation, it reaches the MempoolTransactionQueue which manages transaction ordering and queuing. The TryPushTransaction method is used to add transactions to the queue.

  1. TryPushTransaction gets called which will create an entry in the map for that sender. As the code shows, it will create a new entry if it doesn't exist.
bool TryPushTransaction(ExecutionThread, MempoolTransaction transaction) {
    // Get or create this account state.
    SenderAccountState& sender_account = sender_accounts[transaction.transaction->from];
    
    // ... validation logic ...
    
    if (transaction_queue.size() >= maximum_queue_size) {
        // We are at the queue size limit. In this case we allow the sender to replace one of their
        // transactions with a lower transaction, if they are able to. Otherwise we fail the push.
        // ...
        return false;  // ← LEAK: entry created but will never be removed!
    }
  1. Later when transactions are processed, entries are removed from the map only when all transactions from a sender are consumed:
void ProcessTransactions(...) {
    // The caller has asked to either pop this transaction, or any transaction from the sender.
    if (sender_account.in_flight_transactions.size() == 1) {
        // There is only one transaction left, so just delete the sender account.
        sender_accounts.erase(sender);
        return;
    }
    // ... other cleanup logic
}

This pattern presents a memory leak in the following case:

  • Any transaction that gets rejected due to queue size limits will create a SenderAccountState entry but never add actual transactions to it, leaving an empty entry that persists forever since the cleanup logic only triggers when processing existing transactions.

This attack would be FREE since no gas would be charged as transactions are rejected before reaching the execution phase.

Affected Code

bool TryPushTransaction(ExecutionThread, MempoolTransaction transaction) {
    // Get or create this account state.
    SenderAccountState& sender_account = sender_accounts[transaction.transaction->from];

    if (sender_account.in_flight_transactions.contains(transaction.transaction->nonce)) {
        // This transaction's nonce is already in the mempool. If this is the case, we replace the
        // transaction and leave the senders place in the queue.
        sender_account.in_flight_transactions.at(transaction.transaction->nonce) =
            std::move(transaction);
        return true;
    }

    if (transaction_queue.size() >= maximum_queue_size) {
        // We are at the queue size limit. In this case we allow the sender to replace one of their
        // transactions with a lower transaction, if they are able to. Otherwise we fail the push.
        if (!sender_account.in_flight_transactions.empty() &&
            transaction.transaction->nonce < sender_account.in_flight_transactions.rbegin()->first) {
            // Replace the highest nonce transaction with this transaction.
            // ... replacement logic
            return true;
        } else {
            // There is no transaction we can replace, and the queue is full.
            return false;  // ← LEAK: sender_account entry persists with empty in_flight_transactions
        }
    }
    
    // Add transaction to queue...
}

void ProcessTransactions(...) {
    // ... processing logic
    if (sender_account.in_flight_transactions.size() == 1) {
        // There is only one transaction left, so just delete the sender account.
        sender_accounts.erase(sender);
    }
    // ... 
}

Impact

  • Memory Exhaustion: sender_accounts map grows without bound when mempool is full → OOM.
  • Performance Degradation: Memory pressure due to memory leak and hash map lookup overhead.

The severity is lower than the in_flight_accounts vulnerability because:

  • The attack requires the mempool to be full, making it more difficult to execute
  • The leaked objects (SenderAccountState with empty in_flight_transactions) are smaller

Assets: somnia\mempool\mempool_transaction_queue.h


Classification

Impact Rate: 3/5

Likelihood Rate: 2/5

Severity: Low


Recommendations

Remediation:

Ensure map growth is not unbounded as currently. This can be achieved by refactoring TryPushTransaction a little and moving if (transaction_queue.size() >= maximum_queue_size) before SenderAccountState& sender_account = sender_accounts[transaction.transaction->from];.

Validation steps


Evidences

Reproduce:

The attack would happen as follows:

  1. Fill the mempool queue to its maximum capacity with legitimate transactions that triggers COULD_BE_EXECUTED_IN_FUTURE (for example hitting NONCE_TOO_LARGE code path) so they are added to the non_executable_transaction_queue.
  2. Send additional transactions from different accounts (so sender changes, which adds new entries in the map) to create dummy entry in sender_accounts.
  3. These entries will never be erased from the map since no transaction will be added to those senders, thus never reaching the sender_accounts.erase code path.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Low
Bounty$3,418
Visibilitypartially
VulnerabilityBlockchain
Participants (3)
company admin
author
triage team