Platform: Code4rena
Start Date: 24/07/2023
Pot Size: $100,000 USDC
Total HM: 18
Participants: 73
Period: 7 days
Judge: alcueca
Total Solo HM: 8
Id: 267
League: ETH
Rank: 10/73
Findings: 4
Award: $1,212.54
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Aymen0909
Also found by: ABAIKUNANBAEV, Jigsaw, hals, sces60107
523.9156 USDC - $523.92
Judge has assessed an item in Issue #273 as 2 risk. The relevant finding follows:
[L-01]
#0 - c4-judge
2023-08-24T20:47:55Z
alcueca marked the issue as duplicate of #276
#1 - c4-judge
2023-08-24T20:48:30Z
alcueca marked the issue as satisfactory
112.7642 USDC - $112.76
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L237-L239 https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L400-L402
TemporalGovernor
contract: any verified action approval (VAA)/proposal can be executed by anyone if it has been queued and passed the time delay.TemporalGovernor
contract doesn't have any balance, as there's no receive()
or payable functions to receive the funds that will be sent to the proposal's target address.vm.payload
) will not be executed since thetarget.call{value:value}(data)
will revert.File: src/core/Governance/TemporalGovernor.sol Line 237-239: function executeProposal(bytes memory VAA) public whenNotPaused { _executeProposal(VAA, false); }
File: src/core/Governance/TemporalGovernor.sol Line 400-402: (bool success, bytes memory returnData) = target.call{value: value}( data );
testExecuteSucceeds
test in TemporalGovernorExec.t.sol
file,and modified to demonstrate the issue; where a proposal is set to send an EOA receiverAddress a value of 1 ether, but will revert due to lack of funds (follow the comments in the test):function testProposalWithValueExecutionFails() public { address receiverAddress = address(0x2); address[] memory targets = new address[](1); targets[0] = address(receiverAddress); uint256[] memory values = new uint256[](1); values[0] = 1 ether; // the proposal has a value of 1 eth to send it to the receiverAddress bytes[] memory payloads = new bytes[](1); payloads[0] = abi.encodeWithSignature(""); /// to be unbundled by the temporal governor bytes memory payload = abi.encode( address(governor), targets, values, payloads ); mockCore.setStorage( true, trustedChainid, governor.addressToBytes(admin), "reeeeeee", payload ); governor.queueProposal(""); bytes32 hash = keccak256(abi.encodePacked("")); (bool executed, uint248 queueTime) = governor.queuedTransactions(hash); assertEq(queueTime, block.timestamp); assertFalse(executed); //---executing the reposal vm.warp(block.timestamp + proposalDelay); //check that the balance of receiverAddress is zero before execution: assertEq(receiverAddress.balance, 0); // executeProposal function will revert due to lack of funds: vm.expectRevert(); governor.executeProposal(""); assertFalse(executed); // the proposal wasn't executed assertEq(receiverAddress.balance, 0); // the receiverAddress hasn't received any funds because the proposal execution has reverted due to lack of funds }
$ forge test --match-test testProposalWithValueExecutionFails -vvv Running 1 test for test/unit/TemporalGovernor/TemporalGovernorExec.t.sol:TemporalGovernorExecutionUnitTest [PASS] testProposalWithValueExecutionFails() (gas: 458086) Test result: ok. 1 passed; 0 failed; finished in 2.05ms
Manual Testing & Foundry.
Add receive()
function to the TemporalGovernor
contract; so that it can receive funds (native tokens) to be sent with proposals.
ETH-Transfer
#0 - 0xSorryNotSorry
2023-08-02T08:54:52Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-08-02T08:54:56Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-08-03T13:18:34Z
0xSorryNotSorry marked the issue as primary issue
#3 - ElliotFriedman
2023-08-03T22:19:32Z
good finding!
#4 - c4-sponsor
2023-08-03T22:19:35Z
ElliotFriedman marked the issue as sponsor confirmed
#5 - c4-judge
2023-08-12T20:38:27Z
alcueca marked the issue as selected for report
🌟 Selected for report: immeas
Also found by: 0x70C9, 0xAnah, 0xArcturus, 0xComfyCat, 0xWaitress, 0xackermann, 0xkazim, 2997ms, 33audits, Arz, Aymen0909, ChrisTina, JP_Courses, John_Femi, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Nyx, Rolezn, Sathish9098, Stormreckson, T1MOH, Tendency, Topmark, Udsen, Vagner, albertwh1te, ast3ros, banpaleo5, berlin-101, catellatech, cats, codetilda, cryptonue, eeshenggoh, fatherOfBlocks, hals, jamshed, jaraxxus, josephdara, kankodu, kodyvim, kutugu, lanrebayode77, mert_eren, nadin, naman1778, niki, petrichor, ravikiranweb3, said, solsaver, souilos, twcctop, wahedtalash77
44.8793 USDC - $44.88
ID | Title | Severity |
---|---|---|
L-01 | fastTrackProposalExecution function is missing whenPaused modifier | Low |
L-02 | Revoking guardian role is dangerous to the governor contract | Low |
L-03 | getChainlinkPrice will revert if the asset decimals is greater than 18 | Low |
L-04 | Missing min & max value check when updating closeFactorMantissa | Low |
L-05 | Tokens with same symbols will collide in ChainlinkOracle | Low |
L-06 | Market functions will always revert once block.timestamp reaches type(uint32).max | Low |
L-07 | Liquidations can be done without incentivizing the liquidator | Low |
NC-01 | There's no check on the min & max proposalDelay when setting it | Non Critical |
fastTrackProposalExecution
function is missing whenPaused
modifier <a id="l-01" ></a>TemporalGovernor
contract: as per the comments of the fastTrackProposalExecution
function; this function is meant to be called by the owner of the contract when it's paused and in cases of emergencies.fastTrackProposalExecution
function is to enable the owner to execute any urgent proposal during these times.fastTrackProposalExecution
function is missing the whenPaused
modifier; so it can be executed by the owner anytime outside emergency cases.File: src/core/Governance/TemporalGovernor.sol Line 261-268: /// @notice Allow the guardian to process a VAA when the /// Temporal Governor is paused this is only for use during /// periods of emergency when the governance on moonbeam is /// compromised and we need to stop additional proposals from going through. /// @param VAA The signed Verified Action Approval to process function fastTrackProposalExecution(bytes memory VAA) external onlyOwner { _executeProposal(VAA, true); /// override timestamp checks and execute }
Manual Testing.
Add whenPaused
modifier to the fastTrackProposalExecution
function
TemporalGovernor
contract: the guardian role is meant to execute proposals in a fast track without queuing them in cases of emergencies (when the governance on moonbeam is compromised) and to pause/unpause the contract with a governonce proposal.fastTrackProposalExecution
function will be disabled permanently & the contract can never be paused in cases of emergencies.File: src/core/Governance/TemporalGovernor.sol Line 266-268: function fastTrackProposalExecution(bytes memory VAA) external onlyOwner { _executeProposal(VAA, true); /// override timestamp checks and execute }
File: src/core/Governance/TemporalGovernor.sol Line 205-221: function revokeGuardian() external { address oldGuardian = owner(); require( msg.sender == oldGuardian || msg.sender == address(this), "TemporalGovernor: cannot revoke guardian" ); _transferOwnership(address(0)); guardianPauseAllowed = false; lastPauseTime = 0; if (paused()) { _unpause(); } emit GuardianRevoked(oldGuardian); }
Manual Testing.
revokeGuardian()
: make sure to transfer ownership to the TemporalGovernor
contract address so that the owner/guardian role can't be lost.getChainlinkPrice
will revert if the asset decimals is greater than 18 <a id="l-03" ></a>In ChainlinkOracle
contract: getChainlinkPrice
will revert if the asset decimals is greater than 18.
This will prevent markets from using any underlying assets with decimals >18, as their price feed can't be normalized.
File:src/core/Oracles/ChainlinkOracle.sol Line 85: uint256 decimalDelta = uint256(18).sub(uint256(token.decimals())); //!@audit : will revert if token.decimals() >18
Manual Testing.
Update getChainlinkPrice
function to account for tokens with decimals > 18.
closeFactorMantissa
<a id="l-04" ></a>closeFactorMantissa
is a multiplier used to calculate the maximum repayAmount when liquidating a borrow (the liquidator may not repay more than what is allowed by the closeFactor).Comptroller
contract : the admin can change the closeFactorMantissa
without checking if it complies with the closeFactorMinMantissa
(which is 0.05e18) & closeFactorMaxMantissa
(which is 0.9e18).If this value is set accidentally to a value much lower than the closeFactorMinMantissa
(say 0); then liquidators will not be able to liquidate unhealthy positions (temporarily until the admin resets it again):
uint borrowBalance = MToken(mTokenBorrowed).borrowBalanceStored(borrower); uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance);//@audit which will equal to zero if closeFactorMantissa=0 if (repayAmount > maxClose) { //@audit : this statement will always be true in this case return uint(Error.TOO_MUCH_REPAY); }
And if this value is set to a value much higher than the closeFactorMaxMantissa
(say 10e18); then liquidators can repay the full borrow;and this will be dangerous to the market stability (temporarily until the admin resets it again):
uint borrowBalance = MToken(mTokenBorrowed).borrowBalanceStored(borrower); uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance);//@audit which will equal to a very large value if closeFactorMantissa is set to 10e18 if (repayAmount > maxClose) { //@audit : so this condition can be bypassed return uint(Error.TOO_MUCH_REPAY); }
File: src/core/Comptroller.sol Line 689-698: function _setCloseFactor(uint newCloseFactorMantissa) external returns (uint) { // Check caller is admin require(msg.sender == admin, "only admin can set close factor"); uint oldCloseFactorMantissa = closeFactorMantissa; closeFactorMantissa = newCloseFactorMantissa; emit NewCloseFactor(oldCloseFactorMantissa, closeFactorMantissa); return uint(Error.NO_ERROR); }
In liquidateBorrowAllowed function
File: src/core/Comptroller.sol Line 417-421: uint borrowBalance = MToken(mTokenBorrowed).borrowBalanceStored(borrower); uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance); if (repayAmount > maxClose) { return uint(Error.TOO_MUCH_REPAY); }
Manual Testing.
In _setCloseFactor
function: check if newCloseFactorMantissa
is within the max & min values before assigng it to the closeFactorMantissa
.
ChainlinkOracle
<a id="l-05" ></a>ChainlinkOracle
contract : asset tokens are saved in feeds mapping as feeds[keccak256(abi.encodePacked(symbol))]=AggregatorV3Interface
.getUnderlyingPrice
or getFeed
is called.File:src/core/Oracles/ChainlinkOracle.sol Line 144-153: function setFeed(string calldata symbol, address feed) external onlyAdmin { require( feed != address(0) && feed != address(this), "invalid feed address" ); emit FeedSet(feed, symbol); feeds[keccak256(abi.encodePacked(symbol))] = AggregatorV3Interface( feed ); }
File:src/core/Oracles/ChainlinkOracle.sol Line 82: price = getChainlinkPrice(getFeed(token.symbol()));
File:src/core/Oracles/ChainlinkOracle.sol Line 158-162: function getFeed( string memory symbol ) public view returns (AggregatorV3Interface) { return feeds[keccak256(abi.encodePacked(symbol))]; }
Manual Testing.
Modify feeds mapping to save token address insted of token symbol; this will require updating functions that uses token symbols to use addresses instead accordingly.
block.timestamp
reaches type(uint32).max
<a id="l-06" ></a>The cause of the problem is caused by calculateNewIndex
function:
In MultiRewardDistributor
contract : calculateNewIndex
function is used to calculate the global reward indicies for markets for the sake of rewards distribution management.
The function calculates the tokenAccrued
based on the deltaTimestamps
and the market emissionsPerSecond of rewards tokens:
where deltaTimestamps
is the difference between supplyGlobalTimestamp
and the current block.timestamp
.
uint32 blockTimestamp = safe32( block.timestamp, "block timestamp exceeds 32 bits" ); uint256 deltaTimestamps = sub_( blockTimestamp, uint256(_currentTimestamp) );
as can be seen; the current block.timestamp
is downcasted to unit32 by using safe32
function:
function safe32( uint n, string memory errorMessage ) internal pure returns (uint32) { require(n < 2 ** 32, errorMessage); return uint32(n); }
then subtracted from the emissionConfig.config.borrowGlobalTimestamp
which is _currentTimestamp
input.
The problem arises when the current block.timestamp
reaches type(uint32).max
as the calculation of blockTimestamp
will always revert .
Even if this underflow is to happen in the very far future;but risks must be avoided.
calculateNewIndex
function will always revert once block.timestamp
hits type(uint32).max
; so this will cause the other functions using it to revert as well.Comptroller
and MultiRewardDistributor
are using this function indirectly to update accrued rewards, so the market will stop being functional and users funds will be stuck (can't redeem/borrow/transfer/repay/...).In src/core/MultiRewardDistributor/MultiRewardDistributor.sol
:
function calculateNewIndex( uint256 _emissionsPerSecond, uint32 _currentTimestamp, uint224 _currentIndex, uint256 _rewardEndTime, uint256 _denominator ) internal view returns (IndexUpdate memory) { uint32 blockTimestamp = safe32( block.timestamp, "block timestamp exceeds 32 bits" ); uint256 deltaTimestamps = sub_( blockTimestamp, uint256(_currentTimestamp) ); // If our current block timestamp is newer than our emission end time, we need to halt // reward emissions by stinting the growth of the global index, but importantly not // the global timestamp. Should not be gte because the equivalent case makes a // 0 deltaTimestamp which doesn't accrue the last bit of rewards properly. if (blockTimestamp > _rewardEndTime) { // If our current index timestamp is less than our end time it means this // is the first time the endTime threshold has been breached, and we have // some left over rewards to accrue, so clamp deltaTimestamps to the whatever // window of rewards still remains. if (_currentTimestamp < _rewardEndTime) { deltaTimestamps = sub_(_rewardEndTime, _currentTimestamp); } else { // Otherwise just set deltaTimestamps to 0 to ensure that we short circuit // in the next step deltaTimestamps = 0; } } // Short circuit to update the timestamp but *not* the index if there's nothing // to calculate if (deltaTimestamps == 0 || _emissionsPerSecond == 0) { return IndexUpdate({ newIndex: _currentIndex, newTimestamp: blockTimestamp }); } // At this point we know we have to calculate a new index, so do so uint256 tokenAccrued = mul_(deltaTimestamps, _emissionsPerSecond); Double memory ratio = _denominator > 0 ? fraction(tokenAccrued, _denominator) : Double({mantissa: 0}); uint224 newIndex = safe224( add_(Double({mantissa: _currentIndex}), ratio).mantissa, "new index exceeds 224 bits" ); return IndexUpdate({newIndex: newIndex, newTimestamp: blockTimestamp}); }
IndexUpdate memory supplyUpdate = calculateNewIndex(
IndexUpdate memory borrowUpdate = calculateNewIndex(
IndexUpdate memory supplyUpdate = calculateNewIndex(
IndexUpdate memory borrowIndexUpdate = calculateNewIndex(
Manual Testing.
Avoid downcasting block.timestamp
to uint32
.
liquidationIncentiveMantissa
.Comptroller
contract: there's no initial value set for the liquidationIncentiveMantissa
.liquidationIncentiveMantissa
by _setLiquidationIncentive
; but there's no check that liquidations has begun before setting a value for this factor.File:src/core/Comptroller.sol Line 649-653 : numerator = mul_(Exp({mantissa: liquidationIncentiveMantissa}), Exp({mantissa: priceBorrowedMantissa})); denominator = mul_(Exp({mantissa: priceCollateralMantissa}), Exp({mantissa: exchangeRateMantissa})); ratio = div_(numerator, denominator); seizeTokens = mul_ScalarTruncate(ratio, actualRepayAmount);
Manual Testing.
In Comptroller
contract: initialize liquidationIncentiveMantissa
in the constructor:
- constructor() { + constructor(uint _newLiquidationIncentiveMantissa) { admin = msg.sender; + liquidationIncentiveMantissa=_newLiquidationIncentiveMantissa; }
proposalDelay
when setting it <a id="nc-01" ></a>TemporalGovernor
contract: there's no check on the value of proposalDelay
(which is the amount of time a proposal must wait before being processed) when setting it.File:src/core/Governance/TemporalGovernor.sol Line 68: proposalDelay = _proposalDelay;
Manual Testing.
Bound proposalDelay
with a minimum and maximum values and check for it in the constructor before assignment.
#0 - c4-pre-sort
2023-08-03T15:29:07Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-sponsor
2023-08-03T21:51:33Z
ElliotFriedman marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-11T22:26:10Z
alcueca marked the issue as grade-a
#3 - DevHals
2023-08-24T14:49:55Z
Hi, it seems like L-01 is a duplicate of #276
🌟 Selected for report: Sathish9098
Also found by: 0xSmartContract, K42, Udsen, berlin-101, catellatech, cryptonue, hals, jaraxxus, kodyvim, kutugu, solsaver
530.9819 USDC - $530.98
From reviewing the Moonwell codebase,I was able to achieve the following:
The codebase can be divided into three groups:
TemporalGovernor
contract
contains all the governance logic as setting trusted senders between chains by a governance proposal,queuing and executing proposals after being verified.IRModels
contracts:
where two interset rate models are defined to be adopted (one of them) by the markets: jump & whitpaper models.
Oracles
contracts:
where the price feed of markets underlying assets are retrieved either directly or aggregated by the ChainlinkCompositeOracle
if there's no direct feed for a specific aseet.
WETHRouter.sol
:
where users can enter any market with native ethers if they don't have the underlying asset of the market; the router will handle the asset conversion from eth to weth and the deposit (mint), also, it enables users to redeem their mTokens and getting ethers in return.
MErc20.sol
:
represents the gateway for users to interact with the market; it holds the basic functions for mTokens.
MToken.sol
:
this contract is the brain of the mToken, where all the user operations are executed.
Comptroller.sol
:
it is the main contract that handles all markets configurations (set by the admin), and checks if the user operations (minting,borrowing,repaying,liquidations,rewards claiming,...) are allowed or not based on the market (mToken) specifications and user state.
MultiRewardDistributor.sol
:
manages calculating rewards indices with time & rewards distribution to market participant, where every user is incentivized by rewards token accrued with time.
MErc20Delegate.sol
& MErc20Delegator.sol
:
where MErc20Delegator is responsible of upgrading the MToken implementation by setting a new implementation of it & MErc20Delegate receives delegated calls from the MErc20Delegator.
Unitroller.sol
:
it follow the UUP upgrade pattern, where the comptroller implementation address can be changed, pointing to a new comptroller with an updatable logic.
Governance: in TemporalGovernor
User flow: aside from MErc20 delagtion; users have two options to interact with the market at first: either by depositing underlying market tokens by directly interacting with mint
function in MErc20
contract , or by depositing native tokens (ethers) in the WETHRouter
contract to enter a specific market without having it's underlying asset as the router will handle the whole operation for the user:
this is a simple abstract for the user flow when interacting with the market,the flow is similar for redeem,but for borrow,repay,liquidate & rewards claim; the user must directly interact with MErc20
as the WETHRouter
can only handle depositing(mint) & redeem:
MErc20
admin privilages:Comptroller
admin privilages:MultiRewardsDistributor
contract: privilaged to set rewards emission configuration and caps,updating user and markets indicies for rewards calculation & rescue funds which has the same risk explained in point#5 above.TemporalGovernor
contract was detected because it's un-payable contract, so proposals with values to send can't be executed.In TemporalGovernor
: revoking the guardian role is dangerous as the contract will never be pausable again in the cases of emergencies; so it's recommended to preserve this role instead of revoking it permanently.
In Comptroller
: Changing a vital markets configuration parameters as collateral factor without implementing a grace period can harm the user s positions and expose them to liquidations; so considering implementing a grace period when setting such a critical configurations.
Considering Moonwell's commitment to security, regular third-party security audits are advisable to identify and remediate any hidden vulnerabilities. Additionally, providing detailed and extensive documentation for the smart contracts, user interactions, and governance processes will promote community understanding and foster a robust user base.
Approximately 45 hours ; divided between manually reviewing the codebase, reading documentation, foundry testing, and documenting my findings.
45 hours
#0 - c4-pre-sort
2023-08-03T15:36:53Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-sponsor
2023-08-03T22:35:16Z
ElliotFriedman marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-08-03T22:35:20Z
ElliotFriedman marked the issue as sponsor acknowledged
#3 - c4-judge
2023-08-11T20:41:22Z
alcueca marked the issue as grade-a