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: 2/73
Findings: 6
Award: $12,924.20
π Selected for report: 3
π Solo Findings: 2
π Selected for report: immeas
Also found by: 0xkazim, ABAIKUNANBAEV, T1MOH, berlin-101, bin2chen, kutugu, markus_ether
119.3545 USDC - $119.35
fastTrackProposalExecution()
is used to execute specific proposal while TemporalGovernor
is paused. There is invariant in contract that guardian must be not allowed to pause when contract is paused.
However this invariant can be broken unintentionally. And the only way to recover in this situation is to revoke guardian. Then protocol won't be able to execute fastTrackProposals and to pause governance.
Let's take a look at the following scenario:
/// There are a few assumptions that are made in this contract: /// 1. Wormhole is secure and will not send malicious messages or be deactivated. /// 2. Moonbeam is secure. /// 3. Governance on Moonbeam cannot be compromised.
guardianPauseAllowed = false
:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L270-L290function togglePause() external onlyOwner { if (paused()) { _unpause(); } else { require( guardianPauseAllowed, "TemporalGovernor: guardian pause not allowed" ); guardianPauseAllowed = false; lastPauseTime = block.timestamp.toUint248(); _pause(); } /// statement for SMT solver assert(!guardianPauseAllowed); /// this should be an unreachable state }
grantGuardiansPause()
to allow pausing again. Now guardianPauseAllowed = true
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L187-L198function grantGuardiansPause() external { require( msg.sender == address(this), "TemporalGovernor: Only this contract can update grant guardian pause" ); guardianPauseAllowed = true; lastPauseTime = 0; emit GuardianPauseGranted(block.timestamp); }
There are 3 ways to call unpause
:
guardianPauseAllowed == true
function togglePause() external onlyOwner { if (paused()) { _unpause(); } else { require( guardianPauseAllowed, "TemporalGovernor: guardian pause not allowed" ); guardianPauseAllowed = false; lastPauseTime = block.timestamp.toUint248(); _pause(); } /// statement for SMT solver assert(!guardianPauseAllowed); /// this should be an unreachable state }
permissionlessUnpause()
also doesn't pass assert statementfunction permissionlessUnpause() external whenPaused { require( lastPauseTime + permissionlessUnpauseTime <= block.timestamp, "TemporalGovernor: not past pause window" ); lastPauseTime = 0; _unpause(); assert(!guardianPauseAllowed); /// this should never revert, statement for SMT solving emit PermissionlessUnpaused(block.timestamp); }
revokeGuardian()
. But you agree this is not idealfunction 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 Review
Ensure that current state is unpaused in grantGuardiansPause()
:
- function grantGuardiansPause() external { + function grantGuardiansPause() external whenNotPaused { require( msg.sender == address(this), "TemporalGovernor: Only this contract can update grant guardian pause" ); guardianPauseAllowed = true; lastPauseTime = 0; emit GuardianPauseGranted(block.timestamp); }
Governance
#0 - c4-pre-sort
2023-08-03T13:29:14Z
0xSorryNotSorry marked the issue as duplicate of #232
#1 - c4-judge
2023-08-12T20:49:47Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2023-08-12T20:49:51Z
alcueca marked the issue as partial-50
43.3709 USDC - $43.37
Proposals with value != 0 can't be executed because TemporalGovernor.sol doesn't have payable functions
function executeProposal(bytes memory VAA) public whenNotPaused { _executeProposal(VAA, false); }
But proposal is executed with value
:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L399-L402
// Go make our call, and if it is not successful revert with the error bubbling up (bool success, bytes memory returnData) = target.call{value: value}( data );
Manual Review
Add receive() external payable {}
Or make executeProposal
payable
Governance
#0 - c4-pre-sort
2023-08-03T13:20:47Z
0xSorryNotSorry marked the issue as duplicate of #268
#1 - c4-judge
2023-08-12T20:35:30Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2023-08-12T20:35:34Z
alcueca marked the issue as partial-50
2335.7005 USDC - $2,335.70
At the time of deploy, deployer initializes token markets with initial amount to prevent exploit. At least USDC and WETH markets will be initialized during deploy.
But initialMintAmount
is hardcoded to 1e18
which is of for WETH (1,876 USD), but unrealistic for USDC (1,000,000,000,000 USD). Therefore deploy will fail.
Here initialMintAmount = 1 ether
:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/test/proposals/Configs.sol#L55
/// @notice initial mToken mint amount uint256 public constant initialMintAmount = 1 ether;
Here this amount is approved to MToken contract and supplied to mint MToken: https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/test/proposals/mips/mip00.sol#L334-L379
for (uint256 i = 0; i < cTokenConfigs.length; i++) { Configs.CTokenConfiguration memory config = cTokenConfigs[i]; address cTokenAddress = addresses.getAddress( config.addressesString ); ... /// Approvals _pushCrossChainAction( config.tokenAddress, abi.encodeWithSignature( "approve(address,uint256)", cTokenAddress, initialMintAmount ), "Approve underlying token to be spent by market" ); /// Initialize markets _pushCrossChainAction( cTokenAddress, abi.encodeWithSignature("mint(uint256)", initialMintAmount), "Initialize token market to prevent exploit" ); ... }
Manual Review
Specify initialMintAmount
for every token separately in config
Other
#0 - c4-pre-sort
2023-08-03T13:50:18Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T21:45:41Z
valid issue, but we have already fixed this. probably a low sev issue as this deploy script was only configured to work on local testnets where we control the ERC20 tokens
#2 - c4-sponsor
2023-08-03T21:45:49Z
ElliotFriedman marked the issue as disagree with severity
#3 - c4-sponsor
2023-08-07T21:48:18Z
ElliotFriedman marked the issue as sponsor confirmed
#4 - alcueca
2023-08-12T21:01:39Z
The issue is still valid as Medium with the code and knowledge available to the wardens.
#5 - c4-judge
2023-08-12T21:02:04Z
alcueca marked the issue as selected for report
π Selected for report: T1MOH
5190.4455 USDC - $5,190.45
Wormhole Bridge will have incorrect address, which disables whole TemporalGovernance contract and therefore administration of Moonbeam. But during deployment markets are initialized with initial token amounts to prevent exploits, therefore TemporalGovernance must own this initial tokens. But because of incorrect bridge address TemporalGovernance can't perform any action and these tokens are brick. Protocol needs redeployment and loses initial tokens.
To grab this report easily I divided it into 3 parts
WORMHOLE_CORE
set incorrectly
There is no config for Base Mainnet, however this comment states for used parameters:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/test/proposals/mips/mip00.sol#L55-L61/// --------------------------------------------------------------------------------------------------/// /// Chain Name Wormhole Chain ID Network ID Address |/// /// Ethereum (Goerli) 2 5 0x706abc4E45D419950511e474C7B9Ed348A4a716c |/// /// Ethereum (Sepolia) 10002 11155111 0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78 |/// /// Base 30 84531 0xA31aa3FDb7aF7Db93d18DDA4e19F811342EDF780 |/// /// Moonbeam 16 1284 0xC8e2b0cD52Cf01b0Ce87d389Daa3d414d4cE29f3 |/// /// --------------------------------------------------------------------------------------------------///
Why to treat this comment? Other parameters are correct except Base Network ID. But Base Network ID has reflection in code, described in #114. 0xA31aa3FDb7aF7Db93d18DDA4e19F811342EDF780 is address of Wormhole Token Bridge on Base Testnet according to docs and this address has no code in Base Mainnet
wormholeBridge
doesn't contain code:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L344-L350function _executeProposal(bytes memory VAA, bool overrideDelay) private { // This call accepts single VAAs and headless VAAs ( IWormhole.VM memory vm, bool valid, string memory reason ) = wormholeBridge.parseAndVerifyVM(VAA); ... }
/// @notice the deployer should have both USDC, WETH and any other assets that will be started as /// listed to be able to deploy on base. This allows the deployer to be able to initialize the /// markets with a balance to avoid exploits function deploy(Addresses addresses, address) public { ... }
And here governor approves underlying token to MToken and mints initial mTokens. It means that governor has tokens on balance.
function build(Addresses addresses) public { /// Unitroller configuration _pushCrossChainAction( addresses.getAddress("UNITROLLER"), abi.encodeWithSignature("_acceptAdmin()"), "Temporal governor accepts admin on Unitroller" ); ... /// set mint unpaused for all of the deployed MTokens unchecked { for (uint256 i = 0; i < cTokenConfigs.length; i++) { Configs.CTokenConfiguration memory config = cTokenConfigs[i]; address cTokenAddress = addresses.getAddress( config.addressesString ); _pushCrossChainAction( unitrollerAddress, abi.encodeWithSignature( "_setMintPaused(address,bool)", cTokenAddress, false ), "Unpause MToken market" ); /// Approvals _pushCrossChainAction( config.tokenAddress, abi.encodeWithSignature( "approve(address,uint256)", cTokenAddress, initialMintAmount ), "Approve underlying token to be spent by market" ); /// Initialize markets _pushCrossChainAction( cTokenAddress, abi.encodeWithSignature("mint(uint256)", initialMintAmount), "Initialize token market to prevent exploit" ); ... } } ... }
To conclude, there is no way to return the tokens from the TemporalGovernor intended for market initialization
Manual Review
Change WORMHOLE_CORE to 0xbebdb6C8ddC678FfA9f8748f85C815C556Dd8ac6 according to docs
Other
#0 - c4-pre-sort
2023-08-03T14:08:56Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-08-03T19:08:28Z
ElliotFriedman marked the issue as sponsor disputed
#2 - c4-sponsor
2023-08-03T19:09:05Z
ElliotFriedman marked the issue as disagree with severity
#3 - ElliotFriedman
2023-08-03T19:09:21Z
this is correct on base goerli, however we have not added base mainnet contract yet as it has not been deployed.
#4 - ElliotFriedman
2023-08-03T19:09:50Z
this can't be a high or even medium risk bug because this is an issue in our testnet deploy configuration
#5 - c4-judge
2023-08-12T21:03:01Z
alcueca marked the issue as satisfactory
#6 - alcueca
2023-08-15T14:58:20Z
How did I mark this as satisfactory
? Grade-b QA is more accurate.
#7 - alcueca
2023-08-15T14:58:44Z
Also, grossly overinflated.
#8 - c4-judge
2023-08-15T14:58:49Z
alcueca marked the issue as unsatisfactory: Overinflated severity
#9 - alcueca
2023-08-21T21:56:14Z
Upon discussion with the sponsor about differences between this issue and #114, and actual impact of this issue, I'm reinstating Medium severity.
#10 - c4-judge
2023-08-21T21:56:22Z
alcueca changed the severity to 2 (Med Risk)
#11 - c4-judge
2023-08-21T21:56:28Z
alcueca marked the issue as selected for report
π Selected for report: T1MOH
5190.4455 USDC - $5,190.45
Incorrect chainId of Base in deploy parameters results in incorrect deploy and subsequent redeployment
Contract ChainIds.sol is responsible for mapping chainId -> wormholeChainId
which is used in contract Addresses
to associate contract name with its address on specific chain. Addresses
is the main contract which keeps track of all dependency addresses and passed into main deploy()
and here addresses accessed via block.chainId:
https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/test/proposals/mips/mip00.sol#L77
function deploy(Addresses addresses, address) public { ... trustedSenders[0].chainId = chainIdToWormHoleId[block.chainid]; ... memory cTokenConfigs = getCTokenConfigurations(block.chainid); }
Here you can see that Network ID of Base set to 84531. But actual network id is 8453 from Base docs
contract ChainIds { uint256 public constant baseChainId = 84531; uint16 public constant baseWormholeChainId = 30; /// TODO update when actual base chain id is known uint256 public constant baseGoerliChainId = 84531; uint16 public constant baseGoerliWormholeChainId = 30; ... constructor() { ... chainIdToWormHoleId[baseChainId] = moonBeamWormholeChainId; /// base deployment is owned by moonbeam governance ... } }
Manual Review
Change Base Network ID to 8453
Other
#0 - c4-pre-sort
2023-08-03T14:08:57Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-08-03T19:07:53Z
ElliotFriedman marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-12T21:03:20Z
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
sweepToken()
is designed to allow the market owner to withdraw any ERC20 token which might have ended up at MToken address. Underlying token must not be swept, therefore sweepToken()
ensures token is not underlying. However, this can be bypassed if the underlying token is a double-entrypoint token.
Here it ensures that token address is different.
function sweepToken(EIP20NonStandardInterface token) override external { require(msg.sender == admin, "MErc20::sweepToken: only admin can sweep tokens"); require(address(token) != underlying, "MErc20::sweepToken: can not sweep underlying token"); uint256 balance = token.balanceOf(address(this)); token.transfer(admin, balance); }
Double-entrypoint token has multiple addresses, but all the contracts operate on single storage. Examples of such tokens: TUSD (2.8B USD market cap), SNX (740M USD market cap), and other Synthetix tokens
Manual Review
Check that underlying balance didn't change after transfer
function sweepToken(EIP20NonStandardInterface token) override external { require(msg.sender == admin, "MErc20::sweepToken: only admin can sweep tokens"); + uint256 balanceBefore = IERC20(underlying).balanceOf(this); - require(address(token) != underlying, "MErc20::sweepToken: can not sweep underlying token"); uint256 balance = token.balanceOf(address(this)); token.transfer(admin, balance); + require(IERC20(underlying).balanceOf(this) == balanceBefore, "MErc20::sweepToken: can not sweep underlying token"); }
Invalid Validation
#0 - c4-pre-sort
2023-08-03T14:10:18Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-08-03T20:57:59Z
ElliotFriedman marked the issue as sponsor disputed
#2 - ElliotFriedman
2023-08-03T20:57:59Z
Double entrypoint tokens such as SNX or TUSD are not supported in this implementation
#3 - alcueca
2023-08-13T14:11:39Z
Valid QA, since not mentioned in the documentation. I suggest that a governance manual is created with checks like this one.
#4 - c4-judge
2023-08-13T14:11:47Z
alcueca changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-08-13T14:11:52Z
alcueca marked the issue as grade-a