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: 101/178
Findings: 4
Award: $49.93
🌟 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
Users have the ability to prevent liquidation transactions by calling the depositCollateralAndIncreaseShare
function frontrunning the liquidate transaction, which resets the cooldown period to the current block timestamp + cooldown
. This prevents liquidation until the cooldown period has elapsed.
When a user is undercollateralized, they can monitor the liquidation transaction in the mempool and front-run it ahead of time by calling CollateralAndLiquidity:depositCollateralAndIncreaseShares
. The user should provide an amount slightly higher than the enforced DUST amount set by the protocol. This action does not make their position immune to liquidation, but it triggers the internal function StakingRewards:_increaseUserShare
, which resets their cooldown period to block.timestamp + cooldown period.
function _increaseUserShare(address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown) internal { require(poolsConfig.isWhitelisted(poolID), "Invalid pool"); require(increaseShareAmount != 0, "Cannot increase zero share"); UserShareInfo storage user = _userShareInfo[wallet][poolID]; if (useCooldown && 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(); } uint256 existingTotalShares = totalShares[poolID];
After this, when the liquidation transaction is executed, it attempts to decrease the user's stakes using StakingRewards:decreaseUserShares
. This function checks if the cooldown period has passed for the user. Since the user has just modified their cooldown period in the previous transaction, the check will fail and the transaction will revert.
function _decreaseUserShare( address wallet, bytes32 poolID, uint256 decreaseShareAmount, bool useCooldown ) internal { ....Other code.... if (useCooldown) if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { //@audit - this check will fail if liquidation is frontrnned 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(); } ...Other Code... }
This is same liquidation test provided with the test suite, just with the one line modification
// A unit test that verifies the liquidateUser function correctly transfers WETH to the liquidator and WBTC/WETH to the USDS contract function testLiquidatePosition() public { assertEq(collateralAndLiquidity.numberOfUsersWithBorrowedUSDS(), 0); assertEq( wbtc.balanceOf(address(usds)), 0, "USDS contract should start with zero WBTC" ); assertEq( weth.balanceOf(address(usds)), 0, "USDS contract should start with zero WETH" ); assertEq(usds.balanceOf(alice), 0, "Alice should start with zero USDS"); // 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 ); assertEq(reserveWBTC, 0, "reserveWBTC doesn't start as zero"); assertEq(reserveWETH, 0, "reserveWETH doesn't start as zero"); // Alice will deposit collateral and borrow max USDS vm.startPrank(alice); collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC, depositedWETH, 0, block.timestamp, false ); uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice); assertEq( maxUSDS, 0, "Alice doesn't have enough collateral to borrow USDS" ); // Deposit again vm.warp(block.timestamp + 1 hours); collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC, depositedWETH, 0, block.timestamp, false ); // Account for both deposits depositedWBTC = depositedWBTC * 2; depositedWETH = depositedWETH * 2; 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); vm.startPrank(alice); maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice); vm.startPrank(alice); vm.warp(block.timestamp + 1 hours); maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice); collateralAndLiquidity.borrowUSDS(maxUSDS); vm.stopPrank(); assertEq(collateralAndLiquidity.numberOfUsersWithBorrowedUSDS(), 1); uint256 maxWithdrawable = collateralAndLiquidity .maxWithdrawableCollateral(alice); assertEq( maxWithdrawable, 0, "Alice shouldn't be able to withdraw any collateral" ); { uint256 aliceCollateralValue = collateralAndLiquidity .userCollateralValueInUSD(alice); uint256 aliceBorrowedUSDS = usds.balanceOf(alice); assertEq( collateralAndLiquidity.usdsBorrowedByUsers(alice), aliceBorrowedUSDS, "Alice amount USDS borrowed not what she has" ); // Borrowed USDS should be about 50% of the aliceCollateralValue assertTrue( aliceBorrowedUSDS > ((aliceCollateralValue * 499) / 1000), "Alice did not borrow sufficient USDS" ); assertTrue( aliceBorrowedUSDS < ((aliceCollateralValue * 501) / 1000), "Alice did not borrow sufficient USDS" ); } // Try and fail to liquidate alice vm.expectRevert("User cannot be liquidated"); vm.prank(bob); collateralAndLiquidity.liquidateUser(alice); // Artificially crash the collateral price _crashCollateralPrice(); // Delay before the liquidation vm.warp(block.timestamp + 1 days); uint256 bobStartingWETH = weth.balanceOf(bob); uint256 bobStartingWBTC = wbtc.balanceOf(bob); vm.prank(alice); //@audit-info: 101 is 1 wei more than DUST 100 + collateralAndLiquidity.depositCollateralAndIncreaseShare( 101, 101, 0, block.timestamp, false ); // Liquidate Alice's position vm.prank(bob); uint256 gas0 = gasleft(); collateralAndLiquidity.liquidateUser(alice); console.log("LIQUIDATE GAS: ", gas0 - gasleft()); uint256 bobRewardWETH = weth.balanceOf(bob) - bobStartingWETH; uint256 bobRewardWBTC = wbtc.balanceOf(bob) - bobStartingWBTC; // Verify that Alice's position has been liquidated assertEq( collateralAndLiquidity.userShareForPool(alice, collateralPoolID), 0 ); assertEq(collateralAndLiquidity.usdsBorrowedByUsers(alice), 0); // Verify that Bob has received WBTC and WETH for the liquidation assertEq( (depositedWETH * 5) / 100, bobRewardWETH, "Bob should have received WETH for liquidating Alice" ); assertEq( (depositedWBTC * 5) / 100, bobRewardWBTC, "Bob should have received WBTC for liquidating Alice" ); // Verify that the Liquidizer received the WBTC and WETH from Alice's liquidated collateral assertEq( wbtc.balanceOf(address(liquidizer)), depositedWBTC - bobRewardWBTC - 1, "The Liquidizer contract should have received Alice's WBTC" ); assertEq( weth.balanceOf(address(liquidizer)), depositedWETH - bobRewardWETH, "The Liquidizer contract should have received Alice's WETH - Bob's WETH reward" ); assertEq(collateralAndLiquidity.numberOfUsersWithBorrowedUSDS(), 0); }
Running 1 test for src/stable/tests/CollateralAndLiquidity.t.sol:TestCollateral [FAIL. Reason: revert: Must wait for the cooldown to expire] testLiquidatePosition() (gas: 1199341) Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 84.40s Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in src/stable/tests/CollateralAndLiquidity.t.sol:TestCollateral [FAIL. Reason: revert: Must wait for the cooldown to expire] testLiquidatePosition() (gas: 1199341) Encountered a total of 1 failing tests, 0 tests succeeded make: *** [Makefile:4: test-one] Error 1
Manual review
There could be two possible solutions:
_decreaseUserShare
function that does not verify the cooldown if the call is intended to liquidate the user.MEV
#0 - c4-judge
2024-02-02T11:52:46Z
Picodes marked the issue as duplicate of #312
#1 - c4-judge
2024-02-17T18:50:14Z
Picodes marked the issue as satisfactory
16.3165 USDC - $16.32
If there is an active repayment and liquidation action then protocol Owned Liquidity will get withdrawn every time a Liquidizer:performUpkeep
is called to cover the debt even if it is not necessary. If performUpkeep is called multiple times with certain scenarios then POL can be drained.
When users repay USDS using repayUSDS
, A repaid amount is sent to USDS contract for burning when someone calls to perform upkeep. It also increments the burnable USDS amount in Liquidizer.sol
using liquidizer:incrementBurnableUSDS
.
// Repay borrowed USDS and adjust the user's usdsBorrowedByUser function repayUSDS(uint256 amountRepaid) external nonReentrant { ...Other Code... // 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); ...Other Code... }
The Liquidizer.sol
is supposed to account for burnable USDS, liquidated collateral, withdraw protocol-owned liquidity if needed, and swap collateral for USDS to burn.
When someone is liquidated their WBTC & WETH are sent to this contract so it can be swapped to USDS and burned when performUpKeep
is called. If the USDS received from swapping these collateral tokens is not enough to meet the USDS required to burn, it will try to withdraw Protocol Owned Liquidity using dao.withdrawPOL
, The POL is nothing but SALT/DAI and DAI/USDS pool created with rewards owned by protocol
function _possiblyBurnUSDS() internal { // Check if there is USDS to burn if (usdsThatShouldBeBurned == 0) return; uint256 usdsBalance = usds.balanceOf(address(this)); 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; } else { // The entire usdsBalance will be burned - but there will still be an outstanding balance to burn later _burnUSDS(usdsBalance); usdsThatShouldBeBurned -= usdsBalance; // As there is a shortfall in the amount of USDS that can be burned, liquidate some Protocol Owned Liquidity and // send the underlying tokens here to be swapped to USDS => dao.withdrawPOL(salt, usds, PERCENT_POL_TO_WITHDRAW); => dao.withdrawPOL(dai, usds, PERCENT_POL_TO_WITHDRAW); // only getting USDS but not burning it } }
The only time this POL should get withdrawn is when liquidated collateral is not enough to cover the user borrowed USDS amount.
But in the case of repayUSDS
the burnable USDS is incremented without even sending the collateral to cover the burning. This will make Liquidizer:usdsThatShouldBeBurned
always have more to burn without enough assets backing it.
If this happens then the POL will always get withdrawn even when the liquidated collateral is enough to cover the burning. In case of high activity in the protocol and if perform upkeep is called multiple times then always withdrawing the POL could drain the pool.
I couldn’t produce the actual test to show the vulnerability because it involves multiple market participants. The scenario explained below should help you understand the issue:
usdsThatShouldBeBurned
to 100.usdsThatShouldBeBurned
incremented by 1000 making it a total of 1100.Liquidizer:performUpkeep
is called the WBTC and WETH will get swapped to and contract now holds the 1050 USDS balance._possiblyBurnUSDS
when the USDS balance of the contract is compared with usdsThatShouldBeBurned
it will trigger the else block because 1100 > 1050.Manual review
Do not call Liquidizer.incrementBurnableUSDS(amountRepaid);
when the user is repaying.
Error
#0 - c4-judge
2024-02-02T14:04:51Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-08T08:38:25Z
othernet-global (sponsor) confirmed
#2 - othernet-global
2024-02-08T08:39:06Z
This issue is that this line should transfer USDS to the liquidizer instead of the USDS contract:
usds.safeTransferFrom(msg.sender, address(usds), amountRepaid);
#3 - c4-judge
2024-02-17T19:00:18Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-21T16:50:40Z
Picodes marked the issue as duplicate of #137
🌟 Selected for report: PENGUN
Also found by: 0xanmol, Draiakoo, J4X, Matue, ReadyPlayer2, cu5t0mpeo, dutra, falconhoof, grearlake, peanuts, piyushshukla, vnavascues, zhanmingjing, zhaojie
31.1969 USDC - $31.20
Malicious actors can pass proposals without holding the required tokens. This can lead to proposals with potential issues in the system to pass.
A staked amount of SALT can have a minimum unstake period of 1 week, while the voting duration for a proposal can range from 3 to 14 days.
Let's imagine an attacker who has 1 million SALT staked, giving them a voting power of 1 million. To reach the quorum for a vote, only 10% of the total stake is needed. For example, if there are 12 million SALT tokens in total stake, then 1.2 million tokens are required to pass the quorum.
If, for some reason, the DAO sets the minimum unstake period to 1 week and the voting duration is between 7 and 14 days, the user can exploit this scenario. They can create a malicious proposal and vote 1 million SALT for it. Afterward, they can unstake their 1 million SALT within a week and retain 20%, which is 200k, and transfer that to his another address(because voting from the same address is not allowed). They can then stake this 200k SALT again and vote in the same proposal because it has not yet expired..
This allows the user to vote twice with the same SALT. However, the reward for doing this is not substantial. If the user unstakes their 1 million SALT in 1 week, they will lose all of their remaining 800k SALT. Additionally, if the proposal is malicious, other voters can vote against it and cause it to fail. Nonetheless, history has shown significant governance attacks in the past resulting from these types of accounting errors. While the impact may not be high at this time, a malicious actor could potentially exploit this issue to harm the system in a better way.
Do not allow the unstake duration to be less than 2 weeks.
#0 - c4-judge
2024-02-03T14:13:11Z
Picodes changed the severity to 2 (Med Risk)
#1 - c4-judge
2024-02-03T14:13:33Z
Picodes marked the issue as duplicate of #746
#2 - c4-judge
2024-02-14T08:05:17Z
Picodes marked the issue as satisfactory
🌟 Selected for report: neocrao
Also found by: 00xSEV, 0x11singh99, 0x3b, 0xAlix2, 0xRobocop, 0xSmartContractSamurai, 0xanmol, AgileJune, Drynooo, HALITUS, Imp, J4X, KHOROAMU, Kalyan-Singh, MSaptarshi, RootKit0xCE, The-Seraphs, agadzhalov, aman, ayden, cu5t0mpeo, erosjohn, ewah, jasonxiale, jesjupyter, juancito, klau5, memforvik, okolicodes, parrotAudits0, rudolph, t0x1c, wangxx2026, zhaojohnson
1.6255 USDC - $1.63
A depositor calling Liquidity:depositLiquidityAndIncreaseShare
can be front-run by a sole liquidity holder, resulting in the loss of funds for the depositor.
When either of the reserves of a pool is 0 any call to addLiquidity will cause the pool to reset the reserves ratio.
// If either reserve is zero then consider the pool to be empty and that the added liquidity will become the initial token ratio if ((reserve0 == 0) || (reserve1 == 0)) { //@audit reserve1 can be 0 // Update the reserves reserves.reserve0 += uint128(maxAmount0); reserves.reserve1 += uint128(maxAmount1); // Default liquidity will be the addition of both maxAmounts in case one of them is much smaller (has smaller decimals) return (maxAmount0, maxAmount1, (maxAmount0 + maxAmount1)); }
This issue was identified in the trail of bits audits. Although the fix has not been implemented correctly, they have provided a detailed explanation of how the attack can occur. Therefore, I will refer to their description instead of writing my own.Please refer to issue ID 3 of the trail of bit audit for more information on this issue.
Eve is the sole liquidity holder in the WETH/DAI pool, which has reserves of 100 WETH and 200,000 DAI, for a ratio of 1:2,000.
The salty.io team resolves this issue by ensuring that the reserves do not fall below the DUST when withdrawLiquidityAndClaim
is called. However, they made a small mistake or typo that allowed reserve1 to be less than DUST.
// Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. //@audit-issue - Only checking for remaining for reserve0, what about reserve1? require( (reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal" );
If the frontrunner chooses to decrease the reserve1 value to 0 then this check will pass and this attack will come into play.
manual review
Instead of just checking if the reserve0 is less than DUST also check if reserve1 is below DUST
// Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. require( (reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal" );
Invalid Validation
#0 - c4-judge
2024-02-01T23:12:40Z
Picodes marked the issue as duplicate of #647
#1 - c4-judge
2024-02-19T15:39:01Z
Picodes changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-02-21T16:53:02Z
Picodes marked the issue as satisfactory