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: 22/73
Findings: 3
Award: $494.68
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xkazim
Also found by: Auditwolf, BRONZEDISC, Hama, MohammedRizwan, R-Nemes, dacian, kodyvim, markus_ether, nadin, niki, okolicodes
104.4113 USDC - $104.41
https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkOracle.sol#L100-L101 https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkCompositeOracle.sol#L183-L189
The ChainlinkCompositeOracle
and ChainlinkOracle
do not verify if the prices fall within the permissible minimum and maximum values established by Chainlink. Without these checks, prices outside of the extreme ends (i.e., minimum and maximum) will be not accurately reported and when lending for these assets is enabled the protocol can be drained.
Chainlink aggregators have a built in circuit breaker when the price of an asset goes outside of a predetermined price band. The result is that if an asset experiences a huge drop in value (i.e. LUNA crash) the price of the oracle will continue to return the minPrice
instead of the actual price of the asset.
In its current form, the getChainlinkPrice() / getPriceAndDecimals() function retrieve the latest round data from Chainlink. If the asset's market price plummets below minAnswer or skyrockets above maxAnswer, the returned price will still be minAnswer or maxAnswer, respectively, rather than the actual market price. This could potentially lead to an exploitation scenario where the protocol accounts the asset using incorrect price information.
function getChainlinkPrice( AggregatorV3Interface feed ) internal view returns (uint256) { (, int256 answer, , uint256 updatedAt, ) = AggregatorV3Interface(feed) .latestRoundData();
Illustration:
Present price of TokenA is $10 TokenA has a minimum price set at $1 on chainlink The actual price of TokenA dips to $0.10 The aggregator continues to report $1 as the price. Consequently, users can interact with protocol using TokenA as though it were still valued at $1, which is a tenfold overestimate of its real market value.
Manual review
Compare the current price and compare it to the minPrice/maxPrice and revert when the price reached the bounds.
(, int256 answer, , uint256 updatedAt, ) = AggregatorV3Interface(feed) .latestRoundData(); + IChainlinkAggregator aggregator = IChainlinkAggregator(IChainlinkPriceFeed(source).aggregator()); + require(price > int256(aggregator.minAnswer()) && price < int256(aggregator.maxAnswer());
Alternatively a fallback oracle can be used.
Oracle
#0 - 0xSorryNotSorry
2023-08-02T08:22:34Z
The implementation does not set a min/max value by design. Also Chainlink does not return min/max price as per the AggregatorV3 docs HERE contrary to the reported.
Further proof required as per the context.
#1 - c4-pre-sort
2023-08-02T08:22:39Z
0xSorryNotSorry marked the issue as low quality report
#2 - alcueca
2023-08-13T20:48:11Z
The mitigation is wrong, but the finding is valid.
#3 - c4-judge
2023-08-13T20:48:15Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-08-13T21:06:57Z
alcueca marked the issue as duplicate of #340
🌟 Selected for report: immeas
Also found by: 0xkazim, ABAIKUNANBAEV, T1MOH, berlin-101, bin2chen, kutugu, markus_ether
238.709 USDC - $238.71
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L288-L289 https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Governance/TemporalGovernor.sol#L256
Upon calling the pause function, the guardian's right to pause the protocol is revoked until the governance or the guardian themselves reinstate the right.
If the right to paused is restored when the contract is still paused, the contract becomes permanently unpausable. The only way to unpause the contract is to remove the guardian from the protocol.
As soon as the guardian pauses the contract, the boolean guardianPauseAllowed
is reset to false. The governance or the guardian themselves can reinstate the guardian's right to pause by invoking grantGuardiansPause
via _executeProposal
, flipping guardianPauseAllowed
back to true. However, this action stops the the unpausing process, as unpausing requires that guardianPauseAllowed
is false.
assert(!guardianPauseAllowed); /// this should never revert, statement for SMT solving
The only viable resolution would be for the guardian to call revokeGuardian
, which also unpauses the contract.
This action removes the guardian from the protocol by transferring ownership to the null address and can lead to a loss of funds when no guard can stop the protocol during a hack.
A proof of concept that illustrates the issue is shown below:
function testBreakPause() public { // start: contract is paused assertFalse(governor.paused()); // call togglePause(), now the guardianPauseAllowed = false && lastPauseTime = 0 governor.togglePause(); assertTrue(governor.paused()); assertFalse(governor.guardianPauseAllowed()); // call fastTrackProposalExecution() to set guardianPauseAllowed = true vm.prank(address(governor)); // done as prank to simplify governor.grantGuardiansPause(); assertTrue(governor.guardianPauseAllowed()); // now the contract is can not be unpaused again vm.expectRevert(); governor.togglePause(); vm.warp(block.timestamp + governor.permissionlessUnpauseTime()); vm.expectRevert(); governor.permissionlessUnpause(); // unpause contract by revoking guardian governor.revokeGuardian(); assertTrue(governor.paused()); assertEq(governor.owner(), address(0)); }
Manual review.
As long as is desired behavior that the guardian should not receive the right to pause the contract (before the contract is unpaused), we should encorce that bevaior in the function grantGuardiansPause
by enforcing that it can not be called when the contract is not paused.
- function grantGuardiansPause() external { + function grantGuardiansPause() external whenPaused { }
As an alternative solution, permissionlessUnpause
and togglePause
could be adjusted to allow to invoke them, even when guardianPauseAllowed
is true.
- assert(!guardianPauseAllowed);
Governance
#0 - 0xSorryNotSorry
2023-08-02T09:44:04Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-08-02T09:44:07Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-08-03T13:28:25Z
0xSorryNotSorry marked the issue as primary issue
#3 - c4-sponsor
2023-08-03T22:17:02Z
ElliotFriedman marked the issue as sponsor confirmed
#4 - ElliotFriedman
2023-08-03T22:17:06Z
great finding!
#5 - c4-judge
2023-08-12T20:53:10Z
alcueca marked issue #314 as primary and marked this issue as a duplicate of 314
#6 - c4-judge
2023-08-12T20:53:18Z
alcueca marked the issue as satisfactory
🌟 Selected for report: immeas
Also found by: 0xComfyCat, Vagner, ast3ros, kutugu, markus_ether, volodya
151.5613 USDC - $151.56
The _executeProposal
fails to validate that the message received from Wormhole is intended for this contract. A malicous guardian can repurpose a message transmitted via the Wormhole Bridge and execute a illegitimate proposal
The Wormhole book suggest various checks when receiving a message. One such check isto ensure that the incoming message is inteded explicitly for the recipient contract. The recipient contract is expected to perform the following verification
require(parseIntendedRecipient(vm.payload) == address(this));
While this check is included in the _queueProposal
function, is is absent from _executeProposal
.
Regular proposals are typically processed through the queuedTransactions
workflow, which includes this validation. However, the the overrideDelay
option allows a guardian to execute proposals instantly, thereby bypassing this check.
Manual review
Ensure that the received message was crafted for this contract by including the verification step in the code:
+ address intendedRecipient; - (, targets, values, calldatas) = abi.decode( + (intendedRecipient, targets, values, calldatas) = abi.decode( vm.payload, (address, address[], uint256[], bytes[]) ); + // check that VAA is for this contract + require(intendedRecipient == address(this), "TemporalGovernor: Incorrect destination");
Invalid Validation
#0 - c4-pre-sort
2023-08-03T13:24:19Z
0xSorryNotSorry marked the issue as duplicate of #308
#1 - c4-judge
2023-08-12T20:27:58Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2023-08-12T20:28:08Z
alcueca marked the issue as partial-50