Asymmetry Finance afETH Invitational - d3e4's results

The aggregation and optimization layer for Liquid Staking Tokens.

General Information

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

Asymmetry Finance

Findings Distribution

Researcher Performance

Rank: 5/5

Findings: 8

Award: $0.00

QA:
grade-a
Gas:
grade-b
Analysis:
grade-b

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: d3e4

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-01

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/AfEth.sol#L148-L169

Vulnerability details

Impact

The up to 2 % price discrepancy from Chainlink creates an intrinsic arbitrage. Especially, it makes withdrawals worth more than deposits in the sense that one can immediately withdraw more than just deposited.

Proof of Concept

When depositing ETH into AfEth, the ETH is split according to ratio and sold for safEth and vAfEth. The received share of afEth is then determined by the value in ETH of the resulting amounts of safEth and vAfEth. Note that there are two prices involved here: the true price at which ETH is traded for safEth and vAfEth (in sellCvx() and buyCvx()), and the estimated value in ETH that safEth and vAfEth is considered to have (ISafEth.approxPrice() and VotiumStrategy.price()). These are not necessarily the same.

If the ratio by which the deposited ETH is split is not the same as the ratio of the true values of the underlying assets, this implies that a deposit implicitly makes a trade between safEth and vAfEth according to the estimated price which may thus differ from the true price obtained when withdrawing. This presents an arbitrage opportunity. Note that if all prices were the same it would not matter if safEth is "traded" for vAfEth within a deposit as the trade then makes no change in the total value deposited.

The conditions for this issue is thus that VotiumStrategy.price() is different from the price obtained by sellCvx() and buyCvx(), and that the deposit ratio is not the same as the withdrawal ratio.

VotiumStrategy.price() in particular is based on a Chainlink oracle with a 2 % deviation threshold, which means that the true price is allowed to deviate up to 2 %, within 24 hours, from the reported price. ISafEth.approxPrice() may perhaps be similarly inaccurate (I have not looked into this).

The ratio can skew in this way for two reasons. One is when the ratio is different from the ratio of the underlying balances, such as when ratio is changed. Deposits are made according to ratio but withdrawals are made proportionally from the extant balances. In this case the implicit trade between safEth and vAfEth can happen in either direction, either beneficial or detrimental to the depositor (if there is a price discrepancy). The other is caused by the price discrepancy itself when depositing. In this case it is always beneficial to the depositor (and detrimental to the holders).

Example 1a - reconverging ratio, vAfEth is actually more expensive Suppose the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0. Further suppose that the contract thinks all prices are 1, but that 100 ETH actually trades for 102 vAfEth. Then depositing 100 ETH will convert it to 102 vAfEth, which will be valued as 102 ETH. This mints 102 afEth. The balances are now 100 safEth and 102 vAfEth and the total supply is 202 afEth. Withdrawing 102 afEth converts 102/202 of the underlying, i.e. 50.495 safEth and 51.505 vAfEth, into 50.495 + 51.505/1.02 = 100.99 ETH.

Example 1b - reconverging ratio, vAfEth is actually cheaper Suppose the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0. Further suppose that the contract thinks all prices are 1, but that 100 ETH actually trades for 98 vAfEth. Then depositing 100 ETH will convert it to 98 vAfEth, which will be valued as 98 ETH. This mints 98 afEth. The balances are now 100 safEth and 98 vAfEth and the total supply is 198 afEth. Withdrawing 98 afEth converts 98/198 of the underlying, i.e. 49.495 safEth and 48.505 vAfEth, into 49.495 + 48.505/0.98 = 98.99 ETH.

Example 2a - stable ratio, vAfEth is actually more expensive Suppose the contract holds 50 safEth and 50 vAfEth and that the ratio is 0.5. Further suppose that the contract thinks all prices are 1 but that 50 ETH actually trades for 51 vAfEth. Then depositing 100 ETH will convert 50 ETH to 50 safEth and 50 ETH to 51 vAfEth, which will be valued as 101 ETH. This mints 101 afEth. The balances are now 100 safEth and 101 vAfEth and the total supply is 201 afEth. Withdrawing 101 afEth converts 101/201 of the underlying, i.e. 50.249 safEth and 50.751 vAfEth, into 50.249 + 50.751/1.02 = 100.005 ETH.

Example 2b - stable ratio, vAfEth is actually cheaper Suppose the contract holds 50 safEth and 50 vAfEth and that the ratio is 0.5. Further suppose that the contract thinks all prices are 1 but that 50 ETH actually trades for 49 vAfEth. Then depositing 100 ETH will convert 50 ETH to 50 safEth and 50 ETH to 49 vAfEth, which will be valued as 99 ETH. This mints 99 afEth. The balances are now 100 safEth and 99 vAfEth and the total supply is 199 afEth. Withdrawing 99 afEth converts 99/199 of the underlying, i.e. 49.749 safEth and 49.251 vAfEth, into 49.749 + 49.251/0.98 = 100.005 ETH.

Thus one can make a profit by depositing and immediately withdrawing. Immediate withdrawals are possible at the moment locks expire (and before they have been relocked), but it may be enough to just immediately request a withdrawal if the true price is the same (or better) when eventually withdrawn.

The price discrepancy will appear whenever there are price fluctuations of up to 2 % within 24 hours, which seems quite likely.

Regarding the case where the underlying is reconverging after a change of ratio it is worth noting that convergence is quite slow. Several times the entire balances must be traded before the new ratio is approached.

I would have to think more about this.

Assessed type

Math

#0 - c4-judge

2023-10-03T13:04:58Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T08:03:19Z

0xleastwood marked the issue as selected for report

#2 - c4-sponsor

2023-10-04T14:30:49Z

elmutt (sponsor) confirmed

#3 - d3e4

2023-10-05T13:28:53Z

We want that a deposit not diminish the value of previous deposits. That is, withdrawing $w$ shares should return at least as much if withdrawn after a deposit which mints $m$ shares as if withdrawn before. Note that letting a share represent each underlying in equal proportions is the only way to guarantee fairness and fungibility, so we must leave the withdrawal calculation as it is.

Let $d_s$ and $d_v$ be the ether amounts deposited in SafEth and VotiumStrategy, respectively. Let $B_s$ and $B_v$ be the respective balances in AfEth and $T$ the total supply of afEth. Let $P_s(x)$ be the amount of safEth obtained by selling $x$ ether for safEth, and $P_s^{-1}(y)$ the amount of ether obtained by selling safEth for ether (note the abuse of notation and that $P_s^{-1}(P_s(x)) \leq x$ because of fees, spread, slippage etc.), and similarly $P_v$ and $P_v^{-1}$ for vAfEth.

Withdrawing $w$ now should return at most what it would return after a deposit of $d_s + d_v$, i.e.

P_s^{-1}(\frac{w}{T}B_s) + P_v^{-1}(\frac{w}{T}B_v) \leq 
P_s^{-1}(\frac{w}{T+m}(B_s + P_s(d_s))) + P_v^{-1}(\frac{w}{T+m}(B_v + P_v(d_v)))

For small deposits and withdrawals the price functions are approximately linear and we can write e.g. $P_s(d_s)$ as $P_s d_s$, i.e. $P_s$ is just a price point, and we get

\frac{1}{T}(P_s^{-1}B_s + P_v^{-1}B_v) \leq 
\frac{1}{T+m}(P_s^{-1}(B_s + P_s d_s) + P_v^{-1}(B_v + P_v d_v))$

Solving for $m$ we get

m \leq \frac{P_s^{-1}P_s d_s + P_v^{-1}P_v d_v}{P_s^{-1}B_s + P_v^{-1}B_v}T

The difference from the current implementation is that instead of the true prices $P_s^{-1}$ and $P_v^{-1}$, the oracle prices, which we will denote $\hat{P}^{-1}_s$ and $\hat{P}^{-1}_v$, are used instead, i.e.

m = \frac{\hat{P}^{-1}_s P_s d_s + \hat{P}^{-1}_v P_v d_v}
{\hat{P}^{-1}_s B_s + \hat{P}^{-1}_v B_v}T

Since we know the true prices up to within certain bounds, we can minimise $m$, as a function of $P_s^{-1}$ and $P_v^{-1}$, within these bounds. The gradient of $m(P_s^{-1}, P_v^{-1})$ is

\frac{P_s d_s B_v - P_v d_v B_s}
{(P^{-1}_s B_s + P^{-1}_v B_v)^2}
(P^{-1}_v, -P^{-1}_s)

so if $P_s d_s B_v - P_v d_v B_s > 0$ we pick the lower right corner (maximal $P_s^{-1}$, minimal $P_v^{-1}$), and if $P_s d_s B_v - P_v d_v B_s < 0$ we pick the upper left corner (minimal $P_s^{-1}$, maximal $P_v^{-1}$). In the case of equality we can use any (non-zero) prices.

We minimise within the bounds, but we of course want the bounds to be as tight as possible, so that this minimum is as high as possible.

The oracle prices provide us with good bounds, namely $P_s^{-1} \in [0.98 \cdot \hat{P}^{-1}_s, 1.02 \cdot \hat{P}^{-1}_s]$ and $P_v^{-1} \in [0.98 \cdot \hat{P}^{-1}_v, 1.02 \cdot \hat{P}^{-1}_v]$.

There are a few ways to further improve the bounds. During the deposit we learn $P_s$ and $P_v$, from which we can infer $P_s^{-1} \leq 1/P_s$ and $P_v^{-1} \leq 1/P_v$ (equality in the case of no exchange losses). If we know that there is some minimum percentage lost (e.g. exchange fees, slippage, spread) we can refine these to $P_s^{-1} \leq k_s/P_s$ and $P_v^{-1} \leq k_v/P_v$, where $k_s, k_v < 1$ is some factor adjusting for these losses (e.g. $k_s = 0.99$ if there is at least (another) 1 % lost when selling for ether).

If the trading losses are significant it may be necessary to take these into account even for the bounds from the oracle prices, such that both upper and lower bounds are slightly reduced.

#4 - romeroadrian

2023-10-08T14:19:34Z

@0xleastwood I'm really sorry to do this at this stage, but did you have the chance to go through these scenarios? Seems there are lot of suppositions, some of which the same warden is confusingly invalidating in other discussions.

I think examples 1a and 1b have invalid assumptions ("the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0"). In 2a and 2b, what is "the contract thinks all prices are.."? How is the contract thinking prices?

It seems this has nothing to do with the stated impact. The chainlink response is used to price vAfEth, but the core here is a discrepancy of the ratio with the underlying assets (which again confusingly the author is trying to invalidate in other issues). Furthermore the deposit/withdraw cycle can't be executed without exposure to the underlying assets due to the locking mechanism.

#5 - 0xleastwood

2023-10-08T20:50:43Z

@0xleastwood I'm really sorry to do this at this stage, but did you have the chance to go through these scenarios? Seems there are lot of suppositions, some of which the same warden is confusingly invalidating in other discussions.

I think examples 1a and 1b have invalid assumptions ("the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0"). In 2a and 2b, what is "the contract thinks all prices are.."? How is the contract thinking prices?

It seems this has nothing to do with the stated impact. The chainlink response is used to price vAfEth, but the core here is a discrepancy of the ratio with the underlying assets (which again confusingly the author is trying to invalidate in other issues). Furthermore the deposit/withdraw cycle can't be executed without exposure to the underlying assets due to the locking mechanism.

I'll look into these, I do believe it is possible to deposit and withdraw atomically as long as there is an unlocked amount of tokens in the votium strategy contract. That would be the extent at which a withdrawal could be made "instantly".

#6 - 0xleastwood

2023-10-08T20:51:49Z

I do agree that example 1a and 1b are somewhat infeasible because the protocol team has already stated that such a ratio would not exist in the first place. However, there is validity in the other examples.

#7 - elmutt

2023-10-12T20:14:18Z

Thanks for the excellent analysis @d3e4, this one really got us thinking.

After extensive analysis we came to the following conclusions:

  1. It can only happen if the true ratio of safEth to vStrategy in the contract is not equal to the target ratio. We were unable to reproduce this issue if targetRatio ~= trueRatio (see test https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R207).

  2. We were able reproduce the issue by mocking the chainlink oracle to significantly changing the cvxPerEth price and changing the ratio to be very different from the true ratio (see test https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R267)

  3. It appears that a 2% chainlink difference can cause small differences if the ratio is far enough apart. We wrote 2 tests demonstrating this: https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R331 https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R398

These are a little bit concerning We believe this is an acceptable risk because:

  • in another pull request we set a minimum withdraw time of 1 epoch so its impossible to instantly withdraw even if there are unlockable funds in the contract.
  • its risky for them to try this because they are exposed to cvx price changes most of the time unlock time will be much greater than 1 week because there wont be enough unlockable cvx to withdraw right away.
  • we will also have a bot monitoring the ratio and chainlink price and pause the contract if there are any major issues
  1. We also analyzed the amountToMint calculation in afEth.deposit() as follows:

amountToMint = totalValue / priceBeforeDeposit

or

((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / priceBeforeDeposit

or

((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / ((totalVStratValue + totalSafEthValue) / totalAfEthSupply)

or

((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / (((totalVstratBalance * vStratPrice) + (totalSafEthBalance * safEthPrice)) / totalAfEthSupply)

If we assume safEthPrice is constant (in terms of eth it goes up very slowly) so we can pretty much ignore it (replace it with a constant)

or

((safEthValueMinted) + (vStratMinted * vStratPrice)) / (((totalVstratBalance * vStratPrice) + (totalSafEthValue)) / totalAfEthSupply)

Assigning values generated when ratios were roughly equal to everything except vStratPrice and plotting it looks like:

<img width="1052" alt="Screen_Shot_2023-10-12_at_11 09 44_AM" src="https://github.com/code-423n4/2023-09-asymmetry-findings/assets/6187559/b0ce7af1-226e-4af8-b817-ae0d94999ca3">

We think this graph helps show that if ratios are equal it is hard to mess things up with a bad chainlink price. We understand ratios will often not be equal but we wanted to see the base case of ratios being equal

Based on this analysis we think a 2% chainlink variance is an acceptable risk, even when the ratios are far apart. However its possible we missed something and if anyone can demonstrate in a unit test that its a bigger problem we would love to see it!

#8 - elmutt

2023-10-12T20:34:06Z

Also wanted to point out the ratios can easily change if cvx price changes, so it it a real concern. We initially didnt think about it right and assumed ratios would be more stable

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-36

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L261

Vulnerability details

Impact

A withdrawal cannot be finalised if requested at a time when AfEth had only safEth, and that owed share of safEth is permanently lost.

Proof of Concept

It is possible that AfEth holds at most dust amounts of vAfEth (if ratio = 100 %). The amounts of vAfEth and safEth to withdraw is set at the time of the request. So if a withdrawal request is placed at this time then AfEth.requestWithdraw() will call VotiumStrategy.requestWithdraw(0) AfEth.sol#L190-L193

uint256 votiumWithdrawAmount = (withdrawRatio * votiumBalance) / 1e18;
uint256 vEthWithdrawId = AbstractStrategy(vEthAddress).requestWithdraw(
    votiumWithdrawAmount
);

which naturally records a cvxOwed: 0.

When later attempting to withdraw, in VotiumStrategy.withdraw() VotiumStrategy.sol#L119-L122

uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId]
    .cvxOwed;

uint256 ethReceived = sellCvx(cvxWithdrawAmount);

sellCvx(0) is called.

The issue is that this attempts to VotiumStrategyCore.sol#L258-L263

ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying(
    1,
    0,
    0, //@audit dx
    0 // this is handled at the afEth level
);

which reverts as dx == 0.

Since a withdrawal request is permanent and cannot be amended the user will never be able to withdraw his safEth share.

For gas considerations this has to be addressed by checks along the following pathway. In AfEth.requestWithdraw(), skip the call to VotiumStrategy.requestWithdraw() if votiumWithdrawAmount == 0. I don't think cvxPerVotium() can be less than 1e18, but if it can then one also has to check that cvxAmount is non-zero in VotiumStrategy.requestWithdraw() and return without placing the request in VotiumStrategy. This would place an empty request which has to not revert when later called in VotiumStrategy.withdraw().

It is of course possible to let everything run and just amend sellCvx(0) to immediately return 0.

Assessed type

Invalid Validation

#0 - c4-judge

2023-10-03T12:39:05Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-03T13:07:54Z

0xleastwood marked the issue as duplicate of #36

#2 - c4-judge

2023-10-04T10:08:16Z

0xleastwood marked the issue as satisfactory

#3 - c4-judge

2023-10-04T18:57:23Z

0xleastwood changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-34

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategy.sol#L32

Vulnerability details

Impact

AfEth.deposit() may mint an incorrect amount of afEth. VotiumStrategy.price() may return an incorrect price of vAfEth. AfEth.price() may return an incorrect price of afEth.

Proof of Concept

VotiumStrategy.price()

function price() external view override returns (uint256) {
    return (cvxPerVotium() * ethPerCvx(false)) / 1e18;
}

calls ethPerCvx(false) where false implies that the Chainlink response is not validated. VotiumStrategy.price() may thus return an invalid value. VotiumStrategy.price() is used by AfEth.price() in the calculation of the price of afEth. Both of these price() are used in AfEth.deposit() to calculate the amount of afEth to mint. If the Chainlink response is invalid an incorrect amount of afEth may thus be minted, instead of reverting.

ethPerCvx(true) is used in the far less critical AfEth.depositRewards(). It should be used here as well.

Assessed type

Invalid Validation

#1 - c4-judge

2023-10-03T13:54:36Z

0xleastwood marked the issue as duplicate of #34

#2 - c4-judge

2023-10-04T08:20:36Z

0xleastwood marked the issue as satisfactory

#3 - c4-judge

2023-10-04T08:21:35Z

0xleastwood changed the severity to 3 (High Risk)

#4 - c4-judge

2023-10-04T08:22:18Z

0xleastwood marked the issue as partial-50

#5 - 0xleastwood

2023-10-04T08:22:55Z

Partial credit because it is lacking additional information about impact.

#6 - c4-judge

2023-10-04T08:23:16Z

0xleastwood removed the grade

#7 - d3e4

2023-10-05T17:10:44Z

Partial credit because it is lacking additional information about impact.

What information about impact is missing? The main issue #34 explains the calculation chain such that the invalid Chainlink response implies an invalid VotiumStrategy price, which implies an invalid afEth price, which implies an invalid mint amount. This is precisely the impact stated here as well.

#8 - 0xleastwood

2023-10-07T12:43:30Z

Noted.

#9 - c4-judge

2023-10-07T12:43:37Z

0xleastwood marked the issue as full credit

#10 - c4-judge

2023-10-07T12:49:31Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: MiloTruck

Also found by: adriro, d3e4, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
duplicate-25

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/AfEth.sol#L140

Vulnerability details

Impact

AfEth.price() may be calculated as too low.

Proof of Concept

AfEth.requestWithdraw() does not burn the afEth but only transfers it to itself. Hence the withdrawRatio is calculated using only the free supply of afEth: AfEth.sol#L180-L185

// ratio of afEth being withdrawn to totalSupply
// we are transfering the afEth to the contract when we requestWithdraw
// we shouldn't include that in the withdrawRatio
uint256 afEthBalance = balanceOf(address(this));
uint256 withdrawRatio = (_amount * 1e18) /
    (totalSupply() - afEthBalance);

Later the afEth is burned in withdraw() (AfEth.sol#L255).

Similar accounting is done for safEth: AfEth.sol#L195-L197

uint256 safEthBalance = safEthBalanceMinusPending();

uint256 safEthWithdrawAmount = (withdrawRatio * safEthBalance) / 1e18;

Whereas for vAfEth AfEth.sol#L191-L193

uint256 vEthWithdrawId = AbstractStrategy(vEthAddress).requestWithdraw(
    votiumWithdrawAmount
);

VotiumStrategy.requestWithdraw() just burns the vAfEth immediately (VotiumStrategy.sol#L60).

However, the AfEth.price() calculation does not use the same accounting for the effective supply of afEth: 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();
}

It uses totalSupply() instead of totalSupply() - balanceOf(address(this)). This means that with withdrawal requests outstanding the price() will be too low, and deposit() will thus mint too much afEth.

Amending the price() calculation to use totalSupply() - balanceOf(address(this)) in the denominator would resolve this issue.

But a better solution is to just burn the afEth immediately in requestWithdraw(). Since requestWithdraw() immediately fixes the withdrawal in terms of safEth and CVX, it doesn't matter for the withdrawal when the afEth is burned.

Assessed type

ERC4626

#1 - c4-judge

2023-10-03T12:59:51Z

0xleastwood marked the issue as primary issue

#2 - c4-judge

2023-10-03T13:10:19Z

0xleastwood marked the issue as duplicate of #25

#3 - c4-judge

2023-10-04T10:06:38Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: MiloTruck

Also found by: adriro, d3e4, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
duplicate-25

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/AfEth.sol#L183-L185

Vulnerability details

Impact

AfEth.deposit() can be bricked.

Proof of Concept

AfEth makes use of its own balance of afEth as a temporary store of afEth for withdrawal requests. On requestWithdraw() afEth is transferred to the AfEth contract and these are then burned on withdraw(). The contract's balance of afEth is excluded from the calculation of the withdrawRatio. One can thus cause the contract to withdraw all its underlying with less than its total supply. This implies a price of zero by which is divided in deposit().

  1. deposit() some ETH for 2 afEth.
  2. Transfer 1 afEth to the AfEth contract.
  3. requestWithdraw() the remaining 1 afEth. This is precisely totalSupply() - afEthBalance in
uint256 afEthBalance = balanceOf(address(this));
uint256 withdrawRatio = (_amount * 1e18) /
    (totalSupply() - afEthBalance);

so withdrawRatio is set to 100 % and the entire safEth and vAfEth balances are withdrawn. The request may or may not be finalised by withdraw(); the withdrawals are still implicit in the accounting. Only the 1 afEth transferred with the withdrawal request would be burned; the totalSupply() still carries the 1 afEth transferred directly to the contract.

Now price()

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();
}

returns 0.

This causes deposit() to revert at uint256 amountToMint = totalValue / priceBeforeDeposit;.

deposit() is the only intended way to bring the safEth and vAfEth balances to non-zero so the only way out would be to send safEth or vAfEth directly to the contract. But all this would of course be claimable by the very next deposit, and so we return to the same state. The contract is effectively bricked.

The afEth transferred by requestWithdraw() could be specifically accounted instead of just using the contract balance.

But a better solution is to just burn the afEth immediately in requestWithdraw(). Since requestWithdraw() immediately fixes the withdrawal in terms of safEth and CVX, it doesn't matter for the withdrawal when the afEth is burned.

Assessed type

ERC4626

#1 - c4-judge

2023-10-03T11:38:32Z

0xleastwood marked the issue as primary issue

#2 - c4-judge

2023-10-04T08:05:59Z

0xleastwood marked the issue as duplicate of #25

#3 - 0xleastwood

2023-10-04T08:09:04Z

The fix and impact is technically the same as #25 as we are incorrectly calculating the circulating supply of tokens which is used to calculate price().

#4 - c4-judge

2023-10-04T10:06:54Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: MiloTruck

Also found by: MiloTruck, adriro, d3e4, m_Rassska, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
duplicate-23

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L239 https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L272

Vulnerability details

Impact

Rewards deposit is not slippage protected and susceptible to MEV-attacks.

Proof of Concept

VotiumCoreStrategy.buyCvx() is not slippage protected, as even acknowledged by the comment in

ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying{
    value: _ethAmountIn
}(
    0,
    1,
    _ethAmountIn,
    0 // this is handled at the afEth level
);

This is handled at the afEth level only for deposits (when it is called via VotiumStrategy.deposit() by AfEth.deposit() which has a _minout), but NOT for rewards deposits.

Rewards are initiated by VotiumStrategyCore.applyRewards() which calls VotiumStrategyCore.depositRewards() either directly or via AfEth.depositRewards(). VotiumStrategyCore.depositRewards() then buyCvx(). None of these functions has a _minout or similar for slippage protection (it is sufficient to note that applyRewards() doesn't have such a parameter).

Add a if (ethReceived < _minout) revert BelowMinOut() slippage protection in VotiumStrategyCore.applyRewards().

Assessed type

MEV

#0 - c4-judge

2023-09-30T15:15:20Z

0xleastwood marked the issue as duplicate of #39

#1 - elmutt

2023-10-02T19:14:33Z

@0xleastwood I dont think this is a dupe of 39

this ticket is talking about applyRewards(). 39 is talking about other functions

#2 - 0xleastwood

2023-10-03T11:08:28Z

@0xleastwood I dont think this is a dupe of 39

this ticket is talking about applyRewards(). 39 is talking about other functions

It's addressing the same function, the buyCvx(). Where the call is originating from is not important.

#3 - elmutt

2023-10-03T13:05:14Z

My plan to fix this and #24 is pass _minOut to afEth.depositRewards() (in an upcoming PR).

Together with the fix for #31 (https://github.com/asymmetryfinance/afeth/pull/169) I think this issue will then be fixed

#4 - 0xleastwood

2023-10-04T07:37:21Z

Giving partial credit to this because it does not consider the case for withdraw()/sellCvx()

#5 - c4-judge

2023-10-04T07:37:37Z

0xleastwood marked the issue as partial-50

#6 - c4-judge

2023-10-04T07:49:42Z

0xleastwood marked the issue as duplicate of #23

#7 - d3e4

2023-10-05T17:11:19Z

I mentioned the case for withdraw()/sellCvx() in #67 (QA) under L-07. I considered those of low severity since the user is not supposed to interact directly with VotiumStrategy, which implies that no security guarantees are given and thus none are violated. The case of buyCvx() is more severe because it involves a lack of slippage protection in AfEth. But even here the issue is not in buyCvx() itself. As @elmutt correctly points out, the focal point of the issue is applyRewards(), which is where the _minOut parameter must be supplied. The main issue #23 doesn't even mention the impact on AfEth, but rather the same warden reported this in #24.

#8 - elmutt

2023-10-06T00:38:30Z

My plan to fix this and #24 is pass _minOut to afEth.depositRewards() (in an upcoming PR).

Together with the fix for #31 (asymmetryfinance/afeth#169) I think this issue will then be fixed

https://github.com/asymmetryfinance/afeth/pull/176 https://github.com/asymmetryfinance/afeth/pull/178

#9 - 0xleastwood

2023-10-07T12:45:17Z

Noted, restoring full credit to account for the added context outlined in the warden's QA report #67.

#10 - c4-judge

2023-10-07T12:45:21Z

0xleastwood marked the issue as full credit

#11 - c4-judge

2023-10-07T12:49:24Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4

Labels

bug
2 (Med Risk)
partial-50
duplicate-54

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L287-L290

Vulnerability details

Description

VotiumStrategyCore.applyRewards() gives unlimited allowance on its claimed rewards tokens. It is not thereafter reset and there is not even any way to reset the allowance. It is dangerous to trust the spenders indefinitely in case they are compromised or otherwise turns undesirable.

Reset the allowance to 0 at the end of applyRewards().

Assessed type

ERC20

#0 - c4-judge

2023-10-03T13:54:04Z

0xleastwood marked the issue as primary issue

#1 - 0xleastwood

2023-10-04T08:40:07Z

It's unclear to me, but I don't think VotiumStrategy holds any tokens and therefore, the rewarder in VotiumStrategyCore would be unable to rug by approving arbitrary tokens where they are the spender right? @elmutt

#2 - c4-judge

2023-10-04T08:40:15Z

0xleastwood marked the issue as selected for report

#3 - 0xleastwood

2023-10-04T09:31:59Z

It's unclear to me, but I don't think VotiumStrategy holds any tokens and therefore, the rewarder in VotiumStrategyCore would be unable to rug by approving arbitrary tokens where they are the spender right? @elmutt

Actually it is pretty clear that there will be tokens held by the contract.

#4 - c4-judge

2023-10-04T09:32:17Z

0xleastwood marked the issue as duplicate of #54

#5 - c4-judge

2023-10-04T09:32:22Z

0xleastwood marked the issue as not selected for report

#6 - c4-judge

2023-10-04T09:32:26Z

0xleastwood marked the issue as satisfactory

#7 - c4-judge

2023-10-04T09:32:35Z

0xleastwood marked the issue as partial-50

#8 - 0xleastwood

2023-10-04T09:33:01Z

Giving partial credit because it is missing a lot of context as compared to the primary issue.

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
duplicate-49

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategy.sol#L78

Vulnerability details

Impact

A user might be given an unnecessarily late withdrawal epoch. VotiumStrategy.requestWithdraw() might revert altogether.

Proof of Concept

VotiumStrategy.requestWithdraw() finds the epoch at which withdrawal is possible by the following logic:

(
    ,
    uint256 unlockable,
    ,
    ILockedCvx.LockedBalance[] memory lockedBalances
) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(address(this));
uint256 cvxAmount = (_amount * _priceInCvx) / 1e18;
cvxUnlockObligations += cvxAmount;

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) {
        /*
        Withdraw epoch set to the unlock time of lockedBalances[i].
        */
    }
}
// should never get here
revert InvalidLockedAmount();

This fails to account for when totalLockedBalancePlusUnlockable is sufficient for immediate withdrawal (before the for-loop), and at best sets it to the epoch of the first locked balance. Withdrawal which should be possible immediately is thus artificially given an extended lock time. If all locks have expired then lockedBalances is a zero-length array and the for-loop is skipped and it reaches revert InvalidLockedAmount().

Check whether totalLockedBalancePlusUnlockable >= cvxUnlockObligations before adding any lockedBalances[i].amount and set the withdrawal to the current epoch or immediately effectuate a withdrawal.

Assessed type

Invalid Validation

#0 - c4-judge

2023-10-03T18:37:22Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T08:49:15Z

0xleastwood marked issue #49 as primary and marked this issue as a duplicate of 49

#2 - c4-judge

2023-10-04T10:08:05Z

0xleastwood marked the issue as satisfactory

#3 - c4-sponsor

2023-10-04T14:51:08Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev

Labels

bug
grade-a
QA (Quality Assurance)
sponsor confirmed
Q-02

Awards

Data not available

External Links

[L-01] Dust loss

Dust may be lost in AfEth.deposit() by calculating uint256 sValue = (amount * ratio) / 1e18; and then uint256 vValue = (amount * (1e18 - ratio)) / 1e18;. The second should instead be calculated as uint256 vValue = amount - sValue;. This is also cheaper.

[L-02] Division before multiplication

AfEth.price() loses some precision in the form of (a/k + b/k)*k <= a + b. Refactor:

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;
+        safEthBalanceMinusPending());
    uint256 vEthValueInEth = (vEthStrategy.price() *
-        vEthStrategy.balanceOf(address(this))) / 1e18;
+        vEthStrategy.balanceOf(address(this)));
-    return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply();
+    return (vEthValueInEth + safEthValueInEth) / totalSupply();
}

[L-03] Missing zero-address check for manager in VotiumStrategyCore.initialize()

manager is initialised without any check. Unlike for other initialised values there is no function to later change/set manager.

[L-04] Unsafe setRatio()

ratio > 1e18 breaks deposit(). AfEth.setRatio(_newRatio) should check that _newRatio <= 1e18.

[L-05] AfEth.canWithdraw() does not consider pauseWithdraw

AfEth.canWithdraw() is inconsistent with pauseWithdraw which is checked in AfEth.withdraw(). This is the only place where it is internally used, so it may just be inlined instead. If this function still needs external visibility it should be amended to also check pauseWithdraw itself.

[L-06] VotiumStrategy.canWithdraw() returns true for non-existent withdraw requests

VotiumStrategy.canWithdraw() should return false when withdrawIdToWithdrawRequestInfo[_withdrawId].epoch == 0 which means that there is no withdraw request with that id.

[L-07] VotiumStrategy deposits and withdrawals are not slippage protected

Rather slippage is handled by AfEth.deposit() and by AfEth.withdraw(). VotiumStrategy() can be deposited into and withdrawn from directly. If this is not the intention consider preventing user mistakes by restricting calls to AfEth, or, if VotiumStrategy() ever is to be accessed directly, add a slippage protection.

[L-08] depositRewards(_amount) do not check that _amount == msg.value

This goes for both AfEth.depositRewards() and VotiumStrategyCore.depositRewards(). Any discrepancy would be lost. AfEth.depositRewards() is called only by VotiumStrategyCore.applyRewards() (and may therefore be changed to external), whereas VotiumStrategyCore.depositRewards() is called either by VotiumStrategyCore.applyRewards() or by AfEth.depositRewards(). VotiumStrategyCore.applyRewards() is not payable but obtains ether by itself. VotiumStrategyCore.depositRewards() can therefore not check msg.value in the case it is used internally. It can be checked when called by AfEth.depositRewards(), and AfEth.depositRewards() can be checked in any case. Consider checking that _amount == msg.value when possible or restricting the call flow. Alternatively, use msg.value or address(this).balance, whichever is applicable.

[L-09] No way to retrieve stuck ether

VotiumStrategyCore has a withdrawStuckTokens(), but no function to unstuck ether. Nor does AfEth. Both have receive() external payable {}. Consider implementing functions to retrieve stuck ether.

[N-01] Comment errors

The comment Sets the target ratio of safEth to votium. is misleading. The ratio is not "safEth to votium" i.e. safEth/votium, but safEth to total balances, i.e. safEth/(safEth + votium).

VotiumStrategy.requestWithdraw() does not burn afEth tokens as stated in a notice. It burns its own vAfEth tokens. The afEth tokens are burned in AfEth.withdraw().

cvxPerVotium returns the price of CVX per vAfEth, not per afEth as stated in the comments.

[N-02] Typos

transfering -> transferring equivilent -> equivalent

#0 - c4-judge

2023-10-04T06:33:45Z

0xleastwood marked the issue as grade-b

#1 - c4-judge

2023-10-08T12:16:54Z

0xleastwood marked the issue as grade-a

#2 - c4-sponsor

2023-10-09T16:36:35Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4, m_Rassska, rvierdiiev

Labels

bug
G (Gas Optimization)
grade-b
G-01

Awards

Data not available

External Links

Use try-catch instead of retrieving check-value

In `VotiumStrategy.relock()``

(, uint256 unlockable, , ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(
    address(this)
);
if (unlockable > 0)
    ILockedCvx(VLCVX_ADDRESS).processExpiredLocks(false);

unlockable is retrieved simply to avoid calling processExpiredLocks, which reverts if there are no expired locks. lockedBalances() is not a trival function and can be avoided here by just putting processExpiredLocks() in a try-catch instead.

Use VotiumStrategy.withdrawTime() in VotiumStrategy.requestWithdraw() instead of identical inline code

VotiumStrategy.requestWithdraw() performs the exact same calculation as VotiumStrategy.withdrawTime() to find the epoch at which there is enough to unlock this position. withdrawTime() can be reused in requestWithdraw() for finding the lockedBalances[i].unlockTime. The other calculations do not have to be performed in the loop, and withdrawTime() has the same revert condition. Note that cvxUnlockObligations += cvxAmount; would have to be performed after withdrawTime().

Refactor if-statement

In VotiumStrategy.relock()

uint256 cvxAmountToRelock = cvxBalance > cvxUnlockObligations
    ? cvxBalance - cvxUnlockObligations
    : 0;
if (cvxAmountToRelock > 0) {
    IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock);
    ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0);
}

can be refactored into

if (cvxBalance > cvxUnlockObligations) {
    uint256 cvxAmountToRelock = cvxBalance - cvxUnlockObligations;
    IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock);
    ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0);
}

ratio, then remainder

uint256 vValue = (amount * (1e18 - ratio)) / 1e18;, can be calculated as uint256 vValue = amount - sValue;, where uint256 sValue = (amount * ratio) / 1e18;. This is also prevents dust loss.

#0 - c4-judge

2023-10-04T05:58:34Z

0xleastwood marked the issue as grade-b

Findings Information

🌟 Selected for report: m_Rassska

Also found by: adriro, d3e4

Labels

analysis-advanced
grade-b
A-01

Awards

Data not available

External Links

Centralisation risks

Deposits and withdrawals to AfEth can be independently paused by the owner. Especially pausing withdrawals could be leveraged to harm users. The protocol fee can be set up to 100 % by the owner, which combined with paused withdrawals effectively steals the users stake. The owner could also just steal all CVX, or any other reward tokens, from VotiumStrategy by using withdrawStuckTokens(). The rewarder can also exploit applyRewards() to give anyone unlimited allowance on any of VotiumStrategy's tokens.

Ratio mechanism

The ratio can be freely set by the owner (even to > 100 % such that it causes deposits to revert). The ratio between the held safEth and vAfEth is adjusted by depositing in the set ratio. Withdrawals are done in proportion to whatever the balances are. Only the rewards adjusts the balances actively towards the intended ratio by depositing only on the side where there is a shortage. The rewards are relatively small compared to the balances however. This means that the balance ratio will converge approximately exponentially but quite slowly. Several times the balances have to be deposited and withdrawn in order to reach close to the new ratio.

Tokenisation of underlying assets

Several issues were found related to how AfEth exchanges its afEth for its underlying safEth and vAfEth. A complicating factor is that unlike a vault with only one asset, this one has two. That means that afEth cannot simply represent a direct share of its underlying, but there must be an intermediary valuation by which they can be measured together. Here their estimated values in ETH is used. An issue arises from this when the estimated value is not the same as the effective value when actually deposited or withdrawn.

Time spent:

40 hours

#0 - c4-judge

2023-10-04T05:41:19Z

0xleastwood marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter