#48717 [SC-Insight] RateLimiter current capacity can be permanently held at zero
Submitted on Jul 7th 2025 at 10:03:32 UTC by @Blobism for Audit Comp | Folks Smart Contract Library
Report ID: #48717
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/Folks-Finance/algorand-smart-contract-library/blob/main/contracts/library/RateLimiter.py
Impacts:
Permanent denial of service of a smart contract functionality
Bypass of the rate limit beyond set parameters
Description
Brief/Intro
An integer division in the RateLimiter capacity update allows an attacker to hold the current capacity of a rate limit bucket at zero WITHOUT having to actually fill the capacity of that bucket. The attack can be conducted on any rate limiting bucket, even those which may be designed to rate limit the actions of specific users.
Vulnerability Details
The fundamental bug is in RateLimiter _update_capacity:
def _update_capacity(self, bucket_id: Bytes32) -> None:
# fails if bucket is unknown
rate_limit_bucket = self._get_bucket(bucket_id)
# ignore if duration is zero
if not rate_limit_bucket.duration.native:
return
# increase capacity by fill rate of <limit> per <duration> without exceeding limit
time_delta = Global.latest_timestamp - rate_limit_bucket.last_updated.native
new_capacity_without_max = rate_limit_bucket.current_capacity.native + (
(rate_limit_bucket.limit.native * time_delta) // rate_limit_bucket.duration.native # <--- issue 1
)
# update capacity and last updated timestamp
self.rate_limit_buckets[bucket_id].current_capacity = rate_limit_bucket.limit \
if new_capacity_without_max > rate_limit_bucket.limit else ARC4UInt256(new_capacity_without_max)
self.rate_limit_buckets[bucket_id].last_updated = ARC4UInt64(Global.latest_timestamp) # <--- issue 2The first issue is that the integer division will result in zero if time_delta is sufficiently small. The second issue is that even if the change in capacity is zero due to this integer division, the last_updated value is still set to the latest timestamp.
What this means is that if _update_capacity is called frequently enough, the bucket will never refill its capacity, because the last_updated value will keep getting updated to a new timestamp without any increase in current capacity.
While _update_capacity itself is not exposed via the ABI, an attacker can get access to it by calling the get_current_capacity method on-chain. Note that while get_current_capacity is marked "readonly", it can be invoked on-chain to update state: a distinct bug which is useful for this attack.
Attack Scenario 1: Global Rate Limit
A critical smart contract method is accessible to everyone but placed behind a rate limiter
An attacker uses this method or lets others invoke the method to drain the capacity of the bucket to zero
Now, the attacker can frequently invoke
get_current_capacityon-chain to keep the bucket capacity at zero, without having to invoke the critical smart contract method at all
Attack Scenario 2: Per-User Throttling
A critical smart contract method is rate-limited per-user, so each user has a bucket which contains their current capacity
The bucket is still viewable to the attacker with
get_current_capacity, as RateLimiter places no restrictions on thisThe attacker will constantly call
get_current_capacityon the bucket of the user or users they want to deny service toEvery time those users reduce the capacity of their own buckets by calling the critical smart contract method, the capacity will never refill again, due to the attacker repeatedly calling
get_current_capacityon those bucketsEventually, the current capacity of the bucket will hit zero, and will stay at zero as long as the attacker keeps calling
get_current_capacityon-chain
Impact Details
Critical functionality of a smart contract can be permanently halted if it is behind a rate limiter, as long as an attacker has funds to keep invoking the _update_capacity method on-chain. Thus, this is more feasible with methods that have a restrictive rate limit, but could potentially be applied to any rate limited method.
References
See contracts/library/RateLimiter.py
Proof of Concept
Proof of Concept
The PoC below demonstrates an attacker repeatedly calling get_current_capacity on-chain, forcing the bucket capacity to stay at zero, despite the fact that enough time should have passed for the capacity to go above zero.
Save the diff below to poc.diff then run git apply poc.diff.
Then run the test like this:
Was this helpful?