Platform: Code4rena
Start Date: 12/05/2023
Pot Size: $35,000 USDC
Total HM: 10
Participants: 5
Period: 3 days
Judge: kirk-baird
Total Solo HM: 3
Id: 240
League: ETH
Rank: 2/5
Findings: 4
Award: $0.00
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: adriro
Also found by: bin2chen, ronnyx2017
Data not available
https://github.com/code-423n4/2023-05-xeth/blob/main/src/AMO2.sol#L256-L261 https://github.com/code-423n4/2023-05-xeth/blob/main/src/AMO2.sol#L599-L606 https://github.com/code-423n4/2023-05-xeth/blob/main/src/AMO2.sol#L631-L638 https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L204-L206
The lp tokens held by CVXStaker can't be able to used or withdrew by AMO2. Although the jam is not permanent and the owner of the CVXStaker can use recoverToken
function to withdraw them, it will cause the functions about removing liquidity break down in some cases.
The functions about removing liquidity, rebalanceUp / removeLiquidity / removeLiquidityOnlyStETH , have similar codes for checking if AMO owns enough LP to burn:
uint256 amoBalance = cvxStaker.stakedBalance(); if (lpAmount > amoBalance) { revert LpBalanceTooLow(); } cvxStaker.withdrawAndUnwrap(lpAmount, false, address(this));
And they will call the cvxStaker.withdrawAndUnwrap
function to pull lp tokens after the check.
The LP balance of the AMO is from cvxStaker.stakedBalance()
:
function stakedBalance() public view returns (uint256 balance) { balance = IBaseRewardPool(cvxPoolInfo.rewards).balanceOf(address(this)); }
It only returns the lp balance staked in the cvx reward pool. However, the lp tokens can be held by the CVXStaker itself in some cases, instead of staking in the CVX pool. This part of the lp tokens can be withdrew normally in the cvxStaker.withdrawAndUnwrap
function:
uint256 clpBalance = clpToken.balanceOf(address(this)); uint256 toUnstake = (amount < clpBalance) ? 0 : amount - clpBalance; if (toUnstake > 0) { ... } if (to != address(0)) { // unwrapped amount is 1 to 1 clpToken.safeTransfer(to, amount); }
But because the amoBalance loses this part of tokens in the check, the AMO2 can't withdraw this part of tokens from the CVXStaker.
There are some cases that the lp tokens can stay in the CVXStaker contract instead of sending to the CVX reward pool. For example:
AMO transfers the lp tokens to the cvxStaker first before calling depositAndStake:
IERC20(address(curvePool)).safeTransfer( address(cvxStaker), lpAmountOut ); cvxStaker.depositAndStake(lpAmountOut);
If CVX pool is currently shutdown, depositAndStake will not deposit the lp tokens received to the CVX reward pool:
// Only deposit if the aura pool is open. Otherwise leave the CLP Token in this contract. if (!isCvxShutdown()) { clpToken.safeIncreaseAllowance(address(booster), amount); booster.deposit(cvxPoolInfo.pId, amount, true); }
to
address or sendToOperator flag:function withdrawAndUnwrap( uint256 amount, bool claim, address to ) external onlyOperatorOrOwner { ... if (to != address(0)) { // unwrapped amount is 1 to 1 clpToken.safeTransfer(to, amount); } } function withdrawAllAndUnwrap( bool claim, bool sendToOperator ) external onlyOwner { ... if (sendToOperator) { uint256 totalBalance = clpToken.balanceOf(address(this)); clpToken.safeTransfer(operator, totalBalance); } }
Manual review
The lp balance of the AMO2 should be staked balance + lp balance of the CVXStaker .
Invalid Validation
#0 - c4-judge
2023-05-16T08:06:46Z
kirk-baird marked the issue as duplicate of #33
#1 - c4-judge
2023-05-27T02:54:47Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: d3e4
Also found by: ronnyx2017
Data not available
Judge has assessed an item in Issue #19 as 2 risk. The relevant finding follows:
For the first staker of the wxETH, the totalSupply of the wxETH is 0. So he can wrap the xETH to wxETH as 1:1.
function exchangeRate() public view returns (uint256) { /// @dev if there are no tokens minted, return the initial exchange rate uint256 _totalSupply = totalSupply(); if (_totalSupply == 0) { return INITIAL_EXCHANGE_RATE; } If the drip is started before the first staker, the unlocked funds can be withdrew immediately in the same block by calling unstake.
#0 - c4-judge
2023-05-27T03:15:29Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-05-27T03:15:33Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: ronnyx2017
Data not available
https://github.com/code-423n4/2023-05-xeth/blob/main/src/AMO2.sol#L323-L325 https://github.com/code-423n4/2023-05-xeth/blob/main/src/AMO2.sol#L335-L351
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.
AMO2.rebalanceUp
can be executed sucessfully only if the preRebalanceCheck
returns true.
bool isRebalanceUp = preRebalanceCheck(); if (!isRebalanceUp) revert RebalanceUpNotAllowed();
So as the logic in the preRebalanceCheck
, if isRebalanceUp = ture
, the token balances in the curve should satisfy the following condition
xETHBal / (stETHBal + xETHBal) > REBALANCE_UP_THRESHOLD
The default value of REBALANCE_UP_THRESHOLD is 0.75 :
uint256 public REBALANCE_UP_THRESHOLD = 0.75E18;
It's always greater than 0.5 . So when removing liquidity for only xETH, which is the rebalanceUp
function actually doing, the slippage will be positive instead of negative. You will receive more than 1 uint of xETH when you burn the lp token of 1 virtual price.
But the slippage caculation is incorrect in the bestRebalanceUpQuote
:
// min received caculate in bestRebalanceUpQuote bestQuote.min_xETHReceived = applySlippage( (vp * defenderQuote.lpBurn) / BASE_UNIT ); // applySlippage function function applySlippage(uint256 amount) internal view returns (uint256) { return (amount * (BASE_UNIT - maxSlippageBPS)) / BASE_UNIT; }
It uses the target amount subtracted slippage as the min token amount received. It is equivalent to expanding the slippage in the opposite direction. It makes the contractQuote
can't protect the defenderQuote
, increasing the risk of a large sandwich.
Manual review
rebalanceUp and rebalanceDown should use different slippage calculation methods in the applySlippage
.
MEV
#0 - c4-judge
2023-05-16T08:07:49Z
kirk-baird marked the issue as primary issue
#1 - d3e4
2023-05-17T08:51:56Z
Whether you will receive more or less xETH per LP token is accounted for in the price vp
. (vp * defenderQuote.lpBurn) / BASE_UNIT
is the amount of xETH received when burning lpBurn
LP tokens. We want to receive as much xETH as possible, but at least applySlippage
of that, e.g. 99%.
It doesn't matter what direction we are exchanging; we always want to receive as much as possible for whatever fix amount we provide. So the slippage should always be calculated as less than 100% of the ideal amount received.
#2 - c4-sponsor
2023-05-18T02:24:38Z
vaporkane marked the issue as sponsor disputed
#3 - kirk-baird
2023-05-27T02:23:23Z
I agree with the comments that slippage is calculated as a percentage of the received token. This is the desirable behaviour and therefore this issue is invalid.
#4 - c4-judge
2023-05-27T02:23:26Z
kirk-baird marked the issue as unsatisfactory: Invalid
#5 - 5z1punch
2023-05-30T05:03:29Z
Whether you will receive more or less xETH per LP token is accounted for in the price
vp
.(vp * defenderQuote.lpBurn) / BASE_UNIT
is the amount of xETH received when burninglpBurn
LP tokens. We want to receive as much xETH as possible, but at leastapplySlippage
of that, e.g. 99%. It doesn't matter what direction we are exchanging; we always want to receive as much as possible for whatever fix amount we provide. So the slippage should always be calculated as less than 100% of the ideal amount received.
In fact, the price vp
only accounts the token amounts when the peg pool is balance, which means stETH:xEth is 1:1 in the pool. The case in the comment is a from the point of a defi user instead of a rebalance mechanism. The REBALANCE_UP_THRESHOLD = 0.75 , and REBALANCE_DOWN_THRESHOLD = 0.68 . So when calling rebalanceUp, there must be more than 75% xETH in the pool and after the rebalance, there should be more than 68% xETH in the pool. Otherwise the pool is unbalanced again. So I you use vp
minus slippage as the contractQuote, the rebalance up can reduce the xETH proportion to lower than 50%, which makes the pool fall into the rebalance down process.
#6 - 5z1punch
2023-05-30T05:56:03Z
Aha, I find a similar issue submitted by other wanden https://github.com/code-423n4/2023-05-xeth-findings/issues/35. This issue is the root cause and solution for the https://github.com/code-423n4/2023-05-xeth-findings/issues/35.
Actually, rebalanceDown can't leap the boundary about REBALANCE_UP_THRESHOLD if slippage is appropriate. Because the rebalanceDown mints more xETH and adds it to the pool. If it adds too much xETH, the lp minted will lower than the slippage allows.
But for the rebalanceUp, the slippage protects nothing because of incorrect calculation method.
#7 - 5z1punch
2023-05-31T11:31:17Z
Hi @kirk-baird , just to make sure you don't miss the issue
#8 - kirk-baird
2023-06-05T00:07:30Z
@vaporkane you're input would be valuable if that's okay.
Okay so the first point I am taking out of this is
In fact, the price vp only accounts the token amounts when the peg pool is balance, which means stETH:xEth is 1:1 in the pool.
Does vp * defenderQuote.lpBurn / BASE_UNIT
always give us the correct value of xETH
that would be received when burning lpBurn
amount of LP tokens?
#9 - romeroadrian
2023-06-05T14:43:01Z
Aha, I find a similar issue submitted by other wanden #35. This issue is the root cause and solution for the #35.
Actually, rebalanceDown can't leap the boundary about REBALANCE_UP_THRESHOLD if slippage is appropriate. Because the rebalanceDown mints more xETH and adds it to the pool. If it adds too much xETH, the lp minted will lower than the slippage allows.
But for the rebalanceUp, the slippage protects nothing because of incorrect calculation method.
I can't see how this is linked to #35.
When the pool is unbalanced you won't get exactly the virtual price amount when you redeem it as one coin. This would imply that there's always 1:1 relation which is not what curve does. But I think it is fair to use it as a slippage check, which is the sponsor's intention here.
What the warden is trying to argue is that, let's say that virtual price is 1.05 so 1 LP should be redeemed as 1.05. Due to imbalance (rebalance up should require 75% of xETH using the default settings), if you redeem it as one coin (xETH) you won't actually get 1.05, you will get a bit more because you are removing the coin which has more liquidity. Note that we are talking about curve's stable pool model, where balance differences are eased.
Note also that this is a safeguard measure against a defender supplied parameter. The calculation is only used if the argument provided by the off chain process ends up being lower than the quote from calculation, which means that the calculation is just a lower bound safety measure.
#10 - kirk-baird
2023-06-05T23:17:19Z
Thanks for the feedback @romeroadrian. To summarise this issue there is a miscalculation in the slippage protection which could allow for a small sandwich attack. The conditions are that a defender must send a transaction with the minimum xETH received to be significantly lower than the real price in which case the applySlippage()
lower bound will be taken rather than the provided minimum. Since there is a bug in the applySlippage()
calculations this may result in a sandwich attack.
I'm going to reinstate this issue as medium severity as there is a small bug in the slippage protection which under niche circumstances could result in a sandwich attack.
Noting, although related to #35 I do not see this as a direct duplicate since it is a slippage attack rather than over rebalancing.
#11 - c4-judge
2023-06-05T23:17:25Z
kirk-baird marked the issue as satisfactory
#12 - c4-judge
2023-06-05T23:17:40Z
kirk-baird marked the issue as selected for report
#13 - romeroadrian
2023-06-05T23:34:44Z
Thanks for the feedback @romeroadrian. To summarise this issue there is a miscalculation in the slippage protection which could allow for a small sandwich attack. The conditions are that a defender must send a transaction with the minimum xETH received to be significantly lower than the real price in which case the
applySlippage()
lower bound will be taken rather than the provided minimum. Since there is a bug in theapplySlippage()
calculations this may result in a sandwich attack.I'm going to reinstate this issue as medium severity as there is a small bug in the slippage protection which under niche circumstances could result in a sandwich attack.
Noting, although related to #35 I do not see this as a direct duplicate since it is a slippage attack rather than over rebalancing.
I'm not sure I would call this a bug, as the intention is not to prevent MEV (which is already mitigated by the min amount) but to prevent a rogue or malfunctioning defender. Just making sure my previous comment intention is fully understood, I'll leave the final decision (severity) to you.
🌟 Selected for report: bin2chen
Also found by: ronnyx2017
Data not available
https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L170-L179
Loss all the stEth and xEth lp tokens.
The CVXStaker.withdrawAllAndUnwrap can be called by the admin. And if the sendToOperator param is true, all the lp tokens of the CVXStaker contract (include lp tokens staked in the CVX and left in the CVXStaker itself ) will be withdrew to the operator, which is the AMO2 contract.
IBaseRewardPool(cvxPoolInfo.rewards).withdrawAllAndUnwrap(claim); if (sendToOperator) { uint256 totalBalance = clpToken.balanceOf(address(this)); clpToken.safeTransfer(operator, totalBalance); }
There is not a function can manipulate ERC20 tokens directly in the AMO2 contract. But there are 6 functions can interact with Curve LP:
1 & 2 & 3: rebalanceUp & removeLiquidity & removeLiquidityOnlyStETH , these three functions have similar checks at the beginning:
uint256 amoBalance = cvxStaker.stakedBalance(); if (lpAmount > amoBalance) { revert LpBalanceTooLow(); }
Because all the lp tokens in the staker have been withdrew to the AMO2 itself, so amoBalance = 0
and these three functions will always revert.
4 & 5 & 6: rebalanceDown & addLiquidity & addLiquidityOnlyStETH, these three functions have similar code for adding liquidity:
lpOut = curvePool.add_liquidity(amounts, minLpOut); IERC20(address(curvePool)).safeTransfer(address(cvxStaker), lpOut); cvxStaker.depositAndStake(lpOut);
The lp token amount finally sent out from the AMO2 contract is from the return value of the curvePool.add_liquidity
function. So these three funtions can only mint and stake new lp tokens. They are unable to control the lp tokens stuck in the contract itself.
Manual review
Add an external function to stake or unwrap(remove liquidity) the lp tokens in the contract.
DoS
#0 - c4-judge
2023-05-16T07:57:19Z
kirk-baird marked the issue as duplicate of #6
#1 - c4-judge
2023-05-27T03:03:41Z
kirk-baird changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-05-27T03:03:50Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: bin2chen, carlitox477, d3e4, ronnyx2017
Data not available
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/wxETH.sol#L202-L216
For the first staker of the wxETH, the totalSupply of the wxETH is 0. So he can wrap the xETH to wxETH as 1:1.
function exchangeRate() public view returns (uint256) { /// @dev if there are no tokens minted, return the initial exchange rate uint256 _totalSupply = totalSupply(); if (_totalSupply == 0) { return INITIAL_EXCHANGE_RATE; }
If the drip is started before the first staker, the unlocked funds can be withdrew immediately in the same block by calling unstake.
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L126-L132
If the cvxPoolInfo has not been initialized before calling CVXStaker.depositAndStake
, which means the cvxPoolInfo.pid = 0
, some unexpected token transactions will be made.
Because the pid 0 is a valid and active pool in the CVX booster.
if (!isCvxShutdown()) { clpToken.safeIncreaseAllowance(address(booster), amount); booster.deposit(cvxPoolInfo.pId, amount, true); }
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L62-L72
In fact, the token
and rewards
address can be got by calling booster.poolInfo(cvxPoolInfo.pId)
. It ensures that the addresses are valid and consistent.
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L55-L61
CVXStaker.setCvxPoolInfo:
/** * @dev Sets the CVX pool information. * @param _pId The pool ID of the CVX pool. * @param _token The address of the CLP token. * @param _rewards The address of the CVX reward pool. * Only the contract owner can call this function. */ function setCvxPoolInfo( uint32 _pId, address _token, address _rewards )
The param _token
should be the wrapped DepositToken of the CVX pool, instead of the LP token.
#0 - c4-sponsor
2023-05-23T09:05:16Z
vaporkane marked the issue as sponsor confirmed
#1 - c4-judge
2023-05-28T08:03:56Z
kirk-baird marked the issue as grade-b
🌟 Selected for report: adriro
Also found by: carlitox477, d3e4, ronnyx2017
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/wxETH.sol#L73 Because the `dripEnabled` is false as default, the drip won't be started until the owner calls the startDrip function, which will set the drip lastReport to current block.
startDrip
function.code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/wxETH.sol#L170-L174
If dripEnabled
is true, the first line of the startDrip
will revert directly:
function startDrip() external onlyOwner drip { /// @dev if the drip is already running, revert if (dripEnabled) revert DripAlreadyRunning();
So the dripEnabled
must be false. If dripEnabled
is false, the drip modifier will return directly without any changes:
function _accrueDrip() private { /// @dev if drip is disabled, no need to accrue if (!dripEnabled) return;
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#LL68C36-L68C36
The global var cvxPoolInfo.token
is never used in the CVXStaker contract.
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/CVXStaker.sol#L191-L196
If the reward balance is 0, it's no need to call external erc20 transfer function.
code lines: https://github.com/code-423n4/2023-05-xeth/blob/main/src/wxETH.sol#L222-L256
If lockedFunds is 0, dripEnabled will be set to false again at next drip.
If dripRatePerBlock is 0, drip can't unlock any funds but increaces gas consumption after startDrip.
#0 - c4-judge
2023-05-28T08:00:51Z
kirk-baird marked the issue as grade-b