Summary
The selectPresident
function in the RankedChoice.sol
contract is vulnerable to a Denial of Service (DoS) attack. By inflating the number of unique candidates through malicious rankings, an attacker can cause the function to exceed the block gas limit, preventing the election from concluding and effectively halting the core functionality of the contract.
Vulnerability Details
Issue:
The contract allows each voter to submit up to MAX_CANDIDATES
(10) candidates in their ranking. However, there's no limit on the total number of unique candidates that can be added to the election across all voters. Attackers can exploit this by submitting rankings filled with unique, previously unused candidate addresses, causing the s_candidateList
to grow excessively.
During the selectPresident
function call, the contract processes all these candidates through nested loops and recursive calls in _selectPresidentRecursive
. With a sufficiently large s_candidateList
, the function consumes more gas than the block gas limit allows, leading to a transaction failure due to gas exhaustion.
Proof of Concept:
Attack Scenario:
Multiple malicious voters collude to submit rankings with unique, unused candidate addresses.
Each voter submits the maximum number of candidates (MAX_CANDIDATES
) in their ranking.
The total number of unique candidates increases dramatically (e.g., 50 voters × 10 candidates each = 500 unique candidates).
Effect on selectPresident
:
The function attempts to process all unique candidates.
Due to the increased number of candidates, the function exceeds the block gas limit and fails.
Proof of code:
function testDoSByInflatingCandidates() public {
uint256 numVoters = 50;
address[] memory testVoters = new address[]();
for (uint256 i = 0; i < numVoters; i++) {
testVoters[i] = address(uint160(i + VOTERS_ADDRESS_MODIFIER));
}
rankedChoice = new RankedChoice(testVoters);
for (uint256 i = 0; i < numVoters; i++) {
address[] memory orderedCandidates = new address[]();
for (uint256 j = 0; j < MAX_CANDIDATES; j++) {
orderedCandidates[j] = address(uint160((i * MAX_CANDIDATES) + j + CANDIDATES_ADDRESS_MODIFIER));
}
vm.prank(testVoters[i]);
rankedChoice.rankCandidates(orderedCandidates);
vm.stopPrank();
}
Impact
The Denial of Service vulnerability allows malicious voters to prevent the election process from completing by causing the selectPresident
function to exceed the gas limit. This effectively halts the core functionality of the contract, as no new president can be selected until the issue is resolved.
Tools Used
Foundry and Forge: Used to write and execute the test cases demonstrating the vulnerability and verifying the effectiveness of the fix.
Recommendations
To mitigate the Denial of Service vulnerability via candidate list inflation, the following steps are recommended:
Step 1: Implement Candidate Registration to Limit Total Candidates
Objective: Limit the total number of candidates by requiring them to register before the election starts. Only registered candidates can be ranked by voters.
Implementation:
a. Add a Mapping for Registered Candidates
In your RankedChoice.sol
contract, add the following state variable:
mapping(address => bool) public registeredCandidates;
b. Create a Candidate Registration Function
function registerCandidate() external {
registeredCandidates[msg.sender] = true;
}
c. Modify _rankCandidates
to Check for Registered Candidates
Update the _rankCandidates
function:
function _rankCandidates(
address[] memory orderedCandidates,
address voter
) internal {
if (orderedCandidates.length > MAX_CANDIDATES) {
revert RankedChoice__InvalidInput();
}
if (!_isInArray(VOTERS, voter)) {
revert RankedChoice__InvalidVoter();
}
for (uint256 i = 0; i < orderedCandidates.length; i++) {
if (!registeredCandidates[orderedCandidates[i]]) {
revert RankedChoice__CandidateNotRegistered();
}
}
if (_hasDuplicates(orderedCandidates)) {
revert RankedChoice__DuplicateCandidatesInRanking();
}
if (s_rankings[voter][s_voteNumber].length > 0) {
revert RankedChoice__VoterHasAlreadyVoted();
}
s_rankings[voter][s_voteNumber] = orderedCandidates;
}
d. Define Custom Errors at the top of the contract:
error RankedChoice__CandidateNotRegistered();
error RankedChoice__DuplicateCandidatesInRanking();
error RankedChoice__VoterHasAlreadyVoted();
e. Add a Helper Function to Check for Duplicates
function _hasDuplicates(address[] memory array) internal pure returns (bool) {
for (uint256 i = 0; i < array.length; i++) {
for (uint256 j = i + 1; j < array.length; j++) {
if (array[i] == array[j]) {
return true;
}
}
}
return false;
}
The use of this helper function inside the rankCandidates
ensures that voters cannot include the same candidate multiple times in their ranking.
Step 2: Optimize the selectPresident
function
Objective: Replace the recursive _selectPresidentRecursive
function with an iterative approach to reduce gas consumption and ensure the function can handle the election process efficiently.
Implementation:
Modify the selectPresident
function as follows:
function selectPresident() external {
if (block.timestamp - s_previousVoteEndTimeStamp <= i_presidentalDuration) {
revert RankedChoice__NotTimeToVote();
}
for (uint256 i = 0; i < VOTERS.length; i++) {
address[] memory orderedCandidates = s_rankings[VOTERS[i]][s_voteNumber];
for (uint256 j = 0; j < orderedCandidates.length; j++) {
if (!_isInArray(s_candidateList, orderedCandidates[j])) {
s_candidateList.push(orderedCandidates[j]);
}
}
}
address[] memory candidateList = s_candidateList;
uint256 roundNumber = 0;
while (candidateList.length > 1) {
for (uint256 i = 0; i < candidateList.length; i++) {
s_candidateVotesByRound[candidateList[i]][s_voteNumber][roundNumber] = 0;
}
for (uint256 i = 0; i < VOTERS.length; i++) {
address[] memory ranking = s_rankings[VOTERS[i]][s_voteNumber];
for (uint256 j = 0; j < ranking.length; j++) {
address candidate = ranking[j];
if (_isInArray(candidateList, candidate)) {
s_candidateVotesByRound[candidate][s_voteNumber][roundNumber] += 1;
break;
}
}
}
address fewestVotesCandidate = candidateList[0];
uint256 fewestVotes = s_candidateVotesByRound[fewestVotesCandidate][s_voteNumber][roundNumber];
for (uint256 i = 1; i < candidateList.length; i++) {
uint256 votes = s_candidateVotesByRound[candidateList[i]][s_voteNumber][roundNumber];
if (votes < fewestVotes) {
fewestVotes = votes;
fewestVotesCandidate = candidateList[i];
}
}
address[] memory newCandidateList = new address[]();
uint256 index = 0;
for (uint256 i = 0; i < candidateList.length; i++) {
if (candidateList[i] != fewestVotesCandidate) {
newCandidateList[index] = candidateList[i];
index++;
}
}
candidateList = newCandidateList;
roundNumber++;
}
if (candidateList.length != 1) {
revert RankedChoice__SomethingWentWrong();
}
s_currentPresident = candidateList[0];
s_candidateList = new address[](0);
s_previousVoteEndTimeStamp = block.timestamp;
s_voteNumber += 1;
}
Explanation:
Iterative Approach: The recursive function is replaced with an iterative loop to prevent excessive gas consumption and potential stack overflow issues.
Clearing the Candidate List: Using s_candidateList = new address;
effectively clears the candidate list after the election concludes, preparing it for the next voting session.
Testing the Fix:
After applying the recommended fixes, the following test was conducted:
function testDoSByInflatingCandidatesAfterFix() public {
uint256 numVoters = 50;
address[] memory testVoters = new address[]();
for (uint256 i = 0; i < numVoters; i++) {
testVoters[i] = address(uint160(i + VOTERS_ADDRESS_MODIFIER));
}
rankedChoice = new RankedChoice(testVoters);
uint256 numCandidates = 10;
address[] memory registeredCandidates = new address[]();
for (uint256 i = 0; i < numCandidates; i++) {
registeredCandidates[i] = address(uint160(i + CANDIDATES_ADDRESS_MODIFIER));
vm.prank(registeredCandidates[i]);
rankedChoice.registerCandidate();
vm.stopPrank();
}
for (uint256 i = 0; i < numVoters; i++) {
address[] memory orderedCandidates = new address[]();
for (uint256 j = 0; j < MAX_CANDIDATES; j++) {
orderedCandidates[j] = registeredCandidates[j % numCandidates];
}
vm.prank(testVoters[i]);
rankedChoice.rankCandidates(orderedCandidates);
vm.stopPrank();
}
vm.warp(block.timestamp + rankedChoice.getDuration());
rankedChoice.selectPresident();
address newPresident = rankedChoice.getCurrentPresident();
assertTrue(newPresident != address(0), "No president was selected.");
emit log_named_address("New President Selected:", newPresident);
}
Test Result:
The test passed successfully, confirming that the selectPresident
function executed without gas exhaustion.
A new president was selected, indicating that the core functionality of the contract remains intact after applying the fix.