31552 - [SC - Insight] Lack of the validation for a Flash token protec...
Submitted on May 21st 2024 at 08:14:05 UTC by @Oxmuxyz for Boost | Alchemix
Report ID: #31552
Report type: Smart Contract
Report severity: Insight
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
There is no the validation for a Flash token protection
in the VotingEscrow#getVotes()
, which potentially allow a malicious actor to launch a Flash token
attack to manipulate the governance voting result.
Vulnerability Details
Within the VotingEscrow contract, the ownershipChange
storage would be defined to set the block number for a tokenId
of veALCX when there is an ownership change like this:
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L82
/// @notice Sets the block number for a tokenId when there is an ownership change
mapping(uint256 => uint256) public ownershipChange;
Within the VotingEscrow#_transferFrom()
, the current block number (block.number
) would be stored into the ownershipChange
storage. According to the inline comment of the VotingEscrow#_transferFrom()
, this is done because of the Flash token protection like this:
Set the block of ownership transfer (for Flash token protection)
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L942-L943
function _transferFrom(address _from, address _to, uint256 _tokenId, address _sender) internal {
...
// Set the block of ownership transfer (for Flash token protection) ///<---------- @audit
ownershipChange[_tokenId] = block.number; ///<---------- @audit
Within the VotingEscrow#balanceOfToken()
, the block number of the _tokenId
of veALCX, which is stored in the ownershipChange
storage, would be checked. If the VotingEscrow#balanceOfToken()
would be called in the same block with the veALCX token transfer (VotingEscrow#_transferFrom()
), 0
would be returned. This reason is to prevent buying and voting in the same block and perform a Flash token protection (as the inline comment explains in the VotingEscrow#_transferFrom()
above). Then, the VotingEscrow#_balanceOfTokenAt()
would internally be invoked like this:
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L367-L368
/// @inheritdoc IVotingEscrow
function balanceOfToken(uint256 _tokenId) external view returns (uint256) {
if (ownershipChange[_tokenId] == block.number) return 0; ///<---------- @audit - it returns 0 if this function would be called in the same block with the veALCX token transfer. However, this check is not present in the VotingEscrow# getVotes() function.
return _balanceOfTokenAt(_tokenId, block.timestamp); ///<---------- @audit
}
The VotingEscrow#getVotes()
would be used to count the voting power of a given account
. Within the VotingEscrow#getVotes()
, the VotingEscrow#_balanceOfTokenAt()
would internally be invoked like this: https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L274
/// @inheritdoc IVotingEscrow
function getVotes(address account) external view override(IVotes, IVotingEscrow) returns (uint256) {
uint32 nCheckpoints = numCheckpoints[account];
if (nCheckpoints == 0) {
return 0;
}
uint256[] memory _tokenIds = checkpoints[account][nCheckpoints - 1].tokenIds;
uint256 votes = 0;
uint256 tokenIdCount = _tokenIds.length;
for (uint256 i = 0; i < tokenIdCount; i++) {
uint256 tId = _tokenIds[i];
votes = votes + _balanceOfTokenAt(tId, block.timestamp); ///<---------- @audit
}
return votes;
}
Within the VotingEscrow#getVotes()
above, it is supposed to be checked whether or not the VotingEscrow#getVotes()
is not called in the same block with the veALCX token transfer (VotingEscrow#_transferFrom()
) before the VotingEscrow#_balanceOfTokenAt()
is internally invoked (like the VotingEscrow#balanceOfToken()
) for a Flash token protection.
However, within the VotingEscrow#getVotes()
above, there is no validation to check whether or not the VotingEscrow#getVotes()
is not called in the same block with the veALCX token transfer (VotingEscrow#_transferFrom()
) before the VotingEscrow#_balanceOfTokenAt()
is internally invoked.
This is problematic. Because, within the VotingEscrow#getVotes()
above, the VotingEscrow#_balanceOfTokenAt()
will internally be invoked with each tokenId (tId
) of veALCX respectively in the for-loop. Since there is no validation before the VotingEscrow#_balanceOfTokenAt()
is called, the voting power for these all tokenIds (tld
s) of veALCX will be calculated as normal - even if the VotingEscrow#getVotes()
would be called in the same block with the veALCX token transfer (VotingEscrow#_transferFrom()
).
Hence, there will be no Flash token protection - if the voting mechanism integration is made with the VotingEscrow#getVotes()
.
This lead to potentially allowing a malicious actor to launch a Flash token
attack to manipulate the governance voting result.
NOTE: I acknowledge this is a view function and the impact is limited within the current codebase in the scope. However, I assume that the VotingEscrow#getVotes()
will be used for actual voting and user voting powers will be determined based on that. I submitted it as medium severity even though voting manipulations would normally be high because of that assumption.
Impact Details
This allow a malicious actor to launch a Flash token attack by utilizing the VotingEscrow#getVotes()
- if the voting mechanism integration is made with the VotingEscrow#getVotes()
.
PoC
References
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L942-L943
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L367-L368
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L274
Recommendation
Within the VotingEscrow#getVotes()
, consider implementing the validation to check whether or not the VotingEscrow#getVotes()
is not called in the same block with the veALCX token transfer (VotingEscrow#_transferFrom()
) before the VotingEscrow#_balanceOfTokenAt()
is internally invoked like this:
/// @inheritdoc IVotingEscrow
function getVotes(address account) external view override(IVotes, IVotingEscrow) returns (uint256) {
uint32 nCheckpoints = numCheckpoints[account];
if (nCheckpoints == 0) {
return 0;
}
uint256[] memory _tokenIds = checkpoints[account][nCheckpoints - 1].tokenIds;
uint256 votes = 0;
uint256 tokenIdCount = _tokenIds.length;
for (uint256 i = 0; i < tokenIdCount; i++) {
uint256 tId = _tokenIds[i];
- votes = votes + _balanceOfTokenAt(tId, block.timestamp);
+ /// @audit - Only add the voting power (votes) if it is not the same block.
+ if (ownershipChange[_tokenId] != block.number) {
+ votes = votes + _balanceOfTokenAt(tId, block.timestamp);
+ }
}
return votes;
}
Proof of Concept
Last updated
Was this helpful?