# 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**](https://immunefi.com/audit-competition/movement-labs-attackathon)

* **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>

```rust
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>

```rust
    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>

```rust
/// 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>

```rust

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.

```rust
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.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/movement-labs-attackathon/42153-bc-critical-attackers-can-exploit-bug-in-blob-verification-to-execute-replay-attack-by-re-exec.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
