https://github.com/hackenproof-public/somnia
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.
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!
}
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:
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
sender_accounts map grows without bound when mempool is full → OOM.The severity is lower than the in_flight_accounts vulnerability because:
SenderAccountState with empty in_flight_transactions) are smallerAssets:
somnia\mempool\mempool_transaction_queue.h
Impact Rate: 3/5
Likelihood Rate: 2/5
Severity: Low
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];.
Reproduce:
The attack would happen as follows:
COULD_BE_EXECUTED_IN_FUTURE (for example hitting NONCE_TOO_LARGE code path) so they are added to the non_executable_transaction_queue.sender_accounts.sender_accounts.erase code path.