Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

`votingBooth:: _distributeRewards` Incorrect calculation for `rewardPerVoter` leads to user receiving less rewards than supposed to be and extra eth will stuck in the contract forever

Summary

Due to flawed calculations in _distributeRewards function, Maximum of 33% ETH could stuck forever in contract.

Vulnerability Details

In _distributeRewards function, users who have voted for for will be awarded with eth if proposal is passed. If proposal is not passed then all eth is sent back to the s_creator.

function _distributeRewards() private {
// get number of voters for & against
uint256 totalVotesFor = s_votersFor.length;
uint256 totalVotesAgainst = s_votersAgainst.length;
uint256 totalVotes = totalVotesFor + totalVotesAgainst;
// rewards to distribute or refund. This is guaranteed to be
// greater or equal to the minimum funding amount by a check
// in the constructor, and there is intentionally by design
// no way to decrease or increase this amount. Any findings
// related to not being able to increase/decrease the total
// reward amount are invalid
uint256 totalRewards = address(this).balance;
// if the proposal was defeated refund reward back to the creator
// for the proposal to be successful it must have had more `For` votes
// than `Against` votes
if (totalVotesAgainst >= totalVotesFor) {
// proposal creator is trusted to create a proposal from an address
// that can receive ETH. See comment before declaration of `s_creator`
_sendEth(s_creator, totalRewards);
}
// otherwise the proposal passed so distribute rewards to the `For` voters
else {
@> uint256 rewardPerVoter = totalRewards / totalVotes;
for (uint256 i; i < totalVotesFor; ++i) {
// proposal creator is trusted when creating allowed list of voters,
// findings related to gas griefing attacks or sending eth
// to an address reverting thereby stopping the reward payouts are
// invalid. Yes pull is to be preferred to push but this
// has not been implemented in this simplified version to
// reduce complexity & help you focus on finding the
// harder to find bug
// if at the last voter round up to avoid leaving dust; this means that
// the last voter can get 1 wei more than the rest - this is not
// a valid finding, it is simply how we deal with imperfect division
if (i == totalVotesFor - 1) {
rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotes, Math.Rounding.Ceil);
}
_sendEth(s_votersFor[i], rewardPerVoter);
}
}
}

Check the highlighted code. There is flaw in calculation where rewardPerVoter is calculated, here rewardPerVoter means users who have voted for for.
As per current logic of the contract, it divides the available eth with number of total votes. Which reduce the voter share, as it is including the voters which has voted for 'against` as well.

Consider the following example, there are 5 allowed users and contract is initialized with 10 ether. first 2 votes true(for) and 3rd one votes false(against). Now proposal is passed, as per current contract logic, it will calculate reward share as 10 / 3 = 3.33 eth. so user who voted for for, will get 3.33 eth each and 3.33 eth will remain in the contract. There is no way to take that eth out.

This will lead to loss of funds and voters dissatisfaction (as they are receiving less rewards than it supposed to be).

POC

Replace the import like this in existing test suite

- import {Test} from "forge-std/Test.sol";
+ import {Test, console2} from "forge-std/Test.sol";

then add the following function

function testUsersWillReceiveLessEthAndExtraFundStuckInContractForever() public {
/// in setup 5 voters are allowed
console2.log("VotingBooth balance before:", address(booth).balance);
/// first voter vote
console2.log("user1 balance before:", address(0x1).balance);
vm.prank(address(0x1));
booth.vote(true);
/// second voter vote
console2.log("user2 balance before:", address(0x2).balance);
vm.prank(address(0x2));
booth.vote(true);
/// third voter vote
vm.prank(address(0x3));
booth.vote(false);
console2.log("user1 balance after:", address(0x1).balance);
console2.log("user2 balance after:", address(0x2).balance);
console2.log("VotingBooth balance after:", address(booth).balance);
}

now run forge test --mt testUsersWillReceiveLessEthAndExtraFundStuckInContractForever -vvvcommand in your terminal to see the result.
Result will be shown like below:

[⠢] Compiling...
[⠒] Compiling 1 files with 0.8.23
[⠑] Solc 0.8.23 finished in 1.31s
Compiler run successful!
Running 1 test for test/VotingBoothTest.t.sol:VotingBoothTest
[PASS] testUsersWillReceiveLessEthAndExtraFundStuckInContractForever() (gas: 262272)
Logs:
VotingBooth balance before: 10000000000000000000
user1 balance before: 0
user2 balance before: 0
user1 balance after: 3333333333333333333
user2 balance after: 3333333333333333334
VotingBooth balance after: 3333333333333333333
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.42ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Loss of funds, due to eth stuck in contract and user dissatisfaction.

Tools Used

Manual Review, Foundry

Recommendations

Remove the extra code and implement it in following way.

function _distributeRewards() private {
// get number of voters for & against
uint256 totalVotesFor = s_votersFor.length;
- uint256 totalVotesAgainst = s_votersAgainst.length;
- uint256 totalVotes = totalVotesFor + totalVotesAgainst;
// rewards to distribute or refund. This is guaranteed to be
// greater or equal to the minimum funding amount by a check
// in the constructor, and there is intentionally by design
// no way to decrease or increase this amount. Any findings
// related to not being able to increase/decrease the total
// reward amount are invalid
uint256 totalRewards = address(this).balance;
// if the proposal was defeated refund reward back to the creator
// for the proposal to be successful it must have had more `For` votes
// than `Against` votes
if (totalVotesAgainst >= totalVotesFor) {
// proposal creator is trusted to create a proposal from an address
// that can receive ETH. See comment before declaration of `s_creator`
_sendEth(s_creator, totalRewards);
}
// otherwise the proposal passed so distribute rewards to the `For` voters
else {
- uint256 rewardPerVoter = totalRewards / totalVotes;
+. uint256 rewardPerVoter = totalRewards / totalVotesFor;
for (uint256 i; i < totalVotesFor; ++i) {
// proposal creator is trusted when creating allowed list of voters,
// findings related to gas griefing attacks or sending eth
// to an address reverting thereby stopping the reward payouts are
// invalid. Yes pull is to be preferred to push but this
// has not been implemented in this simplified version to
// reduce complexity & help you focus on finding the
// harder to find bug
// if at the last voter round up to avoid leaving dust; this means that
// the last voter can get 1 wei more than the rest - this is not
// a valid finding, it is simply how we deal with imperfect division
if (i == totalVotesFor - 1) {
rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotes, Math.Rounding.Ceil);
}
_sendEth(s_votersFor[i], rewardPerVoter);
}
}
}
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

VotingBooth._distributeRewards(): Incorrect computation of rewardPerVoter

Support

FAQs

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