30655 - [SC - Critical] Binary search does not correctly handle duplica...
Last updated
Was this helpful?
Last updated
Was this helpful?
Submitted on May 3rd 2024 at 05:25:10 UTC by @Holterhus for
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
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.
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
)
Consider, for example, the following scenario:
A proposal in the AlchemixGovernor
has a snapshot timestamp at time t
.
At exactly time t
, an attacker calls checkpoint()
and then createLock()
in the VotingEscrow
contract.
There are now two entries in pointHistory
at timestamp t
- one with the increased supply from createlock()
, 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 calling checkpoint()
. 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).
See the proof of concept below.
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.