30655 - [SC - Critical] Binary search does not correctly handle duplica...
Submitted on May 3rd 2024 at 05:25:10 UTC by @Holterhus for Boost | Alchemix
Report ID: #30655
Report type: Smart Contract
Report severity: Critical
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/VotingEscrow.sol
Impacts:
Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results
Description
Brief/Intro
Throughout the VotingEscrow
contract, there are several functions that conduct a binary search to determine the first entry containing a timestamp that's before or equal to a target timestamp. In some of these functions, the binary search does not deterministically handle the scenario where multiple exact matches exist. By strategically checkpointing multiple times on a given timestamp, an attacker can inflate the relative amount of power the system considers them to have. This behavior can be abused to change the results of a governance proposal, or to receive more rewards than intended.
Vulnerability Details
The totalSupplyAtT()
function has the following binary search implementation:
Notice that in this function, the binary search is instantly concluded when a match is found. This behavior means that totalSupplyAtT()
can potentially return any of the exact matches in the pointHistory
mapping. This is incorrect. An example of a correct implementation would be something similar to _balanceOfTokenAt()
, which always returns the "right-most" (i.e. most recent) exact match.
This would not be a problem if each entry had a unique timestamp. However, there can be duplicate timestamps in the pointHistory
entries (for example, notice how _checkpoint()
will always increase _epoch
by 1). On the other hand there can't be duplicate timestamps in thecheckpoints
mapping (for example, see the _findWhatCheckpointToWrite()
function).
So, in the case of an exact timestamp match, the getPastVotes()
and balanceOfTokenAt()
functions always return the state when the timestamp ended, but the totalSupplyAtT()
function can return any of the states from that timestamp. This leads to incorrect results in places that compare values from both functions (e.g. in the claimable()
calculation in the RevenueHandler
, or the _quorumReached()
function in the AlchemixGovernor
)
Impact Details
Consider, for example, the following scenario:
A proposal in the
AlchemixGovernor
has a snapshot timestamp at timet
.At exactly time
t
, an attacker callscheckpoint()
and thencreateLock()
in theVotingEscrow
contract.There are now two entries in
pointHistory
at timestampt
- one with the increased supply fromcreatelock()
, and one with the original supply.The
totalSupplyAtT()
function can return either one depending on the number of epochs. The attacker can force the original supply to be returned by arbitrarily adding more checkpoints (to change the result of the binary search).Now, the
_quorum()
value can be arbitrarily changed by callingcheckpoint()
. This means the governance can consider some proposals to have reached quorum, even though they did not.
Other than this, it appears that RevenueHandler
logic can be tricked into giving a user too many tokens (more than 100% of the entire epoch even).
References
See the proof of concept below.
Proof of Concept
I've created the following test case which can be added to the AlchemixGovernorTest
contract:
By running forge test --rpc-url <ETH_RPC_URL> --match-test testBinarySearchExploit -vvv
I get the following output:
which shows that a governance proposal can have its result manipulated.
Last updated