Manifold mevETH2 Audit
Introduction & Scope
This audit looks into the following contracts of mevETH2: WagyuStaker.sol, MevEthShareVault.sol and MevEth.sol.
This audit was conducted by kebabsec members sai, FlameHorizon, okkothejawa and shung, with elcid as our new intern.
Note: This report does not provide any guarantee or warranty of security for the project.
Executive Summary
Table of Contents
- Findings
- [INFORMATIONAL] Mismatch with access control
- [INFORMATIONAL]
i++Can be unchecked for gas savings - [INFORMATIONAL]
dataLocation optimization - [INFORMATIONAL] Removed feature still referred in the comments
- [LOW] Missing address check in constructor of
MevEthShareVault.sol - [MEDIUM]
send()Has hardcoded amount of gas it allows to use - [INFORMATIONAL] Unnecessary variable logging in
RewardPayment - [LOW] Events should be emitted for change in variables
mevEthandprotocolFeeTo - [INFORMATIONAL] Redundant subtraction in
logRewards - [INFORMATIONAL] Redundant casting when assigning
stakingModule - [LOW] Missing zero address check for
newMevEthShareVault - [LOW] Withdraw event does not account for fees
- [MEDIUM]
redeemCreamAllows for minting ofmevETHeven when contract is paused - [LOW]
_isZeroNot more efficient than equality check - [LOW] Using type
uint256for all constants is more efficient - [LOW]
allowanceShould not be decreased if a user has max allowance - [MEDIUM]
redeemCreamfunction is not working ascreamTokencannot be transferred toaddress(0) - [HIGH]
redeemCreamis vulnerable to a sandwich inflation attack - [LOW]
maxWithdrawIs not being used internally - [MEDIUM] Sandwich protection in
_withdrawcan be bypassed - [HIGH]
MevEthShareVaultfunctionspayValidatorWithdrawandlogRewardsare order dependent - [INFORMATIONAL] Typos
- [INFORMATIONAL] Unused imports
- [LOW] Unstable dependency versions used
- [MEDIUM]
payValidatorWithdrawmight cause temporary partial DOS forMevEthShareVaultdue to insolvency - [HIGH] Calling
createValidatoron an existing validator may lead to loss of user funds - [MEDIUM] Hardcoding the withdrawal size of a validator can lead to problems
- [LOW]
createValidatorshould assert that the activated validator withdraws to cleared addresses
Findings:
1. [INFORMATIONAL] Mismatch with access control
Context: WagyuStaker.sol#L124-L125
Severity: Informational
Description: The comment in the lines above claims that the function is only callable by an admin, which does not happen, instead , the function is only callable by an operator, through the use of onlyOperator. This could be dangerous if the protocol wants to retain control of this function.
Recommendation: Change onlyOperator modifier for an onlyAdmin modifier, or equivalent to such.
2. [INFORMATIONAL] i++ Can be unchecked for gas savings
Context: WagyuStaker.sol#L155-158
Severity: Informational
Description: In this situation, ++i can be unchecked as overflow is practically impossible.
Recommendation: Possibly uncheck to save gas
3. [INFORMATIONAL] dataLocation optimization
Context: WagyuStaker.sol#L159
Severity: Informational
Description: The location of data would be better if it was calldata rather than memory, as it would save gas.
Recommendation: Possibly change memory for data
4. [INFORMATIONAL] Removed feature still referred in the comments
Context: WagyuStake.sol#L86-L87
Severity: Informational
Description: In these lines, the comment block refers to a beneficiary having a role in the following function, but this isn’t defined anywhere in the contract, it seems like the feature was removed.
Recommendation: Possibly change the comment to stay in context with the function.
5. [LOW] Missing address check in constructor of MevEthShareVault.sol
Context: MevEthShareVault.sol#L52-L55
Severity: Low
Description: The input variable _mevEth is not checked for zero address.
Recommendation: Add a zero address check to avoid errors.
6. [MEDIUM] send()Has hardcoded amount of gas it allows to use
Context: MevEthShareVault.sol#L91-L94
Severity: low
Description: send() only allows the recipient contract to use 2300 gas.
A smart contract functionality cannot be hard dependent on gas because the gas cost of some Opcodes are subject to change.
Future price updates of opcodes can affect the viability of .send().
Recommendation: .call() should be used instead. Alternatively, Solmate’s safeTransferETH can also be used.
7. [INFORMATIONAL] Unnecessary variable logging in RewardPayment
Context: MevEthShareVault.sol#L31-L34
Severity: Informational
Description: It is unnecessary to include block.number and block.coinbase within the event, as these can be retrieved easily from the block event was emitted from.
Recommendation: Instead, retrieve these from the block when the event is emitted.
8. [LOW] Events should be emitted for change in variables mevEth and protocolFeeTo
Context: MevEthShareVault.sol#L59-L60
Severity: Low
Description: Variables mevEth and protocolFeeTo should be logged with events to keep track of changes.
Recommendation: Create an event to log changes in these two variables.
9. [INFORMATIONAL] Redundant subtraction in logRewards
Context: MevEthShareVault.soll#L128-L133
Severity: Low
Description: In line 133, the event RewardsCollected does the subtraction rewardsEarned - protocolFeesOwed, this is uncessary, as this calculation is already done in line 128 to assign the variable _rewards.
Recommendation: use _rewards in the event instead of rewardsEarned - protocolFeesOwed.
10. [INFORMATIONAL] Redundant casting when assigning stakingModule
Context: MevEth.sol#L207-210
Severity: Low
Description: In these lines, the castings are redundant, as pendingStakingModule is already declared as of type IStakingModule.
Recommendation: just using stakingModule = pendingStakingModule; should have the intended effect.
11. [LOW] Missing zero address check for newMevEthShareVault
Context: MevEth.sol#L245-L257
Severity: Low
Description: It is perhaps better to have a zero address check for the variable newMevEthShareVault in commitUpdateMevEthShareVault in order to prevent time and gas waste in case of a mistake, as it currently finishes execution normally and the error only gets caught in the next step of the process in line 256.
Recommendation: Add zero address check for newMevEthShareVault
12. [LOW] Withdraw event does not account for fees
Context: MevEth.sol#L610-L613
Severity: Low
Description: Line 613 emits Withdraw, but this does not take fees into account. convertToShares(assets) != shares in this context as withdraw and redeem passes down fee adjusted values for these variables.
Recommendation: Account for the fees in this event.
13. [MEDIUM]redeemCream Allows for minting of mevETH even when contract is paused
Context: MevEth.sol#L721-L724
Severity: Medium
Description: While both deposit and mint calls _stakingUnpaused(), redeemCream doesn’t, meaning that there would be a way to mint mevETH through Cream ether even if the contract was paused. This may create risk during an emergency scenario of an ongoing hack concerning mevETH followed by a pause in this contract so that to prevent minting more mevETH, but this function would theoretically enable the minting of more mevETH.
Recommendation: Change _stakingUnpaused() so it also pauses minting, and use it in this function.
14. [LOW] _isZero Not more efficient than equality check
Context: File.sol#L123
Severity: Low
Description: Using a separate function introduces a jump opcode, which is more costly than an equality check. Compiler optimization might also make a == 0 even more efficient.
Recommendation: Simply use an equality check
15. [LOW] Using type uint256for all constants is more efficient
Context: MevEth.sol#L48-L59
Severity: Low
Description: Not using type uint256 for these variables incurs overhead fees when using them. Constants are not packed with regular variables so there is no gas saving here.
Recommendation: Use type uint256 for all constants.
16. [LOW] allowance Should not be decreased if a user has max allowance
Context: MevEth.sol#L621-L626
Severity: Low
Description: Granting an entity max allowance should have the effect of virtually infinite spending, therefore should not be decreased in that scenario.
Recommendation: Do not reduce allowance in case of max allowance.
17. [MEDIUM] redeemCream function is not working as creamToken cannot be transferred to address(0)
Context: MevEth.sol#L729-L732
Severity: Medium
Description: creamToken on mainnet reverts on transfer to address(0) therefore the function redeemCream does not work at all and it always reverts.
This can be seen here in the ERC20 contract of creamToken:
| |
Recommendation: Instead, you could add
| |
and then use
| |
in the function to achieve the same effect of transfer to address(0).
18. [HIGH] redeemCream is vulnerable to a sandwich inflation attack
Context: MevEth.sol#L738-L741
Severity: High
Description: This function leads to a high severity sandwiching the initial depositor to steal funds attack, see the PoC for it here. This attack depends on redeemCream working correctly (refer to #29), so for the PoC to work, transfer to zero address bug is patched by burning the tokens instead.
The issue arises as redeemCream does not implement the security measure of a minimum deposit, which prevents this attack to happen through other paths such as deposit succeeded by withdraw as _deposit asserts that the deposited amount of assets is no less than 0.01 ether. As this guarantees any deposit to be larger than 0.01 ether which makes truncation of the share amount to 0 virtually impossible. The following explains why this is almost impossible for the paths other than the redeemCream for mainnet and it also explains the attack more in detail:
| |
This attack is facilitated by tricking convertToShares to return a 0 amount of shares even though a non-zero amount of assets is given to the function. This only happens when both fraction.base and fraction.elastic is non-zero and assets * fraction.base is smaller than fraction.elastic. Through redeemCream it is possible to make an initial deposit of 1 wei as minimum deposit check is not utilized, and as fraction.elastic can be inflated by directly dumping ether through grantRewards it is easily possible to get into a scenario of assets * fraction.base < fraction.elastic assuming assets is known before as this is a front-running attack.
The attacker can see the first legit deposit incoming, and can front-run it by the deposit of 1 wei with redeemCream, bringing both fraction.elastic and fraction.base to 1 wei. Then the attacker can inflate fraction.elastic to assets amount of the first legit deposit plus 1 wei so that the division truncates and 0 shares are minted to the victim front-ran depositor, and the attacker can back-run this deposit and burn all of her shares to receive both her assets and the victim’s assets.
Such an attack is not likely to occur with the other paths, as _deposit implements a 0.01 ether minimum deposit requirement which guarantees the fraction.base to be at least 0.01 ether if it’s non-zero. This in turn makes the truncation requirement:
assets * 0.01 ether (minimum) < fraction.elastic, and as assets can be at least 0.01 ether through the other paths than redeemCream, this requires fraction.elastic to be larger than 0.01 ether ^2 in the best case for the attacker, which is 10^14 ether which is larger than the current mainnet Ether supply, rendering this attack practically impossible for the other functions.
redeemCream function also does not implement the sandwich protection mechanism of lastDeposit even though this mechanism itself is vulnerable as explained in https://github.com/kebabsec/review-manifold/issues/32, after its patched it should be applied to redeemCream as well.
Recommendation: Patch redeemCream so that it also implements a minimum deposit requirement like _deposit, also implement sandwich protection like _deposit.
19. [LOW] maxWithdraw Is not being used internally
Context: MevEth.sol#L558-L561
Severity: Low
Description: maxWithdraw is not used internally and therefore should be external instead of public.
Recommendation: Change public to external.
20. [MEDIUM] Sandwich protection in _withdraw can be bypassed
Context: MevEth.sol#L584-L586
Severity: Medium
Description: The _withdraw function contains a protection mechanism against sandwich attacks as well as prevent flash mints to end up withdrawn. The current implementation assumes that msg.sender same block. This can be easily bypassed by using a separate contract or by manipulating the receiver and owner parameters. the following Proof Of Concept demonstrates few ways .
- Transfering: Depositor transfers received shares, making receiver to withdraw.,
- Receiveing:
_depositallows specifying thereceiverwho may not be themsg.senderandreceivercould initiate the withdrawal on the same block. - Approving: A depositor can grants allowance of received shares which simply withdraws on behalf of
owner.
Recommendation:
Extend Validation Checks. receiver and owner needs to be validated against deposits done on the last block. receiver from deposits cannot withdraw, and neither on behalf of owner.
In _withdraw:
| |
In _deposit:
| |
Note that transfer and transferFrom are not accounting for block, and requires additional changes. Revisit the roles of receiver and owner in deposit and withdraw to ensure they cannot be misused. Using a Time/Block-based mechanism prevent immediate withdrawals.
21. [HIGH]MevEthShareVault functions payValidatorWithdraw and logRewards are order dependent
Context: MevEthShareVault.sol#L120,
Description:
MevEthShareVault functions payValidatorWithdraw and logRewards depend on correct calling order to prevent accounting corruption. Calling logRewards when there is withdrawn validator stake in the contract will result in the stake amount to be registered as protocolBalance.rewards. This can be an issue if there is a full beacon chain withdrawal right as the operator calls logRewards. Consider the following scenario:
- There is 8 ETH in the contract.
- Operator calls
logRewards(2 eth)with the intention to register2 ethas protocol fee and6 ethas rewards. - Right before the operator tx is processed, a full beacon chain withdrawal of 32 eth occurs.
- This results in
2 ethbeing registered as protocol fee,38 ethas rewards, and0 ethas withdrawal amount.
Recommendation:
TBD. The accounting of logRewards and payValidatorWithdraw should be combined in a single function. This has other caveats. We will update this recommendation.
22. [INFORMATIONAL] Typos
MevEth.sol#L639-L642 Description: In line 624, “Mevth” should be “MevEth”.
MevEth.sol#L62-L63, MevEth.sol#L66-L67 Description: In line 62 and line 66, there is a typo, “comitted” should be “committed”.
MevEthShareVault.sol#L115-L116) Description: Line 116 contains a typo, “Cahce” should be “cache”
MevEthShareVault.sol#L89 Description: This comment has a typo “prorotocol” should be “protocol”
23. [INFORMATIONAL] Unused imports
Context: MevEth.sol#L25-L28 Severity: Informational Description: There’s unused imports in this contract. Recommendation: Remove unused imports.
Context: WagyuStaker.sol#L8-L11 Severity: Informational Description: Unused import. Recommendation: Remove unused imports
24. [LOW] Unstable dependency versions used
Context: lib/
Description:
Contract dependencies should always use audited release code. But versions of Solmate and OpenZeppelin included in lib/ are unstable, non-release code. When importing core dependencies, developers should deliberately choose which release version to import, instead of simply importing latest version from the main/master branch.
Recommendation:
Install dependencies using release tags (e.g.: forge install transmissions11/solmate@v7), and do not update dependencies without deliberation.
25. [MEDIUM]payValidatorWithdraw might cause temporary partial DOS for MevEthShareVault due to insolvency
Context: MevEthShareVault#L156
Description: payValidatorWithdraw has a balance sanity check to make sure that payValidatorWithdraw has enough ETH balance so that it can send 32 ethers to mevEth.
| |
Yet MevEthShareVault has the additional liabilities of protocolBalance.rewards and protocolBalance.fees which are increment-only and the only way to decrement them is by paying them out fully. As this is the case, if there are protocolBalance.rewards and protocolBalance.fees which are accumulated but not paid out (meaning that the values are non-zero), and payValidatorWithdraw occurs, the contract may wrongly send the rewards and fees as a withdraw action of 32 ethers, which would lead to those rewards not being accounted as fraction.elastic in mevEth and as they are not from an actual withdrawal they are not reflected in fraction.base either, so the users cannot receive this yield. We think the only way to “salvage” these ethers is by spinning up a new validator with them through createValidator.
(Note that this recovery action can be performed by calling createValidator to an existing validator and then waiting for a partial withdrawal through createValidator but this is assumed to be an issue itself in this audit, thus the phrasing above.)
It should be noted that if such a recovery mechanism is to be used, the users would receive less rewards in the short term, but as the value is not lost and simply auto-compounded without consent, we consider that this warrants only a medium severity issue as value is not lost.
Recommendation: Change the line to
| |
26. [HIGH] Calling createValidator on an existing validator may lead to loss of user funds
Context: MevEth.sol#L309
Description: “Beacon Deposit” contract of Ethereum staking does not check if a call of deposit is made to an existing (active) validator or a fresh (inactive) one. Due to this it is possible for createValidator to be called multiple times for the same validator assuming that there are enough ethers. As Beacon Chain regularly sweeps excess ethers in validators (if a validator’s balance is more than 32 ether it is considered a reward and a partial withdrawal action is initiated), the excess 32 ether would be considered as a reward and would be accounted in fraction.elastic even though they are also accounted in fraction.base. This would inflate the corresponding ether amounts of shares, and a malicious existing user of the system can utilize this to steal the ethers of other people.
PoC Scenario:
- 64 ether are accumulated in
mevEth.fraction.base == fraction.elastic == 64 etherat this point. Let’s assume there are two holders for simplicity, with 1 share each. - Operators want to spin up two new validators, but by operational errors,
createValidatoris called on the same validator twice. The validator’s balance is 64 ether, the sweeping occurs and 32 ether is sent back to the protocol as rewards.fraction.elastic == 96 etherat this point. - Assume the validator exits. The actual balance is 64 ether, and normally 32 ether belongs to first holder while the the other 32 belongs to the other holder.
- Malicious first holder burns his share to get half of
fraction.elasticwhich is 48 ether, even though they only supplied 32 initially. The extra 16 ether is stolen from the second holder.
Recommendation: Keep a mapping of deposited validator public keys so that createValidator is not called on the same validator twice.
27. [MEDIUM] Hardcoding the withdrawal size of a validator can lead to problems
Context: MevEth.sol#L350
Description: Both MevEth and MevEthShareVault hardcodes the withdrawal size of a validator as 32 ether, yet a validator may exit with less due to penalties or slashing. This hardcoding may lead the funds to be stuck indefinitely in their respective contract and users not being able to withdraw. Alternatively, the maximum effective balance of a validator might be increased due to potential Ethereum updates, which would make withdrawals larger than 32 ether possible, which would require changes to the hardcoding of 32 ether.
Recommendation: Account for other possible withdrawal sizes.
Manifold: Acknowledged.
This design was chosen to simplify accounting and limit risks. To expand, here is the previous version and the issues we had with it:
| |
- simplify accounting 1 validator created = 32 eth, 1 validator exited = 32 eth
- operator mistaken amount calls would cause irreversible price changes
- any call effecting fraction.elastic, effects the price and is vulnerable to front-running
- as long as net rewards outweighs net slashing, reward payouts can account for slashing by subtracting slashed amounts on exit from next reward payouts.
- Note this is an assumption that currently holds true with a large margin of error
28. [LOW]createValidator should assert that the activated validator withdraws to cleared addresses
Context: MevEth.sol#L309
Description: As can be deduced from grantValidatorWithdraw of MevEth:
| |
It is accepted that validators can only withdraw to stakingModule or mevEthShareVault address, including the case mevEthShareVault actually being a multisig.
Yet, createValidator does no assertions on if newData.withdrawal_credentials is one of these two addresses or not, meaning that it is possible by operator error to configure a validator that withdraws to another address. This would mean that the user deposited ether that is also reflected in fraction.base cannot be retrieved back easily and may lead to temporary denial of service. If such a scenario occurs, and assuming the operators are not malicious and it was caused by an error, the validator that is wrongly configured may exit and if the wrong withdrawal address is also controlled by the team they can send these otherwise lost ether to the staking module and payValidatorWithdraw can be called so that the accounting is not affected. On the other hand, if the withdrawal address were to be set to an address that is not controlled by the team the funds may get lost.
Recommendation: Assert newData.withdrawal_credentials is either staking module or the share vault.