41437 [BC-High] an edge case allows duplicate transactions to be added to the mempool of the sequencer
#41437 [BC-High] An edge-case allows duplicate transactions to be added to the mempool of the sequencer
Submitted on Mar 15th 2025 at 07:38:26 UTC by @Capybara for Attackathon | Movement Labs
Report ID: #41437
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/mempool/util
Impacts:
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
An edge-case allows duplicate transactions to be added to the mempool of the sequencer.
Blocks are created from a limited amount of transactions extracted from the mempool, therefore having duplicated transactions in the mempool results in a block being produced with less real user transactions than it should.
From the list of impacts for this contest, the appropriate impact for an edge-case that causes the sequencer to produce blocks with less user transactions than it should is: Causing network processing nodes to process transactions from the mempool beyond set parameters.
Vulnerability Details
The mempool transaction key
In simple terms, the mempool works like a key
-value
database.
Transactions are added under a key
, computed using the function below:
fn construct_mempool_transaction_key(transaction: &MempoolTransaction) -> Result<String, Error> {
// Pre-allocate a string with the required capacity
let mut key = String::with_capacity(32 + 1 + 32 + 1 + 32 + 1 + 32);
// Write key components. The numbers are zero-padded to 32 characters.
key.write_fmt(format_args!(
"{:032}:{:032}:{:032}:{}",
transaction.transaction.application_priority(),
transaction.timestamp,
transaction.transaction.sequence_number(),
transaction.transaction.id(),
))
.map_err(|_| Error::msg("Error writing mempool transaction key"))?;
Ok(key)
}
Code from: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/mempool/move-rocks/src/lib.rs#L23-L36
The timestamp of a MempoolTransaction
One of the values used to compute the key
is a "timestamp
" generated by the sequencer receiving the batch of transactions to add to the mempool, using the function below:
/// Creates a new MempoolTransaction with the current timestamp floored to the nearest slot.
/// todo: probably want to move this out to a factory.
pub fn slot_now(transaction: Transaction) -> MempoolTransaction {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Self::at_time(transaction, timestamp)
}
pub fn at_time(transaction: Transaction, timestamp: u64) -> Self {
let floor = (timestamp / Self::SLOT_SECONDS) * Self::SLOT_SECONDS;
Self { transaction, timestamp: floor, slot_seconds: Self::SLOT_SECONDS }
}
Code from: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/mempool/util/src/lib.rs#L226-L233
Recalculating the system timestamp for every transaction in the batch
When processing a batch of transactions, a safe sequencer implementation should read the system time only once and produce a timestamp floored to the nearest slot for all transactions in the batch.
Unfortunately, what the current implementation does is to recalculate the floored timestamp for every transaction in the batch.
The timestamp of a system is constantly increasing, which may lead to some transactions receiving a floored timestamp that is later than others.
The likelihood is increased in the Movement Network because the SLOT_SECONDS
is a very low constant (2).
const SLOT_SECONDS: u64 = 2;
/// Adds transactions to the mempool.
fn add_transactions(
&self,
transactions: Vec<Transaction>,
) -> impl Future<Output = Result<(), anyhow::Error>> {
async move {
let mempool_transactions =
transactions.into_iter().map(MempoolTransaction::slot_now).collect();
self.add_mempool_transactions(mempool_transactions).await
}
}
Code from: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/mempool/util/src/lib.rs#L84-L93
Storing a batch of transactions in the mempool
As a reference, below is the function that stores the transaction in the database:
async fn add_mempool_transactions(
&self,
transactions: Vec<MempoolTransaction>,
) -> Result<(), anyhow::Error> {
let db = self.db.clone();
tokio::task::spawn_blocking(move || {
let mempool_transactions_cf_handle = db
.cf_handle(cf::MEMPOOL_TRANSACTIONS)
.ok_or_else(|| Error::msg("CF handle not found"))?;
let transaction_lookups_cf_handle = db
.cf_handle(cf::TRANSACTION_LOOKUPS)
.ok_or_else(|| Error::msg("CF handle not found"))?;
// Add the transactions and update the lookup table atomically
// in a single write batch.
// https://github.com/movementlabsxyz/movement/issues/322
let mut batch = WriteBatch::default();
for transaction in transactions {
if Self::internal_has_mempool_transaction(&db, transaction.transaction.id())? {
continue;
}
let serialized_transaction = bcs::to_bytes(&transaction)?;
let key = construct_mempool_transaction_key(&transaction)?;
batch.put_cf(&mempool_transactions_cf_handle, &key, &serialized_transaction);
batch.put_cf(
&transaction_lookups_cf_handle,
transaction.transaction.id().to_vec(),
&key,
);
}
db.write(batch)?;
Ok::<(), Error>(())
})
.await??;
Ok(())
}
Code from: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/mempool/move-rocks/src/lib.rs#L119-L159
The section relevant to the bug, is a loop through every transaction in the batch:
let mut batch = WriteBatch::default();
for transaction in transactions {
//.. more code here
First, the algorithm confirms the transaction does not already exists in the dabase by looking for the transaction id:
let mut batch = WriteBatch::default();
for transaction in transactions {
if Self::internal_has_mempool_transaction(&db, transaction.transaction.id())? {
continue;
}
//.. more code here
Then, it puts (but does not write yet into the db) the current transaction under the key
generated in the previous sections, and keeps looping to make sure none of the transactions it's about to commit already exists in the mempool:
let mut batch = WriteBatch::default();
for transaction in transactions {
if Self::internal_has_mempool_transaction(&db, transaction.transaction.id())? {
continue;
}
let serialized_transaction = bcs::to_bytes(&transaction)?;
let key = construct_mempool_transaction_key(&transaction)?;
batch.put_cf(&mempool_transactions_cf_handle, &key, &serialized_transaction);
batch.put_cf(
&transaction_lookups_cf_handle,
transaction.transaction.id().to_vec(),
&key,
);
}
Finally, it writes in a batch to the db:
db.write(batch)?;
Duplicating transactions
Putting it all together:
Transactions are stored in the mempool under a
key
.The
key
is generated at runtime using the timestamp of the system converted into seconds.It is possible to submit a batch of transactions to be added to the mempool.
A malicious batch of transactions that contains the same transaction repeated, can generate different floored timestamps depending of the UNIX time of the system at the time of processing the batch, which leads to different Mempool
key
s for the same transactions, and as a result the same transactions gets stored multiple times in the mempool.Blocks are produced by extracting transactions from the mempool until the block size is filled. This edge case allows filling part of the block space with duplicate transactions, leaving out legit transactions from other users.
I hope this effort contributes to the security and stability of the Movement Network chain.
Proof of Concept
Proof of Concepts
I have 3 proof of concepts for this report, to help the Movement Network devs understand and confirm this edge case as valid.
First, let's conduct a simple test to verify that two identical transactions created in the sequencer, with a one-second difference in timestamps, may result in two distinct floored timestamps.
#[test]
fn test_at_time_capy_poc() {
// Unix timestamp to seconds:
let mut seconds = 1615470987;
// Tx 1 generating a floor timestamp for these seconds:
let transaction1 = MempoolTransaction::at_time(Transaction::test(), seconds.clone());
// 1 second later
seconds += 1;
// Tx 2 generates a different floor timestamp for the same transaction data
let transaction2 = MempoolTransaction::at_time(Transaction::test(), seconds );
assert_ne!(transaction1.clone().timestamp, transaction2.clone().timestamp);
}
Add test to:
./attackathon-movement/protocol-units/mempool/util/src/lib.rs
Now, let's test a simulation of the UNIX timestamp increasing at runtime, while processing duplicated transactions, leading to different keys
for the mempool and storing the same transaction twice in the mempool.
For this test, we could either set the UNIX timestamp of the operating system to a specific value (which increases the difficulty of creating a proof of concept that is easy to reproduce for everyone involved) or process a large amount of calculations to allow the timestamp to increase at runtime by up to 1 second.
Below is the test, heavily commented at every step:
///
/// When sending the same Transaction multiple times in a Batch Write, the system will generate
/// a key for the Transaction and override the data in the same key, preventing duplication.
///
/// The KEY used to save transactions in the mempool is generated with calculations done
/// on the UNIX timestamp of the system.
///
/// Naturally, the UNIX timestamp is constantly increasing, which may lead to having 2
/// identical Transactions in a Batch generate 2 different mempool keys, allowing duplicated Transactions in the mempool.
///
/// Maliciously sending the same transaction multiple times in the same Batch, depending on the UNIX timestamp of the system when processing the batch, leads to the Transaction getting saved twice (under 2 different keys) in the mempool.
///
/// It is worth remembering that blocks are generated using a limited amount of transactions
/// from the mempool, therefore maliciously cluttering the mempool with duplicated transactions prevents
/// real users from getting their transactions processed.
///
#[test]
fn test_mempool_transaction_timestamp_capy_poc() {
// To simulate calculating a KEY for the same transaction in two different timestamps we
// can either set the UNIX timestamp of the system to a specific value, such that
// increasing +1 milisecond would generate a different slot in `slot_now`, or we process a
// large enough amount of data so the UNIX timestamp naturally increases by itself until it
// generates a different slot.
//
// The second option is clearly simpler to implement, and is the one we use to proof the
// concept.
//
// Step 1- Create a vector with enough Transactions so we don't have to manually set the
// timestamp of the system a specific value, instead, it increases naturally wile executing code.
let transactions: Vec<Transaction> = (0..20000000).map(|_| Transaction::test()).collect();
// Step 2- Generate Mempool Transactions
let mempool_transactions: Vec<MempoolTransaction> = transactions.into_iter().map(MempoolTransaction::slot_now).collect();
// Step 3- Check if all timestamps are the same
let all_same_timestamp = mempool_transactions.iter().all(|tx| tx.timestamp == mempool_transactions[0].timestamp);
// Transactions have different timestamps.
assert!(!all_same_timestamp);
println!("Transactions have different timestamps.");
// The KEY for the two identical transactions is different, therefore they will get stored
// in the mempool as 2 different transactions.
let key0 = construct_mempool_transaction_key(&mempool_transactions[0]).unwrap();
let keyLast = construct_mempool_transaction_key(&mempool_transactions[19999999]).unwrap();
assert_ne!(key0, keyLast, "Keys are not equal");
}
Add test to:
./attackathon-movement/protocol-units/mempool/util/src/lib.rs
Finally, let's confirm with a proof of concept that when reading transactions from the mempool to fill a block, it reads the duplicated transactions and they count when calculating if the block is already filled or it needs more txs.
The code that fills the block with txs from the mempool is:
async fn wait_for_next_block(&self) -> Result<Option<Block>, anyhow::Error> {
let mut transactions = Vec::with_capacity(self.block_size as usize);
let now = Instant::now();
loop {
let current_block_size = transactions.len() as u32;
if current_block_size >= self.block_size {
break;
}
let remaining = self.block_size - current_block_size;
let mut transactions_to_add = self.mempool.pop_transactions(remaining as usize).await?;
transactions.append(&mut transactions_to_add);
// sleep to yield to other tasks and wait for more transactions
tokio::task::yield_now().await;
if now.elapsed().as_millis() as u64 > self.building_time_ms {
break;
}
}
if transactions.is_empty() {
Ok(None)
} else {
let new_block =
self.build_next_block(block::BlockMetadata::default(), transactions).await?;
Ok(Some(new_block))
}
}
Code from: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/sequencing/memseq/sequencer/src/lib.rs#L101-L131
Code for the test:
#[tokio::test]
async fn test_capybara_rocksdb_mempool_basic_operations() -> Result<(), Error> {
// Create a mempool
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().to_str().unwrap();
let mempool = RocksdbMempool::try_new(path)?;
// Create a base transaction
let base = Transaction::test();
let transaction_id = base.id();
// Two unix timestamps with a 1 second difference are generated for the same base transaction
let transaction1 = MempoolTransaction::at_time(base.clone(), 3);
let transaction2 = MempoolTransaction::at_time(base.clone(), 4);
// Create a vector of transactions to add to the mempool
let tx_vec: Vec<MempoolTransaction> = [transaction1.clone(), transaction2.clone()].to_vec();
// Ensure both transactions have the same transaction ID
assert_eq!(transaction1.clone().transaction.id(), transaction_id);
assert_eq!(transaction2.clone().transaction.id(), transaction_id);
// Add both transactions to the mempool
mempool.add_mempool_transactions(tx_vec.clone()).await?;
// Check the transaction id is in the pool
assert!(mempool.has_mempool_transaction(transaction_id.clone()).await?);
// Confirm that there are 2 identical transactions pending in the mempool
let transactions = mempool.pop_mempool_transactions(2).await?;
assert_eq!(transactions.len(), 2, "There are not exactly 2 transactions in the mempool");
Ok(())
}
Add to:
/attackathon-movement/protocol-units/mempool/move-rocks/src/lib.rs
Was this helpful?