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: 1/73
Findings: 8
Award: $15,362.75
🌟 Selected for report: 6
🚀 Solo Findings: 2
🌟 Selected for report: immeas
5190.4455 USDC - $5,190.45
Wormhole governance can change signing guardian sets. If this happens between a proposal is queued and a proposal is executed. The second verification in _executeProposal
will fail as the guardian set has changed between queuing and executing.
This would cause a proposal to fail to execute after the proposalDelay
has passed. Causing a lengthy re-sending of the message from Timelock
on source chain, then proposalDelay
again.
To execute a proposal on TemporalGovernor
it first needs to be queued. When queued it is checked that the message is valid against the Wormhole bridge contract:
File: Governance/TemporalGovernor.sol 295: function _queueProposal(bytes memory VAA) private { 296: /// Checks 297: 298: // This call accepts single VAAs and headless VAAs 299: ( 300: IWormhole.VM memory vm, 301: bool valid, 302: string memory reason 303: ) = wormholeBridge.parseAndVerifyVM(VAA); 304: 305: // Ensure VAA parsing verification succeeded. 306: require(valid, reason);
After some more checks are done the message is queued by its wormhole hash.
Then, after proposalDelay
, anyone can call executeProposal
Before the proposal is executed though, a second check against the Wormhole bridge contract is done:
File: Governance/TemporalGovernor.sol 344: function _executeProposal(bytes memory VAA, bool overrideDelay) private { 345: // This call accepts single VAAs and headless VAAs 346: ( 347: IWormhole.VM memory vm, 348: bool valid, 349: string memory reason 350: ) = wormholeBridge.parseAndVerifyVM(VAA);
The issue is that the second time the verification might fail.
During the wormhole contract check for validity there is a check that the correct guardian set has signed the message:
79: /// @dev Checks if VM guardian set index matches the current index (unless the current set is expired). 80: if(vm.guardianSetIndex != getCurrentGuardianSetIndex() && guardianSet.expirationTime < block.timestamp){ 81: return (false, "guardian set has expired"); 82: }
But(!), Wormhole governance can change the signers:
76: /** 77: * @dev Deploys a new `guardianSet` via Governance VAA/VM 78: */ 79: function submitNewGuardianSet(bytes memory _vm) public { // ... parsing and verification 104: // Trigger a time-based expiry of current guardianSet 105: expireGuardianSet(getCurrentGuardianSetIndex()); 106: 107: // Add the new guardianSet to guardianSets 108: storeGuardianSet(upgrade.newGuardianSet, upgrade.newGuardianSetIndex); 109: 110: // Makes the new guardianSet effective 111: updateGuardianSetIndex(upgrade.newGuardianSetIndex); 112: }
Were this to happen between queueProposal
and executeProposal
the proposal would fail to execute.
Manual audit
Consider only check the VAA
s validity against Wormhole in _executeProposal
when it is fasttracked (overrideDelay==true
).
However then anyone can just just take the hash from the proposal VAA
and submit whatever commands. One idea to prevent this is to instead of storing the VAA
vm.hash
, use the hash of the complete VAA
as key in queuedTransactions
. Thus, the content cannot be altered.
Or, the whole VAA
can be stored alongside the timestamp and the executeProposal
call can be made with just the hash.
Timing
#0 - 0xSorryNotSorry
2023-08-03T08:15:29Z
This looks like the intended mechanism to avoid malicious proposal being executed originated from the malicious guardians.
#1 - c4-pre-sort
2023-08-03T13:49:27Z
0xSorryNotSorry marked the issue as primary issue
#2 - ElliotFriedman
2023-08-03T22:28:51Z
good finding and this is expected behavior as wormhole guardians may change if signers are ever compromised
#3 - c4-sponsor
2023-08-03T22:28:57Z
ElliotFriedman marked the issue as sponsor confirmed
#4 - c4-judge
2023-08-12T20:19:23Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2023-08-19T20:49:27Z
alcueca marked the issue as selected for report
2335.7005 USDC - $2,335.70
When distributing rewards for a market, each emissionConfig
is looped over and sent rewards for. disburseBorrowerRewardsInternal
as an example, the same holds true for disburseSupplierRewardsInternal
:
File: MultiRewardDistributor/MultiRewardDistributor.sol 1147: function disburseBorrowerRewardsInternal( 1148: MToken _mToken, 1149: address _borrower, 1150: bool _sendTokens 1151: ) internal { 1152: MarketEmissionConfig[] storage configs = marketConfigs[ 1153: address(_mToken) 1154: ]; // ... mToken balance and borrow index 1162: // Iterate over all market configs and update their indexes + timestamps 1163: for (uint256 index = 0; index < configs.length; index++) { 1164: MarketEmissionConfig storage emissionConfig = configs[index]; // ... index updates 1192: if (_sendTokens) { 1193: // Emit rewards for this token/pair 1194: uint256 pendingRewards = sendReward( // <-- will trigger transfer on `emissionToken` 1195: payable(_borrower), 1196: emissionConfig.borrowerRewardsAccrued[_borrower], 1197: emissionConfig.config.emissionToken 1198: ); 1199: 1200: emissionConfig.borrowerRewardsAccrued[ 1201: _borrower 1202: ] = pendingRewards; 1203: } 1204: } 1205: } ... 1214: function sendReward( 1215: address payable _user, 1216: uint256 _amount, 1217: address _rewardToken 1218: ) internal nonReentrant returns (uint256) { // ... short circuits breakers 1232: uint256 currentTokenHoldings = token.balanceOf(address(this)); 1233: 1234: // Only transfer out if we have enough of a balance to cover it (otherwise just accrue without sending) 1235: if (_amount > 0 && _amount <= currentTokenHoldings) { 1236: // Ensure we use SafeERC20 to revert even if the reward token isn't ERC20 compliant 1237: token.safeTransfer(_user, _amount); // <-- if this reverts all emissions fail 1238: return 0; 1239: } else { // .. default return _amount 1245: return _amount; 1246: } 1247: }
If one transfer reverts the whole transaction fails and no rewards will be paid out for this user. Hence if there is a malicious token that would revert on transfer it would cause no rewards to paid out. As long as there is some rewards accrued for the malicious emissionConfig
.
The users with already unclaimed rewards for this emissionConfig
would have their rewards permanently locked.
The admin
(TemporalGovernor
) of MultiRewardsDistributor
could update the reward speed for the token to 0
but that would just prevent further damage from being done.
Upgradeable tokens aren't unusual and hence the token might seem harmless to begin with but be upgraded to a malicious implementation that reverts on transfer.
Test in MultiRewardDistributor.t.sol
, MultiRewardSupplySideDistributorUnitTest
, most of the test is copied from testSupplierHappyPath
with the addition of MaliciousToken
:
contract MaliciousToken { function balanceOf(address) public pure returns(uint256) { return type(uint256).max; } function transfer(address, uint256) public pure { revert("No transfer for you"); } }
function testAddMaliciousEmissionToken() public { uint256 startTime = 1678340000; vm.warp(startTime); MultiRewardDistributor distributor = createDistributorWithRoundValuesAndConfig(2e18, 0.5e18, 0.5e18); // malicious token added MaliciousToken token = new MaliciousToken(); distributor._addEmissionConfig( mToken, address(this), address(token), 1e18, 1e18, block.timestamp + 365 days ); comptroller._setRewardDistributor(distributor); emissionToken.allocateTo(address(distributor), 10000e18); vm.warp(block.timestamp + 1); mToken.mint(2e18); assertEq(MTokenInterface(mToken).totalSupply(), 2e18); // Wait 12345 seconds after depositing vm.warp(block.timestamp + 12345); // claim fails as the malicious token reverts on transfer vm.expectRevert("No transfer for you"); comptroller.claimReward(); // no rewards handed out assertEq(emissionToken.balanceOf(address(this)), 0); }
Manual audit
Consider adding a way for admin
to remove an emissionConfig
.
Alternatively, the reward transfer could be wrapped in a try
/catch
and returning _amount
in catch
. Be mindful to only allow a certain amount of gas to the transfer then as otherwise the same attack works with consuming all gas available.
DoS
#0 - c4-pre-sort
2023-08-03T13:49:30Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T22:26:27Z
emission creator (comptroller admin) and emission owners are trusted, it is assumed they will not add any poison reward tokens.
#2 - c4-sponsor
2023-08-03T22:26:30Z
ElliotFriedman marked the issue as sponsor disputed
#3 - alcueca
2023-08-12T21:54:49Z
@ElliotFriedman, I'm not sure yet if anyone else has reported this, but the emissions token doesn't need to be even suspicious. USDC and USDT can blacklist users. If you use those tokens as emissions and any of your rewards holders get blacklisted, this issue will get triggered.
I'm marking this as valid medium at 50% payout.
#4 - c4-judge
2023-08-12T21:54:57Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2023-08-12T21:55:05Z
alcueca marked the issue as partial-50
#6 - c4-judge
2023-08-12T21:55:10Z
alcueca marked the issue as selected for report
#7 - lyoungblood
2023-08-21T22:42:06Z
I'm not sure yet if anyone else has reported this, but the emissions token doesn't need to be even suspicious. USDC and USDT can blacklist users. If you use those tokens as emissions and any of your rewards holders get blacklisted, this issue will get triggered.
I still dispute the validity of this finding. If a user's wallet got blacklisted by Circle, it is true that the transfer would revert, but removing the emissionconfig is not a solution to this. We can't and won't solve for inappropriate user activity by denying all users the ability to claim USDC rewards. This is working as designed.
#8 - alcueca
2023-08-21T23:16:28Z
The issue exists despite the quality of the mitigations proposed by the warden. The sponsor may choose to develop a mitigation of its own, or to acknowledge the issue and not fix it.
Given that this issue will impact only individual users, a fix might not be necessary, depending on the sponsor priorities.
If it would be me, I would implement a separate set of external reward disbursement functions where the markets for which rewards are disbursed are passed on as a parameter, so that Usdc-blacklisted users can still receive rewards for other markets.
#9 - lyoungblood
2023-08-22T04:05:20Z
Since tokens (EmissionConfig) cannot be added except by admin (the DAO), I still dispute the validity of the finding, or at least the severity. We have to be reasonable in our assumptions and the assumption that the admin will add a malicious/censorable token can't really be a precursor to a valid finding. The admin can directly remove funds from the contract with removeReserves, but we wouldn't consider a finding like that valid.
#10 - alcueca
2023-08-23T20:28:58Z
@lyoungblood, USDC and USDT are both censorable, and it sounds pretty reasonable that would be used as emission tokens.
🌟 Selected for report: immeas
5190.4455 USDC - $5,190.45
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L27 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol#L81-L86
guardian
is mentioned as an area of concern in the docs:
Specific areas of concern include: ...
- TemporalGovernor which is the cross chain governance contract. Specific areas of concern include delays, the pause guardian, ...
guardian
is a roll that has the ability to pause and unpause TemporalGovernor
. In code, it uses the owner
from OpenZeppelin Ownable
as guardian
. The issue is that Ownable::transferOwnership
is not overridden. Only guardian
(owner
) can transfer the role.
This can be a conflict of interest if there is a falling out between governance and the guardian. If the guardian
doesn't want to abstain, governance only option would be to call revokeGuardian
which sets owner
to address(0)
. This permanently removes the ability to pause the contract which can be undesirable.
Simple test in TemporalGovernorExec.t.sol
:
function testGovernanceCannotTransferGuardian() public { address[] memory targets = new address[](1); targets[0] = address(governor); uint256[] memory values = new uint256[](1); bytes[] memory payloads = new bytes[](1); payloads[0] = abi.encodeWithSelector(Ownable.transferOwnership.selector,address(newAdmin)); bytes memory payload = abi.encode(address(governor), targets, values, payloads); mockCore.setStorage(true, trustedChainid, governor.addressToBytes(admin), "reeeeeee", payload); governor.queueProposal(""); vm.warp(block.timestamp + proposalDelay); // governance cannot transfer guardian vm.expectRevert(abi.encodeWithSignature("Error(string)", "Ownable: caller is not the owner")); governor.executeProposal(""); }
Manual audit
Consider overriding transferOwnership
and either limit it to only governance (msg.sender == address(this)
) or both guardian
and governance.
Governance
#0 - c4-pre-sort
2023-08-03T13:49:34Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T22:24:00Z
this is an interesting finding. only guardian can change guardian, however, guardian can only pause once and is limited in abilities to being able to fast track execution. and unpause. after a single malicious pause, the guardian would no longer be able to pause, and 30 days later, governance would reopen.
#2 - c4-sponsor
2023-08-03T22:40:22Z
ElliotFriedman marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-12T20:23:37Z
alcueca marked the issue as satisfactory
#4 - alcueca
2023-08-12T20:23:58Z
Barely making it as Medium.
#5 - c4-judge
2023-08-21T21:38:15Z
alcueca marked the issue as selected for report
🌟 Selected for report: immeas
Also found by: 0xkazim, ABAIKUNANBAEV, T1MOH, berlin-101, bin2chen, kutugu, markus_ether
310.3217 USDC - $310.32
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L27 https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L188-L198 https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L256
When a guardian
pauses TemporalGovernor
they lose the ability to pause again. This can then be reinstated by governance using grantGuardiansPause
.
However grantGuardiansPause
can be called when the contract is still paused. This would break the assertions in permissionlessUnpause
and togglePause
: assert(!guardianPauseAllowed)
.
Also, TemporalGovernor
inherits from OpenZeppelin Ownable
. There the owner
has the ability to renounceOwnership
.
This could cause TemporalGovernor
to end up in a bad state, since revokeGuardian
call that has special logic for revoking the guardian.
There is the ability to combine these two issues to brick the contract:
guardian
pauses the contract.grantGuardiansPause
which the guardian
executes (while still paused). This step disables both permissionlessUnpause
and togglePause
. Though the contract is still rescuable through revokeGuardian
.guardian
calls renounceOwnership
from Ownable
. Which disables any possibility to execute commands on TemporalGovernor
. Hence no more ability to call revokeGuardian
.The contract is bricked. Since permissionlessUnpause
can't be called due to the assert(!guardianPauseAllowed)
. No cross chain calls an be processed because executeProposal
is whenNotPaused
and there is no guardian
to execute fastTrackProposalExecution
. Thus the TemporalGovernor
will be stuck in paused state.
It is also possible for the guardian
to hold the contract hostage before step 3 (renounceOwnership
) as they are the only ones who can execute calls on it (through fastTrackProposalExecution
).
This is scenario relies on governance sending a cross chain grantGuardiansPause
while the contract is still paused and a malicious guardian
which together are unlikely. Though the docs mention this as a concern:
Specific areas of concern include: ...
- TemporalGovernor which is the cross chain governance contract. Specific areas of concern include delays, the pause guardian, putting the contract into a state where it cannot be updated.
Test in TemporalGovernorExec.t.sol
:
function testBrickTemporalGovernor() public { // 1. guardian pauses contract governor.togglePause(); assertTrue(governor.paused()); assertFalse(governor.guardianPauseAllowed()); // 2. grantGuardianPause is called address[] memory targets = new address[](1); targets[0] = address(governor); uint256[] memory values = new uint256[](1); bytes[] memory payloads = new bytes[](1); payloads[0] = abi.encodeWithSelector(TemporalGovernor.grantGuardiansPause.selector); bytes memory payload = abi.encode(address(governor), targets, values, payloads); mockCore.setStorage(true, trustedChainid, governor.addressToBytes(admin), "reeeeeee", payload); governor.fastTrackProposalExecution(""); assertTrue(governor.guardianPauseAllowed()); // 3. guardian revokesOwnership governor.renounceOwnership(); // TemporalGovernor is bricked // contract is paused so no proposals can be sent vm.expectRevert("Pausable: paused"); governor.executeProposal(""); // guardian renounced so no fast track execution or togglePause assertEq(address(0),governor.owner()); // permissionlessUnpause impossible because of assert vm.warp(block.timestamp + permissionlessUnpauseTime + 1); vm.expectRevert(); governor.permissionlessUnpause(); }
Manual audit
However unlikely this is to happen, there are some simple steps that can be taken to prevent if from being possible:
Consider adding whenNotPaused
to grantGuardiansPause
to prevent mistakes (or possible abuse). And also overriding the calls renounceOwnership
and transferOwnership
. transferOwnership
should be a governance call (msg.sender == address(this)
) to move the guardian
to a new trusted account.
Perhaps also consider if the assert(!guardianPauseAllowed)
is worth just for pleasing the SMT solver.
DoS
#0 - ElliotFriedman
2023-08-01T00:47:15Z
this is a valid finding
#1 - 0xSorryNotSorry
2023-08-03T08:37:16Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#2 - c4-pre-sort
2023-08-03T08:37:21Z
0xSorryNotSorry marked the issue as high quality report
#3 - c4-pre-sort
2023-08-03T13:29:37Z
0xSorryNotSorry marked the issue as duplicate of #232
#4 - c4-judge
2023-08-12T20:53:12Z
alcueca marked the issue as selected for report
#5 - c4-sponsor
2023-08-15T17:36:33Z
ElliotFriedman marked the issue as sponsor confirmed
🌟 Selected for report: immeas
Also found by: 0xComfyCat, Vagner, ast3ros, kutugu, markus_ether, volodya
394.0594 USDC - $394.06
Wormhole cross chain communication is multicasted to all their supported chains:
Please notice that there is no destination address or chain in these functions. VAAs simply attest that "this contract on this chain said this thing." Therefore, VAAs are multicast by default and will be verified as authentic on any chain they are brought to.
The TermporalGovernor
contract handles this by checking queueProposal
that the intendedRecipient
is itself:
File: core/Governance/TemporalGovernor.sol 320: // Very important to check to make sure that the VAA we're processing is specifically designed 321: // to be sent to this contract 322: require(intendedRecipient == address(this), "TemporalGovernor: Incorrect destination");
There is also a fastTrackProposalExecution
that the guardian can perform (owner
is referred ot as guardian
):
File: core/Governance/TemporalGovernor.sol 261: /// @notice Allow the guardian to process a VAA when the 262: /// Temporal Governor is paused this is only for use during 263: /// periods of emergency when the governance on moonbeam is 264: /// compromised and we need to stop additional proposals from going through. 265: /// @param VAA The signed Verified Action Approval to process 266: function fastTrackProposalExecution(bytes memory VAA) external onlyOwner { 267: _executeProposal(VAA, true); /// override timestamp checks and execute 268: }
This will bypass the queueProposal
flow and execute commands immediately.
The issue here is that there is no check in _executeProposal
that the intendedRecipient
is this TemporalGovernor
.
governor
can execute any commands communicated across Wormhole
as long as they are from a trusted source.
This requires that the targets
line up between chains for a governor
to abuse this. However only one target
must line up as the .call
made does not check for contract existence. Since "unaligned" addresses between chains will most likely not exist on other chains these calls will succeeed.
Test in TemporalGovernorExec.t.sol
, all of the test is copied from testProposeFailsIncorrectDestination
with just the change at the end where instead of calling queueProposal
, fastTrackProposalExecution
is called instead, which doesn't revert, thus highligting the issue.
function testFasttrackExecuteSucceedsIncorrectDestination() public { address[] memory targets = new address[](1); targets[0] = address(governor); uint256[] memory values = new uint256[](1); values[0] = 0; TemporalGovernor.TrustedSender[] memory trustedSenders = new TemporalGovernor.TrustedSender[](1); trustedSenders[0] = ITemporalGovernor.TrustedSender({ chainId: trustedChainid, addr: newAdmin }); bytes[] memory payloads = new bytes[](1); payloads[0] = abi.encodeWithSignature( /// if issues use encode with selector "setTrustedSenders((uint16,address)[])", trustedSenders ); /// wrong intendedRecipient bytes memory payload = abi.encode(newAdmin, targets, values, payloads); mockCore.setStorage( true, trustedChainid, governor.addressToBytes(admin), "reeeeeee", payload ); // guardian can fast track execute calls intended for other contracts governor.fastTrackProposalExecution(""); }
Manual audit
Consider adding a check in _executeProposal
for intendedRecipient
same as is done in _queueProposal
.
Invalid Validation
#0 - c4-pre-sort
2023-08-03T13:23:27Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T21:54:34Z
this is a valid finding
#2 - c4-sponsor
2023-08-03T21:54:37Z
ElliotFriedman marked the issue as sponsor confirmed
#3 - ElliotFriedman
2023-08-03T21:55:45Z
however, in order for this to be exploitable, the proper governor would have to emit the proper calldata on another chain, with the exactly needed calldata formatting, and call publishMessage for this to be exploitable which is a 0% chance of happening
#4 - c4-judge
2023-08-12T20:32:51Z
alcueca marked the issue as selected for report
86.7417 USDC - $86.74
Cross chain proposals can contain instructions to send native value. TemporalGovernor
then sends this value when doing the call to the target contract:
File: core/Governance/TemporalGovernor.sol 400: (bool success, bytes memory returnData) = target.call{value: value}( 401: data 402: );
The issue is however that TemporalGovernor
has no way to receive value so it will revert since there will not be any value in the contract.
If a proposal needed requires native value, TemporalGovernor
will not be able to execute it, thus impeding on the functionality of the contract. Unless extraordinary methods (SELFDESTRUCT
/SENDALL
) are taken to fund the contract.
Test in TemporalGovernorExec.t.sol
:
// to show it doesn't fail on transferring to `this` receive() external payable {} function testExecuteProposalWithValue() public { vm.warp(1); /// queue at 0 to enable testing of message already executed path address[] memory targets = new address[](1); targets[0] = address(this); uint256[] memory values = new uint256[](1); values[0] = 1000; bytes[] memory payloads = new bytes[](1); /// 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); } vm.warp(block.timestamp + proposalDelay); // EvmError: OutOfFund vm.expectRevert(); governor.executeProposal(""); // revert no fallback vm.deal(address(this),1000); vm.expectRevert(); payable(address(governor)).transfer(1000); }
Manual audit
Consider adding a receive
function.
ETH-Transfer
#0 - c4-pre-sort
2023-08-03T13:21:08Z
0xSorryNotSorry marked the issue as duplicate of #268
#1 - c4-judge
2023-08-12T20:36:33Z
alcueca marked the issue as satisfactory
1796.6927 USDC - $1,796.69
https://github.com/code-423n4/2023-07-moonwell/blob/main/test/proposals/mips/mip00.sol#L351-L367
mip00.sol
explains the setup of the whole system. build
contains the final configuration for the deployment.
There each MToken
is unpaused and an initial mint is done to prevent exchange rate manipulation.
The issue is that each token uses the same initialMintAmount
. In the comments ETH
and USDC
are mentioned as tokens. Since these have different decimals the real value used for minting will be very different.
initialMintAmount
is 1 ether
in the base contract Configs.sol
. Since it is unlikely that the deploying contract will have 1e18 USDC
($1 trillion) the build
part of the setup and configuration will likely fail. Even if this isn't the actual initial mint amount there is no number that would fit both 6 decimal USDC
and 18 decimal ETH
at the same time.
https://github.com/code-423n4/2023-07-moonwell/blob/main/test/proposals/mips/mip00.sol#L334-L379
File: test/proposals/mips/mip00.sol // each token config is looped over 334: for (uint256 i = 0; i < cTokenConfigs.length; i++) { 335: Configs.CTokenConfiguration memory config = cTokenConfigs[i]; 336: 337: address cTokenAddress = addresses.getAddress( 338: config.addressesString 339: ); // ... unpause mint 351: /// Approvals 352: _pushCrossChainAction( 353: config.tokenAddress, 354: abi.encodeWithSignature( 355: "approve(address,uint256)", 356: cTokenAddress, 357: initialMintAmount // <-- same `initialMintAmount` for all tokens 358: ), 359: "Approve underlying token to be spent by market" 360: ); 361: 362: /// Initialize markets 363: _pushCrossChainAction( 364: cTokenAddress, 365: abi.encodeWithSignature("mint(uint256)", initialMintAmount), // <-- same `initialMintAmount` for all tokens 366: "Initialize token market to prevent exploit" 367: ); // ... set collateral factor 379: }
initialMintAmount
can be found to be 1 ether
in base contract Configs.sol
:
https://github.com/code-423n4/2023-07-moonwell/blob/main/test/proposals/Configs.sol#L54-L55
File: test/proposals/Configs.sol 54: /// @notice initial mToken mint amount 55: uint256 public constant initialMintAmount = 1 ether;
Manual audit
Consider adding a field in the configuration for initialMintAmount
for each token.
ERC20
#0 - c4-pre-sort
2023-08-03T13:50:35Z
0xSorryNotSorry marked the issue as duplicate of #143
#1 - c4-judge
2023-08-12T21:02:00Z
alcueca marked the issue as satisfactory
🌟 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
58.3431 USDC - $58.34
id | title |
---|---|
L-01 | guardian can fast track every proposal |
L-02 | TemporalGovernor can get the same address on different chains |
L-03 | TemporalGovernor::grantGuardiansPause can be called after guardian is revoked |
L-04 | No cap on how many emissionConfig s there can be for a market |
L-05 | L-05 Neither _setMarketBorrowCaps or _setMarketSupplyCaps checks if market is listed |
L-06 | Lack of tests for supplyCap |
S-01 | ChainlinkCompositeOracle::getDerivedPriceThreeOracles is very specialized |
R-01 | MultiRewardDistributor::calculateNewIndex parameter _currentTimestamp is confusing |
R-02 | unnecessary _amount> 0 check |
R-03 | MultiRewardDistributor : IndexUpdate struct is unnecessary |
R-04 | parameter _mTokenData for MultiRewardDistributor::calculateBorrowRewardsForUser is unnecessary |
NC-01 | wrong grammatical number in grantGuardiansPause |
NC-02 | erroneous docs |
NC-03 | weird word describing the future: |
NC-04 | forgotten cToken references |
NC-05 | incorrect comments |
NC-06 | comment slash off by one |
guardian
can fast track every proposalIn TemporalGovernor
the guardian
has a special permission that they can fastTrackProposalExecution
which will bypass the normal queueProposal
flow:
File: core/Governance/TemporalGovernor.sol 261: /// @notice Allow the guardian to process a VAA when the 262: /// Temporal Governor is paused this is only for use during 263: /// periods of emergency when the governance on moonbeam is 264: /// compromised and we need to stop additional proposals from going through. 265: /// @param VAA The signed Verified Action Approval to process 266: function fastTrackProposalExecution(bytes memory VAA) external onlyOwner { 267: _executeProposal(VAA, true); /// override timestamp checks and execute 268: }
This as the comment suggest should only be done in emergencies.
However there is nothing indicating that the VAA
in question is supposed to be fast tracked. Hence a guardian
can always fast track any VAA
s that they please, bypassing the queueProposal
functionality.
Consider adding another parameter to the payload
indicating if the VAA
is intended to be fast tracked or not.
TemporalGovernor
can get the same address on different chainsIn mip00.sol
the deploy and configuration proceedure for the TemporalGovernor
is detailed:
https://github.com/code-423n4/2023-07-moonwell/blob/main/test/proposals/mips/mip00.sol#L103-L109
File: test/proposals/mips/mip00.sol 103: /// this will be the governor for all the contracts 104: TemporalGovernor governor = new TemporalGovernor( 105: addresses.getAddress("WORMHOLE_CORE"), 106: proposalDelay, 107: permissionlessUnpauseTime, 108: trustedSenders 109: );
new
will invoke the CREATE
opcode. This only relies on the msg.sender
and nonce
. Since the deploy
call executes a certain amount of calls when it executes the deploy of TemporalGovernor
it will have certain nonce
. Hence the address of TemporalGovernor
will be dependent on the account that executes it. If this is run by a new account the address of TemporalGovernor
can be the same if it is executed by the same new account on another chain.
This is important because if two TemporalGovernor
contracts on different chains gets the same address they can execute each others proposals:
File: core/Governance/TemporalGovernor.sol 320: // Very important to check to make sure that the VAA we're processing is specifically designed 321: // to be sent to this contract 322: require(intendedRecipient == address(this), "TemporalGovernor: Incorrect destination");
Pay attention to which accounts and how the execution of the deploy script is done. Perhaps use CREATE2
with a random salt
to deploy TemporalGovernor
to guarantee it gets a different address on each chain.
TemporalGovernor::grantGuardiansPause
can be called after guardian
is revokedFile: Governance/TemporalGovernor.sol 187: /// @notice grant the guardians the pause ability 188: function grantGuardiansPause() external { 189: require( 190: msg.sender == address(this), 191: "TemporalGovernor: Only this contract can update grant guardian pause" 192: ); 193: 194: guardianPauseAllowed = true; 195: lastPauseTime = 0; 196: 197: emit GuardianPauseGranted(block.timestamp); 198: }
This doesn't do any thing bad other than set guardianPauseAllowed=true
, which is set to false in revokeGuardian
.
Consider only allowing calls to grantGuardiansPause
when guardian != address(0)
.
emissionConfig
s there can be for a marketToo many emissionConfig
s could lead to out of gas errors when updating reward indexes. Since every operation on an mToken
triggers this the whole market would be DoSed if too many emissionConfig
s would be added.
Consider adding a way to remove an emissionConfig
_setMarketBorrowCaps
or _setMarketSupplyCaps
checks if market is listedWhen adding a cap on borrow/supply admin
or borrow/supplyCapGuardian
can call [_setMarketBorrowCaps
])(https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Comptroller.sol#L807-L819) or _setMarketSupplyCaps
respectively.
However in none of the there is any check that the market (MToken
) for which they add the supply/borrow cap is actually listed. Hence admin
or guardian
could by mistake send the wrong address and then think that they configured the cap for that market when they didn't.
Add a check that the MToken
is listed when setting borrow/supply caps.
supplyCap
MÌ€oonwell introduces a new feature to the Comptroller
: supplyCap
per market. This limits how much can be minted to that market. There is however no tests for this new feature. Test guarantee that the feature works as expected and will continue to work as expected when doing changes to the code. As such they are a core guarantee for code and protocol safety.
Consider adding a few tests for the supplyCap
feature
ChainlinkCompositeOracle::getDerivedPriceThreeOracles
is very specializedFile: Oracles/ChainlinkCompositeOracle.sol 157: return 158: ((firstPrice * secondPrice * thirdPrice) / scalingFactor) 159: .toUint256();
Here the three prices for the oracles are multiplied together. This requires a setup of prices like this:
First one is stated to be ETH
/USD
so there would have to be oracles:
ETH
/USD
, A
/ETH
, B
/A
to get the price B
/USD
This is very limited which chainlink prices support "chaining" like this, the one used in integration test seems to one of the few:
ETH
/USD
, stETH
/ETH
, wstETH
/stETH
on Arbitrum.
Consider adding the ability to either multiply or divide by the last one, as that would give greater flexibility in which feeds can be used.
MultiRewardDistributor::calculateNewIndex
parameter _currentTimestamp
is confusingFile: MultiRewardDistributor/MultiRewardDistributor.sol 906: * @param _currentTimestamp The current index timestamp 914: uint32 _currentTimestamp,
_currentTimestamp
implies now when it is however the last index timestamp. Consider renaming it to lastIndexTimestamp
.
_amount> 0
checkIn sendReward
, the function sort circuit if _amount
to be sent is 0
, later however it is checked that this amount
is >0
which there is no way it cannot be:
File: MultiRewardDistributor/MultiRewardDistributor.sol 1219: // Short circuit if we don't have anything to send out 1220: if (_amount == 0) { 1221: return _amount; 1222: } // @audit due to check above there is no way _amount cannot be >0 1235: if (_amount > 0 && _amount <= currentTokenHoldings) {
Consider removing the _amount > 0
check since it is always true.
MultiRewardDistributor
: IndexUpdate
struct is unnecessaryIndexUpdate
is used to return the result of calculateNewIndex
.
However in calculateNewIndex
the same value for IndexUpdate.newTimestamp
is always used, block.timestamp
:
949: return 950: IndexUpdate({ 951: newIndex: _currentIndex, 952: newTimestamp: blockTimestamp 953: }); 967: return IndexUpdate({newIndex: newIndex, newTimestamp: blockTimestamp});
Hence, calculateNewIndex
could simply return newIndex
(uin224
). Then in updateMarketSupplyIndexInternal
and updateMarketBorrowIndexInternal
the supply/borrowGlobalTimestamp
can be set directly to block.timestamp
.
_mTokenData
for MultiRewardDistributor::calculateBorrowRewardsForUser
is unnecessaryThe struct MTokenData
has two fields: mTokenBalance
and borrowBalanceStored
. However only one of them is used in calculateBorrowRewardsForUser
.
Therefore it is confusing that the whole struct is passed to the function as that implies that both fields would be used.
Consider just passing borrowBalanceStored
directly instead of the struct.
grantGuardiansPause
grantGuardiansPause
grants guardian
pause. Since there is only one guardian
the name should be singular not plural: grantGuardianPause
.
https://github.com/code-423n4/2023-07-moonwell/blob/main/docs/TEMPORALGOVERNOR.md
Guardian Pause: The guardian has the ability to pause the contract temporarily. When the guardian pauses the contract, all proposal executions are halted. The contract can only be unpaused by a governance proposal after a specified time delay.
This is not true. A governance proposal cannot unpause the TemporalGovernor
. Unpause can only be performed by governor
through togglePause
or by the permissionlessUnpause
after a delay. The only way governance could unpause is by calling revokeGuardian
which is final. This would require the guardian
to execute it though since only guardian
is allowed to execute commands while the contract is paused.
File: MultiRewardDistributor/MultiRewardDistributor.sol 788: // Must be older than our existing end time AND the current block 789: require( 790: _newEndTime > currentEndTime, 791: "_newEndTime MUST be > currentEndTime" 792: );
older
here is confusing. Either say Must be further in the future
or simply larger
cToken
referencesFile: MultiRewardDistributor/MultiRewardDistributor.sol 842: // Calculate change in the cumulative sum of the reward per cToken accrued 843: // Calculate reward accrued: cTokenAmount * accruedPerCToken 844: // Calculate change in the cumulative sum of the reward per cToken accrued
File: MultiRewardDistributor/MultiRewardDistributor.sol 835: // If our user's index isn't set yet, set to the current global supply index 836: if ( 837: userSupplyIndex == 0 && _globalSupplyIndex >= initialIndexConstant 838: ) { 839: userSupplyIndex = initialIndexConstant; //_globalSupplyIndex; 840: } ... 874: // If our user's index isn't set yet, set to the current global borrow index 875: if ( 876: userBorrowIndex == 0 && _globalBorrowIndex >= initialIndexConstant 877: ) { 878: userBorrowIndex = initialIndexConstant; //userBorrowIndex = _globalBorrowIndex; 879: }
If the index is not yet set it's set to initialIndexConstant
not the global index as the comment suggests
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Comptroller.sol#L984-L988
/** <-- slash only 3 whitespaces in * @notice Call out to the reward distributor to update its borrow index and this user's index too * @param mToken The market to synchronize indexes for * @param borrower The borrower to whom rewards are going */
The slash is only 3 not 4 whitespaces in.
#0 - c4-pre-sort
2023-08-03T15:25:33Z
0xSorryNotSorry marked the issue as high quality report
#1 - ElliotFriedman
2023-08-03T18:44:58Z
l01- correct that guardian can fast track, however this is intended behavior l02- correct, however this system is only going to be deployed on a single chain l03- correct, however this doesn't matter as the guardian would not be able to pause or unpause because address 0 is the guardian, so that doesn't work l04- duplicate finding, this is true, however this is expected behavior l05- true, but there isn't any negative effect to this happening as an unlisted token in the comptroller cannot be minted or borrowed
#2 - c4-sponsor
2023-08-03T22:30:11Z
ElliotFriedman marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-11T22:07:51Z
alcueca marked the issue as selected for report
#4 - ElliotFriedman
2023-08-11T22:16:14Z
.
#5 - liveactionllama
2023-08-22T22:23:53Z
Just noting for the final audit report: the judge (@alcueca) has shared that they agree with the severity/validity of the items listed in the warden's submission.