#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?