Platform: Code4rena
Start Date: 18/09/2023
Pot Size: $31,310 USDC
Total HM: 15
Participants: 5
Period: 7 days
Judge: 0xLeastwood
Total Solo HM: 6
Id: 286
League: ETH
Rank: 1/5
Findings: 7
Award: $0.00
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: d3e4, rvierdiiev
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L175-L215
AfEth withdrawing will not work when ratio will be 0. It will be not possible to withdraw.
Any ratio for the 2 tokens of afEth can be set by owner. AfEth.withdraw function will not work in case if ratio will be 0, which means that safEth percentage is 0. This is because, safEth doesn't allow to unstake 0 amount.
Also, when ratio is e18, that means that vEth percentage is 0, but requestWithdraw
function will still calculate withdraw time, which is not needed in this case and withdraw will be called.
VsCode
First of all, calculate withdraw time only for vEth amount, not whole amount. Then in case if ratio is e18, then you don't need to do that. Also don't unstake/withdraw 0 shares for safEth and vEth.
Error
#0 - elmutt
2023-09-29T18:01:34Z
#1 - c4-judge
2023-10-03T18:30:37Z
0xleastwood marked the issue as duplicate of #36
#2 - c4-judge
2023-10-04T08:58:11Z
0xleastwood marked the issue as not a duplicate
#3 - c4-judge
2023-10-04T08:58:18Z
0xleastwood marked the issue as duplicate of #18
#4 - 0xleastwood
2023-10-04T09:00:45Z
So technically this is a duplicate of #18 and #36 but it fails to really describe either one in full detail. Not sure how this should be treated, but I will try to duplicate this issue so that it can be partially rewarded for both. 50% for #18 and 25% for #36.
#5 - c4-judge
2023-10-04T18:57:54Z
0xleastwood changed the severity to 3 (High Risk)
#6 - c4-judge
2023-10-04T18:59:24Z
0xleastwood marked the issue as partial-50
#7 - c4-judge
2023-10-04T19:15:39Z
0xleastwood changed the severity to QA (Quality Assurance)
#8 - c4-judge
2023-10-04T19:16:23Z
This previously downgraded issue has been upgraded by 0xleastwood
#9 - c4-judge
2023-10-04T19:16:40Z
0xleastwood changed the severity to QA (Quality Assurance)
#10 - c4-judge
2023-10-04T19:19:37Z
This previously downgraded issue has been upgraded by 0xleastwood
#11 - c4-judge
2023-10-04T19:19:44Z
0xleastwood marked the issue as full credit
#12 - c4-judge
2023-10-04T19:19:49Z
0xleastwood marked the issue as not a duplicate
#13 - c4-judge
2023-10-04T19:20:07Z
0xleastwood marked the issue as duplicate of #36
#14 - c4-judge
2023-10-04T19:20:21Z
0xleastwood changed the severity to 3 (High Risk)
#15 - c4-judge
2023-10-04T19:20:26Z
0xleastwood marked the issue as partial-25
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, rvierdiiev
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L138 https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L32
Stale cvx price can be used while depositing
When user deposits, then price of afEth token is calculated. It's needed to know how many tokens user will receieve.
This price consists of safEth price and vEth price.
This is how price is found for vEth. https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L31-L33
function price() external view override returns (uint256) { return (cvxPerVotium() * ethPerCvx(false)) / 1e18; }
Here, ethPerCvx
function is called and false
is passed as param. This param tells function if it's needed to validate chainlink price. In case if it's false
then there will be no validation and as result there is a risk that chainlink will return stale price. As user also provides slippage that actually doesn't mean that he will not be affected, it's still possible that wrong price will fit slippage.
VsCode
Validate chainlink data in this case.
Error
#0 - elmutt
2023-09-30T20:24:45Z
#1 - c4-judge
2023-10-03T18:16:57Z
0xleastwood marked the issue as duplicate of #34
#2 - c4-judge
2023-10-04T08:21:32Z
0xleastwood changed the severity to 3 (High Risk)
#3 - c4-judge
2023-10-04T08:22:37Z
0xleastwood marked the issue as partial-50
#4 - 0xleastwood
2023-10-04T08:23:00Z
Partial credit because it is lacking additional information about impact.
#5 - c4-judge
2023-10-04T08:23:12Z
0xleastwood marked the issue as satisfactory
#6 - c4-judge
2023-10-04T08:23:22Z
0xleastwood removed the grade
#7 - c4-judge
2023-10-04T08:23:44Z
0xleastwood marked the issue as partial-50
#8 - rvierdiyev
2023-10-06T08:26:23Z
@0xleastwood with all respect, but impact is described at the top
When user deposits, then price of afEth token is calculated. It's needed to know how many tokens user will receieve.
so this means that wrong price will give wrong tokens amount
#9 - 0xleastwood
2023-10-06T08:50:24Z
That's not why I gave partial credit. You failed to detail the impact of such issue and under what situations this will affect deposits. Look at the primary issue and #28 to see what it would look like to contain sufficient context. And then look at #64 which I also gave partial credit to.
#10 - rvierdiyev
2023-10-06T09:12:16Z
thanks, i will try to learn the difference and improve
🌟 Selected for report: MiloTruck
Also found by: adriro, d3e4, rvierdiiev
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L133-L141
Attacker can mint afEth with cheaper price and then withdraw.
When user would like to buy some amount
of afEth tokens, then price
is calculated. This price will be used to calculate amount of tokens to mint.
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L133-L141
function price() public view returns (uint256) { if (totalSupply() == 0) return 1e18; AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress); uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) * safEthBalanceMinusPending()) / 1e18; uint256 vEthValueInEth = (vEthStrategy.price() * vEthStrategy.balanceOf(address(this))) / 1e18; return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply(); }
This is how price is calculated. First, function calculates eth amount that contract holds in safEth tokens. You can see, that safEthBalanceMinusPending
function is used to get amount. This function doesn't include safEth that is requested to withdraw. And then vEth amount in terms of eth is calculated. And this total amount then divided by whole totalSupply.
Now, we need to look, how withdraw requests work. First important this is that afEth, sent by user, isn't burnt, but transferred to the contract, which means that totalSupply
remains same after request.
But, when vEth is requested to withdraw, then it shares are burnt at the moment of request. Also pendingSafEthWithdraws
is increased with safEth amount.
This means that when withdraw request is done, then eth amount of safEth token and vEth token has decreased, but totalSupply of afEth is still the same. As result, price of token decreases.
While this incorrect for everyone, this also can be used by attacker(some eth whale) in order to mint tokens cheaper. To do that he will need to deposit big amount of eth to the afEth. Then signal withdraw request for all his tokens and mint for example same amount. Soon, he will start earn profit every round he does so, as he will always mint tokens cheaper.
VsCode
Change last line of price function:
return ((vEthValueInEth + safEthValueInEth) * 1e18) / (totalSupply() - balanceOf(address(this)));
Error
#0 - elmutt
2023-09-29T19:15:19Z
#1 - c4-judge
2023-10-03T13:06:38Z
0xleastwood marked the issue as duplicate of #59
#2 - c4-judge
2023-10-03T13:09:44Z
0xleastwood marked the issue as duplicate of #25
#3 - c4-judge
2023-10-04T10:05:24Z
0xleastwood marked the issue as satisfactory
Data not available
VotiumStrategyCore.applyRewards can be sandwhiched, so users rewards will be lost.
VotiumStrategyCore.applyRewards
will be used in order to swap all rewards to eth and then distribute eth to the safEth or vEth.
The problem here is that sellCvx
function doesn't have slippage protection, which means that attacker can sandwhich this function call and make contract lose funds during the swap. Pls, note, that AfEth.depositRewards
also can't protect form this.
VsCode
Deal with slippage when you call sellCvx
function.
Error
#0 - c4-judge
2023-10-03T12:38:52Z
0xleastwood marked the issue as duplicate of #39
#1 - c4-judge
2023-10-04T07:49:31Z
0xleastwood marked the issue as duplicate of #23
#2 - c4-judge
2023-10-04T07:53:32Z
0xleastwood marked the issue as partial-25
#3 - 0xleastwood
2023-10-04T07:53:51Z
Only covers 1/3 of the edge cases outlined in the primary issue.
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
VotiumStrategy.withdrawTime doesn't expect that balance can be already unlocked. As result user can wait more time to witdraw.
When user wants to withdraw, then he needs to initiate requestWithdraw
. As some part of funds are locked as cvx token inside vlcvx that means that they should be withdrawn. When you lock cvx then amount is locked for some period, so you can't withdraw it for that time.
For such reason withdrawTime
function exists, which purpose is to calculate time when needed amount will be available to withdraw.
function withdrawTime( uint256 _amount ) external view virtual override returns (uint256) { uint256 _priceInCvx = cvxPerVotium(); ( , uint256 unlockable, , ILockedCvx.LockedBalance[] memory lockedBalances ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(address(this)); uint256 cvxAmount = (_amount * _priceInCvx) / 1e18; uint256 totalLockedBalancePlusUnlockable = unlockable + IERC20(CVX_ADDRESS).balanceOf(address(this)); for (uint256 i = 0; i < lockedBalances.length; i++) { totalLockedBalancePlusUnlockable += lockedBalances[i].amount; // we found the epoch at which there is enough to unlock this position if ( totalLockedBalancePlusUnlockable >= cvxUnlockObligations + cvxAmount ) { return lockedBalances[i].unlockTime; } } revert InvalidLockedAmount(); }
unlockable
is amount that can be withdrawn right now from VLCVX_ADDRESS. And totalLockedBalancePlusUnlockable
is total available balance at the moment.
As you can see, this code doesn't check that totalLockedBalancePlusUnlockable > amount
, it checks it only inside the loop. And because of that, it's possible that balance is already enough, but function will use time of first lockedBalances[0].unlockTime
as withdraw time, which will make user wait more to withdraw.
Exactly same thing exists in the requestWithdraw
function as it actually does almost same and tries to find epoch, when funds will be available.
VsCode
Consider possibility, that totalLockedBalancePlusUnlockable > amount
even without additional withdraw.
Error
#0 - c4-judge
2023-10-03T18:35:20Z
0xleastwood marked the issue as primary issue
#1 - c4-judge
2023-10-03T18:37:43Z
0xleastwood marked the issue as duplicate of #63
#2 - c4-judge
2023-10-04T10:07:09Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
Last stakers may not receive funds back.
When user wants to withdraw, then he needs to initiate requestWithdraw
. As some part of funds are locked as cvx token inside vlcvx that means that they should be withdrawn. When you lock cvx then amount is locked for some period, so you can't withdraw it for that time.
For such reason withdrawTime
function exists, which purpose is to calculate time when needed amount will be available to withdraw.
function withdrawTime( uint256 _amount ) external view virtual override returns (uint256) { uint256 _priceInCvx = cvxPerVotium(); ( , uint256 unlockable, , ILockedCvx.LockedBalance[] memory lockedBalances ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(address(this)); uint256 cvxAmount = (_amount * _priceInCvx) / 1e18; uint256 totalLockedBalancePlusUnlockable = unlockable + IERC20(CVX_ADDRESS).balanceOf(address(this)); for (uint256 i = 0; i < lockedBalances.length; i++) { totalLockedBalancePlusUnlockable += lockedBalances[i].amount; // we found the epoch at which there is enough to unlock this position if ( totalLockedBalancePlusUnlockable >= cvxUnlockObligations + cvxAmount ) { return lockedBalances[i].unlockTime; } } revert InvalidLockedAmount(); }
This function reverts in case if it didn't trigger return
in the loop. So protocol developers doesn't expect that it's possible.
However, it is. In case if there are no items in the lockedBalances
, but unlockable != 0
, that means that all locks are expired and such situation is likely to occur when users are going to quit AfEth token, so there is small amount of funds locked and new locks are not created.
So imagine situation that unlockable
is 10 eth and user would like to withdraw it, but because loop will be skipped, then function will revert, which means that user can't withdraw. It will be still possible to recover funds by relocking, but user will have to wait more.
Same problem exists in the VotiumStrategy.requestWithdraw
function.
VsCode
As i recommended in another finding you need to expect that totalLockedBalancePlusUnlockable
at the beginning is already enough to fulfill request.
Error
#0 - c4-judge
2023-10-03T18:39:28Z
0xleastwood marked the issue as duplicate of #63
#1 - c4-judge
2023-10-04T10:07:18Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: rvierdiiev
Data not available
VotiumStrategyCore.applyRewards can be sandwhiched to make profit.
VotiumStrategyCore.applyRewards function will swap all rewards of contract into eth and then stake them into safEth or vEth contract. As result price of afEth token will increase.
Usually, when user want to withdraw, then he needs to make withdraw request, which will calculate when it will be possible to withdraw. So on that timestamp user will then call withdraw. So this is usually 2 step process that can't be done in same tx.
But it's also possible that on the moment of VotiumStrategyCore.applyRewards
function call, unlockable
amount will have some gap, that user can use to withdraw instantly.
In this case user can just deposit that gap amount right before, VotiumStrategyCore.applyRewards
call and then request withdraw and withdraw, all in same block. This will be easy profit for use, but condition should satisfy: unlockable
should have enough gap, so user can earn at least more than tx cost on this attack.
VsCode
I don't know good solution.
Error
#0 - elmutt
2023-09-28T01:45:30Z
super interesting find. Will discuss with the team the implications of this
#1 - elmutt
2023-09-28T19:14:59Z
@rvierdiyev I think I understand the exploit but want to be sure. Here is an example how I see it:
Am I understanding the problem correctly?
#2 - rvierdiyev
2023-09-29T07:26:11Z
@elmutt yes, but just unlockable = 999 cvx is not enough, as we have such thing as cvxUnlockObligations as well(amount that is already reserved). let's say cvxUnlockObligations is 499, so by gap i mean unlockable - cvxUnlockObligations (as new withdrawer can't pretend on obligations to other people), which is 500 in our case everything else is described correctly
#3 - c4-judge
2023-10-03T18:32:55Z
0xleastwood marked the issue as primary issue
#4 - c4-judge
2023-10-03T18:33:27Z
0xleastwood marked the issue as duplicate of #45
#5 - c4-judge
2023-10-04T09:45:47Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L215-L220 VotiumStrategyCore.withdrawStuckTokens function can be used by malicious owner to withdraw cvx tokens from contract.
If token is cvx, then revert.
AfEth.setRatio doesn't validate input. Because of that it's possible that owner will provided incorrect ratio and contract will stop working.
Check that ratio is between 0 and e18.
#0 - 0xleastwood
2023-10-04T06:01:29Z
QA-01 is a duplicate of #53
#1 - c4-judge
2023-10-04T06:03:39Z
0xleastwood marked the issue as grade-b
#2 - c4-judge
2023-10-09T10:19:36Z
0xleastwood marked the issue as grade-a
#3 - c4-sponsor
2023-10-09T16:20:45Z
elmutt (sponsor) confirmed
🌟 Selected for report: adriro
Also found by: d3e4, m_Rassska, rvierdiiev
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L160
Calculate vValue
in such way: uint256 vValue = amount - sValue;
and avoid additional arithmetical operations.
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L162-L164
Don't use +=
as totalValue
is always 0.
Change to:
totalValue = (sMinted * ISafEth(SAF_ETH_ADDRESS).approxPrice(true)) + (vMinted * vStrategy.price());
#0 - c4-judge
2023-10-04T05:57:13Z
0xleastwood marked the issue as grade-b