Platform: Code4rena
Start Date: 08/05/2023
Pot Size: $90,500 USDC
Total HM: 17
Participants: 102
Period: 7 days
Judge: 0xean
Total Solo HM: 4
Id: 236
League: ETH
Rank: 3/102
Findings: 6
Award: $6,224.32
π Selected for report: 1
π Solo Findings: 1
π Selected for report: Team_Rocket
Also found by: 0xkazim, BPZ, Bauchibred, BoltzmannBrain, Brenzee, DeliChainSec, Franfran, Lilyjjo, MohammedRizwan, SaeedAlipoor01988, Yardi256, ast3ros, berlin-101, carlitox477, fs0c, peritoflores, sashik_eth, sces60107, thekmj, volodya, zzykxx
66.5871 USDC - $66.59
Detailed description of the impact of this finding. The variable calculations in WhitePaperInterestRateModel are incorrect compared to BaseJumpRateModelV2 due to a lack of synchronization in blocksPerYear.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. blocksPerYear should be 10512000 just like in WhitePaperInterestRateModel for bsc chain
/** * @notice The approximate number of blocks per year that is assumed by the interest rate model */ uint256 public constant blocksPerYear = 2102400;
contracts/WhitePaperInterestRateModel.sol#L17
/** * @notice The approximate number of blocks per year that is assumed by the interest rate model */ uint256 public constant blocksPerYear = 10512000;
contracts/BaseJumpRateModelV2.sol#L23
There will be invalid baseRatePerBlock
and multiplierPerBlock
constructor(uint256 baseRatePerYear, uint256 multiplierPerYear) { baseRatePerBlock = baseRatePerYear / blocksPerYear; multiplierPerBlock = multiplierPerYear / blocksPerYear; emit NewInterestParams(baseRatePerBlock, multiplierPerBlock); }
contracts/WhitePaperInterestRateModel.sol#L36
Manual
Change to 10512000
Invalid Validation
#0 - c4-judge
2023-05-16T09:23:59Z
0xean marked the issue as duplicate of #559
#1 - c4-judge
2023-06-05T13:59:35Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-06-05T14:38:31Z
0xean changed the severity to 3 (High Risk)
π Selected for report: 0x73696d616f
Also found by: 0xadrii, 0xbepresent, 0xkazim, J4de, peanuts, pontifex, rvierdiiev, volodya
96.0525 USDC - $96.05
Detailed description of the impact of this finding. The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
According to the comments from oracle file. updatePrice should always be called before calling getUnderlyingPrice.
/** * @notice Updates the TWAP pivot oracle price. * @dev This function should always be called before calling getUnderlyingPrice * @param vToken vToken address */ function updatePrice(address vToken) external override { (address pivotOracle, bool pivotOracleEnabled) = getOracle(vToken, OracleRole.PIVOT); if (pivotOracle != address(0) && pivotOracleEnabled) { //if pivot oracle is not TwapOracle it will revert so we need to catch the revert try TwapInterface(pivotOracle).updateTwap(vToken) {} catch {} } }
/contracts/ResilientOracle.sol#L175
There are functions in the code that do not call updatePrice before calling getUnderlyingPrice while some functions call updatePrice. E.x. this function is not calling updatePrice calls _checkRedeemAllowed -> calls _getHypotheticalLiquiditySnapshot -> calls _safeGetUnderlyingPrice(asset)
function exitMarket(address vTokenAddress) external override returns (uint256) { _checkActionPauseState(vTokenAddress, Action.EXIT_MARKET); VToken vToken = VToken(vTokenAddress); /* Get sender tokensHeld and amountOwed underlying from the vToken */ (uint256 tokensHeld, uint256 amountOwed, ) = _safeGetAccountSnapshot(vToken, msg.sender); /* Fail if the sender has a borrow balance */ if (amountOwed != 0) { revert NonzeroBorrowBalance(); } /* Fail if the sender is not permitted to redeem all of their tokens */ _checkRedeemAllowed(vTokenAddress, msg.sender, tokensHeld); Market storage marketToExit = markets[address(vToken)]; /* Return true if the sender is not already βinβ the market */ if (!marketToExit.accountMembership[msg.sender]) { return NO_ERROR; } /* Set vToken account membership to false */ delete marketToExit.accountMembership[msg.sender]; /* Delete vToken from the accountβs list of assets */ // load into memory for faster iteration VToken[] memory userAssetList = accountAssets[msg.sender]; uint256 len = userAssetList.length; uint256 assetIndex = len; for (uint256 i; i < len; ++i) { if (userAssetList[i] == vToken) { assetIndex = i; break; } } // We *must* have found the asset in the list or our redundant data structure is broken assert(assetIndex < len); // copy last item in list to location of item to be removed, reduce length by 1 VToken[] storage storedList = accountAssets[msg.sender]; storedList[assetIndex] = storedList[storedList.length - 1]; storedList.pop(); emit MarketExited(vToken, msg.sender); return NO_ERROR; }
contracts/Comptroller.sol#L199
The same way you can track that these functions doesn't call updatePrice before calling getUnderlyingPrice
liquidateCalculateSeizeTokens
calls _safeGetUnderlyingPrice -> getUnderlyingPrice
getHypotheticalAccountLiquidity
calls _getHypotheticalLiquiditySnapshot -> _safeGetUnderlyingPrice -> getUnderlyingPrice
setCollateralFactor
inside Comptroller.sol
Inside PoolLens.sol
function getPoolBadDebt(address comptrollerAddress) external view returns (BadDebtSummary memory) { uint256 totalBadDebtUsd; // Get every market in the pool ComptrollerViewInterface comptroller = ComptrollerViewInterface(comptrollerAddress); VToken[] memory markets = comptroller.getAllMarkets(); PriceOracle priceOracle = comptroller.oracle(); BadDebt[] memory badDebts = new BadDebt[](markets.length); BadDebtSummary memory badDebtSummary; badDebtSummary.comptroller = comptrollerAddress; badDebtSummary.badDebts = badDebts; // // Calculate the bad debt is USD per market for (uint256 i; i < markets.length; ++i) { BadDebt memory badDebt; badDebt.vTokenAddress = address(markets[i]); badDebt.badDebtUsd = VToken(address(markets[i])).badDebt() * priceOracle.getUnderlyingPrice(address(markets[i])); badDebtSummary.badDebts[i] = badDebt; totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd; } badDebtSummary.totalBadDebtUsd = totalBadDebtUsd; return badDebtSummary; }
contracts/Lens/PoolLens.sol#L268
function vTokenUnderlyingPrice(VToken vToken) public view returns (VTokenUnderlyingPrice memory) { ComptrollerViewInterface comptroller = ComptrollerViewInterface(address(vToken.comptroller())); PriceOracle priceOracle = comptroller.oracle(); return VTokenUnderlyingPrice({ vToken: address(vToken), underlyingPrice: priceOracle.getUnderlyingPrice(address(vToken)) }); }
contracts/Lens/PoolLens.sol#L408
I think it will be cleaner to place update price inside _safeGetUnderlyingPrice instead of inside every function like its now. Code wil be cleaner as well. Add updatePrice to other functions inside PoolLens as well
function _safeGetUnderlyingPrice(VToken asset) internal view returns (uint256) { + oracle.updatePrice(address(asset)); uint256 oraclePriceMantissa = oracle.getUnderlyingPrice(address(asset)); if (oraclePriceMantissa == 0) { revert PriceError(address(asset)); } return oraclePriceMantissa; }
Invalid Validation
#0 - c4-judge
2023-05-17T22:24:44Z
0xean marked the issue as duplicate of #88
#1 - c4-judge
2023-06-05T14:09:54Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-06-08T14:39:45Z
0xean marked the issue as duplicate of #486
#3 - c4-judge
2023-06-08T14:43:10Z
0xean marked the issue as partial-50
π Selected for report: peanuts
Also found by: 0xStalin, jasonxiale, volodya
731.996 USDC - $732.00
Detailed description of the impact of this finding. Inconsistent scaling of USD in bad debt in the project.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
usdvalue is being calculated differently in project. This is how its calculated for auction, As you can see its being scaled down by / 1e18
. Its the only place in project where getUnderlyingPrice * tokenAmount
is being scaled down.
for (uint256 i; i < marketsCount; ++i) { uint256 marketBadDebt = vTokens[i].badDebt(); priceOracle.updatePrice(address(vTokens[i])); uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18; poolBadDebt = poolBadDebt + usdValue; auction.markets[i] = vTokens[i]; auction.marketDebt[vTokens[i]] = marketBadDebt; marketsDebt[i] = marketBadDebt; }
contracts/Shortfall/Shortfall.sol#L393
This is how badDebtUsd is being calculated inside poollens
for (uint256 i; i < markets.length; ++i) { BadDebt memory badDebt; badDebt.vTokenAddress = address(markets[i]); badDebt.badDebtUsd = VToken(address(markets[i])).badDebt() * priceOracle.getUnderlyingPrice(address(markets[i])); badDebtSummary.badDebts[i] = badDebt; totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd; }
contracts/Lens/PoolLens.sol#L268
Manual
As I understood, scaling down should be removed inside shortfall due the only place in the project where getUnderlyingPrice * tokenAmount
is being scaled down
for (uint256 i; i < marketsCount; ++i) { uint256 marketBadDebt = vTokens[i].badDebt(); priceOracle.updatePrice(address(vTokens[i])); - uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18; + uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt); poolBadDebt = poolBadDebt + usdValue; auction.markets[i] = vTokens[i]; auction.marketDebt[vTokens[i]] = marketBadDebt; marketsDebt[i] = marketBadDebt; }
Math
#0 - c4-sponsor
2023-05-23T20:20:03Z
chechu marked the issue as sponsor confirmed
#1 - chechu
2023-05-23T20:20:40Z
The wrong implementation is in PoolLens, not in Shortfall
#2 - Debugger022
2023-05-24T06:18:06Z
We will fix in PoolLens and Shortfall will be remained as it is.
#3 - c4-judge
2023-06-02T14:15:10Z
0xean marked the issue as duplicate of #468
#4 - c4-judge
2023-06-02T14:17:02Z
0xean marked the issue as not a duplicate
#5 - c4-judge
2023-06-02T14:17:11Z
0xean marked the issue as duplicate of #316
#6 - c4-judge
2023-06-05T14:01:47Z
0xean marked the issue as satisfactory
51.6843 USDC - $51.68
The CToken is a yield bearing asset which is minted when any user deposits some units of
underlying
tokens. The amount of CTokens minted to a user is calculated based upon
the amount of underlying
tokens user is depositing.
As per the implementation of CToken contract, there exist two cases for CToken amount calculation:
VToken.totalSupply()
is 0
.Here is the actual CToken code (extra code and comments clipped for better reading):
function _exchangeRateStored() internal view virtual returns (uint256) { uint256 _totalSupply = totalSupply; if (_totalSupply == 0) { /* * If there are no tokens minted: * exchangeRate = initialExchangeRate */ return initialExchangeRateMantissa; } else { /* * Otherwise: * exchangeRate = (totalCash + totalBorrows + badDebt - totalReserves) / totalSupply */ uint256 totalCash = _getCashPrior(); uint256 cashPlusBorrowsMinusReserves = totalCash + totalBorrows + badDebt - totalReserves; uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply; return exchangeRate; } } function _mintFresh( address payer, address minter, uint256 mintAmount ) internal { /* Fail if mint not allowed */ comptroller.preMintHook(address(this), minter, mintAmount); /* Verify market's block number equals current block number */ if (accrualBlockNumber != _getBlockNumber()) { revert MintFreshnessCheck(); } Exp memory exchangeRate = Exp({ mantissa: _exchangeRateStored() }); ...
A sophisticated attack can impact all user deposits until the lending protocols owners and users are notified and contracts are paused. Since this attack is a replicable attack it can be performed continuously to steal the deposits of all depositors that try to deposit into the CToken contract.
The loss amount will be the sum of all deposits done by users into the CToken multiplied by the underlying token's price.
Suppose there are 10
users and each of them tries to deposit 1,000,000
underlying tokens into the CToken contract. Price of underlying token is $1
.
Total loss (in $) = $10,000,000
The fix to prevent this issue would be to enforce a minimum deposit that cannot be withdrawn. This can be done by minting small amount of CToken units to 0x00
address on the first deposit.
Instead of a fixed 1000
value an admin controlled parameterized value can also be used to control the burn amount on a per CToken basis.
Other
#0 - c4-judge
2023-05-17T22:01:54Z
0xean marked the issue as duplicate of #314
#1 - c4-judge
2023-06-05T14:00:45Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-06-05T14:37:35Z
0xean changed the severity to 3 (High Risk)
#3 - c4-judge
2023-06-05T14:37:43Z
0xean changed the severity to 2 (Med Risk)
π Selected for report: volodya
5221.3704 USDC - $5,221.37
Detailed description of the impact of this finding. Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Whenever user wants to know his pending rewards he calls getPendingRewards
sometimes it returns incorrect results.
There is a bug inside calculateBorrowerReward
and calculateSupplierReward
function calculateBorrowerReward( address vToken, RewardsDistributor rewardsDistributor, address borrower, RewardTokenState memory borrowState, Exp memory marketBorrowIndex ) internal view returns (uint256) { Double memory borrowIndex = Double({ mantissa: borrowState.index }); Double memory borrowerIndex = Double({ mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower) }); // @audit // if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) { // Covers the case where users borrowed tokens before the market's borrow state index was set borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); } Double memory deltaIndex = sub_(borrowIndex, borrowerIndex); uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex); uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex); return borrowerDelta; }
contracts/Lens/PoolLens.sol#L495
function calculateSupplierReward( address vToken, RewardsDistributor rewardsDistributor, address supplier, RewardTokenState memory supplyState ) internal view returns (uint256) { Double memory supplyIndex = Double({ mantissa: supplyState.index }); Double memory supplierIndex = Double({ mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier) }); // @audit // if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) { // Covers the case where users supplied tokens before the market's supply state index was set supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); } Double memory deltaIndex = sub_(supplyIndex, supplierIndex); uint256 supplierTokens = VToken(vToken).balanceOf(supplier); uint256 supplierDelta = mul_(supplierTokens, deltaIndex); return supplierDelta; }
contracts/Lens/PoolLens.sol#L516
Inside rewardsDistributor original functions written likes this
function _distributeSupplierRewardToken(address vToken, address supplier) internal { ... if (supplierIndex == 0 && supplyIndex >= rewardTokenInitialIndex) { // Covers the case where users supplied tokens before the market's supply state index was set. // Rewards the user with REWARD TOKEN accrued from the start of when supplier rewards were first // set for the market. supplierIndex = rewardTokenInitialIndex; } ... }
contracts/Rewards/RewardsDistributor.sol#L340
function _distributeBorrowerRewardToken( address vToken, address borrower, Exp memory marketBorrowIndex ) internal { ... if (borrowerIndex == 0 && borrowIndex >= rewardTokenInitialIndex) { // Covers the case where users borrowed tokens before the market's borrow state index was set. // Rewards the user with REWARD TOKEN accrued from the start of when borrower rewards were first // set for the market. borrowerIndex = rewardTokenInitialIndex; } ... }
Rewards/RewardsDistributor.sol#L374
function calculateSupplierReward( address vToken, RewardsDistributor rewardsDistributor, address supplier, RewardTokenState memory supplyState ) internal view returns (uint256) { Double memory supplyIndex = Double({ mantissa: supplyState.index }); Double memory supplierIndex = Double({ mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier) }); - if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) { + if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { // Covers the case where users supplied tokens before the market's supply state index was set supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); } Double memory deltaIndex = sub_(supplyIndex, supplierIndex); uint256 supplierTokens = VToken(vToken).balanceOf(supplier); uint256 supplierDelta = mul_(supplierTokens, deltaIndex); return supplierDelta; }
function calculateBorrowerReward( address vToken, RewardsDistributor rewardsDistributor, address borrower, RewardTokenState memory borrowState, Exp memory marketBorrowIndex ) internal view returns (uint256) { Double memory borrowIndex = Double({ mantissa: borrowState.index }); Double memory borrowerIndex = Double({ mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower) }); - if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) { + if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { // Covers the case where users borrowed tokens before the market's borrow state index was set borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); } Double memory deltaIndex = sub_(borrowIndex, borrowerIndex); uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex); uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex); return borrowerDelta; }
Invalid Validation
#0 - c4-sponsor
2023-05-23T20:16:37Z
chechu marked the issue as sponsor confirmed
#1 - c4-judge
2023-06-05T14:00:02Z
0xean marked the issue as satisfactory
π Selected for report: brgltd
Also found by: 0x73696d616f, 0xAce, 0xSmartContract, 0xWaitress, 0xkazim, 0xnev, Aymen0909, BGSecurity, Bauchibred, Cayo, ChrisTina, Franfran, IceBear, Infect3d, Kose, Lilyjjo, PNS, RaymondFam, Sathish9098, Team_Rocket, Udsen, YakuzaKiawe, YoungWolves, berlin-101, bin2chen, btk, codeslide, fatherOfBlocks, frazerch, kodyvim, koxuan, lfzkoala, lukris02, matrix_0wl, nadin, naman1778, sashik_eth, tnevler, volodya, wonjun, yjrwkk
56.6347 USDC - $56.63
Number | Optimization Details | Context |
---|---|---|
[L-01] | Users can borrow up to the borrowCap amount, but they should borrow less than that. | 2 |
[N-01] | Users might not have the ability to exit before a critical change takes place | 1 |
Total 3 issues
According to the docs from the code function Borrowing that brings total borrows to borrow cap will revert
/** * @notice Set the given borrow caps for the given vToken markets. Borrowing that brings total borrows to or above borrow cap will revert. ...
contracts/Comptroller.sol#L839
this means that whevener user should not be able to hit borrowCap and revert if nextTotalBorrows == borrowCap
but it doesn't
if (borrowCap != type(uint256).max) { uint256 totalBorrows = VToken(vToken).totalBorrows(); uint256 nextTotalBorrows = totalBorrows + borrowAmount; // @audit should be, users can borrow more than allowed // if (nextTotalBorrows >= borrowCap) { if (nextTotalBorrows > borrowCap) { revert BorrowCapExceeded(vToken, borrowCap); } }
contracts/Comptroller.sol#L354
Same issue with supplyCap or change documentation
if (borrowCap != type(uint256).max) { uint256 totalBorrows = VToken(vToken).totalBorrows(); uint256 nextTotalBorrows = totalBorrows + borrowAmount; - if (nextTotalBorrows > borrowCap) { + if (nextTotalBorrows >= borrowCap) { revert BorrowCapExceeded(vToken, borrowCap); } }
if (supplyCap != type(uint256).max) { uint256 vTokenSupply = VToken(vToken).totalSupply(); Exp memory exchangeRate = Exp({ mantissa: VToken(vToken).exchangeRateStored() }); uint256 nextTotalSupply = mul_ScalarTruncateAddUInt(exchangeRate, vTokenSupply, mintAmount); - if (nextTotalSupply > supplyCap) { + if (nextTotalSupply >= supplyCap) { revert SupplyCapExceeded(vToken, supplyCap); } }
According to report by openzeppelin
two functions users may need to exit Compound (namely
redeem
andrepayBorrow
) always available to users, so users have the ability to exit before a critical change takes place.
In current implementation protocol introduced ways to forbid users to exit protocol.
function preRepayHook(address vToken, address borrower) external override { _checkActionPauseState(vToken, Action.REPAY); ... }
contracts/Comptroller.sol#L391
function preRepayHook(address vToken, address borrower) external override { _checkActionPauseState(vToken, Action.REPAY); ... }
contracts/Comptroller.sol#L391
function preRedeemHook( address vToken, address redeemer, uint256 redeemTokens ) external override { _checkActionPauseState(vToken, Action.REDEEM); ....
contracts/Comptroller.sol#L297
Remove those checks if you would like to follow compound principals for user`s convenience.
function preRepayHook(address vToken, address borrower) external override { - _checkActionPauseState(vToken, Action.REPAY); oracle.updatePrice(vToken); if (!markets[vToken].isListed) { revert MarketNotListed(address(vToken)); } // Keep the flywheel moving uint256 rewardDistributorsCount = rewardsDistributors.length; for (uint256 i; i < rewardDistributorsCount; ++i) { Exp memory borrowIndex = Exp({ mantissa: VToken(vToken).borrowIndex() }); rewardsDistributors[i].updateRewardTokenBorrowIndex(vToken, borrowIndex); rewardsDistributors[i].distributeBorrowerRewardToken(vToken, borrower, borrowIndex); } }
function preRedeemHook( address vToken, address redeemer, uint256 redeemTokens ) external override { - _checkActionPauseState(vToken, Action.REDEEM); oracle.updatePrice(vToken); _checkRedeemAllowed(vToken, redeemer, redeemTokens); // Keep the flywheel moving uint256 rewardDistributorsCount = rewardsDistributors.length; for (uint256 i; i < rewardDistributorsCount; ++i) { rewardsDistributors[i].updateRewardTokenSupplyIndex(vToken); rewardsDistributors[i].distributeSupplierRewardToken(vToken, redeemer); } }
#0 - c4-judge
2023-05-18T18:50:48Z
0xean marked the issue as grade-b