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: 102/178
Findings: 4
Award: $47.36
🌟 Selected for report: 0
🚀 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/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L154 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L104-L111
This vulnerability allows a malicious user to prevent liquidation by frontrunning liquidation transaction with a call to depositCollateralAndIncreaseShare()
. This causes the call to liquidateUser()
to revert.
This could lead to the protocol having a lot of bad debt.
The problem originates from the call within liquidateUser()
to _decreaseUserShare()
with the coolDown flag set to true. THis call was done to reduce the user share since it's asset are being sold off to pay back loan.
However, with the coolDown being set to true, it gives a malicious actor the opportunity to make the transaction fail by frontrunning it with a call to depositCollateralAndIncreaseShare() to increase shares a with small amount of liquidity, which will extends the user.cooldownExpiration
, because of that, liquidateUser()
will revert.
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(); }
user.cooldownExpiration
, and Bob transaction reverts.Currently extension is by an hour, but can be six hours, the longer the extension time, the easier the attack.
Manual review.
cooldown flag should be set to false in liquiateUser() function.
// Decrease the user's share of collateral as it has been liquidated and they no longer have it. _decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, false );
Invalid Validation
#0 - c4-judge
2024-01-31T22:44:19Z
Picodes marked the issue as duplicate of #891
#1 - c4-judge
2024-02-21T16:13:49Z
Picodes marked the issue as satisfactory
16.3165 USDC - $16.32
Users have the liberty to borrow USDS by providing overcollaterized asset(WBTC/WETH), this asset can with returned back when the user repays the loan, when a user defaults, the user asset are then liquidated and converted to USDS to be burnt.
In the case of liquidation, after another user helps to liquidate an underwater loan for a commission, the asset are sent to Liquidizer contract, when collateralAndLiquidity.liquidizer().performUpkeep();
is called, the liquidizer contract sells the required collaterals that have been sent to convert them to USDS, then calls _possiblyBurnUSDS();
to burn all usdsThatShouldBeBurned
// This corresponds to USDS that was borrowed by users who had their collateral liquidated. // Because liquidated collateral no longer exists, the borrowed USDS needs to be burned in order to "undo" the collateralized position // and return state back to where it was before the liquidated user deposited collateral and borrowed USDS. uint256 public usdsThatShouldBeBurned;
as stated by the comment above usdsThatShouldBeBurned
corresponds to usds that was borrowed by users that had their collateral liquidated.
The problem here is that users that did not have their collateral liquidated also increases the value. This should not be so because in repayUSDS()
borrowed USDS have been transferred to USDS contract to be burnt internally
// Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid );
The implication will be that, the liquidizer will burn excess USDS than it should
Let us consider a case of two borrowers; Alice and Bob
usdsThatShouldBeBurned
was still incremented by same amount.
So USDS balance in USDS contract is 1,000,000 and usdsThatShouldBeBurned
is 1,000,000// Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid );
liquidateUser()
to liquidate Bob, Bob's collaterals are pulled out and sent to the Liquidizer contract, and usdsThatShouldBeBurned
is increased by another 1,000,000 to make it 2,000,000collateralAndLiquidity.liquidizer().performUpkeep();
was called, required amount of previously sent tokens are swapped for USDS, total from the sales of WETH, WBTC and DAI made USDS balance of the Liquidizer to be 2,000,000._possiblyBurnUSDS();
was then called, _burnUSDS( 2,000,000 )
will lead to transferring the 2,000,000 USDS to the USDS contract(Note: this should have been 1,000,000 since the Alice USDS was previously sent to the USDS contract as soon as repayment was done)// Burn the specified amount of USDS function _burnUSDS(uint256 amountToBurn) internal { usds.safeTransfer( address(usds), amountToBurn ); usds.burnTokensInContract(); }
Now let see what happens in usds.burnTokenInContract()
// USDS tokens will need to be sent here before they are burned (on a repayment or liquidation). // There should otherwise be no USDS balance in this contract. function burnTokensInContract() external { uint256 balance = balanceOf( address(this) ); _burn( address(this), balance ); emit USDSTokensBurned(balance); } }
From the code snippet above, we can see that when balance is checked, USDS contract will have 3,000,000 USDS instead of 2,000,000 and all will be burnt, burning excess of 1,000,000 USDS. Meanwhile the excess 1,000,000 USDS should have been left in the Liquidizer contract should incase of when there is shortage of USDS and liquidated amount need to be balanced
if ( usdsBalance >= usdsThatShouldBeBurned ) { // Burn only up to usdsThatShouldBeBurned. // Leftover USDS will be kept in this contract in case it needs to be burned later. _burnUSDS( usdsThatShouldBeBurned ); usdsThatShouldBeBurned = 0; }
Manual review
// Repay borrowed USDS and adjust the user's usdsBorrowedByUser function repayUSDS( uint256 amountRepaid ) external nonReentrant { require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" ); require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" ); require( amountRepaid > 0, "Cannot repay zero amount" ); // Decrease the borrowed amount for the user usdsBorrowedByUsers[msg.sender] -= amountRepaid; // Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned -- liquidizer.incrementBurnableUSDS( amountRepaid ); //@audit remove this ++ usds.burnTokensInContract(); // Check if the user no longer has any borrowed USDS if ( usdsBorrowedByUsers[msg.sender] == 0 ) _walletsWithBorrowedUSDS.remove(msg.sender); emit RepaidUSDS(msg.sender, amountRepaid); }
Context
#0 - c4-judge
2024-02-02T14:07:18Z
Picodes marked the issue as duplicate of #240
#1 - c4-judge
2024-02-17T19:00:20Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-21T16:50:39Z
Picodes marked the issue as duplicate of #137
#3 - c4-judge
2024-02-21T16:58:17Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: juancito
Also found by: 0xCiphky, 0xRobocop, 0xWaitress, DanielArmstrong, J4X, PENGUN, erosjohn, haxatron, klau5, lanrebayode77, pina, twcctop, zhaojie
18.5696 USDC - $18.57
The creation of proposals can be front-run with another proposal bearing the same name, causing the previous transaction to fail.
// Make sure that a proposal of the same name is not already open for the ballot require( openBallotsByName[ballotName] == 0, "Cannot create a proposal similar to a ballot that is still open" ); require( openBallotsByName[ string.concat(ballotName, "_confirm")] == 0, "Cannot create a proposal for a ballot with a secondary confirmation" );
Manual review.
Each proposal should have a nonce attached to the name to differentiate them even with the same name.
DoS
#0 - c4-judge
2024-02-01T16:04:08Z
Picodes marked the issue as duplicate of #621
#1 - c4-judge
2024-02-19T16:49:22Z
Picodes marked the issue as partial-50
#2 - Picodes
2024-02-19T16:49:32Z
No real impact or meaningful example is provided
🌟 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
DAO can propose whitelisting of a token by calling proposeTokenWhitelisting()
, with more than one poolsConfig.numberOfWhitelistedPools()
, only the token with the highest support vote can be whitelisted at a certain period.
// Fail to whitelist for now if this isn't the whitelisting proposal with the most votes - can try again later. uint256 bestWhitelistingBallotID = proposals.tokenWhitelistingBallotWithTheMostVotes(); require( bestWhitelistingBallotID == ballotID, "Only the token whitelisting ballot with the most votes can be finalized" );
However, the problem here is that proposals.tokenWhitelistingBallotWithTheMostVotes();
might return an incorrect value under certain condition. For instance when more than one token has the same number of Yes as the highest value of yes.
Let us consider a case study;
proposals.tokenWhitelistingBallotWithTheMostVotes();
is called,
Token A will be returned as the one with the most YES vote, which is not correct in this case because Token C also has 600 YES votes.
However, Token A will be whitelisted because of its placement in the array and not because it has the highest YES votes while Token C with the same number of YES votes will be discarded.{ 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; }
Manual review.
Modify the code in such a way that, when there is more than one token with the highest number of YES, have a condition to settle for that. This might be to discard the ballot or to find the token with the highest number of total votes(highest participation).
Invalid Validation
#0 - c4-judge
2024-02-03T08:55:46Z
Picodes changed the severity to QA (Quality Assurance)
#1 - Picodes
2024-02-03T08:55:59Z
Downgrading to QA as this is an arbitrary edge case.
#2 - c4-judge
2024-02-03T13:15:16Z
Picodes marked the issue as grade-b