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: 10/178
Findings: 2
Award: $1,268.77
π Selected for report: 1
π Solo Findings: 0
π Selected for report: Bauchibred
Also found by: grearlake
1196.6399 USDC - $1,196.64
Take a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/price_feed/CoreUniswapFeed.sol#L49-L75
// Returns amount of token0 * (10**18) given token1 function _getUniswapTwapWei( IUniswapV3Pool pool, uint256 twapInterval ) public view returns (uint256) { uint32[] memory secondsAgo = new uint32[](2); secondsAgo[0] = uint32(twapInterval); // from (before) secondsAgo[1] = 0; // to (now) // Get the historical tick data using the observe() function (int56[] memory tickCumulatives, ) = pool.observe(secondsAgo); //@audit int24 tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(twapInterval))); uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick( tick ); uint256 p = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96 ); uint8 decimals0 = ( ERC20( pool.token0() ) ).decimals(); uint8 decimals1 = ( ERC20( pool.token1() ) ).decimals(); if ( decimals1 > decimals0 ) return FullMath.mulDiv( 10 ** ( 18 + decimals1 - decimals0 ), FixedPoint96.Q96, p ); if ( decimals0 > decimals1 ) return ( FixedPoint96.Q96 * ( 10 ** 18 ) ) / ( p * ( 10 ** ( decimals0 - decimals1 ) ) ); return ( FixedPoint96.Q96 * ( 10 ** 18 ) ) / p; }
This function is used to get twap price tick using uniswap oracle. it uses pool.observe()
to get tickCumulatives
array which is then used to calculate int24 tick
.
The problem is that in case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative, then the tick should be rounded down as it's done in the uniswap library.
As result, in case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative and (tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0
, then returned tick will be bigger then it should be, which opens possibility for some price manipulations and arbitrage opportunities.
In case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative and ((tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0
, then returned tick will be bigger than it should be which places protocol wanting prices to be right not be able to achieve this goal, note that where as protocol still relies on multiple sources of price, they still come down and end on weighing the differences between the prices and reverting if a certain limit is passed, effectively causing the pricing logic to be unavailable and also reverting on important functions like CollateralAndLiquidity::liquidate()
cause a call to underlyingTokenValueInUSD()
is made which would not be available.
Add this line.
if (tickCumulatives[1] - tickCumulatives[0] < 0 && (tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0) timeWeightedTick --;
Math
#0 - c4-judge
2024-02-03T15:27:53Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-08T10:21:34Z
othernet-global (sponsor) confirmed
#2 - othernet-global
2024-02-17T22:27:14Z
Now rounds down for negative ticks as suggested.
https://github.com/othernet-global/salty-io/commit/4625393e9bd010778003a1424201513885068800
#3 - c4-judge
2024-02-19T17:53:51Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-19T17:53:55Z
Picodes marked the issue as selected for report
#5 - othernet-global
2024-02-23T03:28:13Z
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
π 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
72.1303 USDC - $72.13
Issue ID | Description |
---|---|
QA-01 | proposeCountryInclusion() does not have proper checks to ensure that a valid country is proposed to be included |
QA-02 | Hardcoding recipient while withdrawing is bad practice |
QA-03 | Protocol's implementation of feed addresses could lead to a DOS of not less than a month |
QA-04 | Liquidation could be frontrun |
QA-05 | latestRoundData() returns 0 in any failure would probably fault multiple logics |
QA-06 | Values are still being stored in ether value |
QA-07 | excludedCountriesUpdated() should be called after a country gets included |
QA-08 | _possiblyCreateProposal() should be effectively reimplemented |
QA-09 | Btc feeds are planned to be used for wbtc which would cause a massive pricing error if wbtc ever depegs |
QA-10 | tokenWhitelistingBallotWithTheMostVotes() should consider more edge cases |
proposeCountryInclusion()
does not have proper checks to ensure that a valid country is proposed to be includedTake a look at Proposals.sol#L224-L232
function proposeCountryInclusion( string calldata country, string calldata description ) external nonReentrant returns (uint256 ballotID) { require( bytes(country).length == 2, "Country must be an ISO 3166 Alpha-2 Code" ); string memory ballotName = string.concat("include:", country ); return _possiblyCreateProposal( ballotName, BallotType.INCLUDE_COUNTRY, address(0), 0, country, description ); }
Now consider this:
// Example of a vulnerable input string calldata country = "A,"; // Contains a comma string calldata description = "Some description"; // Calling the function with the vulnerable input uint256 ballotID = proposeCountryInclusion(country, description);
In the above example, we have provided an input country
with the value "A,", which includes a comma. According to the current validation check, this input would be accepted as a valid ISO 3166 Alpha-2 country code because it has a length of 2 characters. However, it violates the expected format of a valid country code, cause from here we can see that all valid codes are to be made up of two alphabetical characters from AA..=ZZ
Apply these modifications to the already existing test here: https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/dao/tests/Proposals.t.sol#L1356-L1372
// A unit test for the proposeCountryInclusion and proposeCountryExclusion functions to verify they don't allow an empty country name. function testProposeCountryInclusionExclusionEmptyName() public { - string memory emptyCountryName = ""; + string memory emptyCountryName = "A,"; // Proposing country inclusion with empty country name should fail vm.startPrank(alice); staking.stakeSALT(1000 ether); - vm.expectRevert("Country must be an ISO 3166 Alpha-2 Code"); // no reversion would occur even when code is not a valid Alpha-2 country code proposals.proposeCountryInclusion(emptyCountryName, "description"); vm.stopPrank(); // Proposing country exclusion with empty country name should fail vm.startPrank(alice); - vm.expectRevert("Country must be an ISO 3166 Alpha-2 Code"); proposals.proposeCountryExclusion(emptyCountryName, "description"); vm.stopPrank(); }
The input validation in the code does not sufficiently verify the format of the country code, potentially allowing invalid inputs to be accepted. This leads to unexpected behavior in the smart contract and compromises its functionality. Specifically, the current validation check only verifies the length of the input string and does not ensure that it consists of valid uppercase alphabetic characters.
To enhance the input validation and ensure that the country code adheres to the ISO 3166 Alpha-2 code format, consider verifying that both characters in the input string are uppercase letters (A-Z):
require( (bytes(country)[0] >= bytes("A")[0] && bytes(country)[0] <= bytes("Z")[0]) && (bytes(country)[1] >= bytes("A")[0] && bytes(country)[1] <= bytes("Z")[0]), "Country code must consist of two uppercase letters" );
Take a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/pools/Pools.sol#L218-L231
function withdraw( IERC20 token, uint256 amount ) external nonReentrant { require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); _userDeposits[msg.sender][token] -= amount; // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
As seen, this function is used to withdraw tokens that were previously deposited for users, issue here is thatr the function hardcodes the recipient and does not allow the msg.sender
to provide a fresh address to possibly send these tokens, now in a case where tokens like USDC and USDT are whitelisted (i.e tokens that employ blacklisting mechanisms) and msg.sender
now becomes blacklisted, then users funds are stuck in the contract.
Potential funds stuckage for users, depending on whitelisting of tokens that employ this functionality.
Accept a fresh address for the withdraw()
function and if user doesn't provide this address then funds xan just be sent to the msg.sender
.
Take a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/price_feed/PriceAggregator.sol#L31-L35
// The required cooldown between calls to setPriceFeed. // Allows time to evaluate the performance of the recently updatef PriceFeed before further updates are made. // Range: 30 to 45 days with an adjustment of 5 days //@audit uint256 public priceFeedModificationCooldown = 35 days;
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;
As seen this duration "priceFeedModificationCooldownExpiration" is used as a period to evaluate the performance of the recently updated PriceFeed before further updates are made, now issue with this is that, when it comes to chainlink feeds they could getr deprecated and as such their addresses could even change which would cause calls to query the prices to even revert.
Intended design could actually cause a DOS of at least 30 days, i.e an instance where the stored address for the chainlink feed is inacessible and it can't be changed cause protocol needs to wait for a range of 30..45 days, cause these queries would always revert at an attempt to get the price from the second pricefeed
Have a side stepper for emergency situtations to set the chainlink feeds for scenarios like explained in the report.
When a position is no longer sufficiently collateralized, the liquidateUser()
function can be called to liquidate the position and earn a 5% bonus (current default value). However, when someone finds a liquidation opportunity and calls this function, anyone can frontrun it and execute the liquidation first to get the bonus reward.
Take a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/stable/CollateralAndLiquidity.sol#L140-L191
function liquidateUser( address wallet ) external nonReentrant { require( wallet != msg.sender, "Cannot liquidate self" ); // First, make sure that the user's collateral ratio is below the required level require( canUserBeLiquidated(wallet), "User cannot be liquidated" ); uint256 userCollateralAmount = userShareForPool( wallet, collateralPoolID ); // Withdraw the liquidated collateral from the liquidity pool. // The liquidity is owned by this contract so when it is withdrawn it will be reclaimed by this contract. (uint256 reclaimedWBTC, uint256 reclaimedWETH) = pools.removeLiquidity(wbtc, weth, userCollateralAmount, 0, 0, totalShares[collateralPoolID] ); // Decrease the user's share of collateral as it has been liquidated and they no longer have it. _decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true ); // The caller receives a default 5% of the value of the liquidated collateral. uint256 rewardPercent = stableConfig.rewardPercentForCallingLiquidation(); uint256 rewardedWBTC = (reclaimedWBTC * rewardPercent) / 100; uint256 rewardedWETH = (reclaimedWETH * rewardPercent) / 100; // Make sure the value of the rewardAmount is not excessive uint256 rewardValue = underlyingTokenValueInUSD( rewardedWBTC, rewardedWETH ); // in 18 decimals uint256 maxRewardValue = stableConfig.maxRewardValueForCallingLiquidation(); // 18 decimals if ( rewardValue > maxRewardValue ) { rewardedWBTC = (rewardedWBTC * maxRewardValue) / rewardValue; rewardedWETH = (rewardedWETH * maxRewardValue) / rewardValue; } // Reward the caller wbtc.safeTransfer( msg.sender, rewardedWBTC ); weth.safeTransfer( msg.sender, rewardedWETH ); // Send the remaining WBTC and WETH to the Liquidizer contract so that the tokens can be converted to USDS and burned (on Liquidizer.performUpkeep) wbtc.safeTransfer( address(liquidizer), reclaimedWBTC - rewardedWBTC ); weth.safeTransfer( address(liquidizer), reclaimedWETH - rewardedWETH ); // Have the Liquidizer contract remember the amount of USDS that will need to be burned. uint256 originallyBorrowedUSDS = usdsBorrowedByUsers[wallet]; liquidizer.incrementBurnableUSDS(originallyBorrowedUSDS); // Clear the borrowedUSDS for the user who was liquidated so that they can simply keep the USDS they previously borrowed. usdsBorrowedByUsers[wallet] = 0; _walletsWithBorrowedUSDS.remove(wallet); emit Liquidation(msg.sender, wallet, reclaimedWBTC, reclaimedWETH, originallyBorrowedUSDS); }
On the long run, there might no longer be an incentive to liquidate bad positions because the liquidation rewards can be stolen by frontrunning.
Implement a Commit-Reveal mechanism or incentivize users to use Flashbots when calling the liquidateUser()
function.
latestRoundData()
returns 0 in any failure would probably fault multiple logicsFaulting of multiple pricing logic, this is cause even if protocol uses two different sources of pricing having another one being faulty and the returned price being zero could end up causing protocol to even unfairly liquidate a user and what not.
Looking at the pricing logic used in protocol for chainlink we can see that it returns 0 if it encounters any error.
Reimplement what's returned if the querying fails for whatever reason.
Multiple instances of values being stored in ether value to aid with testing that hasn't been updated, for example the bootstrappingRewards
from the DAOConfig is to be in direct relation with the amount of SALT tokens before whitelisting, i.e by multiplying it by 2, issue now is that this value is stored in ether value
Store values how they are to be stored.
excludedCountriesUpdated()
should be called after a country gets includedTake a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/dao/DAO.sol#L184-L202 from _executeApproval()
else if ( ballot.ballotType == BallotType.INCLUDE_COUNTRY ) { excludedCountries[ ballot.string1 ] = false; emit GeoExclusionUpdated(ballot.string1, false, exchangeConfig.accessManager().geoVersion()); } else if ( ballot.ballotType == BallotType.EXCLUDE_COUNTRY ) { excludedCountries[ ballot.string1 ] = true; // If the AccessManager doesn't implement excludedCountriesUpdated, this will revert and countries will not be able to be excluded until the AccessManager is working properly. exchangeConfig.accessManager().excludedCountriesUpdated(); emit GeoExclusionUpdated(ballot.string1, true, exchangeConfig.accessManager().geoVersion()); }
As we can see where as whenever a country gets excluded the excludedCountriesUpdated
gets called on the access manager this is not done for the inclusion of countries.
Uneven code implementation.
Add this line: exchangeConfig.accessManager().excludedCountriesUpdated();
to the instance pertaining inclusion of countries
_possiblyCreateProposal()
should be effectively reimplementedThe _possiblyCreateProposal
function appears to have an issue related to the calculation of requiredXSalt
when certain conditions are met. Specifically, when the total staked shares (totalStaked
) is less than a certain threshold, requiredXSalt
can become zero, leading to the failure of proposals. This issue affects the ability of users to create proposals, potentially preventing valid proposals from being submitted and impacting the governance process.
Consider the following scenario:
daoConfig.requiredProposalPercentStakeTimes1000
is set to its maximum value (e.g., 2000).totalStaked
is less than 50 shares, such as 49 shares.In this case, when calculating requiredXSalt
:
uint256 requiredXSalt = (totalStaked * daoConfig.requiredProposalPercentStakeTimes1000()) / (100 * 1000);
The result will be zero because the expression (totalStaked * daoConfig.requiredProposalPercentStakeTimes1000())
will round down to zero since totalStaked
is less than 50 shares. This leads to requiredXSalt
being zero, which is contrary to the intended behavior where users should have a minimum requiredXSalt
to create proposals.
Reimplement the _possiblyCreateProposal
function to take this into account.
Looking at protocol we can see that it uses the btc/usd feed instead of the wbtc/usd one
A massive pricing error if wbtc ever depegs
Use chainlink's wbtc/usd
feed instead of
tokenWhitelistingBallotWithTheMostVotes()
should consider more edge casesThe tokenWhitelistingBallotWithTheMostVotes
function is designed to return the ballotID
of the whitelisting ballot that currently has the most "yes" votes. However, there is an issue in the code that can lead to an edge case where two or more ballots have the same number of "yes" votes, but only one of them is considered, potentially leading to unfair or unexpected results.
Unfair/Unexpected result in the sense that if a call to fialize the whitelisting for this tokens gets called, i.e DAO.sol#L235-L236, it would revert for an instance of a token with the shared mostVotes
in as much as it doesn't come first in the iteration
Take a look at Proposals.sol#L419-L444
function tokenWhitelistingBallotWithTheMostVotes() external view returns (uint256) { uint256 quorum = requiredQuorumForBallotType( BallotType.WHITELIST_TOKEN); uint256 bestID = 0; uint256 mostYes = 0; for( uint256 i = 0; i < _openBallotsForTokenWhitelisting.length(); i++ ) { uint256 ballotID = _openBallotsForTokenWhitelisting.at(i); uint256 yesTotal = _votesCastForBallot[ballotID][Vote.YES]; uint256 noTotal = _votesCastForBallot[ballotID][Vote.NO]; if ( (yesTotal + noTotal) >= quorum ) // Make sure that quorum has been reached if ( yesTotal > noTotal ) // Make sure the token vote is favorable if ( yesTotal > mostYes ) // Make sure these are the most yes votes seen { bestID = ballotID; mostYes = yesTotal; } } return bestID; }
Now consider a scenario where two whitelisting ballots have the following vote totals:
According to the current code, the tokenWhitelistingBallotWithTheMostVotes
function would select Ballot 2 as the one with the most "yes" votes because it is the first one encountered that meets the conditions. Ballot 4, with an equal number of "yes" votes, would not be considered.
To address the issue where multiple whitelisting ballots have the same number of "yes" votes, and only one is considered, we can implement a modification to ensure that all ballots with the most "yes" votes are considered, the function could be reimplemented to then return an array of ballotID
values for all valid whitelisting ballots with the most "yes" votes. This ensures that all deserving ballots are considered, eliminating the edge case where only one is selected when multiple have the same "yes" vote count.
#0 - c4-judge
2024-02-03T14:10:14Z
Picodes marked the issue as grade-a
#1 - othernet-global
2024-02-08T10:19:50Z
QA-01 proposeCountryInclusion() does not have proper checks to ensure that a valid country is proposed to be included Acknowledged.
QA-02 Hardcoding recipient while withdrawing is bad practice Acceptable.
QA-03 Protocolβs implementation of feed addresses could lead to a DOS of not less than a month Chainlink going down would require relying on the other two feeds until it could be brought back up.
QA-04 Liquidation could be frontrun Acceptable.
QA-05 latestRoundData() returns 0 in any failure would probably fault multiple logics Yes, a returned price of zero if something is wrong means that the other two feeds are used to derive price.
QA-06 Values are still being stored in ether value Acknowledged
QA-07 excludedCountriesUpdated() should be called after a country gets included Calling excludedCountriesUpdated() when a country is included is unecessary as all previously valid users are still valid.
QA-08 _possiblyCreateProposal() should be effectively reimplemented Having totalShares be 49 is practically a non-issue as making proposals is activated after 45 days. It would have been more of a delay if there was no proposal delay.
QA-09 Btc feeds are planned to be used for wbtc which would cause a massive pricing error if wbtc ever depegs Yes, WBTC depegging would cause significant issues across DeFi.
QA-10 tokenWhitelistingBallotWithTheMostVotes() should consider more edge cases Acceptable
#2 - c4-sponsor
2024-02-08T10:19:54Z
othernet-global (sponsor) acknowledged