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: 70/178
Findings: 3
Award: $115.49
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xbepresent
Also found by: 00xSEV, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xRobocop, 0xanmol, 0xlemon, 0xpiken, Arz, Audinarey, Auditwolf, Aymen0909, Banditx0x, CaeraDenoir, DanielArmstrong, Draiakoo, HALITUS, Infect3d, J4X, Jorgect, Kalyan-Singh, KingNFT, Krace, PENGUN, Toshii, Udsen, ayden, b0g0, c0pp3rscr3w3r, developerjordy, djxploit, erosjohn, holydevoti0n, iamandreiski, israeladelaja, juancito, klau5, lanrebayode77, memforvik, mussucal, n0kto, novodelta, pkqs90, solmaxis69, stackachu, twcctop, zhaojie, zhaojohnson
0.7809 USDC - $0.78
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L154 https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L70-L76 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/Liquidity.sol#L107 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L146-L147 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L64-L71 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L104-L111
The CollateralAndLiquidity.liquidateUser
function is used to liquidate a position which has fallen under the minimum collateral ratio. During the liquidateUser
function execution the StakingRewards._decreaseUserShare
function is called to decrease the user's share of collateral as it has been liquidated and they no longer have it, as shown below:
_decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true );
As it is evident from the above code snippet the useCooldown bool is set to true
. Hence the StakingRewards._decreaseUserShare
function will perform the following check to ensure that the cool down period has expired
.
if ( useCooldown ) //@audit-info - check if the cooldown is mentioned if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); //@audit-info - ensure hte cool down period has passed // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); }
if the block.timestamp < user.cooldownExpiration
the transaction will revert and the liquidation won't be executed.
A malicious borrower can exploit the user.cooldownExpiration
to revert the liquidation attempts and thus keep his position alive until he can get his collateralization ratio above the minimumCollateralRatioPercent
or can wait till his collateralization ratio is below < 100 thus incurring loss on the protocol. The malicious borrower can manipulate the user.cooldownExpiration
to prevent the liquidation as explained below:
CollateralAndLiquidity.depositCollateralAndIncreaseShare
and deposits collateral (WBTC/WETH) worth of USD 10,000 and borrows USDS 5000. (consider the initialCollateralRatioPercent = 200).user.cooldownExpiration
since the StakingRewards._increaseUserShare
is called. The user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown()
is executed and the user.cooldownExpiration
is set to one hour from the transaction execution time (block.timestamp).user.cooldownExpiration
will be expired.minimumCollateralRatioPercent
(Assume minimumCollateralRatioPercent is the default value of 110).liquidation transaction
by calling the CollateralAndLiquidity.depositCollateralAndIncreaseShare
function by depositing WBTC and WETH
just more than PoolUtils.DUST (100)
value. For example 101
tokens considering 18 decimal places.StakingRewards._increaseUserShare
and the user.cooldownExpiration
will be incremented by another 1 hour.liquidation
transaction is being executed the block.timestamp < user.cooldownExpiration
will occur since the user.cooldownExpiration
has not expired and the liquidation transaction will revert.user.cooldownExpiration
by depositing WBTC and WETH
tokens just more than the PoolUtils.DUST amount of 100
(Eg : 101).The following impacts are possible as a result of the above attack vector:
borrower
falls steeply and collateralization ration goes below 100 then even if the liquidation is successfully executed the protocol will not able to fully recover the lent amount and thus results in loss of funds to the protocol.minimumCollateralRatioPercent
such that his position is secured thus getting unfair advantage._decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true );
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L154
function depositCollateralAndIncreaseShare( uint256 maxAmountWBTC, uint256 maxAmountWETH, uint256 minLiquidityReceived, uint256 deadline, bool useZapping ) external nonReentrant ensureNotExpired(deadline) returns (uint256 addedAmountWBTC, uint256 addedAmountWETH, uint256 addedLiquidity) { // Have the user deposit the specified WBTC/WETH liquidity and increase their collateral share (addedAmountWBTC, addedAmountWETH, addedLiquidity) = _depositLiquidityAndIncreaseShare( wbtc, weth, maxAmountWBTC, maxAmountWETH, minLiquidityReceived, useZapping ); emit CollateralDeposited(msg.sender, addedAmountWBTC, addedAmountWETH, addedLiquidity); }
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L70-L76
_increaseUserShare( msg.sender, poolID, addedLiquidity, true );
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/Liquidity.sol#L107
require( maxAmountA > PoolUtils.DUST, "The amount of tokenA to add is too small" ); require( maxAmountB > PoolUtils.DUST, "The amount of tokenB to add is too small" );
https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L146-L147
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(); }
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L64-L71
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(); }
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L104-L111
Manual Review and VSCode
The useCooldown
bool value is used to prevent reward hunting (ie - quickly depositing and withdrawing large amounts of liquidity to snipe rewards as they arrive). And there is no reward hunting expected to be performed during the liquidaiton. Hence there is no advantage to be gained by setting the useCooldown
bool value to true
during the liquidation
when calling the CollateralAndLiquidity.liquidateUser
. Since the liquidation
can be called by anyone other than the wallet which is being liquidated
itself. Hence there is no undue advantage a user can gain via reward hunting during the liquidation
. Hence we can set the useCooldown
bool value to false
when calling the StakingRewards._decreaseUserShare
function inside the CollateralAndLiquidity.liquidateUser
.
Hence the updated _decreaseUserShare
function call is as shown below:
_decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, false );
This will ensure no cool down period
check is performed during the liquidation transaction thus not letting a malicious user to revert the liquidation transaction by updating the user.cooldownExpiration
timestamp by depositing WBTC and WETH just more than the DUST
amount.
Other
#0 - c4-judge
2024-01-31T22:41:28Z
Picodes marked the issue as duplicate of #891
#1 - c4-judge
2024-02-21T16:12:52Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Udsen
Also found by: 0xfave, Banditx0x, DanielArmstrong, Draiakoo, J4X, Jorgect, ether_sky, santiellena, stackachu
103.0228 USDC - $103.02
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L113-L118 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L132-L133 https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L99
The StakingRewards._decreaseUserShare
function is used to decrease a user's share for the pool and have any pending rewards sent to them. When the amount of pending rewards are calculated, initially the virtualRewardsToRemove
are calculated as follows:
uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare;
Then the virtualRewardsToRemove
is substracted from the rewardsForAmount
value to calculate the claimableRewards
amount as shown below:
if ( virtualRewardsToRemove < rewardsForAmount ) claimableRewards = rewardsForAmount - virtualRewardsToRemove;
But the issue here is that the virtualRewardsToRemove
calculation is rounded down in favor of the user and not in the favor of the protocol. Since the virtualRewardsToRemove
is rounded down there is an opportunity to the user to call the StakingRewards._decreaseUserShare
function with a very small decreaseShareAmount
value such that the virtualRewardsToRemove
will be rounded down to 0
. Providing a very small decreaseShareAmount
value is possible since only input validation on decreaseShareAmount
is ! = 0
as shown below:
require( decreaseShareAmount != 0, "Cannot decrease zero share" );
When the claimableRewards
is calculated it will be equal to the rewardsForAmount
value since the virtualRewardsToRemove
will be 0
. This way the user can keep on removing his liquidity from a particular pool by withdrawing small decreaseShareAmount
at a time such that keeping virtualRewardsToRemove
at 0
due to rounding down.
Furthermore the decreaseShareAmount
value should be selected in such a way rewardsForAmount
is calculated to a considerable amount after round down (not zero) and the virtualRewardsToRemove
should round down to zero.
Hence as a result the user
can withdraw all the rewardsForAmount
as the claimableRewards
even though some of those rewards are virtual rewards
which should not be claimable as clearly stated by the following natspec
comment:
// Some of the rewardsForAmount are actually virtualRewards and can't be claimed.
Hence as a result the user is able to get an undue advantage and claim more rewards for his liquidity during liquidity withdrawable. This happens because the user
can bypass the virtual reward
subtraction by making it round down to 0
. As a result the virtualReward
amount of the rewardsForAmount
, which should not be claimable is also claimed by the user unfairly.
// Determine the share of the rewards for the amountToDecrease (will include previously added virtual rewards) uint256 rewardsForAmount = ( totalRewards[poolID] * decreaseShareAmount ) / totalShares[poolID]; // 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;
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L113-L118
if ( virtualRewardsToRemove < rewardsForAmount ) claimableRewards = rewardsForAmount - virtualRewardsToRemove;
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L132-L133
require( decreaseShareAmount != 0, "Cannot decrease zero share" );
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L99
Manual Review and VSCode
Hence it is recommended to round up the virtualRewardsToRemove
value during its calculation such that it will not be rounded down to zero for a very small decreaseShareAmount
. This way user
is unable to claim the rewards which he is not eligible for and the rewards will be claimed after accounting for the virtual rewards
.
Other
#0 - c4-judge
2024-02-02T22:14:24Z
Picodes marked the issue as primary issue
#1 - c4-judge
2024-02-02T22:16:20Z
Picodes marked issue #464 as primary and marked this issue as a duplicate of 464
#2 - c4-judge
2024-02-03T15:18:55Z
Picodes marked the issue as duplicate of #223
#3 - c4-judge
2024-02-18T11:53:54Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-18T16:44:29Z
Picodes marked the issue as selected for report
#5 - c4-sponsor
2024-02-20T03:56:49Z
othernet-global (sponsor) confirmed
#6 - othernet-global
2024-02-20T03:56:53Z
virtualRewards now rounded up on _decreaseUserShare
https://github.com/othernet-global/salty-io/commit/b3b8cb955db2b9f0e47a4964e1e4f833a447a72d
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
11.6897 USDC - $11.69
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L37-L44 https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L50-L51 https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L122-L123 https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L151-L158
The PriceAggregator.setInitialFeeds
function is used to set the initial price feeds and only be called by the owner. And this function can only be called once by the owner
as it is depicted in the following conditional check.
require( address(priceFeed1) == address(0), "setInitialFeeds() can only be called once" );
And if the owner
needs to change the priceFeeds there after he will have to call the PriceAggregator.setPriceFeed
function which has a price modification cool down period
implemented. This is why the setInitialFeeds
is implemented as a function that can be called only once.
And the way the priceAggregator._aggregatePrices
function works, even two price feeds will be enough for the price aggregator to work properly since the following condition will be bypassed with two valid price feeds.
if ( numNonZero < 2 ) return 0;
Hence the owner
can call PriceAggregator.setInitialFeeds
function multiple times to change the priceFeed2 and priceFeed3
as he needs without complementing the price modification cool down period
.
Let's consider the following scenario:
PriceAggregator.setInitialFeeds
function with priceFeeds1 == address(0)
and provides valid price feed addresses to _priceFeed2 and _priceFeed3
.priceFeed1
will remain == address(0)
.require( address(priceFeed1) == address(0), "setInitialFeeds() can only be called once" )
check to call the PriceAggregator.setInitialFeeds
function to change the priceFeed2 and priceFeed3
as he wishes without complementing the price modification cool down period
priceFeed1 being address(0)
the price aggregator will work fine since call to the priceFeed1 (in the PriceAggregator._getPriceBTC and priceAggregator._getPriceETH functions) is performed in a try-catch
block and as a result the call to address(0) will not revert the transaction.priceAggregator
contract to work properly.Hence if the owner
requires he can decide to keep on changing the priceFeed2 and priceFeed3
as he needs with out complementing the price modification cool down period. This will make the PriceAggregator.setPriceFeed
function less useful since its price modification cool down period implementation can be bypassed by the owner
easily, to change the priceFeeds as the owner wishes. And furthermore this should not be considered as a admin mistake
since the PriceAggregator.setInitialFeeds
function is designed in such a way that even the owner
is not allowed to call the function more than once. This vulnerability clearly shows how the owner can call the PriceAggregator.setInitialFeeds
function to change the priceFeeds2 and priceFeeds3
multiple times contrary to what the function is designed to do.
Note : Even though the L1 or the automated report
states User facing functions should have address(0) checks
it does not mention this exact vulnerability and the impact of it.
function setInitialFeeds( IPriceFeed _priceFeed1, IPriceFeed _priceFeed2, IPriceFeed _priceFeed3 ) public onlyOwner { require( address(priceFeed1) == address(0), "setInitialFeeds() can only be called once" ); priceFeed1 = _priceFeed1; priceFeed2 = _priceFeed2; priceFeed3 = _priceFeed3; }
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L37-L44
if ( block.timestamp < priceFeedModificationCooldownExpiration ) return;
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L50-L51
if ( numNonZero < 2 ) return 0;
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L122-L123
try priceFeed.getPriceBTC() returns (uint256 _price) { price = _price; } catch (bytes memory) { // price remains 0 }
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L151-L158
Manual Review and VSCode
Hence it is recommended to implement an address(0) check for the _priceFeed1
address in the PriceAggregator.setInitialFeeds
function such that the transaction will revert if the address(0)
is passed in as the _priceFeed1
. This will ensure that the _priceFeed1
is set to a non-zero value and the PriceAggregator.setInitialFeeds
can only be called once as a result.
require(address(_priceFeed1) != address(0), "_priceFeed1 can not be address(0)");
Invalid Validation
#0 - c4-judge
2024-02-02T22:34:59Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-12T23:26:11Z
othernet-global (sponsor) disputed
#2 - othernet-global
2024-02-12T23:26:40Z
This is not an issue as the contract is deployed with non zero price feeds and checked at the time of deployment.
#3 - Picodes
2024-02-21T11:41:57Z
This report shows how the initial deployer could misconfigure the system to exploit it later, either by using an address(0), either as there is initially no cooldown. As it falls within centralization risk and misconfiguration issues I'll downgrade to QA.
#4 - c4-judge
2024-02-21T11:42:07Z
Picodes marked the issue as satisfactory
#5 - c4-judge
2024-02-21T11:43:18Z
Picodes changed the severity to QA (Quality Assurance)
#6 - c4-judge
2024-02-21T17:01:44Z
Picodes marked the issue as grade-b