#42896 [BC-High] attackers can exploit sequence number tolerance mechanism to to cause movement network da lightnode loose money for submitting failed blocks to celestia

#42896 [BC-High] Attackers can exploit sequence_number tolerance mechanism to to cause Movement Network DA Lightnode loose money for submitting failed blocks to Celestia

Submitted on Mar 28th 2025 at 14:18:07 UTC by @perseverance for Attackathon | Movement Labs

  • Report ID: #42896

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/execution/maptos/opt-executor

  • Impacts:

    • Direct loss of funds

    • Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments

Description

Background Information and Vulnerability Details

sequence_number tolerance

When users submit transactions to mempool, it is allowed that the sequence number is in range from commited_sequence_number (that is sequence number of the last succesfull transaction of the user) + 1 to commited_sequence_number + TOO_NEW_TOLERANCE ( = 32) .

The sequencer node check for valid sequence number in submit_transaction function.

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L267-L272

	async fn submit_transaction(
		&mut self,
		transaction: SignedTransaction,
	) -> Result<SubmissionStatus, Error> {
	// SNIP 
	let sequence_number = match self.has_invalid_sequence_number(&transaction)? {
			SequenceNumberValidity::Valid(sequence_number) => sequence_number,
			SequenceNumberValidity::Invalid(status) => {
				return Ok(status); // @note if the sequence number is invalid, we return the status immediately without adding the transaction to the mempool
			}
		};
	// 

The check for sequence number can be seen in the function has_invalid_sequence_number

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L179-L218

// this checks that the sequence number is too old or too new
		let committed_sequence_number =
			vm_validator::get_account_sequence_number(&state_view, transaction.sender())?;

		debug!(
			"Used sequence number: {:?} Committed sequence number: {:?}",
			used_sequence_number, committed_sequence_number
		);
		let min_used_sequence_number =
			if used_sequence_number > 0 { used_sequence_number + 1 } else { 0 }; 

		let min_sequence_number = (min_used_sequence_number).max(committed_sequence_number);

		let max_sequence_number = committed_sequence_number + TOO_NEW_TOLERANCE;

		info!(
			"min_sequence_number: {:?} max_sequence_number: {:?} transaction_sequence_number {:?}",
			min_sequence_number,
			max_sequence_number,
			transaction.sequence_number()
		);

		if transaction.sequence_number() < min_sequence_number {
			info!("Transaction sequence number too old: {:?}", transaction.sequence_number());
			return Ok(SequenceNumberValidity::Invalid((
				MempoolStatus::new(MempoolStatusCode::InvalidSeqNumber),
				Some(DiscardedVMStatus::SEQUENCE_NUMBER_TOO_OLD),
			)));
		}

		if transaction.sequence_number() > max_sequence_number {
			info!("Transaction sequence number too new: {:?}", transaction.sequence_number());
			return Ok(SequenceNumberValidity::Invalid((
				MempoolStatus::new(MempoolStatusCode::InvalidSeqNumber),
				Some(DiscardedVMStatus::SEQUENCE_NUMBER_TOO_NEW),
			)));
		}

		Ok(SequenceNumberValidity::Valid(committed_sequence_number))
	}

So 1 user can submit maximum 32 transactions with sequence number from commited_sequence_number + 1 to commited_sequence_number + 32.

Validation separate transaction before commiting to the mempool

Please note that when user submit a transaction to the mempool, the transaction is validated against the latest state view of the blockchain. Each transaction is validated separately. The behavior is seen in the code as following.

As comment and the code show that each transaction is validated with re-creation with latest database.

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L250-L253

async fn submit_transaction(
		&mut self,
		transaction: SignedTransaction,
	) -> Result<SubmissionStatus, Error> {
    // SNIP
    // Pre-execute Tx to validate its content.
		// Re-create the validator for each Tx because it uses a frozen version of the ledger.
		let vm_validator = VMValidator::new(Arc::clone(&self.db_reader));
		let tx_result: aptos_types::transaction::VMValidatorResult = vm_validator.validate_transaction(transaction.clone())?;

    // SNIP
}

So if each transaction passed the validation then it is included in the mempool.

Lifecycle of a transaction

The transactions passed validation from the mempool ==> included in the mempool ==> submit (or batch_write) to DA Lightnode

Then the transactions are included in blocks and sent from DA Lightnode to Celestia. Code is here: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/sequencing/memseq/sequencer/src/lib.rs#L101-L131

/// Waits for the next block to be built, either when the block size is reached or the building time expires.
	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))
		}
	}

So this function wait_for_next_block batch the transactions to build the block the DA Lightnode batch maximum 10 transactions to a block and build blob to send to Celestia.

The blob will be signed and pay for gas by DA Lightnode.

Executor get / read Blocks from Celestia and execute blocks.

The cost of submitting blob Data is covered by Movement Team and is higher than the cost of a transaction

Gas Unit Price

For the Gas Unit Price Analytics show that users usually submit transactions with the Gas Unit Price from 100 to 125 Octas.

See analytics here: https://explorer.movementnetwork.xyz/analytics?network=mainnet

So the gas unit price is in range from 100 to 200 Octas Move.

Suppose the cost is 200 Octas Move

MOVe price = 0.54 USD

Gas Fee of a simple transfer would be = 0.000402 MOVE = 40_200 Octas MOVE let's assum maximum to be 50_000 Octas

=> Cost of gas fee for a simple transfer : 0.0005 MOVE * 0.54 = 0.00027 USD

Cost of submitting blobs to Celestia

So take some example transactions in celestia, https://celenium.io/tx/9b00e984362bfd0db1b67906608829cf3d9d6a6994e36178bc2de2a9781f9044?tab=messages

The cost of gas on Celestia is 0.034763 for 1019 Bytes => TIA Price = 3.66 ( https://coinmarketcap.com/vi/currencies/celestia/) => Cost for 1 transaction: 0.05 * 3.65 = 0.183 USD

The maximum size of a blob is 2 MB. The blob of blocks that can maximum contains 10 transactions. So the data probably can be bigger and cost more gas, but let's take 0.183 USD as sample for this

So you can notice that the gas pay by users is much less than the gas pay by DA Lightnode.

Attack Scenario

So attacker can submit a transaction 32 transactions as followed


Attacker_Account_1 current sequence number is commited_sequence_number 

Attacker_Account_1 has balance of 1 MOVE 

Transaction 1: Attacker  from Attacker_Account_1 transfer 0.9995 MOVe to Attacker_account2 with sequence number = `commited_sequence + 1`

Attacker_Account_1 has balance big enough to cover the gas fee for 2nd and other transactions, let's say: 1 MOVE

Transaction 2: Attacker_Account1 create a transaction with maximum payload size with Sequence number = `commited_sequence + 2`. The payload maximum is 64 Kbytes. For example, a transaction to create a big smart contract. 

...

Transaction 32: Attacker_Account1 create a transaction with maximum payload size with Sequence number = `commited_sequence + 32`. The payload maximum is 64 Kbytes. For example, a transaction to create a big smart contract. 

The attacker create 31 transactions with maximum payload data to cause maximum gas pay by DA Lightnode to submit blobs to Celestia.

Attack Analysis

So the attacker sent all of this transactions to the mempool in 1 seconds, so all the transactions entered the mempool.

Each transaction will be validated separately. So each transaction will pass validation. So each transaction is included in the mempool.

They will be sent to DA Lightnode.

The transactions will be included in at least 4 blobs. because each blob can contain only maximum 10 transactions.

When the executor get the blob and execute the transactions in sequence.

  • Transaction 1 will pass. After transaction 1, Attacker_Account_1 sent 0.9995 MOVe and spent 0.0005 Move for gas.

Attacker_Account_1 balance = 0 Attacker_Account_2 balance = 0.9995 MOVe

Governed_gas_pool of Movement received 0.0005 Move

  • After transaction 1, Attacker_Account_1 balance = 0 so all other transactions will fail

The Movement Network received nothing.

But the Movement DA Lightnode need to spend gas to submit to Celestia for at least 3 blobs.

=> Cost for 1 transaction: 0.05 * 3.65 = 0.183 USD

=> Cost for 3 transactions: 0.183 * 3 = 0.549 USD

Cost of attack and Impact analysis

So the cost for attacker: 0.0005 Move = 0.00027 USD

The damage for the Movement project: 0.549 USD - 0.00027 USD = 0.54873

The the cost attack ratio per damage is 1 : 2032 that is quite huge number.

So suppose the cost for attacker is 1000 USD => The damage for project: 1000 * 2032 = 2_032_000 USD

The cost for Movement Network can be bigger when take into calculation the actual payload size of blob. In above calculation, I took the gas paid for a simpe small payload of 1019 Bytes.

With this huge ratio, then if the attacker can gain some profit to cover the cost, then he is likely to execute the attack.

Severity Assessment

Bug Severity: Critical

Impact:

  • Loss of funds for Movement Project

  • Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments

Impact:

  • Caused DA Lightnode to spend much gas to pay for failed transactions

  • Resource waste: The sequencer and executor executed almost failed transactions.

  • Severe degredation quality of service since other transactions of valid users can have some delay

Likelihood:

  • High as No special privileges required

  • Can be executed by any user with very cheap cost of attack

The cost of attack vs the damage for project is 1: 2032 ratio

So the cost to cause total denial of service of Movement for 1 day is can be just 7.5 USD depends on how much spam transactions. It is very cheap.

the cost to cause total denial of service of Movement for 1 month is just 224 USD . It is very cheap.

So suppose the cost for attacker is 1000 USD => The damage for project: 1000 * 2032 = 2_032_000 USD

Proof of Concept

Proof of concept

The following attack scenario demonstrate the bug.

Step 1: Setup.

Attacker_Account_1 current sequence number is commited_sequence_number = 0 

Attacker_Account_1 has balance of 1 MOVE 

Transaction 1: Attacker  from Attacker_Account_1 transfer 0.9995 MOVe to Attacker_account2 with sequence number = `0`

Attacker_Account_1 has balance big enough to cover the gas fee for 2nd and other transactions, let's say: 1 MOVE

Transaction 2: Attacker_Account1 create a transaction with maximum payload size with Sequence number = `1`. The payload maximum is 64 Kbytes. For example, a transaction to create a big smart contract. 

...

Transaction 32: Attacker_Account1 create a transaction with maximum payload size with Sequence number = `31`. The payload maximum is 64 Kbytes. For example, a transaction to create a big smart contract. 

Step 2: The attacker sends 32 transactions within 1 seconds, and these transactions enter the mempool. To bypass the rate limit, can send transactions from different machines, IPs

Step 3: Repeat the attack to continue. Can create more accounts if needed

Exact attack scenario can be refined with more test, but can be easily done.

Attackers can create a script to execute this attack.

Was this helpful?