#43150 [BC-High] Excessive transaction processing caused by a faulty garbage collector in transaction_pipe.rs
Submitted on Apr 2nd 2025 at 20:39:14 UTC by @avoloder for Attackathon | Movement Labs
Report ID: #43150
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/execution/maptos/opt-executor
Impacts:
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
It is possible to submit more transactions to the mempool than initially allowed (beyond set parameter, i.e., beyond set limit) due to the miscalculation of transactions in flight (falty garbage collection logic)
Vulnerability Details
transaction_pipe.rs
is responsible for preprocessing and validating transactions before submitting them to the mempool. The receive_transaction_tick()
function receives and adds all transactions to the mempool while also periodically garbage-collecting transactions in flight. A transaction is considered in flight until it exits the mempool and is sent to the DA. In submit_transactions()
, there is a check to determine if the transaction-in-flight limit has been reached, indicating that the mempool is full and no further transactions should be processed at that moment.
// For now, we are going to consider a transaction in flight until it exits the mempool and is sent to the DA as is indicated by WriteBatch.
let in_flight = {
let transactions_in_flight = self.transactions_in_flight.read().unwrap();
transactions_in_flight.get_count()
};
info!(
target: "movement_timing",
in_flight = %in_flight,
"transactions_in_flight"
);
if let Some(inflight_limit) = self.in_flight_limit {
if in_flight >= inflight_limit {
info!(
target: "movement_timing",
"shedding_load"
);
let status = MempoolStatus::new(MempoolStatusCode::MempoolIsFull);
return Ok((status, None));
}
}
We can see that the transactions_in_flight
is of type GcCounter
// Shared reference on the counter of transactions in flight.
transactions_in_flight: Arc<RwLock<GcCounter>>
Furthermore we can see that the GcCounter is a struct defined like this:
/// A garbage collected count, represented as a u64 sum.
/// As slots exit the active window and `gc` is called, they will no longer be included in the count.
/// Note: in the current implementation, the total sum returned by `get_count` is allowed to overflow.
pub struct GcCounter {
/// The number of some unit time a value is valid for.
value_ttl: Duration,
/// The duration of a garbage collection slot in some unit time.
/// This is used to bin values into slots for O(value_ttl/gc_slot_duration * log value_ttl/gc_slot_duration) garbage collection.
gc_slot_duration: Duration,
/// The value lifetimes, indexed by slot.
value_lifetimes: BTreeMap<u64, u64>,
}
The value_lifetimes
BTreeMap is used to count the number of transactions within a given time slot and sum them for the total count when checking the inflight_limit
. This allows for tracking the overall number of transactions while also pruning expired ones.
The issue arises in the gc()
function due to how it removes expired transactions
/// Garbage collects values that have expired.
/// This should be called periodically.
pub fn gc(&mut self, current_time: u64) {
let gc_slot = current_time / self.gc_slot_duration.get();
// remove all slots that are too old
let slot_cutoff =
gc_slot.saturating_sub(self.value_ttl.get() / self.gc_slot_duration.get());
let to_keep = self.value_lifetimes.split_off(&(slot_cutoff + 1));
// Now, `self.value_lifetimes` contains only entries with keys < `slot_cutoff`.
// Reassign `self.value_lifetimes` to `to_keep` to keep only entries >= `slot_cutoff`.
self.value_lifetimes = to_keep;
}
The function calculates the slot_cutoff
, that is the slot below which all transactions are considered old and will be removed. Afterwards it conduct split_off
of BTreeMap value_lifetimes
at keyslot_cutoff + 1
. The comments below indicate that after split_off
the value_lifetimes
contains entries with keys < slot_cutoff
and then afterwards reassigns value_lifetimes
to to_keep
, where the expected behavior is to keep only entries >= slot_cutoff
. This is however not true, considering how split_off
function of a BTreeMap
works. From the official documentation:
"Splits the collection into two at the given key. Returns everything after the given key, including the key".
The function calls split_off
with the key slot_cutoff + 1
which means that to_keep
does not contain values at key slot_cutoff
but rather all values after and equal to slot_cutoff + 1
. After reassigning the value_lifetimes
it will be = to_keep
and the values equal and below slot_cutoff
will be pruned, which contradicts the comment "Reassign self.value_lifetimes
to to_keep
to keep only entries >= slot_cutoff
" and therefore does not fulfill a desired behavior
Impact Details
The gc()
function removes one slot more than needed affecting the total count of transactions in flight and therefore wrongly assume that fewer transactions are in flight than there actually are. This can lead to over-submitting new transactions beyond the intended limit (inflight_limit), potentially overloading the mempool, since the system specifically relies on the inflight_limit
to manage transactions load.
References
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/util/collections/src/garbage/counted.rs#L64-L78
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L148-L153
https://github.com/immunefi-team/attackathon-movement/blob/a2790c6ac17b7cf02a69aea172c2b38d2be8ce00/protocol-units/execution/maptos/opt-executor/src/background/transaction_pipe.rs#L229-L248
Proof of Concept
Proof of Concept
Let's assume the following:
value_ttl: Duration::from_secs(10)
// Values valid for 10s
gc_slot_duration: Duration::from_secs(2)
// Each slot covers 2s
so we have the following GcCounter
:
let mut counter = GcCounter {
value_ttl: Duration::from_secs(10)
gc_slot_duration: Duration::from_secs(2)
value_lifetimes: BTreeMap::new() // Initially empty
};
For the sake of simplicity let's assume we added values at seconds
counter.increment(1, 5); // Time = 1s, value = 5
counter.increment(3, 10); // Time = 3s, value = 10
counter.increment(4, 20); // Time = 4s, value = 20
counter.increment(6, 3); // Time = 6s, value = 3
counter.increment(10, 7); // Time = 8s, value = 7
After doing slot calculation, given gc_slot_duration = 2s
we have following slots and values in the BTreeMap:
{
0 => 5,
1 => 10,
2 => 20,
3 => 3,
5 => 7
}
Calling a gc()
at time 12s
results in the following values:
gc_slot = 12s / 2s = 6s
slot_cutoff = 6 - (10 / 2) = 1
Slot cutoff is at the key 1, which means that only transactions count at slot 0 should be removed, since the end value of value_lifetimes
should contain all entries >= slot_cutoff
.
However, we call split_off(1 + 1)
, where the to_keep
will be the new BTreeMap with following values (according to the split_off
definition, since we do split_off at the key 2):
{
2 => 20,
3 => 3,
5 => 7
}
Now the value_lifetimes
is reassigned to to_keep
which does not contain transaction counts at slot =slot_cutoff
, therefore removing one slot more than intended.
To simplify it further, if the inflight_limit
is 500 and we have 5 equal slots each containing 100 transactions count, in this case, the gc()
should remove only one slot, allowing another 100 transactions to be submitted before filling up the mempool. However, due to the above described, wrong expectations of the split_off()
behavior, 200 transaction counts (2 slots) will be removed, allowing another 200 to enter the mempool (before reaching the limit of the mempool) which should not be the case.
Was this helpful?