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: 51/102
Findings: 2
Award: $108.31
🌟 Selected for report: 0
🚀 Solo Findings: 0
51.6843 USDC - $51.68
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/VToken.sol#L743-L871 https://github.com/code-423n4/2023-05-venus/blob/main/contracts/VToken.sol#L1463-L1482
This is a common attack vector involving shares based liquidity pool or vault contracts. An early user can manipulate the price per share and profit from the next user's deposit because of precision loss caused by the rather large value of price per share.
Apparently, the first investor supplying asset tokens to the VToken
contract can maliciously manipulate the exchangeRate
by supplying the lowest possible amount of asset into the market.
This can artificially and excessively inflate the exchangeRate
, and specifically, due to rounding down by default associated with division truncation, this malicious initial deposit is customized and poised to front-run someone attempting to make the first mint to get the most exploit out of it.
Here is a typical exploit scenario:
initialExchangeRateMantissa = 1e18 (as assigned in TokenTestHelpers.ts)
VToken
contract is using WETH (18 decimals compliant) as its asset token when Alice wants to supply 100e18 WETH.mint()
to get 1 wei of vToken in exchange. The exchange rate is now 1 * 1e18 / 1e18 = 1, i.e. 1 wei of vToken per 1 wei of WETH.function div_(uint256 a, Exp memory b) internal pure returns (uint256) { return div_(mul_(a, expScale), b.mantissa); }
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/VToken.sol#L776
uint256 mintTokens = div_(actualMintAmount, exchangeRate);
totalBorrows
, badDebt
or totalReserves
still default to zeros.uint256 totalCash = _getCashPrior(); uint256 cashPlusBorrowsMinusReserves = totalCash + totalBorrows + badDebt - totalReserves; uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply;
_getCashPrior()
to receive 0 vToken like Alice till he decides to call redeem()
and get transferred all the underlying WETH.Manual
Consider sending the first 1000 shares to address 0, a mitigation approach adopted by the Uniswap V2 protocol when _totalSupply == 0
. Alternatively, establish a minimum deposit amount to prevent an attacker from depositing a minimal amount of WETH to manipulate the exchange rate. This limit should be set high enough to prevent the exchangeRate
from being inflated to the point of causing precision issues.
Additionally, the protocol could look into implementing slippage protection to further mitigate the situations.
Timing
#0 - c4-judge
2023-05-17T12:02:16Z
0xean marked the issue as duplicate of #314
#1 - c4-judge
2023-06-05T14:08:31Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-06-05T14:37:43Z
0xean changed the severity to 2 (Med Risk)
🌟 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
A blockchain halt, like the one that happened to Binance Smart Chain (BSC) in October 2022, is a significant event. When a blockchain halts, it means that no new blocks are being produced, so no transactions can be confirmed. It's akin to a system-wide freeze.
The impact on smart contracts in such a situation would be substantial. Here's what could potentially happen:
Transactions would be stalled: Any transaction that's broadcast during the halt wouldn't get confirmed until the network resumes. This includes transactions that interact with smart contracts. If a user attempted to interact with one of the provided contracts during the halt, their transaction would be stuck in a pending state.
Auctions could be affected: In the given Auction contract, if the halt occurs during an ongoing auction, the auction would effectively be paused. No new bids could be placed, and the auction couldn't be finalized until the network resumes.
Liquidations would be paused: In the case of the Liquidation contract, no liquidations could occur during the halt. If a position becomes undercollateralized during the halt, it couldn't be liquidated until the network resumes.
Time-based logic could be distorted: The block.timestamp is the timestamp when the block was mined, which should be roughly the current time. However, during a blockchain halt, no new blocks are mined, so the block.timestamp doesn't advance. This could distort the logic of contracts that rely on the block.timestamp.
Borrowers were unable to repay their loans, this could indeed potentially lead to their positions becoming undercollateralized, especially if the halt lasted for a significant period of time and interest continued to accrue. If the protocol's interest calculation is such that interest continues to accrue during a halt, and the halt lasts long enough for positions to become undercollateralized, then those positions could indeed be at risk of liquidation once the network resumes.
Once the blockchain resumes, there could be a rush of transactions as users try to interact with contracts that were paused during the halt. This could lead to increased gas prices and potential network congestion.
As a mitigation strategy, contracts could implement a pause functionality that allows them to be manually paused and unpaused in case of such emergencies. However, this introduces a degree of centralization and needs to be handled with care. Furthermore, users and developers on the network should be prepared for such events and have contingency plans in place such as kicking in a grace period for borrowers to rectify their positions.
In Shortfall.placeBid, if auction.auctionType == AuctionType.LARGE_RISK_FUND
and the user accidentally entered 0 bidBps, no one else would be so dumb to place another 0 bid anymore although technically bidBps <= auction.startBidBps
would make it still permissible.
One of the simplest and most effective ways to prevent this kind of issue is to include input validation in the placeBid function. You could add a requirement that bidBps must be greater than 0. This would prevent a 0 bid from being placed and effectively breaking the auction while making the careless bidder bear the entire debt for zero exchange of riskFundBidAmount.
Here is an example of how you could add this validation:
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Shortfall/Shortfall.sol#L158-L202
function placeBid(uint256 bidBps) public { // Input validation + require(bidBps > 0, "Bid must be greater than 0"); // Existing logic ... }
There are three (instead of two) state variables entailed in ReserveHelpers.sol.
Consider having the gap number corrected refactored as follows:
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/RiskFund/ReserveHelpers.sol#L13-L26
mapping(address => uint256) internal assetsReserves; // Store the asset's reserve per pool in the ProtocolShareReserve. // Comptroller(pool) -> Asset -> amount mapping(address => mapping(address => uint256)) internal poolsAssetsReserves; // Address of pool registry contract address internal poolRegistry; /** * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. */ - uint256[48] private __gap; + uint256[47] private __gap;
In Comptroller._safeGetAccountSnapshot(), err
matches with the hard coded constant NO_ERROR
which equals 0
.
Consider removing the redundant check:
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Comptroller.sol#L1402C14-L1417
function _safeGetAccountSnapshot(VToken vToken, address user) internal view returns ( uint256 vTokenBalance, uint256 borrowBalance, uint256 exchangeRateMantissa ) { uint256 err; (err, vTokenBalance, borrowBalance, exchangeRateMantissa) = vToken.getAccountSnapshot(user); - if (err != 0) { - revert SnapshotError(address(vToken), user); - } return (vTokenBalance, borrowBalance, exchangeRateMantissa); }
maxLoopsLimit
could directionally make for loops prone to DoSMaxLoopsLimitHelper._setMaxLoopsLimit() can only set a limit higher than the existing maxLoopsLimit
. In the event maxLoopsLimit
was accidentally overvalued, there would not be an option to downsize it. As a result, all functions dependent on _ensureMaxLoops()
could easily run out of gas in their loop iterations. Although a smaller array could always be inputted, it would disrupt calls having had to self gauge the supposed maxLoopLimit
.
Consider flexibly making _setMaxLoopsLimit()
to set a lower maxLoopLimit
if need be by removing the require check:
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/MaxLoopsLimitHelper.sol#L25-L32
function _setMaxLoopsLimit(uint256 limit) internal { - require(limit > maxLoopsLimit, "Comptroller: Invalid maxLoopsLimit"); uint256 oldMaxLoopsLimit = maxLoopsLimit; maxLoopsLimit = limit; emit MaxLoopsLimitUpdated(oldMaxLoopsLimit, maxLoopsLimit); }
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Comptroller.sol#LL385C48-L385C56
- * @param borrower The account which would borrowed the asset + * @param borrower The account which would borrow the asset
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Comptroller.sol#L808
- require(vToken.isVToken(), "Comptroller: Invalid vToken"); // Sanity check to make sure its really a VToken + require(vToken.isVToken(), "Comptroller: Invalid vToken"); // Sanity check to make sure it is really a VToken
- * @dev Multiply an Exp by a scalar, truncate, then add an to an unsigned integer, returning an unsigned integer. + * @dev Multiply an Exp by a scalar, truncate, then add to an unsigned integer, returning an unsigned integer.
- "RiskFund: finally path must be convertible base asset" + "RiskFund: final path must be convertible base asset"
Before deploying your contract, activate the optimizer when compiling using “solc --optimize --bin sourceFile.sol”. By default, the optimizer will optimize the contract assuming it is called 200 times across its lifetime. If you want the initial contract deployment to be cheaper and the later function executions to be more expensive, set it to “ --optimize-runs=1”. Conversely, if you expect many transactions and do not care for higher deployment cost and output size, set “--optimize-runs” to a high number.
module.exports = { solidity: { version: "0.8.13", settings: { optimizer: { enabled: true, runs: 1000, }, }, }, };
Please visit the following site for further information:
https://docs.soliditylang.org/en/v0.5.4/using-the-compiler.html#using-the-commandline-compiler
Here's one example of instance on opcode comparison that delineates the gas saving mechanism:
for !=0 before optimization PUSH1 0x00 DUP2 EQ ISZERO PUSH1 [cont offset] JUMPI after optimization DUP1 PUSH1 [revert offset] JUMPI
Disclaimer: There have been several bugs with security implications related to optimizations. For this reason, Solidity compiler optimizations are disabled by default, and it is unclear how many contracts in the wild actually use them. Therefore, it is unclear how well they are being tested and exercised. High-severity security issues due to optimization bugs have occurred in the past . A high-severity bug in the emscripten -generated solc-js compiler used by Truffle and Remix persisted until late 2018. The fix for this bug was not reported in the Solidity CHANGELOG. Another high-severity optimization bug resulting in incorrect bit shift results was patched in Solidity 0.5.6. Please measure the gas savings from optimizations, and carefully weigh them against the possibility of an optimization-related bug. Also, monitor the development and adoption of Solidity compiler optimizations to assess their maturity.
Consider having the logic of a modifier embedded through a private function to reduce contract size if need be. A private
visibility that saves more gas on function calls than the internal
visibility is adopted because the modifier will only be making this call inside the contract.
For example, the modifier instance below may be refactored as follows:
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/VToken.sol#L33-L38
+ function _nonReentrant() private view { + require(_notEntered, "re-entered"); + _notEntered = false; + _; + _notEntered = true; // get a gas-refund post-Istanbul + } modifier nonReentrant() { - require(_notEntered, "re-entered"); - _notEntered = false; - _; - _notEntered = true; // get a gas-refund post-Istanbul + _nonReentrant(); _; }
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Comptroller.sol
442: if (!markets[vTokenCollateral].isListed) { 443: revert MarketNotListed(address(vTokenCollateral)); 444: } 497: if (!markets[vTokenCollateral].isListed) { 498: revert MarketNotListed(vTokenCollateral); 499: }
https://github.com/code-423n4/2023-05-venus/blob/main/contracts/Comptroller.sol#L424-L444
function preLiquidateHook( address vTokenBorrowed, address vTokenCollateral, address borrower, uint256 repayAmount, bool skipLiquidityCheck ) external override { // Pause Action.LIQUIDATE on BORROWED TOKEN to prevent liquidating it. // If we want to pause liquidating to vTokenCollateral, we should pause // Action.SEIZE on it _checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE); oracle.updatePrice(vTokenBorrowed); oracle.updatePrice(vTokenCollateral); if (!markets[vTokenBorrowed].isListed) { - revert MarketNotListed(address(vTokenBorrowed)); + revert MarketNotListed(vTokenBorrowed); } if (!markets[vTokenCollateral].isListed) { - revert MarketNotListed(address(vTokenCollateral)); + revert MarketNotListed(vTokenCollateral); }
#0 - c4-judge
2023-05-18T18:46:18Z
0xean marked the issue as grade-b