Movement uses Memseq as its MemoryPool. Once transactions are added to Memseq, there is no mechanism to remove those with client-specified expiration.
Furthermore, when transactions in Memseq are packed into DA (Data Availability), client-specified expiration is not checked. This could result in the execution of expired transactions, potentially leading to user fund losses.
Vulnerability Details
Memseq::wait_for_next_block() is utilized to fetch transactions from the mempool for inclusion into a block.
/// Waits for the next block to be built, either when the block size is reached or the building time expires.asyncfnwait_for_next_block(&self) ->Result<Option<Block>, anyhow::Error> {letmut transactions =Vec::with_capacity(self.block_size asusize);let now =Instant::now();loop {let current_block_size = transactions.len() asu32;if current_block_size >= self.block_size {break; }let remaining = self.block_size - current_block_size;@> letmut transactions_to_add = self.mempool.pop_transactions(remaining asusize).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() asu64> 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)) } }
As shown, Memseq::wait_for_next_block() only removes transactions packed into DA from the mempool via mempool.pop_transactions() , without clearing transactions with client-specified expiration from the mempool .
Transactions in the Mempool have two types of expirations: systemTTL and client-specified expiration .
As seen in the above function, the system only processes transactions with systemTTL expiration via the task mechanism— without any mechanism to remove transactions with client-specified expiration . More critically, client-specified expiration is not checked when these transactions are later packed into DA (Data Availability) or executed by the executor. As a result, expired transactions may still be executed, potentially leading to financial losses for users .
Impact Details
Transactions with a client-specified expiration will be executed, but some of the user's transactions are time-sensitive, such as Trade Transactions. If these transactions are executed beyond the user-specified time, it may result in financial losses for the user.
Let’s assume the following user transaction submission process to examine the issue in this scenario:
A user submits a transaction with a client-specified expiration time of 3 seconds and sets the gas fee to the system’s minimum required value.
The transaction passes all validation checks and is added to the Memseq (memory sequence pool).
Since the transaction has low priority, higher-priority transactions are packaged into DA (Data Availability) first and proceed to the executor for execution.
After several blocks (e.g., 10 seconds later), the transaction from step (1) is finally included in a block. Since there is no timestamp check, even though 10 seconds > 3 seconds, the transaction is still incorrectly included in DA.
The transaction is then executed by the executor.
As a result, transactions with client-specified expiration times may still be executed even after their intended deadline. Some user transactions, such as trade transactions, are time-sensitive. If they are executed beyond the user-specified expiration time, it could lead to financial losses for the user.
Proof of Code
As demonstrated in the test code, timeout transactions can be committed and executed
Test 1:
Add the test code to:
networks/movement/movement-client/src/bin/e2e/time_out.rs
networks/movement/movement-client/Cargo.toml
Run tests:
Test 2:
Modify protocol-units/execution/maptos/opt-executor/src/executor/execution.rs
Test function test_execute_block_state_db:
#[tracing_test::traced_test]
#[tokio::test]
async fn test_execute_block_state_db_timeout() -> Result<(), anyhow::Error> {
// use aptos_logger::{Level, Logger};
// Logger::builder().level(Level::Info).build();
// Create an executor instance from the environment configuration.
let private_key = Ed25519PrivateKey::generate_for_testing();
let (tx_sender, _tx_receiver) = mpsc::channel(1);
let (executor, _tempdir) = Executor::try_test_default(private_key).await?;
let (context, _transaction_pipe) = executor.background(tx_sender)?;
// Initialize a root account using a predefined keypair and the test root address.
// get the raw private key
let raw_private_key = context
.config()
.chain
.maptos_private_key_signer_identifier
.try_raw_private_key()?;
let private_key = Ed25519PrivateKey::try_from(raw_private_key.as_slice())?;
let root_account = LocalAccount::new(
aptos_test_root_address(),
AccountKey::from_private_key(private_key),
0,
);
// Seed for random number generator, used here to generate predictable results in a test environment.
let seed = [3u8; 32];
let mut rng = ::rand::rngs::StdRng::from_seed(seed);
// Loop to simulate the execution of multiple blocks.
for i in 0..10 {
let (epoch, round) = executor.get_next_epoch_and_round()?;
info!("Epoch: {}, Round: {}", epoch, round);
// Generate a random block ID.
let block_id = HashValue::random();
// Clone the signer from the executor for signing the metadata.
let signer = executor.signer.clone();
// Get the current time in microseconds for the block timestamp.
let current_time_microseconds = chrono::Utc::now().timestamp_micros() as u64;
// Create a transaction factory with the chain ID of the executor, used for creating transactions.
let tx_factory =
TransactionFactory::new(context.config().chain.maptos_chain_id.clone())
.with_transaction_expiration_time(
current_time_microseconds - 1000, // current_time_microseconds + (i * 1000 * 1000 * 60 * 30) + 30,
);
// Create a block metadata transaction.
let block_metadata = Transaction::BlockMetadata(BlockMetadata::new(
block_id,
epoch,
round,
signer.author(),
vec![],
vec![],
current_time_microseconds,
// ! below doesn't work, i.e., we can't roll over epochs
// current_time_microseconds + (i * 1000 * 1000 * 60 * 30), // 30 minutes later, thus every other will be across an epoch
));
// Generate a new account for transaction tests.
let new_account = LocalAccount::generate(&mut rng);
let new_account_address = new_account.address();
// Create a user account creation transaction.
let user_account_creation_tx = root_account.sign_with_transaction_builder(
tx_factory.create_user_account(new_account.public_key()),
);
// Create a mint transaction to provide the new account with some initial balance.
let mint_tx = root_account
.sign_with_transaction_builder(tx_factory.mint(new_account.address(), 2000));
// Store the hash of the committed transaction for later verification.
let mint_tx_hash = mint_tx.committed_hash();
// Block Metadata
let transactions =
ExecutableTransactions::Unsharded(into_signature_verified_block(vec![
block_metadata,
Transaction::UserTransaction(user_account_creation_tx),
Transaction::UserTransaction(mint_tx),
]));
debug!("Number of transactions: {}", transactions.num_transactions());
let block = ExecutableBlock::new(block_id.clone(), transactions);
let block_commitment = executor.execute_block(block).await?;
// Access the database reader to verify state after execution.
let db_reader = executor.db_reader();
// Get the latest version of the blockchain state from the database.
let latest_version = db_reader.get_latest_ledger_info_version()?;
info!("Latest version: {}", latest_version);
// Verify the transaction by its hash to ensure it was committed.
let transaction_result =
db_reader.get_transaction_by_hash(mint_tx_hash, latest_version, false)?;
assert!(transaction_result.is_some());
// Create a state view at the latest version to inspect account states.
let state_view = db_reader.state_view_at_version(Some(latest_version))?;
// Access the state view of the new account to verify its state and existence.
let _account_resource =
AccountResource::fetch_move_resource(&state_view, &new_account_address)?.unwrap();
// Check the commitment against state proof
let state_proof = db_reader.get_state_proof(latest_version)?;
let expected_commitment = Commitment::digest_state_proof(&state_proof);
assert_eq!(block_commitment.height(), i + 1);
assert_eq!(block_commitment.commitment(), expected_commitment);
}
Ok(())
}
cd protocol-units/execution/maptos/opt-executor
cargo test test_execute_block_state_db_timeout -- --nocapture