42153 [BC-Critical] attackers can exploit bug in blob verification to execute replay attack by re executing blobs

#42153 [BC-Critical] Attackers can exploit bug in Blob Verification to execute replay attack by re-executing blobs

Submitted on Mar 21st 2025 at 10:40:58 UTC by @perseverance for Attackathon | Movement Labs

  • Report ID: #42153

  • Report Type: Blockchain/DLT

  • Report severity: Critical

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

  • Impacts:

    • Direct loss of funds

Description

Background Information

Description

Brief/Intro

A critical vulnerability exists in Movement Network's Data Availability (DA) layer where the blob ID verification process is incomplete. The current implementation only verifies the signature against the computed ID but does not verify if the blob ID matches the computed ID. This can lead to potential replay attacks and inconsistent state across the network.

According to the architecture of Movement Network ( https://docs.movementnetwork.xyz/general/Mainnet/high_level_architecture ), the blob data is sent and received from Celesia DA Service.

As Celestia is a public chain, anyone can submit malformed blobs. So verification of Blob Data is very important.

InnerSignedBlobV1 has following structure: https://github.com/immunefi-team/attackathon-movement/blob/main/protocol-units/da/movement/protocol/util/src/blob/ir/blob.rs#L12-L20

pub struct InnerSignedBlobV1<C>
where
	C: Curve,
{
	data: InnerSignedBlobV1Data<C>,
	signature: Vec<u8>,
	signer: Vec<u8>,
	id: Id,
}

When receive blobs from Celestia, the node verify the blobs. This verification is very important to protect from hacks.

The vulnerability

Vulnerability Details

The vulnerability exists in the try_verify function in blob.rs:

https://github.com/immunefi-team/attackathon-movement/blob/main/protocol-units/da/movement/protocol/util/src/blob/ir/blob.rs#L35-L46

    pub fn try_verify(&self) -> Result<(), anyhow::Error> {
		let public_key = C::PublicKey::try_from_bytes(self.signer.as_slice())?;
		let signature = C::Signature::try_from_bytes(self.signature.as_slice())?;
		let message = self.data.compute_id()?;
		info!("verifying signature for message {:?}", message);

		if !C::verify(message.as_slice(), &signature, &public_key)? {
			return Err(anyhow::anyhow!("signature verification failed"))?;
		}

		Ok(())
	}

Notice that the code compute_id from the data of InnerSignedBlobV1. So the id is computed from the blob data and timestamp digest. The signature is verified against this compute_id

https://github.com/immunefi-team/attackathon-movement/blob/main/protocol-units/da/movement/protocol/util/src/blob/ir/data.rs#L38-L48

/// Gets an owned copy of the bytes to be signed
	fn to_signing_bytes(&self) -> Vec<u8> {
		[self.blob.as_slice(), &self.timestamp.to_be_bytes()].concat()
	}

	/// Computes the id of InnerSignedBlobV1Data
	pub fn compute_id(&self) -> Result<Id, anyhow::Error> {
		let byte_slice = self.to_signing_bytes();

		Ok(Id::new(C::digest(&byte_slice)?.to_bytes()))
	}

But there is no verification that the blob id is matching the compute_id.

So this allows the attack scenario:

An attacker can take a valid blob from Celestia, modify the id, and post it on Celestia again. Such a blob will pass all the verification steps in movement da and end up in the full-node execution task where blob.id is used for de-duplicating the execution. Since the ID is now forged and new, it will be incorrectly re-executed.

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


async fn process_block_from_da(
		&mut self,
		response: StreamReadFromHeightResponse,
	) -> anyhow::Result<()> {
    
    // SNIP 
    // get the block
		let (block_bytes, block_timestamp, block_id, da_height) = match response
			.blob
			.ok_or(anyhow::anyhow!("No blob in response"))?
			.blob_type
			.ok_or(anyhow::anyhow!("No blob type in response"))?
		{
			// To allow for DA migrations we accept both sequenced and passed through blobs
			blob_response::BlobType::SequencedBlobBlock(blob) => {
				(blob.data, blob.timestamp, blob.blob_id, blob.height)
			}
			// To allow for DA migrations we accept both sequenced and passed through blobs
			blob_response::BlobType::PassedThroughBlob(blob) => {
				(blob.data, blob.timestamp, blob.blob_id, blob.height)
			}
			blob_response::BlobType::Heartbeat(_) => {
				tracing::info!("Receive DA heartbeat");
				// Do nothing.
				return Ok(());
			}
			_ => anyhow::bail!("Invalid blob type"),
		};

		info!(
			block_id = %hex::encode(block_id.clone()),
			da_height = da_height,
			time = block_timestamp,
			"Processing block from DA"
		);

    // check if the block has already been executed
		if self.da_db.has_executed_block(block_id.clone()).await? {
			info!("Block already executed: {:#?}. It will be skipped", block_id);
			return Ok(());
		}
}

Is it a known issue or not?

NO.

In my research, this bug is very similar to a closed bug in Movement issues: https://github.com/movementlabsxyz/movement/issues/877

The bug 877 was closed because it was fixed in pull request https://github.com/movementlabsxyz/movement/pull/888

In that pull request, the movement team introduced the compute_id and include the id in the signature to prevent hacks.

But my bug report circumvented this check. Because although the signature is signed on the id, but since in the function process_block_from_da , the signature verification verify the signature with the computed id. But there is no verification of the blob id.

Since this bug is very similar to report 877, so the attack scenario is valid as commented by Movement team in report 877

About the severity assessment

This is assessed as Critical severity because:

Severity : Critical

Impact: Direct loss of funds

Because:

  • Can lead to replay attacks

  • Can lead to Direct loss of funds

  • If the re-execution of blobs result in succesful transactions then it is double spending.

  • if the re-exeuction is failed, then gas of users can be deducted. There is a known issue of not charging for failed transactions https://github.com/movementlabsxyz/movement/issues/409 . But this will be fixed, so anyway the users balance can be deducted.

Likelihood: High

Since an attacker can exploit this and gain profits, so it is highly likely to happen.

Mitigation

In my understanding, the fix requires adding ID verification in the try_verify function.

pub fn try_verify(&self) -> Result<(), anyhow::Error> {
    let public_key = C::PublicKey::try_from_bytes(self.signer.as_slice())?;
    let signature = C::Signature::try_from_bytes(self.signature.as_slice())?;
    
    // Compute ID from data
    let computed_id = self.data.compute_id()?;
    
    // @note to fix the bug 
    // Verify ID matches
    if computed_id != self.id {
        return Err(anyhow::anyhow!("ID mismatch: computed ID does not match stored ID"))?;
    }
    
    // Verify signature
    if !C::verify(computed_id.as_slice(), &signature, &public_key)? {
        return Err(anyhow::anyhow!("signature verification failed"))?;
    }

    Ok(())
}

This ensures both the signature and ID are properly verified before processing the blob.

Proof of Concept

Proof of concept

The vulnerability can be triggered when:

Step 1: Fork a malicious blob from a valid blob with:

  • Valid data and timestamp

  • Valid signature for computed ID

  • Different valid ID than computed ID that computed from data and timestamp

Step 2: Send the malicious blob to Celestia

Step 3. The node will read data from Celestia and will verify the blob

  • Compute ID from data and timestamp

  • Verify signature against computed ID . => This will pass

Expected result: The blob will be re-executed despite having mismatched IDs, demonstrating the vulnerability.

Was this helpful?