#43108 [BC-Critical] attackers can front run transactions in celestia mempool to cause transactions of many users revert unexpectedly

#43108 [BC-Critical] Attackers can front-run transactions in celestia mempool to cause transactions of many users revert unexpectedly

Submitted on Apr 2nd 2025 at 04:39:16 UTC by @perseverance for Attackathon | Movement Labs

  • Report ID: #43108

  • Report Type: Blockchain/DLT

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/da/movement/

  • Impacts:

    • Direct loss of funds

    • Causing network processing nodes to process transactions from the mempool beyond set parameters

Description

Short summary

Attackers can front-run transactions in Celestia mempool to manipulate the blob height to manipulate order of execution of transactions. This will cause transactions of many users of Movement revert unexpectedly in some cases.

Background Information

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 block_size transactions to a block and build blob to send to Celestia.

The block_size can be 2048 transactions as default value.

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/sequencing/memseq/util/src/lib.rs#L22

/// The configuration for the MemSeq sequencer
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
	/// The chain id of the sequencer
	#[serde(default = "Config::default_sequencer_chain_id")]
	pub sequencer_chain_id: Option<String>,

	/// The path to the sequencer database
	#[serde(default = "Config::default_sequencer_database_path")]
	pub sequencer_database_path: Option<String>,

	/// The memseq build time for the block
	#[serde(default = "default_memseq_build_time")]
	pub memseq_build_time: u64,

	/// The memseq max block size
	#[serde(default = "default_memseq_max_block_size")]
	pub memseq_max_block_size: u32,
}

env_default!(default_memseq_build_time, "MEMSEQ_BUILD_TIME", u64, 1000); // @audit default memseq build time  = 1000 ms = 1 second 

env_default!(default_memseq_max_block_size, "MEMSEQ_MAX_BLOCK_SIZE", u32, 2048); // @audit default max block size is 2048 

impl Default for Config {
	fn default() -> Self {
		Config {
			sequencer_chain_id: Config::default_sequencer_chain_id(),
			sequencer_database_path: Config::default_sequencer_database_path(),
			memseq_build_time: default_memseq_build_time(),
			memseq_max_block_size: default_memseq_max_block_size(),
		}
	}
}

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

So the DA Lightnode now can work in 2 modes: blocky or sequencer mode:

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/da/movement/protocol/light-node/README.md#L1-L6

If the DA Lightnode works in Blocky mode, then block of transactions received from mempool are signed to create Blob data and send to Celestia.

If DA Lightnode works in sequencer mode, then transactions are written to Mempool RockDB and then create blocks and then send to Celestia.

The Blob Data sent to Celestia will have some data:

  1. Celestia Namespace

  2. Blob.Data

  3. Blob.timestamp

  4. Blob Sigature

  5. Blob.id (computed from blob.data and timestamp)

DA Lightnode will call Celestia RPC Client to blob_submit blob to Celestia.

Please note that this will call the Celestia Rust RPC Client (lumina) to submit RPC to Celestia Light Node. Then "Celestia Light node" will sign for the transactions and pay from the "DA Lightnode" account to broadcast to Celestia Blockchain.

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/da/movement/providers/celestia/src/da/mod.rs#L36-L52

/// Creates a new signed blob instance with the provided DaBlob data.
	pub fn create_new_celestia_blob(&self, data: DaBlob<C>) -> Result<CelestiaBlob, anyhow::Error> {
		// create the celestia blob
		CelestiaDaBlob(data.into(), self.celestia_namespace.clone()).try_into()
	}

	/// Submits a CelestiaBlob to the Celestia node.
	pub async fn submit_celestia_blob(&self, blob: CelestiaBlob) -> Result<u64, anyhow::Error> {
		let config = TxConfig::default();
		// config.with_gas(2);
		let height = self.default_client.blob_submit(&[blob], config).await.map_err(|e| { // @audit Celestia RPC Client
			error!(error = %e, "failed to submit the blob");
			anyhow::anyhow!("Failed submitting the blob: {}", e)
		})?;

		Ok(height)
	}

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/Cargo.toml#L231

celestia-rpc = { git = "https://github.com/eigerco/lumina", rev = "c6e5b7f5e3a3040bce4262fe5fba5c21a2637b5" }   #{ version = "0.7.0" }

From Celestia to Validator Executor

The DA Lightnode subscribe to Celestia and read Blob data from Celestia. After some verification, blob will be executed.

This blob is read in StreamReadFromHeightResponse mechanism.

https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/networks/movement/movement-full-node/src/node/tasks/execute_settle.rs#L100-L154

async fn process_block_from_da(
		&mut self,
		response: StreamReadFromHeightResponse, // @audit notice this read from StreamReadFromHeightResponse 
	) -> anyhow::Result<()> {
		// SNIP

		// get the transactions
		let transactions_count = block.transactions().len();
		let span = info_span!(target: "movement_timing", "execute_block", id = ?block_id);
		let commitment =
			self.execute_block_with_retries(block, block_timestamp).instrument(span).await?;

		// decrement the number of transactions in flight on the executor
		self.executor.decrement_transactions_in_flight(transactions_count as u64);

        // SNIP

For more info, please read https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/da/movement/protocol/light-node/src/passthrough.rs#L126

Vulnerability Details and Attack Scenario

So notice that transactions from users are included in mempool and then are included in different blobs. Blobs are submited to Celestia Mempool. When receive blobs from Celestia, there is no mechanism to verify the order of blobs.

Celestia makes use of a standard gas-priced prioritized mempool. By default, transactions with gas prices higher than that of other transactions in the mempool will be prioritized by validators. (reference: Fee market and mempool https://docs.celestia.org/how-to-guides/submit-data#fee-market-and-mempool)

Suppose that Alice submit 2 transactions

Transaction_1: timestamp_1 , sequence number = 1 => Included in Blob_1

Transaction_2: timestampt_2 > timestamp_1 , sequencer number = 2 => Included in Blob_2

Now in normal condition:

Blob_1 are submited to Celestia first, then executed first. Transaction_1 will pass Blob_2 are submitted after Blob_1, then executed later. Transaction_2 will pass.

It is normal and expected.

Attack Scenario

Suppose that Movement configure block build time is quick, for example 1 seconds with 2048 transactions in 1 block

Both Blob_1 and Blob_2 are in the Celestia mempool waiting for including the next block

This is likely happened more when the Celestia network is in high trafic with a lot of transactions in the mempool waiting for including the next block. When the Movement network is busy. There are a lot of transactions in the mempool of Movement.

So Blob_1 and Blob_2 are in Celestia Mempool waiting for being included in Celestia Block.

An attacker can execute front-run attack and submit Blob_2 with higher gas price.

So Blob_2 will be included first and then executed first. Blob_1 will be executed later.

Since transaction_2 of Alice will be executed first, it will fail, because of wrong sequence number.

Since 1 blob can contain 2048 transactions so there is high possibility that changing blob orders can cause transactions of many users to revert.

Cost of attack analysis

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.

So the data probably can be bigger and cost more gas, but let's take 0.2 USD as sample for this.

So for this one attack, the attacker need to spend 0.2 USD.

Severity Assessment

Bug Severity: High

Impact:

  • Lost of user fund: If the Movement enable charge gas fee for failed transactions, then users can lost money for failed transactions

  • Causing users transactions revert unexpectedly

  • Causing network processing nodes to process transactions from the mempool beyond set parameters

Likelihood:

  • High as No special privileges required

  • Can be executed by any attacker with very cheap cost of attack. The cost of an attack is just 0.2 USD

Recommendation

There should be some mechanism to control the blob sent to Celestia and receive to be in right order. Should not rely only on Celestia block height.

Proof of Concept

Proof of concept

The following attack scenario demonstrate the bug.

Step 0: An attacker monitor the precondition for the attack.

When the Celestia network is in high trafic with a lot of transactions. When the Movement network is busy. There are a lot of transactions in the mempool.

Attacker monitors blob submission from Movemnet DA Lightnode to Celestia Namespace.

Suppose there are 2 blobs in Celestia Mempool: Blob_1, Blob_2

Step 1: Attacker take Blob_2 and submit it with higher gas to make it included first .

On Movement Execution node. Blob_2 will be executed before Blob_1. So transactions of many users can be reverted due to wrong sequence number

The below attached sequence diagram help to visualize the attack:

Was this helpful?