#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_tick
during 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_transaction
when 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_transaction
when 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
TransactionPipe
via the mempool client.The
submit_transaction
method 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?