#42936 [BC-Critical] Potential Deadlock or Panic Due to Concurrent Lock Acquisition in `TransactionPipe`
Submitted on Mar 29th 2025 at 18:29:09 UTC by @savi0ur for Attackathon | Movement Labs
Report ID: #42936
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/execution/maptos/opt-executor
Impacts:
Network not being able to confirm new transactions (total network shutdown)
Description
Bug Description
The TransactionPipe struct manages incoming transactions and uses an Arc<RwLock<GcCounter>> to track transactions in flight. Multiple methods acquire this lock, and the code may panic or deadlock if the lock is acquired recursively or concurrently in a way that poisons it. Specifically, the receive_transaction_tick and submit_transaction methods acquire the lock with write().unwrap() without ensuring it isn’t already held, which could lead to a panic, due to unwrap() (e.g., when tried to acquired the write lock again).
https://doc.rust-lang.org/std/sync/struct.RwLock.html#panics-1
Panics This function might panic when called if the lock is already held by the current thread.
Here are the relevant code blocks:
In
receive_transaction_tickduring garbage collection: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L151
pub(crate) async fn receive_transaction_tick(&mut self) -> Result<(), Error> {
// ...
if self.last_gc.elapsed() >= GC_INTERVAL {
let now = Instant::now();
let epoch_ms_now = chrono::Utc::now().timestamp_millis() as u64;
// ...
{
let mut transactions_in_flight = self.transactions_in_flight.write().unwrap(); //@audit-issue this will panic when call again after holding the lock.
transactions_in_flight.gc(epoch_ms_now);
}
// ...
self.last_gc = now;
}In
submit_transactionwhen incrementing transactions in flight: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L296
async fn submit_transaction(
&mut self,
transaction: SignedTransaction,
) -> Result<SubmissionStatus, Error> {
// ...
match status.code {
MempoolStatusCode::Accepted => {
let now = chrono::Utc::now().timestamp_millis() as u64;
// ...
{
let mut transactions_in_flight = self.transactions_in_flight.write().unwrap(); //@audit-issue this will panic when call again after holding the lock.
transactions_in_flight.increment(now, 1);
}
// ...
}
}In
submit_transactionwhen reading the current count: https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L231
let in_flight = {
let transactions_in_flight = self.transactions_in_flight.read().unwrap(); //@audit-issue this will panic when call again after holding the lock.
transactions_in_flight.get_count()
};The use of unwrap() on RwLock operations means that if the lock is poisoned (e.g., due to a panic while held), or if a reentrant call occurs within the same thread, the program will crash. This is particularly risky in an async context where TransactionPipe::tick method is called in a loop, potentially leading to recursive or concurrent lock access, potentially leading to deadlocks or unexpected behavior if not properly coordinated.
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L99-L108
pub async fn run(mut self) -> Result<(), Error> {
loop {
self.tick().await?;
}
}
/// Performs a transaction read, mempool batch formation, and garbage collection.
pub(crate) async fn tick(&mut self) -> Result<(), Error> {
self.receive_transaction_tick().await
}Impact
In a multi-threaded or async context, simultaneous attempts to acquire the write lock (e.g., during garbage collection and transaction submission) could lead to contention or deadlocks, stalling the system.
If the RwLock tried to acquired again in multi-threaded or async context, it will cause a panic due to unwrap(). Any subsequent unwrap() call will crash the application, halting transaction processing and potentially disrupting the mempool services.
References
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L151
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L296
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L231
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L99-L108
https://doc.rust-lang.org/std/sync/struct.RwLock.html#panics-1
Recommendation
Avoid unwrapping locks by replacing unwrap() with proper error handling using match to gracefully handle lock acquisition.
let mut transactions_in_flight = match self.transactions_in_flight.write() {
Ok(guard) => guard,
Err(e) => {
warn!("Failed to acquire write lock on transactions_in_flight: {:?}", e);
return Err(Error::InternalError("Lock poisoned".to_string()));
}
};For read operations, use try_read() to avoid blocking or panicking if the lock is unavailable:
let in_flight = match self.transactions_in_flight.try_read() {
Ok(guard) => guard.get_count(),
Err(_) => {
warn!("Failed to acquire read lock on transactions_in_flight");
return Err(Error::InternalError("Lock contention".to_string()));
}
};Proof of Concept
Proof of Concept
An attacker can submit transactions to the
TransactionPipevia the mempool client.The
submit_transactionmethod processes the transaction and acquires the write lock:
let mut transactions_in_flight = self.transactions_in_flight.write().unwrap();
transactions_in_flight.increment(now, 1);The next call to
receive_transaction_tick(e.g., during garbage collection) attempts to acquire the lock (Note: this is very likely to happens in a multi-threaded and async environment):
let mut transactions_in_flight = self.transactions_in_flight.write().unwrap(); // Panics here
transactions_in_flight.get_count()The unwrap() panic and crash the application.
The node becomes unavailable, disrupting transaction processing and potentially affecting network consensus if multiple nodes are affected.
Was this helpful?