President Elector

First Flight #24
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: medium
Valid

Denial of Service via Candidate List Inflation in selectPresident

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:

  1. 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).

  2. 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 {
// Step 1: Create a reasonable number of voters for the test
uint256 numVoters = 50; // Increased number of voters to accumulate more candidates
address[] memory testVoters = new address[]();
for (uint256 i = 0; i < numVoters; i++) {
testVoters[i] = address(uint160(i + VOTERS_ADDRESS_MODIFIER));
}
// Step 2: Deploy a new RankedChoice contract with these voters
rankedChoice = new RankedChoice(testVoters);
// Step 3: Each voter submits a ranking with MAX_CANDIDATES unique candidates
for (uint256 i = 0; i < numVoters; i++) {
address[] memory orderedCandidates = new address[]();
// Each voter submits unique candidates to inflate the total number
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 to track registered candidates
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 {
// Existing checks
if (orderedCandidates.length > MAX_CANDIDATES) {
revert RankedChoice__InvalidInput();
}
if (!_isInArray(VOTERS, voter)) {
revert RankedChoice__InvalidVoter();
}
// New check for registered candidates
for (uint256 i = 0; i < orderedCandidates.length; i++) {
if (!registeredCandidates[orderedCandidates[i]]) {
revert RankedChoice__CandidateNotRegistered();
}
}
// New check for duplicates within orderedCandidates
if (_hasDuplicates(orderedCandidates)) {
revert RankedChoice__DuplicateCandidatesInRanking();
}
// Check if the voter has already voted
if (s_rankings[voter][s_voteNumber].length > 0) {
revert RankedChoice__VoterHasAlreadyVoted();
}
// Internal Effects
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();
}
// Gather all unique candidates from voters' rankings
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) {
// Reset vote counts for this round
for (uint256 i = 0; i < candidateList.length; i++) {
s_candidateVotesByRound[candidateList[i]][s_voteNumber][roundNumber] = 0;
}
// Tally votes for this round
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;
}
}
}
// Find the candidate with the fewest votes
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];
}
}
// Remove the candidate with the fewest votes
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();
}
// Set the new president
s_currentPresident = candidateList[0];
// Corrected line to clear the candidate list
s_candidateList = new address[](0); // Clear the candidate list
s_previousVoteEndTimeStamp = block.timestamp;
s_voteNumber += 1;
}

Explanation:

  • Candidate Registration: Only registered candidates can be included in voters' rankings, preventing the inflation of s_candidateList.

  • 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 {
// Step 1: Create a reasonable number of voters for the test
uint256 numVoters = 50;
address[] memory testVoters = new address[]();
for (uint256 i = 0; i < numVoters; i++) {
testVoters[i] = address(uint160(i + VOTERS_ADDRESS_MODIFIER));
}
// Step 2: Deploy a new RankedChoice contract with these voters
rankedChoice = new RankedChoice(testVoters);
// Step 3: Register a limited number of candidates
uint256 numCandidates = 10; // Limiting the total number of candidates
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();
}
// Step 4: Each voter submits a ranking using registered candidates
for (uint256 i = 0; i < numVoters; i++) {
address[] memory orderedCandidates = new address[]();
// Voters rank the registered candidates
for (uint256 j = 0; j < MAX_CANDIDATES; j++) {
orderedCandidates[j] = registeredCandidates[j % numCandidates];
}
vm.prank(testVoters[i]);
rankedChoice.rankCandidates(orderedCandidates);
vm.stopPrank();
}
// Step 5: Move forward in time to allow selecting a new president
vm.warp(block.timestamp + rankedChoice.getDuration());
// Step 6: Attempt to call selectPresident
rankedChoice.selectPresident();
// Step 7: Assert that a president was selected
address newPresident = rankedChoice.getCurrentPresident();
assertTrue(newPresident != address(0), "No president was selected.");
// Optionally, log the new president's address
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.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

A high number of candidates could cause an OOG

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.