Salty.IO - J4X's results

An Ethereum-based DEX with zero swap fees, yield-generating Automatic Arbitrage, and a native WBTC/WETH backed stablecoin.

General Information

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

Salty.IO

Findings Distribution

Researcher Performance

Rank: 19/178

Findings: 13

Award: $811.39

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Findings Information

Awards

87.7413 USDC - $87.74

Labels

3 (High Risk)
satisfactory
upgraded by judge
duplicate-614

External Links

Judge has assessed an item in Issue #913 as 2 risk. The relevant finding follows:

[L-08] First staker can claim all rewards from period before StakingRewards Linhe 78

Issue Description:

In the Salty protocol’s StakingRewards contract, there is an issue related to the distribution of rewards when no stakers are present. When rewards are distributed during a period with no stakers and a user subsequently stakes SALT, the implementation of _increaseUserShare() results in the first staker claiming all the rewards accumulated during the staker-less period:`

// Determine the amount of virtualRewards to add based on the current ratio of rewards/shares. // The ratio of virtualRewards/increaseShareAmount is the same as totalRewards/totalShares for the pool. // The virtual rewards will be deducted later when calculating the user's owed rewards. if ( existingTotalShares != 0 ) // prevent / 0 { // Round up in favor of the protocol. uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares );

user.virtualRewards += uint128(virtualRewardsToAdd); totalRewards[poolID] += uint128(virtualRewardsToAdd); }

// Update the deposit balances user.userShare += uint128(increaseShareAmount); totalShares[poolID] = existingTotalShares + increaseShareAmount;

emit UserShareIncreased(wallet, poolID, increaseShareAmount); } The current logic is designed to distribute virtual rewards proportionally among stakers. However, to prevent division by zero, the function inadvertently allocates all accumulated rewards to the first staker in cases where they are the first to stake after a period with no stakers.

Recommended Mitigation Steps

To address this issue, the reward distribution mechanism should be adjusted to withhold rewards during periods with no stakers. The rewards accumulated during these periods should be retained by the rewards distributor and only begin to be distributed again once stakers are present. This change ensures that rewards are fairly allocated among stakers and not disproportionately claimed by a single user due to a timing advantage.

#0 - c4-judge

2024-02-21T17:20:25Z

Picodes marked the issue as duplicate of #614

#1 - c4-judge

2024-02-21T17:20:29Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2024-02-21T17:30:26Z

Picodes changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: 00xSEV

Also found by: Banditx0x, CongZhang-CertiK, J4X, Myrault, OMEN, jesjupyter, linmiaomiao, miaowu, n1punp

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-609

Awards

264.1611 USDC - $264.16

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L108-L146

Vulnerability details

Bug Description

In the Salty protocol, a combination of bugs across various components can be exploited by a malicious actor to liquidate healthy positions in the market. This complex exploit involves manipulating price feed and exploiting low liquidity in Salty's AMM pools.

1. Stablecoin

The protocol's USDS stablecoin, pegged to WBTC and WETH, allows users to deposit collateral and mint USDS. If the collateral value falls below 110%, the positions are subject to liquidation.

2. Pricefeeds

Salty protocol's valuation of collateral relies on three price feeds: Salty's own USDS/WBTC pool, Uniswap's USDC/WBTC pool, and Chainlink's USD/BTC price feed. The system requires at least two of these feeds to provide non-zero values for a valid price assessment. However, a critical flaw arises when either Chainlink's feed returns an incorrectly low value or the Uniswap TWAP returns an incorrectly low value. This could happen due to a flash crash. The protocol's price aggregator, in this case would be picking the 2 closest prices to calculate the average price.

function _aggregatePrices( uint256 price1, uint256 price2, uint256 price3 ) internal view returns (uint256)
	{
	uint256 numNonZero;

	if (price1 > 0)
		numNonZero++;

	if (price2 > 0)
		numNonZero++;

	if (price3 > 0)
		numNonZero++;

	// If less than two price sources then return zero to indicate failure
	if ( numNonZero < 2 )
		return 0;

	uint256 diff12 = _absoluteDifference(price1, price2);
	uint256 diff13 = _absoluteDifference(price1, price3);
	uint256 diff23 = _absoluteDifference(price2, price3);

	uint256 priceA;
	uint256 priceB;

	if ( ( diff12 <= diff13 ) && ( diff12 <= diff23 ) )
		(priceA, priceB) = (price1, price2);
	else if ( ( diff13 <= diff12 ) && ( diff13 <= diff23 ) )
		(priceA, priceB) = (price1, price3);
	else if ( ( diff23 <= diff12 ) && ( diff23 <= diff13 ) )
		(priceA, priceB) = (price2, price3);

	uint256 averagePrice = ( priceA + priceB ) / 2;

	// If price sources are too far apart then return zero to indicate failure
	if (  (_absoluteDifference(priceA, priceB) * 100000) / averagePrice > maximumPriceFeedPercentDifferenceTimes1000 )
		return 0;

	return averagePrice;
	}

So that means that if an attacker is able to use the moment of the flash crash in chainlink or uniswap and reduce the salty price very low, he will put the aggregator into a state where it returns a average price which is very close to zero (ignoring the 3rd normal price). Using this low price the attacker can now liquidate before healthy position.

3. Manipulating Salty

As salty is a very new AMM, liquidity in pools will be very low at the start. This would allow an attacker to get a flashloan and use it to destabilize the USDS/WBTC pool (which we will consider our attack vector for this case) until it has a very low price. If this is done by just swapping huge amounts, it will have a rather high cost as the arbitrage will refill the pool until the USDS/WETH pool is also highly skewed. This is indeed possible with a flashloan but will require higher funds.

// swap: swapTokenIn->swapTokenOut
// arb: WETH->swapTokenOut->swapTokenIn->WETH
return (swapTokenOut, swapTokenIn);

Luckily there is an easy way to circumvent the automated arbitrage which can be used on low liquidity pools. When one looks at the arbitrage functionality in the Pools.sol file one can see that the atomic arbitrage only occurs if the value of the token that was swapped in is < 100 WETH (wei).

if ( swapAmountInValueInETH <= PoolUtils.DUST )
	return 0;
}

Abusing this functionality, a user can do thousands or millions of small swaps until the target price is reached. As salty has no fees the user can swap back all the funds the the same way only incurring minor losses due to rounding.

4. Liquidating

Now that the price is wildly skewed the attacker can start liquidating before healthy positions and receive 5% of each positions collateral. A small bonus is, that as he has devalued the collateral, the requirement of him getting at max maxRewardValueForCallingLiquidation has also been circumvented and he can receive more rewards (depending on how far he has devalued the collateral).

5. Back to normal

The malicious user can now use the rewards he reaped to cancel out the minor losses he had in the manipulation due to rounding and return the USDS/WBTC pool. Then he can repay the full flashloan. He can also keep the rest of his stolen rewards for himself.

Impact

The issue allows a malicious attacker to liquidate healthy positions in the case of either chainlink or uniswap experiencing a flash crash or returning incorrect values. As the whole system is based on the 2/3 rule to protect against these cases one would expect that the system is protected, which is unfortunately not the case as the Salty pricefeed can easily be manipulated.

Proof of Concept

This POC shows an exemplary scenario. It simulates a case in which Chainlink has incorrectly reported the value of USD/ETC for one block and an attacker monitoring the price feed has detected this. From this point on an attacker can start with the following steps (explained in more detail above):

  1. Acquire a flashloan of WETH
  2. Swap it for USDS (possibly in smaller tranches of 100 to reduce cost) in the salty pool
  3. Liquidate now unhealthy positions and reap the rewards
  4. Stabilize the USDS pool again getting most of the WETH back
  5. Repay the WETH flashloan
  6. Keep rest of the rewards for oneself

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by using TWAP for the salty pool too, this way the pool can not easily be manipulated using a flashloan.

Assessed type

Oracle

#0 - c4-judge

2024-02-02T18:43:04Z

Picodes marked the issue as duplicate of #609

#1 - c4-judge

2024-02-19T11:03:35Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2024-02-21T16:20:43Z

Picodes changed the severity to 3 (High Risk)

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L154

Vulnerability details

Bug Description

In the Salty protocol, the USDS stablecoin is collateralized by WETH/WBTC. Users deposit collateral and borrow USDS based on their collateral's value. A liquidation mechanism activates if the collateral value falls below a set threshold (110% by default).

Unfortunately this measurement can be DOSd, forcing the protocol to incur bad debt. To liquidate a user the liquidateUser() function must be called. This function then calls to the _decreaseUserShare() function. The _decreaseUserShare() function gets called with the useCooldown parameter set to true.

if ( useCooldown )
if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown
	{
	require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" );

	// Update the cooldown expiration for future transactions
	user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown();
	}

If this parameter is set, all calls to _decreaseUserShare() will revert while the cooldown has not passed. The cooldown will be restarted everytime a call to _increaseUserShare() happens. So if a user detects that his position will be liquidated, he can frontrun the liquidation and quickly deposit a small amount of collateral restarting his cooldown. The cooldown duration is DAO controlled and can be in the range of 15min-6h. Subsequently the liquidation attempt will revert and the users position will be save from liquidation for up to 6 hours. When the cooldown runs out the user can keep monitoring the mempool and frontrun any new liquidation attempts using the same technique.

Impact

This vulnerability allows users to indefinitely delay liquidation, potentially leading the protocol to incur bad debt. If exploited on a large scale, it could result in the USDS stablecoin becoming undercollateralized, risking a depeg.

Proof of Concept

The exploit scenario is as follows:

  • Alice deposits collateral and borrows USDS.
  • Bob attempts to liquidate Alice's position when it becomes vulnerable.
  • Alice sees Bob's transaction in the mempool and front-runs it by depositing a small amount of collateral.
  • Bob's liquidation attempt fails due to the reset cooldown.
function testLiquidateImpossible() public {

	//------------------------- SETUP --------------------------------------------------

	// Total needs to be worth at least $2500
	uint256 depositedWBTC = ( 1000 ether *10**8) / priceAggregator.getPriceBTC();
	uint256 depositedWETH = ( 1000 ether *10**18) / priceAggregator.getPriceETH();

	(uint256 reserveWBTC, uint256 reserveWETH) = pools.getPoolReserves(wbtc, weth);

	//------------------------ TEST---------------------------------------------------------

	// Alice will deposit collateral
	vm.startPrank(alice);
	collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC * 2, depositedWETH * 2, 0, block.timestamp, false );
	vm.stopPrank();

	// Deposit extra so alice can withdraw all liquidity without having to worry about the DUST reserve limit
	vm.prank(DEPLOYER);
	collateralAndLiquidity.depositCollateralAndIncreaseShare( 1 * 10**8, 1 ether, 0, block.timestamp, false );

	vm.warp( block.timestamp + 1 hours );

	//Alcie borrows the USDS
	vm.startPrank(alice);
	vm.warp( block.timestamp + 1 hours);
	
	uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice);
	collateralAndLiquidity.borrowUSDS( maxUSDS );
	vm.stopPrank();

	// Try and fail to liquidate alice
	vm.expectRevert( "User cannot be liquidated" );
	vm.prank(bob);
	collateralAndLiquidity.liquidateUser(alice);

	// Artificially crash the collateral price
	_crashCollateralPrice();

	//Check if alice would be liquidatable now
	assertEq(collateralAndLiquidity.canUserBeLiquidated(alice), true, "Alice should be liquidatable");

	//Alice sees that bob wants to liquidate her in the mempool and frontruns by depositing a hundred wei (needs to be above DUST) of collateral to keep her position open
	vm.prank(alice);
	collateralAndLiquidity.depositCollateralAndIncreaseShare(101, 101, 0, block.timestamp, false );

	// Bob tries liquidating Alice's position but it reverts due to the cooldown
	vm.prank(bob);

	vm.expectRevert();
	collateralAndLiquidity.liquidateUser(alice);
}

The test must be added to /stable/tests/CollateralAndLiquidity.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testLiquidateImpossible".

Tools Used

Manual Review

Recommended Mitigation Steps

To address this issue, the cooldown mechanism in the _decreaseUserShare() function should be modified. Specifically, when liquidating a user, the function should be called with the useCooldown parameter set to false. This change will prevent users from exploiting the cooldown to avoid liquidation:

_decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, false );

Assessed type

Other

#0 - c4-judge

2024-01-31T22:41:43Z

Picodes marked the issue as duplicate of #891

#1 - c4-judge

2024-02-21T16:12:57Z

Picodes marked the issue as satisfactory

Findings Information

🌟 Selected for report: Udsen

Also found by: 0xfave, Banditx0x, DanielArmstrong, Draiakoo, J4X, Jorgect, ether_sky, santiellena, stackachu

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-1021

Awards

79.2483 USDC - $79.25

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L132-L133

Vulnerability details

Bug Description

The staking mechanism in the Salty protocol, as outlined in the Staking.sol contract, uses the UserShareInfo struct to track user stakes and rewards. A key aspect of this struct is the virtualRewards variable. virtualRewards is incremented when rewards are claimed via the claimAllRewards() function.

struct UserShareInfo
{
	uint128 userShare; // A user's share for a given poolID
	uint128 virtualRewards; // The amount of rewards that were added to maintain proper rewards/share ratio - and will be deducted from a user's pending rewards.
	uint256 cooldownExpiration;	// The timestamp when the user can modify their share
}

However, a flaw arises during partial unstakes handled by _decreaseUserShare(), where virtualRewardsToRemove is calculated for deduction from virtualRewards.

// For the amountToDecrease determine the proportion of virtualRewards (proportional to all virtualRewards for the user)
// Round virtualRewards down in favor of the protocol
uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare;

As mentioned in the comment, the amount is correctly rounded down, to only reduce the virtualRewards variable by a smaller number, which favors the protocol, and does not allow a user to gain more funds than intended on a later redeem. Unfortunately the virtualRewardsToRemove variable is not only used to reduce the virtualRewards variable. It is also later on used to calculate how many funds a user will get transferred:

// Some of the rewardsForAmount are actually virtualRewards and can't be claimed.
// In the event that virtualRewards are greater than actual rewards - claimableRewards will stay zero.
if ( virtualRewardsToRemove < rewardsForAmount )
	claimableRewards = rewardsForAmount - virtualRewardsToRemove;

// Send the claimable rewards
if ( claimableRewards != 0 )
	salt.safeTransfer( wallet, claimableRewards );

This leads to an issue, as the variable was rounded down before, and is now used to reduce the paid out rewards. The before protocol-favoring rounding has now become the opposite. A malicious user can form multiple partial unstakes, targeting this exact rounding error to receive more rewards than he should get.

The same issue also affects partial liquidity removals.

Impact

This vulnerability enables users to claim more rewards than they are entitled to. Although each instance might involve a small amount, the cumulative effect could be significant due to the frequency of occurrences.

Proof of Concept

In the following fuzz test one can see an example of multiple stakes with multiple unstakes and redeems. Example inputs which led to a revert are:

uint256 amountToUnstake = 1648
uint256 amountToUnstake2 = 4037
uint256 amountToStakeAlice = 6768
uint256 amountToStakeBob = 2574219620
uint256 deposit1 = 10066
uint256 deposit2 = 16507

The POC can be found in the following code snippet.

function testRoundingIssueVirtualShares(uint256 amountToUnstake, uint256 amountToUnstake2, uint256 amountToStakeAlice, uint256 amountToStakeBob, uint256 deposit1, uint256 deposit2) public {
	// --------------- SETUP ----------------------------
	vm.assume(amountToStakeAlice != 0 && amountToStakeAlice <= salt.balanceOf(alice) && 
			amountToStakeBob != 0 && amountToStakeBob <= salt.balanceOf(bob) && 
			amountToUnstake != 0 && amountToUnstake < amountToStakeAlice && 
			amountToUnstake2 != 0 && amountToUnstake2 < amountToStakeAlice && amountToUnstake + amountToUnstake2 < amountToStakeAlice && 
			deposit1 < salt.balanceOf(DEPLOYER) && deposit2 < salt.balanceOf(DEPLOYER) && deposit1 + deposit2 <= salt.balanceOf(DEPLOYER));

	uint256 aliceBalance = salt.balanceOf(alice);

	//Bob stakes some salt
	vm.startPrank(bob);
	staking.stakeSALT(amountToStakeBob);
	vm.stopPrank();

	// Alice stakes some SALT
	vm.startPrank(alice);
	staking.stakeSALT(amountToStakeAlice);
	vm.stopPrank();

	// --------------- ISSUE ----------------------------

	//Some rewards are distributed
	AddedReward[] memory addedRewards = new AddedReward[](1);
	addedRewards[0] = AddedReward( PoolUtils.STAKED_SALT, deposit1);

	vm.startPrank(address(DEPLOYER));
	salt.approve( address(staking), type(uint256).max );
	staking.addSALTRewards(addedRewards);
	vm.stopPrank();

	//Alice claims her first rewards
	bytes32[] memory poolIDsArray = new bytes32[](1);
	poolIDsArray[0] = PoolUtils.STAKED_SALT;
	vm.startPrank(alice);
	staking.claimAllRewards(poolIDsArray);
	vm.stopPrank();

	// Some time passes
	vm.warp(block.timestamp + 1 weeks);

	// More rewards get distributed
	vm.startPrank(address(DEPLOYER));
	addedRewards[0] = AddedReward( PoolUtils.STAKED_SALT, deposit2);
	staking.addSALTRewards(addedRewards);
	vm.stopPrank();

	// Alice unstakes in batches
	vm.startPrank(alice);
	uint256 unstakeID = staking.unstake(amountToUnstake, 52);
	uint256 unstakeID2 = staking.unstake(amountToUnstake2, 52);
	uint256 unstakeID3 = staking.unstake(amountToStakeAlice - amountToUnstake - amountToUnstake2, 52);

	//52 weeks pass
	vm.warp(block.timestamp + 52 weeks);

	//Alice finalizes the unstake
	staking.recoverSALT(unstakeID);
	staking.recoverSALT(unstakeID2);
	staking.recoverSALT(unstakeID3);

	//This is calculated generously, in reality it would potentially be even lower due to rounding down at each redeem of rewards
	uint256 aliceShareOfRewards = (amountToStakeAlice * (deposit1 + deposit2)) / (amountToStakeAlice + amountToStakeBob);

	console.logUint(salt.balanceOf(alice));
	console.logUint(aliceBalance + aliceShareOfRewards);

	//Alice was able to get some of the rewards of bob if this fails
	assertTrue(salt.balanceOf(alice) <= aliceBalance + aliceShareOfRewards);

	vm.stopPrank();
}

The test must be added to /staking/tests/Staking.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testRoundingIssueVirtualShares".

Tools Used

Manual Review

Recommended Mitigation Steps

To address this issue, the function should be adjusted so that while the reduction in virtualRewards continues to be rounded down, the calculation of the transferable amount should use a rounded-up version of virtualRewardsToRemove. This change ensures that users cannot exploit the rounding discrepancy to receive extra rewards:

if ( virtualRewardsToRemove + 1 < rewardsForAmount )
	claimableRewards = rewardsForAmount - (virtualRewardsToRemove + 1);

Implementing this modification will align the actual rewards distribution with the intended mechanism, ensuring fairness and preventing exploitation of rounding errors in the staking rewards system.

Assessed type

Math

#0 - c4-judge

2024-02-02T17:33:35Z

Picodes marked the issue as duplicate of #614

#1 - c4-judge

2024-02-18T11:23:21Z

Picodes marked the issue as not a duplicate

#2 - c4-judge

2024-02-18T16:59:42Z

Picodes marked the issue as duplicate of #341

#3 - c4-judge

2024-02-18T16:59:51Z

Picodes marked the issue as not a duplicate

#4 - c4-judge

2024-02-18T16:59:55Z

Picodes marked the issue as satisfactory

#5 - c4-judge

2024-02-18T17:00:13Z

Picodes marked the issue as duplicate of #1021

#6 - Picodes

2024-02-18T17:01:37Z

This report is of high quality but misses the main impact. The main issue here to me is not the "wei" rounding error which would be of Low severity but the fact that the last staker may face an unexpected DoS as the contract won't hold enough rewards

#7 - c4-judge

2024-02-21T16:17:46Z

Picodes changed the severity to 2 (Med Risk)

Findings Information

🌟 Selected for report: falconhoof

Also found by: BiasedMerc, J4X, Rhaydden, cats, forgebyola, inzinko, jesjupyter, josephdara, zhaojie

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-991

Awards

79.2483 USDC - $79.25

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L167

Vulnerability details

Bug Description

The Salty DAO's proposeTokenWhitelisting() function enforces a limit on simultaneous token whitelisting proposals, based on the maxPendingTokensForWhitelisting parameter, which can be set from 3 to 12:

require( _openBallotsForTokenWhitelisting.length() < daoConfig.maxPendingTokensForWhitelisting(), "The maximum number of token whitelisting proposals are already pending" );

To create a whitelisting proposal, users must hold a specified minimum percentage of the total staked salt, ranging from 0.10% to 2%, as checked in _possiblyCreateProposal():

uint256 totalStaked = staking.totalShares(PoolUtils.STAKED_SALT);
uint256 requiredXSalt = ( totalStaked * daoConfig.requiredProposalPercentStakeTimes1000() ) / ( 100 * 1000 );

require( requiredXSalt > 0, "requiredXSalt cannot be zero" );

uint256 userXSalt = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT );
require( userXSalt >= requiredXSalt, "Sender does not have enough xSALT to make the proposal" );

An attacker can exploit this by staking the required minimum percentage in multiple wallets, up to the maxPendingTokensForWhitelisting limit, and then propose whitelisting for invalid or random tokens with each wallet. This action effectively blocks new proposals until the existing ones are finalized, based on the ballotMinimumDuration (3 to 14 days). This blockade is due to the finalizeBallot() function only allowing finalization after the ballotMinimumDuration has passed for a ballot.

Impact

This vulnerability enables an attacker to block a crucial DAO function for up to 14 days, potentially with as little as 0.3% of all staked Salt. Moreover, the attacker could continuously extend the DOS by proposing new invalid whitelisting as soon as previous ones are finalized.

Proof of Concept

As the config parameters can be set by the DAO as wished, we can expect any combination that is within the predefined ranges as a valid state of the contract.

For this issue we will consider the state where

  • maxPendingTokensForWhitelisting = 3
  • requiredProposalPercentStakeTimes1000 = 100 (0.1%)
  • ballotMinimumDuration = 14 days

In this case a malicious actor that owns 3 wallets, which each hold 0.1% of the total staked salt can call proposeTokenWhitelisting() with each wallet and propose a nonviable token. This will lead to a state in which other stakers will not be able to propose any new whitelistings, due to the check for the maxPendingTokensForWhitelisting. This state will continue until the ballotMinimumDuration has passed and the malicious proposals can be finalized. As the malicious actor keeps his stake he can propose a new bad whitelisting as soon as his old one gets finalized, extending the period of DOS over the ballotMinimumDuration indefinitely.

Tools Used

Manual Review

Recommended Mitigation Steps

This issue can be mitigated by increasing the maxPendingTokensForWhitelisting up to a range where it is even with a requiredProposalPercentStake=0.1% infeasible to dos the functionality. An example would be 100-500 as in that case a malicious staker would still need to hold 10% of staked salt which will require significant cost.

Assessed type

DoS

#0 - c4-judge

2024-02-03T17:19:46Z

Picodes marked the issue as duplicate of #621

#1 - c4-judge

2024-02-19T17:07:20Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2024-02-19T17:07:31Z

Picodes marked the issue as not a duplicate

#3 - c4-judge

2024-02-19T17:08:48Z

Picodes marked the issue as duplicate of #685

#4 - c4-judge

2024-02-20T10:47:53Z

Picodes removed the grade

#5 - c4-judge

2024-02-20T10:47:57Z

Picodes marked the issue as satisfactory

#6 - c4-judge

2024-02-26T11:02:53Z

Picodes marked the issue as duplicate of #991

Findings Information

🌟 Selected for report: J4X

Also found by: 0xCiphky, 0xHelium, 0xRobocop, 0xWaitress, 0xpiken, Toshii, aman, ayden, b0g0, djxploit, israeladelaja

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor acknowledged
M-04

Awards

69.5404 USDC - $69.54

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L187

Vulnerability details

Bug Description

The Salty protocol offers the USDS stablecoin, collateralized by WETH/WBTC. Users can deposit collateral and borrow USDS against it. A key safeguard mechanism is liquidation, which is activated when the collateral value drops below a certain threshold (typically 110%). However, a significant flaw exists in the liquidation process, particularly when there is only one user borrowing USDS.

The process to liquidate a user involves calling the liquidateUser() function, which in turn calls pools.removeLiquidity() to withdraw the user's collateral. However, the pools.removeLiquidity() function checks if the remaining reserves after withdrawal are below the DUST threshold and reverts if they are.

require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");

This check creates a situation where, if all the collateral for USDS is held by a single user, it becomes impossible to liquidate this user. Any attempt to liquidate would reduce the reserves below DUST, causing the transaction to revert

Impact

This flaw means the only holder of USDS can evade liquidation, potentially leading to bad debt in the protocol that cannot be recouped or mitigated.

Proof of Concept

The scenario can be demonstrated as follows:

  • Alice is the sole depositor of collateral and borrows the maximum allowable USDS.
  • Another user, Bob, attempts to liquidate Alice when her collateral value crashes.
  • However, the liquidation process reverts because removing Alice's collateral would reduce the reserves below the DUST threshold.
function testLiquidateOnlyHolder() public {
	//------------------------- SETUP --------------------------------------------------

	// Total needs to be worth at least $2500
	uint256 depositedWBTC = ( 1000 ether *10**8) / priceAggregator.getPriceBTC();
	uint256 depositedWETH = ( 1000 ether *10**18) / priceAggregator.getPriceETH();

	(uint256 reserveWBTC, uint256 reserveWETH) = pools.getPoolReserves(wbtc, weth);

	//------------------------ TEST---------------------------------------------------------

	// Alice deposits collateral
	vm.startPrank(alice);
	collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC * 2, depositedWETH * 2, 0, block.timestamp, false );
	vm.stopPrank();

	//Alcie borrows the USDS
	vm.startPrank(alice);
	vm.warp( block.timestamp + 1 hours);
	
	uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice);
	collateralAndLiquidity.borrowUSDS( maxUSDS );
	vm.stopPrank();

	// Try and fail to liquidate alice
	vm.expectRevert( "User cannot be liquidated" );
	vm.prank(bob);
	collateralAndLiquidity.liquidateUser(alice);

	// Artificially crash the collateral price
	_crashCollateralPrice();

	// Bob tries liquidating Alice's position but it reverts due to the reserves falling below dust if she gets liquidated
	vm.prank(bob);
	vm.expectRevert();
	collateralAndLiquidity.liquidateUser(alice);
}

The test must be added to /stable/tests/CollateralAndLiquidity.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testLiquidateOnlyHolder".

Tools Used

Manual Review

Recommended Mitigation Steps

To resolve this issue, the logic in pools.removeLiquidity() needs to be adjusted. The function should allow the withdrawal of the last collateral, even if it reduces the reserves to zero. This adjustment can be implemented with a conditional check that permits reserves to be either above DUST or exactly zero:

require(((reserves.reserve0 >= PoolUtils.DUST && reserves.reserve0 >= PoolUtils.DUST) || (reserves.reserve0 == 0 && reserves.reserve0 == 0)), "Insufficient reserves after liquidity removal");

With this change, the protocol will maintain its ability to liquidate the sole holder of USDS, ensuring the safeguard against bad debt remains effective.

Assessed type

Other

#0 - c4-judge

2024-02-01T22:41:40Z

Picodes marked the issue as duplicate of #278

#1 - c4-judge

2024-02-21T08:12:38Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2024-02-21T08:14:05Z

Picodes marked the issue as selected for report

#3 - c4-sponsor

2024-02-23T03:06:26Z

othernet-global (sponsor) acknowledged

#4 - othernet-global

2024-02-23T03:24:45Z

The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-844

Awards

31.1969 USDC - $31.20

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L259

Vulnerability details

Bug Description

In the Salty DAO's governance mechanism, stakers use their staked salt to vote on ballots through the castVote() function.

However, there is a vulnerability where users can unstake their tokens after voting, wait for a part of their staked tokens to become retrievable, restake them using a different address, and vote again on the same proposal. This exploit is possible because the current system does not utilize snapshot-based voting, which would restrict users to voting with the power they held at the time of the ballot's creation.

Impact

This loophole allows malicious stakers to inflate their voting power artificially. They can effectively vote multiple times with the same tokens by restaking them under different addresses, potentially swaying the outcome of proposals and passing malicious ones without genuine consensus from the staker community.

Proof of Concept

The provided Solidity code showcases an instance where a user exploits the system to vote twice on a proposal:

function testDoubleVote() public
{
	// ----------------------------- SETUP -----------------------------

	address charlie = address(0x3333);

	// Set minUnstakeWeeks to 1 which is in the allowed range
	vm.store(address(stakingConfig), bytes32(uint256(1)), bytes32(uint256(1)));

	// Set minUnstakePercent to 50% which is in the allowed range
	vm.store(address(stakingConfig), bytes32(uint256(3)), bytes32(uint256(50)));

	vm.startPrank(alice);
	//Transfer 55% to bob
	salt.transfer(bob, 5500000 ether);
	//Stake the left 45% of salt
	staking.stakeSALT(4500000 ether);
	vm.stopPrank();

	vm.startPrank(bob);
	//Stake 55% of salt
	salt.approve(address(staking), type(uint256).max);
	staking.stakeSALT(5500000 ether);
	vm.stopPrank();

	// ----------------------------- TEST -----------------------------

	//Alice proposes the country exclusion
	vm.startPrank(alice);
	proposals.proposeCountryExclusion( "ZZ", "description" );

	//Alice votes with her 45% of staked 
	proposals.castVote(1, Vote.YES);

	//Afterwards alice unstakes her funds
	uint256 id = staking.unstake(4500000 ether, 1);

	vm.stopPrank();

	vm.startPrank(bob);

	//Bob votes with his 55% of staked against the proposal
	proposals.castVote(1, Vote.NO);
	vm.stopPrank();

	// A week passes
	vm.warp(block.timestamp + 8 days);

	vm.startPrank(alice);
	//Alice retrieves her staked funds
	staking.recoverSALT(id);

	assertTrue(salt.balanceOf(address(alice)) == 2250000 ether, "Alice should have recovered 50% of her SALT");

	// Alice transfers the salt to her 2nd wallet (charlie)
	salt.transfer(charlie, 2250000 ether);

	vm.stopPrank();
	vm.startPrank(charlie);

	// Now alice restakes the retrieved funds
	salt.approve(address(staking), type(uint256).max);
	staking.stakeSALT(2250000 ether);

	// Alice votes again
	proposals.castVote(1, Vote.YES);

	// The proposal finishes
	vm.warp(block.timestamp + 3 days);

	//The ballot gets passed although onlz 45% of tokens voted for it and 55% against it
	dao.finalizeBallot(1);
	assertTrue(dao.countryIsExcluded("ZZ"), "ZZ should be excluded");
}

The test must be added to /dao/tests/Dao.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testDoubleVote".

Tools Used

Manual Review

Recommended Mitigation Steps

To address this vulnerability, it is recommended to adopt a snapshot-based voting mechanism. This can be effectively implemented using the ERC20Votes library for XSalt tokens. The snapshot system would capture the state of each voter's stake at the time of ballot creation, ensuring that their voting power remains fixed for the duration of the vote. This change would prevent the possibility of double voting with the same staked tokens, thereby preserving the integrity of the voting process and ensuring fair and accurate representation of the staker community's consensus.

Assessed type

Governance

#0 - c4-judge

2024-02-02T11:15:20Z

Picodes marked the issue as duplicate of #746

#1 - c4-judge

2024-02-14T08:04:55Z

Picodes changed the severity to 2 (Med Risk)

#2 - c4-judge

2024-02-14T08:05:04Z

Picodes marked the issue as satisfactory

Awards

8.7582 USDC - $8.76

Labels

bug
2 (Med Risk)
satisfactory
duplicate-838

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/ManagedWallet.sol#L49

Vulnerability details

Bug Description

The Salty protocol's ManagedWallet contract, designed for managing 2 wallet addresses, has a critical flaw in its address update process. This process involves a three-step procedure where the main wallet proposes new main and confirmation wallets, the confirmation wallet confirms or rejects the proposal, and there's a 30-day timelock before the change takes effect.

A significant issue arises because the proposedMainWallet and proposedConfirmationWallet are not reset after a rejection. The receive() function, responsible for handling the confirmation or rejection, does not reset these addresses if a proposal is rejected.

receive() external payable
	{
	require( msg.sender == confirmationWallet, "Invalid sender" );

	// Confirm if .05 or more ether is sent and otherwise reject.
	// Done this way in case custodial wallets are used as the confirmationWallet - which sometimes won't allow for smart contract calls.
	if ( msg.value >= .05 ether )
		activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock
	else
		activeTimelock = type(uint256).max; // effectively never
	}

Additionally, the proposeWallets function is designed to reject any new proposals if a non-zero proposedMainWallet already exists.

// Make sure we're not overwriting a previous proposal (as only the confirmationWallet can reject proposals)

require( proposedMainWallet == address(0), "Cannot overwrite non-zero proposed mainWallet." );

This combination of behaviors leads to a deadlock in the contract where, once a proposal is rejected, no further proposals can be made.

Impact

This bug effectively freezes the ManagedWallet contract, preventing any further updates to the main and confirmation wallets if a proposal is ever rejected.

Proof of Concept

The issue can be demonstrated as follows:

  • A main wallet proposes new main and confirmation wallets.
  • The confirmation wallet rejects the proposal.
  • Subsequent attempts to propose new wallets fail due to the non-reset proposedMainWallet.
function testRejectFreezesFunctionality() public {
	address nonZeroProposedMainWallet = address(0x5555);
	address nonZeroProposedConfirmationWallet = address(0x6666);
	ManagedWallet managedWallet = new ManagedWallet(alice, address(0x2222));

	// Simulate that the mainWallet has already proposed wallets
	vm.prank(alice);
	managedWallet.proposeWallets(nonZeroProposedMainWallet, nonZeroProposedConfirmationWallet);

	//Now the confirmation wallet rejects the proposal
	vm.startPrank(address(this));
	vm.deal(address(this), 0.04 ether);
	(bool success,) = address(managedWallet).call{value: 0.04 ether}("");
	vm.stopPrank();

	// Try proposing new wallets again, which will not be possible
	address newProposedMainWallet = address(0x7777);
	address newProposedConfirmationWallet = address(0x8888);
	vm.startPrank(alice);
	vm.expectRevert("Cannot overwrite non-zero proposed mainWallet.");
	managedWallet.proposeWallets(newProposedMainWallet, newProposedConfirmationWallet);

	//Try changing the wallets
	vm.expectRevert();
	managedWallet.changeWallets();

	// Spin the time 1000 years forwards
	vm.warp(block.timestamp + 365000 days);

	//Try changing the wallets again
	vm.expectRevert();
	managedWallet.changeWallets();

	//ManagedWallet is frozen and the addresses can never be changed.
}

The test must be added to /root_tests/ManagedWallet.t.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testRejectFreezesFunctionality".

Tools Used

Manual Review

Recommended Mitigation Steps

To resolve this issue, the contract needs to reset the proposedMainWallet and proposedConfirmationWallet whenever a proposal is rejected. This can be implemented in the receive() function as follows:

solidity

receive() external payable
	{
	require( msg.sender == confirmationWallet, "Invalid sender" );

	// Confirm if .05 or more ether is sent and otherwise reject.
	// Done this way in case custodial wallets are used as the confirmationWallet - which sometimes won't allow for smart contract calls.
	if ( msg.value >= .05 ether )
		activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock
	else
		activeTimelock = type(uint256).max; // effectively never
	}

Assessed type

DoS

#0 - c4-judge

2024-02-02T10:41:40Z

Picodes marked the issue as duplicate of #838

#1 - c4-judge

2024-02-17T18:21:16Z

Picodes marked the issue as satisfactory

#2 - Picodes

2024-02-17T18:23:25Z

Medium severity is appropriate here under "function of the protocol or its availability could be impacted".

Awards

1.6255 USDC - $1.63

Labels

2 (Med Risk)
satisfactory
duplicate-784

External Links

Judge has assessed an item in Issue #913 as 2 risk. The relevant finding follows:

[L-01] Reserves Above DUST Incorrectly Enforced Pools Line 270

Issue Description:

In the Salty protocol’s _adjustReservesForSwap() function, the intention, as per the accompanying comment (β€œ// Make sure that the reserves after swap are above DUST”), is to ensure that the reserves always remain above a certain threshold defined by the DUST constant. However, the current implementation of this check uses >= (greater than or equal to), which leads to an off-by-one error. The actual requirement, as intended, is for the reserves to be strictly greater than DUST.

Current implementation:

require( (reserve0 >= PoolUtils.DUST) && (reserve1 >= PoolUtils.DUST), "Insufficient reserves after swap"); This condition allows the reserves to be exactly equal to DUST, which contradicts the intended logic of keeping them above DUST.

Recommended Mitigation Steps

To align the implementation with the intended behavior, the require statement should be adjusted to enforce that reserves are strictly greater than DUST. This change will ensure that the reserves are maintained above the minimal threshold, as originally planned.

Proposed change:

require( (reserve0 > PoolUtils.DUST) && (reserve1 > PoolUtils.DUST), "Insufficient reserves after swap"); By modifying the condition to > (greater than), the function will correctly enforce that the reserves are maintained above the DUST level, thus adhering to the protocol’s intended safety and operational standards.

#0 - c4-judge

2024-02-03T13:18:27Z

Picodes marked the issue as duplicate of #647

#1 - c4-judge

2024-02-19T15:38:19Z

Picodes marked the issue as satisfactory

Findings Information

Awards

26.3224 USDC - $26.32

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-716

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L396 https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L317-L339

Vulnerability details

Bug Description

In the Salty DAO, a voting mechanism exists where users can propose and vote on ballots. The requiredQuorumForBallotType() function plays a crucial role in ensuring that a sufficient percentage of stakers has voted on a ballot, as it calculates the quorum by multiplying the total stakes with the set percentage for the ballot type.

However, the issue arises because this function uses the current total amount of staked salt rather than the amount that was staked at the time of the ballot's creation. This discrepancy allows a malicious staker to vote on a ballot, then unstake their salt, effectively reducing the total staked amount and the required quorum. Consequently, a ballot can be finalized with fewer votes than originally intended.

This issue can also be exploited in the opposite direction, as a malicious user could frontrun the finalization of a ballot, which already slightly passes the quorum, and quickly stake salt. This way the quorum would be lifted above the current votes resulting in the other users call to finalize reverting.

Impact

This vulnerability allows a malicious user to manipulate the quorum requirements and finalize ballots that should not be passable, as they haven't met the intended quorum threshold.

Proof of Concept

The provided Solidity code demonstrates how the system can be exploited:

function testSpoofQuorum() public
{
	// ----------------------------- SETUP -----------------------------
	// Give alice 10M so the 0,5% bracket can be ignored
	vm.prank(address(daoVestingWallet));
	salt.transfer(alice, 9000000 ether);

	vm.startPrank(alice);
	//Transfer 75% to bob
	salt.transfer(bob, 7500000 ether);
	//Stake the left 25% of salt
	staking.stakeSALT(2500000 ether);
	vm.stopPrank();

	vm.startPrank(bob);
	//Stake 75% of salt
	salt.approve(address(staking), type(uint256).max);
	staking.stakeSALT(7500000 ether);
	vm.stopPrank();

	// ----------------------------- TEST -----------------------------

	//Alice proposes the country exclusion
	vm.startPrank(alice);
	proposals.proposeCountryExclusion( "US", "description" );

	//Alice votes with her 25% of staked
	proposals.castVote(1, Vote.YES);

	// Spin the time forward
	vm.warp(block.timestamp + 11 days);

	// Try finalizing which will not work as 30% quorom for this kind of ballot was not passed
	vm.expectRevert();
	dao.finalizeBallot(1);

	// Alice unstakes to lower the quorum
	uint256 id = staking.unstake(2500000 ether, 5);

	//Now finalizing the ballot works
	dao.finalizeBallot(1);
	assertTrue(dao.countryIsExcluded( "US" ), "USA should be excluded" );

	//Alice reclaims her vote by canceling the unstake
	staking.cancelUnstake(id);

	// Assert that alice still has her stake
	assert(staking.userXSalt((alice)) == 2500000 ether);
}

The test must be added to /dao/tests/Dao.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testSpoofQuorum".

Tools Used

Manual Review

Recommended Mitigation Steps

To address this issue, it is recommended to modify the Ballot struct to include an additional field that tracks the total staked salt at the time of ballot creation. Consequently, when quorums are checked, this stored value should be used instead of the current total staked salt. This change ensures that the quorum calculation remains consistent with the state of stakes at the time of ballot initiation, preventing the possibility of manipulation through unstaking.

The revised Ballot struct would be:

struct Ballot
{
	uint256 ballotID;
	bool ballotIsLive;

	BallotType ballotType;
	string ballotName;
	address address1;
	uint256 number1;
	string string1;
	string description;
	uint256 totalStaked; (newly added)

	// The earliest timestamp at which a ballot can end. Can be open longer if the quorum has not yet been reached for instance.
	uint256 ballotMinimumEndTime;
}

Assessed type

Governance

#0 - c4-judge

2024-02-01T16:40:33Z

Picodes marked the issue as duplicate of #46

#1 - c4-judge

2024-02-21T14:26:29Z

Picodes marked the issue as satisfactory

Findings Information

Awards

26.3224 USDC - $26.32

Labels

bug
2 (Med Risk)
satisfactory
duplicate-716

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L335 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/Staking.sol#L114-L120

Vulnerability details

Bug Description

In the Salty DAO's voting system, the requiredQuorumForBallotType() function is pivotal in ensuring a ballot receives votes from a sufficient percentage of stakers. This function calculates a required quorum based on the total stakes and compares it to a minimumQuorum, set as 0.5% of the total SALT token supply. If the calculated quorum is below this minimum, the minimumQuorum is used instead.

function requiredQuorumForBallotType( BallotType ballotType ) public view returns (uint256 requiredQuorum)
	{
	// The quorum will be specified as a percentage of the total amount of SALT staked
	uint256 totalStaked = staking.totalShares( PoolUtils.STAKED_SALT );
	require( totalStaked != 0, "SALT staked cannot be zero to determine quorum" );

	if ( ballotType == BallotType.PARAMETER )
		requiredQuorum = ( 1 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );
	else if ( ( ballotType == BallotType.WHITELIST_TOKEN ) || ( ballotType == BallotType.UNWHITELIST_TOKEN ) )
		requiredQuorum = ( 2 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );
	else
		// All other ballot types require 3x multiple of the baseQuorum
		requiredQuorum = ( 3 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );

	// Make sure that the requiredQuorum is at least 0.50% of the total SALT supply.
	// Circulating supply after the first 45 days of emissions will be about 3 million - so this would require about 16% of the circulating
	// SALT to be staked and voting to pass a proposal (including whitelisting) 45 days after deployment..
	uint256 totalSupply = ERC20(address(exchangeConfig.salt())).totalSupply();
	uint256 minimumQuorum = totalSupply * 5 / 1000;

	if ( requiredQuorum < minimumQuorum )
		requiredQuorum = minimumQuorum;
	}

However, a vulnerability exists due to the fluctuating totalSupply of SALT, which changes when tokens are burned. A user close to the 0.5% ownership threshold can vote, then unstake and burn their SALT, effectively lowering the minimum quorum threshold. This allows the user to finalize a proposal even though they voted with less than 0.5% of the total supply.

Impact

This issue enables a malicious staker to finalize a ballot below the intended minimum quorum, potentially manipulating the outcome of key protocol decisions.

Proof of Concept

A test scenario demonstrates how this issue can be exploited:

function testQuorumReducedByBurning() public {
	//------------------------ SETUP ------------------------

	// Set minUnstakeWeeks to 1 which is in the allowed range
	vm.store(address(stakingConfig), bytes32(uint256(1)), bytes32(uint256(1)));

	// Set minUnstakePercent to 10% which is in the allowed range
	vm.store(address(stakingConfig), bytes32(uint256(3)), bytes32(uint256(10)));

	// Set up the parameters for the proposal
	uint256 proposalNum = 0; // Assuming an enumeration starting at 0 for parameter proposals
	address proposer = alice;

	//------------------------ ISSUE ------------------------

	// Alice stakes her SALT to get voting power
	vm.prank(address(daoVestingWallet));
	salt.transfer(proposer, 1000000 ether);

	vm.startPrank(proposer);
	staking.stakeSALT(499999 ether); // CLosely below the minimum quorum

	// Propose a parameter ballot
	proposals.proposeParameterBallot(proposalNum, "Increase max pools count");
	vm.stopPrank();

	uint256 ballotID = 1;

	// Cast a vote, but not enough for quorum
	vm.startPrank(proposer);
	proposals.castVote(ballotID, Vote.NO_CHANGE);
	vm.stopPrank();

	// Increase block time so the ballot would finilaizable if the quorum is passed
	vm.warp(block.timestamp + daoConfig.ballotMinimumDuration());

	// Expect revert because minimum quorum is still not reached
	vm.expectRevert("The ballot is not yet able to be finalized");
	dao.finalizeBallot(ballotID);

	//Nor burn the SALT by unstaking
	vm.startPrank(proposer);
	uint256 unstakeId = staking.unstake(499999 ether - 1, 1);

	// Make a week pass (the unstaking could have also been taking place directly when unstaking)
	vm.warp(block.timestamp + 1 weeks);

	// Finish the unstake (burning 90% of the SALT for the ) falling below the quorum
	staking.recoverSALT(unstakeId);

	// Now it should be possible to finalize the ballot without reverting
	dao.finalizeBallot(ballotID);

	// Check that the ballot is finalized
	bool isBallotFinalized = proposals.ballotForID(ballotID).ballotIsLive;
	assertEq(isBallotFinalized, false, "Ballot should be finalized");
}

The test must be added to /dao/tests/Dao.t.sol and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testQuorumReducedByBurning".

Tools Used

Manual Review

Recommended Mitigation Steps

To mitigate this issue, the Ballot struct should include the totalSupply of SALT at the time of ballot creation. This value should then be used to calculate the minimum quorum when finalizing the ballot:

struct Ballot
	{
	uint256 ballotID;
	bool ballotIsLive;

	BallotType ballotType;
	string ballotName;
	address address1;
	uint256 number1;
	string string1;
	string description;
	uint256 totalSupply; <---- Added

	// The earliest timestamp at which a ballot can end. Can be open longer if the quorum has not yet been reached for instance.
	uint256 ballotMinimumEndTime;
	}

Incorporating the totalSupply into the Ballot struct ensures that the minimum quorum reflects the SALT supply at the time of voting, preserving the integrity of the voting process and preventing manipulation through token burning.

Assessed type

Governance

#0 - c4-judge

2024-02-03T10:13:05Z

Picodes marked the issue as duplicate of #46

#1 - c4-judge

2024-02-21T14:26:18Z

Picodes marked the issue as satisfactory

#2 - J4X-98

2024-02-22T06:45:43Z

Hey @Picodes,

thanks for reviewing this submission. This issue is not a duplicate of #46. I have already submitted the vulnerability, which is described in #46 in my separate issue #187.

The 2 described vulnerabilities look similar, but target different issues and also require different fixes.

This issue:

  • Targets the check for Β΄minimumQuorumΒ΄
  • Exploits it by burning salt and permanently reducing the totalSupply
  • Can be fixed by adding the totalSupply to the ballot

Issue #46:

  • Targets the check for Β΄requiredQuorumΒ΄
  • Exploits by unstaking -> passing -> canceling Unstake
  • Can be fixed by adding the totalStaked to the ballot

#3 - InfectedIsm

2024-02-22T10:07:48Z

Hi, the fact that the user has to burn its SALT (i.e lose money) only to be able to propose pass a proposal, plus the fact that this can only happens when less that 0.5% SALT are staked make it very unlikely to happen. #716 is considered medium when user isn't incurring any loss, and the exploit can be achieved regardless of the quantity of staked SALT. So I hardly see how this submission can be considered a medium risk too.

#4 - J4X-98

2024-02-22T10:25:50Z

Hi @InfectedIsm,

First I would like to add that you seem to misunderstand the issue. The minimumQuorum is enforced when passing ballots, not when proposing them. Additionally I would like to answer your added points:

the fact that the user has to burn its SALT (i.e lose money) only to be able to propose a proposal, ..., make it very unlikely to happen

In a Med Severity issue, there does not needs to be any profit and there can be a loss for the exploiter. The requirement, as per the Severity Classification is "Assets not at direct risk, but the function of the protocol or its availability could be impacted". In this case the intended functionality of the minQuorum is clearly impacted. If it is unlikely that an attacker abuses it or not does not impact the severity. If permanently loosing salt to make a ballot pass makes sense will depend on the ballot. If a user can gain something worth more than the salt lost from the ballot being passed, it can be a reasonable call.

the fact that this can only happens when less that 0.5% SALT are staked make it very unlikely to happen

The restriction of it only being possible while less than 0.5% of salt are staked does not affect the validity of the issue in any way, it just restricts in which protocol states it can happen. As 0.5% of salt being staked is a valid protocol state, this does not change the issue.

#5 - J4X-98

2024-02-22T10:27:59Z

But lets wait for @Picodes judging :)

#6 - InfectedIsm

2024-02-22T10:57:45Z

Hi @J4X-98, my bad for having mixed "propose" and "pass" when expressing myself as this is obviously worse to be able to pass a proposal than to be only able propose it. That was clearly "pass" that I had in mind, so correcting it.

I'm definitely not saying that this finding is invalid, but rather expressing the fact that likelihood of being executed is far away from #716 and wanted to highlight it.

"If permanently loosing salt to make a ballot pass makes sense will depend on the ballot. If a user can gain something worth more than the salt lost from the ballot being passed, it can be a reasonable call." I totally agree, and no scenario has been demonstrated here on how this could be exploited to gain an advantage, which seems pretty difficult by the fact that proposals are very limited on Salty, as parameters can only be modified by ~10% steps of the value.

But I completely see your point here, I think I made mine and as you said will leave this to judge now

#7 - Picodes

2024-02-27T19:57:46Z

@J4X-98 to me it's the same issue and the proof is that your mitigations are in fact the same suggestion: store the quorum during the proposal creation to avoid any manipulation during the voting phase. Let me know if I am missing something.

#8 - J4X-98

2024-02-27T20:27:04Z

Hi @Picodes,

thanks for reviewing my comment. In my opinion, the 2 are different issues, although they are fixed similarly. They result from different root issues, break different functionalities in the code, and are exploited in different ways.

Attack on stakedSalt

Root Issue: Missing storage of stakedSalt variable in the ballot struct Broken Functionality: requiredQurorum gets set lower than intended allowing to pass votes when a lot of people have staked Attack: Vote->Unstake Salt->Pass Ballot->CancelUnstake Fix: Save the stakedSalt parameter in the ballot struct

Attack on totalSalt

Root Issue: Missing storage of totalSalt variable in the ballot struct Broken Functionality: minimumQuorum will not overwrite the requiredQuorum as the following snippet does not get executed:

	if ( requiredQuorum < minimumQuorum )
		requiredQuorum = minimumQuorum;
	}

This will allow ballots to pass easier than intended if very few have staked which should result in triggering the overwrite of requiredQuorum with minimumQuorum.

Attack: Vote->Unstake Salt at worst possible conditions -> Wait 1 week -> Finish Unstake and burn Salt -> Pass Vote Fix: Save the totalSalt parameter in the ballot struct

Argumentation:

This differentiates the issues in my opinion. From my knowledge of the C4 judging guidelines to be duplicated, issues have to result from the same root issue, which is not the case here.

Additionally, I would like to add that even if the issues were bundled into a root, then all issues that only found the incorrect use of stakedSalt would be 50% dups of the main issue, and this issue and #187 would together make up the main issue describing the full impact of the vulnerability. This is especially the case as the current primary issue does not in any way describe the problem of totalSalt being decreased by burning, or mentions a mitigation for it. Which could result in a sponsor reading the report, potentially only fixing 1 of the 2 issues.

#9 - Picodes

2024-02-27T23:04:53Z

@J4X-98 I still respectfully disagree.

To me, the root issue is clearly that the quorum isn't stored. Note how the mitigation of https://github.com/code-423n4/2024-01-salty-findings/issues/716 solves both issues. Then, as the quorum depends on 2 variables (the staked SALT and the total supply) you very smartly came with 2 scenarios. But that doesn't make it 2 distinct issues in my opinion.

Regarding how to handle duplicate findings and partial credits, the rule is:

"All issues which identify the same functional vulnerability will be considered duplicates regardless of effective rationalization of severity or exploit path. However, any submissions which do not identify or effectively rationalize the top identified severity case may be judged as β€œpartial credit” and may have their shares divided at judge’s sole discretion."

So here all issues identifying the fact that you can manipulate the quorum by unstaking should get full credit as it is "the top identified severity case".

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
duplicate-621

Awards

37.1392 USDC - $37.14

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L196-L209

Vulnerability details

Bug Description

In the Salty DAO, the proposeSendSALT() function restricts the number of simultaneous SendSalt proposals to one, by designating the ballot's name as "sendSALT". However, this restriction, combined with the DAO's threshold settings for proposing a ballot, can be exploited. Specifically, in scenarios where only 0.10% of all staked salt is required to propose a ballot, a malicious user can monopolize the sendSalt functionality.

The exploitation arises because each proposal is not finalizable until the ballotMinimumDuration has elapsed, which can range from 3 to 14 days. A user with just 0.1% of the staked salt can repeatedly submit frivolous sendSalt proposals. Each time a proposal is close to finalization, the malicious user can finalize it and immediately submit another, effectively blocking legitimate use of the sendSalt functionality.

Impact

This vulnerability allows a single user with a minimal stake to consistently disrupt the sendSalt function of the DAO, effectively denying other users the ability to utilize this feature.

Proof of Concept

Assuming the DAO configuration where:

  • requiredProposalPercentStakeTimes1000 = 100 (0.1%),
  • ballotMinimumDuration = 14 days,`

In this case a malicious actor that owns a wallet, which holds 0.1% of the total staked salt can call proposeSendSALT() and propose a malicious transfer. This will lead to a state in which other stakers will not be able to propose any new sendSalt proposals. This state will continue until the ballotMinimumDuration has passed and the malicious proposals can be finalized. As the malicious actor keeps his stake he can propose a new bad proposal as soon as his old one gets finalized, extending the period of DOS over the ballotMinimumDuration indefinitely.

Tools Used

Manual Review

Recommended Mitigation Steps

Several approaches can mitigate this issue:

  1. Remove the Single Proposal Restriction: Allowing more than one sendSalt proposal at a time would prevent a single user from monopolizing this function.
  2. Penalize Malicious Proposals: Implement a mechanism where the DAO can penalize malicious actors by seizing a portion of their staked salt if they abuse the proposal system.
  3. Implement a Proposal Cooldown: Introduce a cooldown period after a user's proposal is finalized, during which they cannot submit new proposals. This cooldown would limit the ability of a user to continuously submit proposals and monopolize the functionality.

Each of these solutions offers a way to prevent the abuse of the sendSalt proposal system while maintaining the integrity and functionality of the DAO's governance process.

Assessed type

Governance

#0 - c4-judge

2024-02-03T16:40:06Z

Picodes marked the issue as duplicate of #621

#1 - c4-judge

2024-02-19T17:06:22Z

Picodes marked the issue as satisfactory

#2 - Picodes

2024-02-19T17:06:39Z

This report misses the other names conflicts that could occur

Findings Information

🌟 Selected for report: thekmj

Also found by: 00xSEV, J4X, Lalanda, OMEN, Toshii, Tripathi, Ward, eeshenggoh, grearlake, juancito, peanuts

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-60

Awards

53.4926 USDC - $53.49

External Links

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L108-L146

Vulnerability details

Bug Description

In the Salty protocol, collateral valuation for the USDS stablecoin is determined using three price feeds: Chainlink (BTC->USD), Uniswap (WBTC->USDC), and Salty's internal feed (WBTC->USDS). The potential issue arises in the event of a WBTC depeg from BTC. Chainlink's oracle would continue to provide the standard Bitcoin price, not reflecting WBTC's actual market value. The Uniswap feed is more reliable in this scenario, as it would reflect the depegged price of WBTC. However, the Salty WBTC->USDS feed is critically flawed because USDS is collateralized by WBTC. In a depeg situation, as WBTC's value falls, so does USDS's, resulting in a skewed and inaccurate exchange rate in the Salty internal feed.

Salty's price aggregator, which uses the average of the two closest prices, would ignore the accurate Uniswap price, leading to an overvaluation of WBTC.

uint256 diff12 = _absoluteDifference(price1, price2);
uint256 diff13 = _absoluteDifference(price1, price3);
uint256 diff23 = _absoluteDifference(price2, price3);

uint256 priceA;
uint256 priceB;

if ( ( diff12 <= diff13 ) && ( diff12 <= diff23 ) )
	(priceA, priceB) = (price1, price2);
else if ( ( diff13 <= diff12 ) && ( diff13 <= diff23 ) )
	(priceA, priceB) = (price1, price3);
else if ( ( diff23 <= diff12 ) && ( diff23 <= diff13 ) )
	(priceA, priceB) = (price2, price3);

uint256 averagePrice = ( priceA + priceB ) / 2;

This overvaluation would enable the minting of undercollateralized USDS, exploiting the discrepancy between the actual market value and the calculated value from the flawed price feeds.

Impact

This vulnerability can cause the USDS stablecoin to become severely undercollateralized and potentially worthless, leading to a loss of peg and a significant systemic risk for the Salty protocol.

Proof of Concept

To illustrate the impact of this issue, let's consider a hypothetical scenario of WBTC depegging in the Salty protocol:

Initial State:

  • Chainlink BTC->USD price feed: $40,000 (for BTC).
  • Salty WBTC->USDS internal price feed: $40,000 (for WBTC).
  • Uniswap WBTC->WETH->USDC price feed: $40,000 (for WBTC).

After WBTC Depegging:

  • Chainlink BTC->USD price feed remains unchanged at $40,000, as it tracks BTC, not WBTC.
  • Uniswap WBTC->WETH->USDC price feed quickly adjusts to the depegged value of WBTC, let's say it falls to $500.
  • Salty WBTC->USDS internal price feed lags in response to the depegging due to WBTC's role in collateralizing USDS. Assume it adjusts to $36,000 after some delay.

In this scenario, the Salty protocol's price aggregator will exclude the accurate Uniswap price, and instead, average out the Chainlink and Salty internal prices, valuing WBTC at around 38,000 dollars. This is despite the fact that its real market value on Uniswap is only 500 dollars.

Consequences:

  • An attacker can exploit this discrepancy by purchasing WBTC at its actual depegged market value (500 dollar) on Uniswap and then use it as collateral in Salty, where it is still valued at the inflated price (38,000 dollar).

  • This allows the attacker to mint a significantly larger amount of USDS than is justified by the actual market value of their collateral, effectively creating undercollateralized USDS.

Limitations & Circumventing the Limitations:

  • The exploit hinges on ensuring the price difference between Salty's internal WBTC->USDS feed and Chainlink's BTC->USD feed doesn't exceed 7%, as per the maximumPriceFeedPercentDifferenceTimes1000 limit. If USDS devalues significantly, causing this threshold to be breached, the exploit's viability is compromised.

  • To circumvent this, the attacker can use the minted USDS to manipulate the WBTC->USDS rate within Salty, keeping the price difference within the 7% limit. This manipulation allows continuous exploitation of the system, as the attacker can maintain an inflated WBTC value in Salty, enabling the minting of more USDS.

Tools Used

Manual Review

Recommended Mitigation Steps

To mitigate this issue, the Salty protocol should replace its internal WBTC->USDS oracle with an external, stablecoin-based oracle that isn't pegged to WBTC, such as USDT or USDC. This external oracle could be sourced from another reliable on-chain AMM or an off-chain entity, providing an accurate and independent valuation of WBTC relative to a stable asset, thereby preventing exploitation in the event of a WBTC depeg.

Assessed type

Oracle

#0 - c4-judge

2024-02-01T23:17:16Z

Picodes marked the issue as duplicate of #632

#1 - c4-judge

2024-02-20T15:51:36Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2024-02-20T15:52:18Z

Picodes marked the issue as selected for report

#3 - c4-judge

2024-02-20T15:53:36Z

Picodes marked issue #60 as primary and marked this issue as a duplicate of 60

#4 - c4-judge

2024-02-21T16:57:40Z

Picodes changed the severity to 2 (Med Risk)

#5 - J4X-98

2024-02-22T07:27:34Z

Hey @Picodes,

thanks for reviewing my submissions. I would like to add 2 points to this judging.

1. Severity Downgrade

I think that this issue suffices a high severity, as it allows malicious users to generate undercollateralized positions devaluing all other users' USDS by making them able to mint nearly unlimited amounts of it. This clearly leads to a loss of user funds, leading to a high severity.

2. Duplication

I additionally would like to add, that out of the issues duplicated to #60 most are invalid and clearly missing the issue:

#942 - clearly misses the issue and describes that the PriceAggregator will revert because two prices fall together, which is totally incorrect.

#930 - also clearly misses the issue, as it states that the collateral will continue to be priced on the chainlink oracle, which is not correct as it will choose the 2 closest (salty & uniswap)

#888 - this issue even describes itself that it found no real vuln as it will choose the other 2 sources "Although it is true that the PriceAggregator still has two other price sources if this occurs (Uniswap TWAP & SALTY spot price), the fundamental issue is that the price returned by the Chainlink Oracle for WBTC is incorrect. "

#777 - No POC, No attack path, only severity "make risk that price can be manipulated become higher" also clearly missing the issue

#646 - describes a valid but different issue, of the TWAP being delayed, allowing for undercollateralized loans for a short time period while the deviation of chainlink and Uniswap is less than the 3%, depending on the sponsor this might be intentional or an additional vuln

#632 - Describes only the loss of 1 price feed, which will be ignored if the other 2 correctly update and not lead to a vuln

#272 - Describes the loss of an oracle, but is not able to create a vuln from it as it only states "When another oracle is compromised" but no way how this could happen

#4 clearly misses the issue, stating that the calculated price will stay incorrectly high if only the chainlink breaks

Only issues #60 & #997 were able to describe the actual vulnerability, although #60 required some user manipulation.

Using the WBTC instead of BTC oracle by itself does not lead to a real vulnerability, as if it would depeg, the other 2 oracles should correctly change based on the price, and the chainlink oracle will be ignored. This is due to the protocol already implementing a safeguard here (2/3). In my opinion, the core issue is that when this depeg happens, WBTC collateral of the stablecoin is valued based on the exchange to the stablecoin (which is collateralized by WBTC). So both prices will fall in synergy, leading the exchange rate to stay pretty much the same although it severely devalues. In that case, the Chainlink and the salty valuation of USDS/WBTC will stay incorrectly high, while Uniswap falls and gets ignored. Only by being aware of this issue the real severity of the depeg (without any need for user interaction or manipulation) is seen. After this, users will be able to create unlimited USDS by creating undercollateralized positions, based on the incorrect prize.

#6 - Tri-pathi

2024-02-23T20:30:54Z

Agree with J4X-98

Most of the duped submission doesn't describe the actual issue so pls have a second look

#7 - Picodes

2024-02-26T14:11:34Z

Regarding the severity first, the definition is:

  • Medium: "leak value with a hypothetical attack path with stated assumptions"
  • High: "Assets can be stolen/lost/compromised directly"

Here the precondition is WBTC depegging from BTC, which is a strong hypothesis as it never happened. So to me, it clearly falls into the Medium category.

#8 - J4X-98

2024-02-27T20:28:23Z

Hey @Picodes,

thanks for reviewing the first part of my comment. I'd kindly ask you to also review the second part, which was additionally confirmed by the fellow warden @Tri-pathi?

#9 - Picodes

2024-02-27T21:52:38Z

Thanks for the reminder. I am on it.

#10 - Picodes

2024-02-27T22:04:37Z

I actually disagree with your vision of the impact of this finding. Salty's internal price can be manipulated anyway as it's a spot price, so the aggregated price can return a price close to whichever other price source the attacker wants. Furthermore depending on the sequence of events and the reason of the depeg (the most likely being a hack of the wbtc infrastructure) the USDS price will take some time to be adjusted. So in the end we don't really know how it'll turn out. By manipulating the spot price you could inflate the valuation of the collateral and mint USDS for free. But you could also manipulate the pool reserve and force liquidations to steal the remaining collateral using the liquidation module.

Overall, my view is that it's quite obvious that an incorrect oracle can have devastating consequences, and that the exact scenario here is unclear, so any report correctly highlighting that in the event of WBTC depeging the oracle will be flawed is eligible to be awarded medium severity.

Low

[L-01] Reserves Above DUST Incorrectly Enforced

Pools Line 270

Issue Description:

In the Salty protocol's _adjustReservesForSwap() function, the intention, as per the accompanying comment ("// Make sure that the reserves after swap are above DUST"), is to ensure that the reserves always remain above a certain threshold defined by the DUST constant. However, the current implementation of this check uses >= (greater than or equal to), which leads to an off-by-one error. The actual requirement, as intended, is for the reserves to be strictly greater than DUST.

Current implementation:

require( (reserve0 >= PoolUtils.DUST) && (reserve1 >= PoolUtils.DUST), "Insufficient reserves after swap");

This condition allows the reserves to be exactly equal to DUST, which contradicts the intended logic of keeping them above DUST.

Recommended Mitigation Steps

To align the implementation with the intended behavior, the require statement should be adjusted to enforce that reserves are strictly greater than DUST. This change will ensure that the reserves are maintained above the minimal threshold, as originally planned.

Proposed change:

require( (reserve0 > PoolUtils.DUST) && (reserve1 > PoolUtils.DUST), "Insufficient reserves after swap");

By modifying the condition to > (greater than), the function will correctly enforce that the reserves are maintained above the DUST level, thus adhering to the protocol's intended safety and operational standards.

[L-02] Liquidity can still be below DUST after a call to addLiquidity()

Pools Line 146-147

Issue Description:

n the Salty protocol's addLiquidity() function, there's an enforcement to ensure the maximum liquidity added by a user is above the PoolUtils.DUST threshold. However, a potential issue arises because the actual liquidity added to the pool may end up being less than PoolUtils.DUST. This discrepancy occurs due to the adjustment of the added liquidity based on the current exchange rate within the _addLiquidity() call. Consequently, a pool might end up with liquidity below the DUST level, conflicting with other contract checks that assume liquidity will always exceed this minimum.

Recommended Mitigation Steps

To resolve this issue, the validation in the addLiquidity function should be based on the actual amounts of liquidity added (addedAmountA and addedAmountB), rather than the maximum amounts (maxAmountA and maxAmountB) initially specified by the user. By checking the addedAmountA and addedAmountB for adherence to the DUST threshold, the protocol can ensure that the liquidity in the pool always remains above this critical level, maintaining consistency with the protocol's rules and intentions.

[L-03] proposeCountryInclusion() does not check for ASCII

Proposals Line 222

Issue Description: In the Salty DAO's proposeCountryInclusion() function, there's an existing validation step to ensure the country codes are in the ISO 3166 Alpha-2 format, which consists of two uppercase ASCII characters. While the function includes a require statement to check the length of the country code, it doesn't verify if the provided bytes are indeed uppercase ASCII characters:

require( bytes(country).length == 2, "Country must be an ISO 3166 Alpha-2 Code" );

This lack of validation means that any two-byte input, regardless of its content, can be passed as a country code, potentially leading to invalid or misleading entries.

Recommended Mitigation Steps

To address this issue, additional validation should be implemented to confirm that each character in the country code falls within the range of uppercase ASCII letters (0x41 to 0x5A). This ensures that only valid ISO 3166 Alpha-2 country codes are accepted. The validation can be integrated into the existing function with checks for each character of the country code:

require(bytes(country)[0] >= 0x41 && bytes(country)[0] <= 0x5A && bytes(country)[1] >= 0x41 && bytes(country)[1] <= 0x5A, "Invalid country code");

By incorporating these character checks, the Salty DAO will accurately enforce the ISO 3166 Alpha-2 standard for country codes, enhancing the integrity of the country inclusion proposal process.

[L-04] proposeCountryInclude does not check if the country is currently excluded`

Proposals Line 222

Issue Description: The proposeCountryInclude function in the Salty DAO is designed to allow proposals for removing a country from the blacklist. However, the current implementation does not include a check to verify whether the specified country is already on the blacklist. This oversight means that proposals could be made and potentially approved for countries that are not currently blacklisted, leading to unnecessary and potentially confusing governance actions.

Recommended Mitigation Steps

To rectify this, an additional validation step should be incorporated into the proposeCountryInclude function to ensure that the country in question is indeed currently excluded (i.e., on the blacklist). This can be done by checking the blacklist status of the country within the DAO:

require(dao.isExcluded(country), "country is not excluded");

Adding this require statement ensures that proposals for including a country are only possible if that country is currently excluded. This check maintains the integrity and relevance of the governance process, ensuring that the DAO's efforts are focused on meaningful and necessary changes.

[L-05] proposeCountryExclusion() does not check for ASCII

Proposals Line 222

Issue Description: The proposeCountryExclusion() function in the Salty DAO is designed for proposals to add countries to the blacklist using their ISO 3166 Alpha-2 codes, which consist of two uppercase ASCII characters. While the function checks that two bytes are provided, it does not verify if these bytes represent valid uppercase ASCII characters, potentially allowing for invalid country codes.

require( bytes(country).length == 2, "Country must be an ISO 3166 Alpha-2 Code" );

Recommended Mitigation Steps

To ensure that only valid ISO 3166 Alpha-2 country codes are proposed for blacklisting, the function should include additional checks to confirm that each character is within the ASCII range for uppercase letters (0x41 to 0x5A). This validation ensures that the input conforms to the correct format for country codes:

require(bytes(country)[0] >= 0x41 && bytes(country)[0] <= 0x5A && bytes(country)[1] >= 0x41 && bytes(country)[1] <= 0x5A, "Invalid country code");

Implementing these character checks will ensure the accuracy and validity of country codes proposed for exclusion, thereby upholding the integrity of the DAO's blacklist management process.

[L-06] proposeWebsiteUpdate() does not check for a valid URL

Proposals Line 249

Issue Description:

In the Salty DAO, the proposeWebsiteUpdate() function facilitates the voting process for changes to the website. However, the function currently lacks validation for the input URL, meaning it does not verify whether the provided string is a valid URL starting with "http://" or "https://". Additionally, it does not check if the rest of the string consists of valid ASCII characters. This oversight could lead to the acceptance of invalid or improperly formatted URLs.

Recommended Mitigation Steps

To ensure that only valid URLs are proposed for website updates, the function should include checks to confirm:

  1. The URL begins with "http://" or "https://".
  2. All subsequent characters in the URL string fall within the valid ASCII range (0x2D to 0x7A).

The implementation could first verify the URL's prefix and then loop through the rest of the string to check each character:

string memory httpPrefix = "http://";
string memory httpsPrefix = "https://";
require(hasPrefix(url, httpPrefix) || hasPrefix(url, httpsPrefix), "URL must start with 'http://' or 'https://'");

for (uint i = 0; i < bytes(url).length; i++) {
    require(bytes(url)[i] >= 0x2D && bytes(url)[i] <= 0x7A, "Invalid character in URL");
}

// 'hasPrefix' is a helper function to check if the URL starts with the specified prefix

By adding these checks, the Salty DAO will ensure that only properly formatted and valid URLs are considered for website updates, enhancing the protocol's governance and operational integrity.

[L-07] requiredXSalt can be zero

Proposals Line 92

Issue Description:

In the _possiblyCreateProposal() function of the Salty DAO, which is called when a user proposes a proposal, there's a calculation to determine the required amount of xSalt (staked SALT) needed:

// Make sure that the sender has the minimum amount of xSALT required to make the proposal
uint256 totalStaked = staking.totalShares(PoolUtils.STAKED_SALT);
uint256 requiredXSalt = ( totalStaked * daoConfig.requiredProposalPercentStakeTimes1000() ) / ( 100 * 1000 );

require( requiredXSalt > 0, "requiredXSalt cannot be zero" );

However, an edge case exists where requiredXSalt can be zero. This situation occurs when the total staked SALT is below 50, and requiredProposalPercentStakeTimes1000 is set to 2% (2000). The resulting calculation would be 49 * 2000 / 100000, which equals zero due to roundign down, thereby preventing any proposals from being created even though SALT is staked.

Recommended Mitigation Steps

To address this issue, an additional check should be added to handle cases where requiredXSalt computes to zero while SALT is staked. In such scenarios, requiredXSalt should default to a minimum value (e.g., 1), enabling proposals as long as the user has staked at least 1 SALT. This change ensures that the ability to create proposals is not inadvertently blocked due to the specificities of the calculation in edge cases:

if (totalStaked > 0 && requiredXSalt == 0) { requiredXSalt = 1; }

Incorporating this logic will maintain the functionality of the proposal system, particularly in low liquidity conditions, ensuring that the governance process remains accessible and fair.

[L-08] First staker can claim all rewards from period before

StakingRewards Linhe 78

Issue Description:

In the Salty protocol's StakingRewards contract, there is an issue related to the distribution of rewards when no stakers are present. When rewards are distributed during a period with no stakers and a user subsequently stakes SALT, the implementation of _increaseUserShare() results in the first staker claiming all the rewards accumulated during the staker-less period:`

// Determine the amount of virtualRewards to add based on the current ratio of rewards/shares.
// The ratio of virtualRewards/increaseShareAmount is the same as totalRewards/totalShares for the pool.
// The virtual rewards will be deducted later when calculating the user's owed rewards.
if ( existingTotalShares != 0 ) // prevent / 0
	{
	// Round up in favor of the protocol.
	uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares );

	user.virtualRewards += uint128(virtualRewardsToAdd);
	totalRewards[poolID] += uint128(virtualRewardsToAdd);
	}

// Update the deposit balances
user.userShare += uint128(increaseShareAmount);
totalShares[poolID] = existingTotalShares + increaseShareAmount;

emit UserShareIncreased(wallet, poolID, increaseShareAmount);
}

The current logic is designed to distribute virtual rewards proportionally among stakers. However, to prevent division by zero, the function inadvertently allocates all accumulated rewards to the first staker in cases where they are the first to stake after a period with no stakers.

Recommended Mitigation Steps

To address this issue, the reward distribution mechanism should be adjusted to withhold rewards during periods with no stakers. The rewards accumulated during these periods should be retained by the rewards distributor and only begin to be distributed again once stakers are present. This change ensures that rewards are fairly allocated among stakers and not disproportionately claimed by a single user due to a timing advantage.

#0 - c4-judge

2024-02-03T13:18:30Z

Picodes marked the issue as grade-a

#1 - c4-judge

2024-02-07T18:07:42Z

Picodes marked the issue as selected for report

#2 - c4-sponsor

2024-02-12T22:41:09Z

othernet-global (sponsor) confirmed

#3 - othernet-global

2024-02-12T22:51:17Z

L1: Comment will be updated to "Make sure that the reserves after swap are at least DUST"

L2. Actual addedLiquidity will be checked against DUST.

L3. Will fix.

L4. Will fix.

L5. Will fix.

L6. Will fix.

L7. Acceptable as proposals cannot be made for 45 days after launch, and staked SALT will be higher than 49.

L8. Acceptable as an adjustment will be made to reset the emissions timestamp on approval of the BootstrapBallot and performUpkeep will be called frequently.

#4 - othernet-global

2024-02-15T02:52:29Z

L2: addLiquidity now requires addedAmount0 and addedAmount1 to be greater than DUST. Fixed in: https://github.com/othernet-global/salty-io/commit/0bb763cc67e6a30a97d8b157f7e5954692b3dd68

#6 - othernet-global

2024-02-19T00:02:42Z

#7 - c4-judge

2024-02-21T17:25:52Z

Picodes marked the issue as not selected for report

#8 - Picodes

2024-02-21T17:25:58Z

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter