Asymmetry Finance afETH Invitational - adriro'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: 4/5

Findings: 13

Award: $0.00

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

🌟 Selected for report: 12

🚀 Solo Findings: 3

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4, rvierdiiev

Labels

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

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L252-L253

Vulnerability details

Summary

Withdrawals of amount zero from both SafEth and VotiumStrategy have issues downstream that will cause the transaction to revert, potentially bricking withdrawals from being executed.

Impact

Withdrawals in AfEth undergo a process to account for any potential delay when withdrawing locked tokens in the VotiumStrategy. When a withdrawal is requested, the implementation calculates the owed amounts for each token and queues the withdrawal. SafEth tokens will be reserved in the contract, and VotiumStrategy will also queue the withdrawal of CVX tokens.

When the time arrives, the user can call withdraw() to execute the withdrawal. This function will unstake from SafEth and withdraw from VotiumStrategy.

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L252-L253

252:         ISafEth(SAF_ETH_ADDRESS).unstake(withdrawInfo.safEthWithdrawAmount, 0);
253:         AbstractStrategy(vEthAddress).withdraw(withdrawInfo.vEthWithdrawId);

Let's first consider the SafEth case. The current unstake() implementation in SafEth will revert if the unstaked amount is zero:

https://etherscan.io/address/0x591c4abf20f61a8b0ee06a5a2d2d2337241fe970#code#F1#L124

119:     function unstake(
120:         uint256 _safEthAmount,
121:         uint256 _minOut
122:     ) external nonReentrant {
123:         if (pauseUnstaking) revert UnstakingPausedError();
124:         if (_safEthAmount == 0) revert AmountTooLow();
125:         if (_safEthAmount > balanceOf(msg.sender)) revert InsufficientBalance();

As we can see in line 124, if _safEthAmount is zero the function will revert, and the transaction to withdraw() will revert too due to the bubbled error. This means that any withdrawal that ends up with a zero amount for SafEth will be bricked.

The VotiumStrategy case has a similar issue. The implementation of withdraw() will call sellCvx() to swap the owed amount of CVX for ETH. This is executed using a Curve pool, as we can see in the following snippet of code:

250:     function sellCvx(
251:         uint256 _cvxAmountIn
252:     ) internal returns (uint256 ethAmountOut) {
253:         address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
254:         // cvx -> eth
255:         uint256 ethBalanceBefore = address(this).balance;
256:         IERC20(CVX_ADDRESS).approve(CVX_ETH_CRV_POOL_ADDRESS, _cvxAmountIn);
257: 
258:         ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying(
259:             1,
260:             0,
261:             _cvxAmountIn,
262:             0 // this is handled at the afEth level
263:         );
264:         ethAmountOut = address(this).balance - ethBalanceBefore;
265:     }

If we drill down in the Curve implementation, we can see that it validates that the input amount is greater than zero:

https://etherscan.io/address/0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4#code#L714

709: def _exchange(sender: address, mvalue: uint256, i: uint256, j: uint256, dx: uint256, min_dy: uint256, use_eth: bool) -> uint256:
710:     assert not self.is_killed  # dev: the pool is killed
711:     assert i != j  # dev: coin index out of range
712:     assert i < N_COINS  # dev: coin index out of range
713:     assert j < N_COINS  # dev: coin index out of range
714:     assert dx > 0  # dev: do not exchange 0 coins

Again, this means that any withdrawal that ends up with a zero amount of vAfEth tokens (or the associated amount of CVX tokens) will be bricked when trying to execute the swap.

This can happen for different reasons. For example the current ratio may be 0 or 1e18, meaning the split goes entirely to SafEth or to VotiumStrategy. Another reason could be rounding, for small quantities the proportion may round down values to zero.

The critical issue is that both withdrawals are executed simultaneously. A zero amount shouldn't matter, but both happen at the time, and one may affect the other. If the SafEth amount is zero, it will brick the withdrawal for a potentially non-zero vAfEth amount. Similarly, if the vAfEth amount is zero, it will brick the withdrawal for a potentially non-zero SafEth amount

Proof of Concept

To simplify the case, let's say the current ratio is zero, meaning all goes to VotiumStrategy.

  1. A user calls requestWithdraw(). Since currently the SafEth ratio is zero, the contract doesn't hold a position in SafEth. This means that safEthWithdrawAmount = 0, and the position is entirely in vAfEth (votiumWithdrawAmount > 0).
  2. Time passes and the user can finally withdraw.
  3. The user calls withdraw(). The implementation will try to call SafEth::unstake(0), which will cause an error, reverting the whole transaction.
  4. The user will never be able to call withdraw(). Even if the ratios are changed, the calculated amount will be already stored in the withdrawIdInfo mapping. The withdrawal will be bricked, causing the loss of the vAfEth tokens.

Recommendation

For SafEth, avoid calling SafEth::unstake() if the calculated amount is zero:

+ if (withdrawInfo.safEthWithdrawAmount > 0) {
    ISafEth(SAF_ETH_ADDRESS).unstake(withdrawInfo.safEthWithdrawAmount, 0);
+ }

For VotiumStrategy, prevent requesting the withdrawal if votiumWithdrawAmount is zero, while also keeping track of this to also avoid executing the withdrawal when AfEth::withdraw() is called.

It is also recommended to add a guard in VotiumStrategy::withdraw() to avoid calling sellCvx() when cvxWithdrawAmount = 0.

-  uint256 ethReceived = sellCvx(cvxWithdrawAmount);
+  uint256 ethReceived = cvxWithdrawAmount > 0 ? sellCvx(cvxWithdrawAmount) : 0;

Assessed type

Other

#1 - c4-judge

2023-10-03T13:07:42Z

0xleastwood marked the issue as primary issue

#2 - 0xleastwood

2023-10-04T07:28:55Z

It's unclear under what circumstances, withdrawRatio will be zero. As it appears, votiumWithdrawAmount is calculated as (withdrawRatio * votiumBalance) / 1e18 and similarly, safEthWithdrawAmount is calculated as (withdrawRatio * safEthBalance) / 1e18. So it seems the withdraw ratio is applied in the same manner to both of these amounts?

#3 - 0xleastwood

2023-10-04T07:30:37Z

The main case where this causes issues is when votiumBalance is non-zero and safEthBalance is zero or vice-versa. I'm curious as to when this might happen @elmutt ?

#4 - c4-judge

2023-10-04T08:17:24Z

0xleastwood marked the issue as selected for report

#5 - elmutt

2023-10-04T13:42:31Z

withdrawRatio

withdrawRatio represents the ratio of the amount being withdrawn to the total supply. So if a user owns 1% of afEth and withdraws their entire balance they will be set to receive 1% of each of the underlying assets (safEth & votiumStrategy) based on their current prices.

It should never be zero unless user is withdrawing the the last afEth from the system but we plan to solve this with an initial seed deposit

#6 - c4-sponsor

2023-10-04T14:39:03Z

elmutt (sponsor) confirmed

#7 - 0xleastwood

2023-10-04T18:40:17Z

Okay good to know, I think I understand what you mean now. Issue appears valid and I think high severity is justified because the last staker would be unable to execute their withdrawal.

#8 - 0xleastwood

2023-10-04T18:45:57Z

However, can you explain why withdrawRatio would be zero upon the last withdrawal? It is calculated as (_amount * 1e18) / (totalSupply() - afEthBalance) where the denominator is equal to the _amount. Hence, this is equal to 1e18. So it attempts to withdraw all votium and safEth tokens from the contract.

A better thing to understand would be, when would either of this token balances be non-zero? And your mitigation is to seed the contract with some tokens initially so the token balance is always positive?

#9 - elmutt

2023-10-05T05:28:17Z

I think we actually have a bug here. We shouldnt be subtracting afEthBalance.

Previously we subtracted it because the afEth contract held the users afEth before finally burning it on withdraw(). Now we just burn it on requestWithdraw() so we shouldn't be subtracting anymore.

does that make sense?

#10 - 0xleastwood

2023-10-05T07:14:55Z

Agreed, that makes sense. No need to track afEthBalance anymore. There might be other areas where this is being done incorrectly too.

#11 - elmutt

2023-10-05T14:34:16Z

Agreed, that makes sense. No need to track afEthBalance anymore. There might be other areas where this is being done incorrectly too.

https://github.com/asymmetryfinance/afeth/pull/172

fix for bug discussed

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, rvierdiiev

Labels

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

Awards

Data not available

External Links

Lines of code

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

Vulnerability details

Summary

The current price implementation for the VotiumStrategy token uses a potentially invalid Chainlink response. This price is then used to calculate the price of AfEth and, subsequently, the amount of tokens to mint while depositing.

Impact

The price of VotiumStrategy tokens are determined by taking the amount of deposited CVX in the strategy, and multiplied by the current price of CVX in terms of ETH. This price is fetched using Chainlink in the ethPerCvx() function:

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L156-L186

156:     function ethPerCvx(bool _validate) public view returns (uint256) {
157:         ChainlinkResponse memory cl;
158:         try chainlinkCvxEthFeed.latestRoundData() returns (
159:             uint80 roundId,
160:             int256 answer,
161:             uint256 /* startedAt */,
162:             uint256 updatedAt,
163:             uint80 /* answeredInRound */
164:         ) {
165:             cl.success = true;
166:             cl.roundId = roundId;
167:             cl.answer = answer;
168:             cl.updatedAt = updatedAt;
169:         } catch {
170:             cl.success = false;
171:         }
172:         // verify chainlink response
173:         if (
174:             (!_validate ||
175:                 (cl.success == true &&
176:                     cl.roundId != 0 &&
177:                     cl.answer >= 0 &&
178:                     cl.updatedAt != 0 &&
179:                     cl.updatedAt <= block.timestamp &&
180:                     block.timestamp - cl.updatedAt <= 25 hours))
181:         ) {
182:             return uint256(cl.answer);
183:         } else {
184:             revert ChainlinkFailed();
185:         }
186:     }

As we can see from the previous snippet of code, if the _validate flag is off, then no validation is done, it can even return an uninitialized response from a failed call given the usage of the try/catch structure. This means that it can invalid price, stale price, or even zero when the call fails.

The VotiumStrategy price() function calls ethPerCvx(false), which means it carries forward any invalid CVX/ETH price.

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L31-L33

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

The price of VotiumStrategy is then used in the AfEth contract to calculate its price and determine the amount of tokens to mint in deposit()

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L133-L169

133:     function price() public view returns (uint256) {
134:         if (totalSupply() == 0) return 1e18;
135:         AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress);
136:         uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) *
137:             safEthBalanceMinusPending()) / 1e18;
138:         uint256 vEthValueInEth = (vEthStrategy.price() *
139:             vEthStrategy.balanceOf(address(this))) / 1e18;
140:         return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply();
141:     }

148:     function deposit(uint256 _minout) external payable virtual {
149:         if (pauseDeposit) revert Paused();
150:         uint256 amount = msg.value;
151:         uint256 priceBeforeDeposit = price();
152:         uint256 totalValue;
153: 
154:         AbstractStrategy vStrategy = AbstractStrategy(vEthAddress);
155: 
156:         uint256 sValue = (amount * ratio) / 1e18;
157:         uint256 sMinted = sValue > 0
158:             ? ISafEth(SAF_ETH_ADDRESS).stake{value: sValue}(0)
159:             : 0;
160:         uint256 vValue = (amount * (1e18 - ratio)) / 1e18;
161:         uint256 vMinted = vValue > 0 ? vStrategy.deposit{value: vValue}() : 0;
162:         totalValue +=
163:             (sMinted * ISafEth(SAF_ETH_ADDRESS).approxPrice(true)) +
164:             (vMinted * vStrategy.price());
165:         if (totalValue == 0) revert FailedToDeposit();
166:         uint256 amountToMint = totalValue / priceBeforeDeposit;
167:         if (amountToMint < _minout) revert BelowMinOut();
168:         _mint(msg.sender, amountToMint);
169:     }

The VotiumStrategy price is first used in line 138 to calculate its TVL (vEthValueInEth). Any invalid price here will also mean an invalid price for AfEth.

Then both the AfEth price (line 151) and again the VotiumStrategy price (line 164) are used in deposit() to calculate the number of minted tokens. Depending on the direction of the wrong price, this means that the user will be minted more or less tokens than it should.

Proof of Concept

Let's suppose the Chainlink feed is stale and the current price of CVX/ETH has increased since then.

  1. A user calls deposit() to create a new position in AfEth.
  2. The function calculates the current price (priceBeforeDeposit) in order to know how many tokens should be minted.
  3. The price() implementation will calculate the Votium strategy TVL using ethPerCvx(false), which will successfully return the stale price.
  4. The price of AfEth will then be calculated using the old data, which will result in a lower value than the actual "real" price.
  5. The user is minted tokens based on the incorrectly calculated priceBeforeDeposit, since this price is lower than the expected "real" price the user will be minted more tokens than expected.

Recommendation

Change the ethPerCvx() argument to true to make sure prices coming from Chainlink are correctly validated.

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

Assessed type

Oracle

#1 - 0xleastwood

2023-10-03T13:24:14Z

Should we not be prioritising liveness here over validating chainlink results?

#2 - c4-judge

2023-10-03T13:24:35Z

0xleastwood marked the issue as primary issue

#3 - 0xleastwood

2023-10-04T08:20:18Z

It seems important to avoid using stale price data which can be readily arbitraged. Severity seems correct.

#4 - c4-judge

2023-10-04T08:20:28Z

0xleastwood marked the issue as selected for report

#5 - c4-sponsor

2023-10-04T14:46:02Z

elmutt (sponsor) confirmed

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/main/contracts/AfEth.sol#L133-L141

Vulnerability details

Summary

When withdrawals are enqueued in AfEth, the implementation will remove the tokens from the caller and lock these in the contract until the withdrawal is made effective. These tokens still count in the supply, and must not be considered during price calculation.

Impact

Withdrawals in AfEth undergo a process to account for any potential delay when withdrawing locked tokens in the VotiumStrategy. During this window of time, AfEth tokens are transferred from the caller into the contract. This can be seen in the implementation of requestWithdraw():

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L175-L187

175:     function requestWithdraw(uint256 _amount) external virtual {
176:         uint256 withdrawTimeBefore = withdrawTime(_amount);
177:         if (pauseWithdraw) revert Paused();
178:         latestWithdrawId++;
179: 
180:         // ratio of afEth being withdrawn to totalSupply
181:         // we are transfering the afEth to the contract when we requestWithdraw
182:         // we shouldn't include that in the withdrawRatio
183:         uint256 afEthBalance = balanceOf(address(this));
184:         uint256 withdrawRatio = (_amount * 1e18) /
185:             (totalSupply() - afEthBalance);
186: 
187:         _transfer(msg.sender, address(this), _amount);
...

Line 187 transfers the tokens from the caller to the same AfEth contract, which are held until withdrawals are made effective, at which point the tokens are burned. Notice that these locked tokens are taken into account while calculating the withdrawRatio in line 184-185.

However, the same consideration is not present in the calculation of price():

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L133-L141

133:     function price() public view returns (uint256) {
134:         if (totalSupply() == 0) return 1e18;
135:         AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress);
136:         uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) *
137:             safEthBalanceMinusPending()) / 1e18;
138:         uint256 vEthValueInEth = (vEthStrategy.price() *
139:             vEthStrategy.balanceOf(address(this))) / 1e18;
140:         return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply();
141:     }

As we can see in the previous snippet of code, the total supply of the token is referenced in lines 134 and 140 without subtracting the "locked" tokens held in the contract. This leads to different inconsistencies that will end up with an incorrect price value.

  • If the single/last token holder requests a withdrawal, then those locked tokens are still counted in the supply. This means that the condition in line 134 will be false, where in reality it should be true since there is no circulating supply, and the return value of this function should be 1e18.
  • When a holder requests a withdrawal, the implementation will reduce the balance of SafEth (by incrementing pendingSafEthWithdraws) and the balance of vAfEth (since those tokens are burned when VotiumStrategy::withdraw() is called). This means that calculation in line 140 will also be affected, vEthValueInEth and safEthValueInEth are calculated with the reduced position, but then those are divided by totalSupply(), which still counts for locked tokens.

Proof of Concept

  1. User holds AfEth tokens and decides to withdraw by calling requestWithdraw().
  2. Tokens are transferred from the user and locked into the contract. Total supply is not affected.
  3. Corresponding token balances of SafEth and vAfEth are reduced in relation to the withdrawal. For SafEth, the pendingSafEthWithdraws is increased, making safEthBalanceMinusPending() account for this difference. For vAfEth, the balance held by the contract is reduced when the tokens are burned when VotiumStrategy::withdraw() is called.
  4. At this point (and until the withdrawal is made effective when withdraw() is called), the implementation of price() will use the reduced balances to calculate safEthValueInEth and vEthValueInEth, but will still normalize them by totalSupply(), which considers the locked tokens in the contract.

Recommendation

The issue could be fixed by directly burning the tokens when requestWithdraw() is called, instead of transferring and locking them in the contract.

If these should still count in the totalSupply() for external reference while the withdrawal is pending, then the implementation of price() must factor them in the calculation:

    function price() public view returns (uint256) {
+       uint256 _totalSupply = totalSupply() - balanceOf(address(this));
-       if (totalSupply() == 0) return 1e18;
+       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();
+       return ((vEthValueInEth + safEthValueInEth) * 1e18) / _totalSupply;
    }

Assessed type

Other

#1 - c4-judge

2023-10-03T13:36:48Z

0xleastwood marked the issue as duplicate of #59

#2 - c4-judge

2023-10-03T13:37:24Z

0xleastwood marked the issue as not a duplicate

#3 - c4-judge

2023-10-03T13:37:33Z

0xleastwood marked the issue as duplicate of #25

#4 - c4-judge

2023-10-04T10:06:25Z

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/main/contracts/strategies/votium/VotiumStrategy.sol#L39 https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L109

Vulnerability details

Summary

Direct deposits and withdrawals within VotiumStrategy lack any slippage controls, which opens up the possibility of sandwich attacks and Miner Extractable Value (MEV) exploits.

Impact

Interactions in the AfEth protocol often require the exchange of ETH for other assets. Users deposit ETH, and the protocol needs to convert it to other LSD tokens while staking in SafEth, and also to CVX tokens while depositing in the VotiumStrategy. Similarly, during withdrawals, the protocol converts those assets back to ETH.

While the AfEth contract implements slippage control in deposit() and withdraw() through a _minout parameter to ensure the return of a minimum number of assets after execution, the same is not true for the VotiumStrategy contract, which can be used in a standalone fashion.

The deposit() function in VotiumStrategy simple takes the received ETH amount and swaps it to CVX using buyCvx():

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L39-L46

39:     function deposit() public payable override returns (uint256 mintAmount) {
40:         uint256 priceBefore = cvxPerVotium();
41:         uint256 cvxAmount = buyCvx(msg.value);
42:         IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
43:         ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
44:         mintAmount = ((cvxAmount * 1e18) / priceBefore);
45:         _mint(msg.sender, mintAmount);
46:     }

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L227-L243

227:     function buyCvx(
228:         uint256 _ethAmountIn
229:     ) internal returns (uint256 cvxAmountOut) {
230:         address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
231:         // eth -> cvx
232:         uint256 cvxBalanceBefore = IERC20(CVX_ADDRESS).balanceOf(address(this));
233:         ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying{
234:             value: _ethAmountIn
235:         }(
236:             0,
237:             1,
238:             _ethAmountIn,
239:             0 // this is handled at the afEth level
240:         );
241:         uint256 cvxBalanceAfter = IERC20(CVX_ADDRESS).balanceOf(address(this));
242:         cvxAmountOut = cvxBalanceAfter - cvxBalanceBefore;
243:     }

In the previous snippet of code, line 239, we can see that the last argument to exchange_underlying() is zero, which is the minimum output amount. The comment reads "this is handled at the afEth level" which is partially true: it is only checked when being used from AfEth, but not for direct depositors of the VotiumStrategy.

The withdraw() function has the same issue when swapping CVX for ETH in sellCvx():

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L109-L29

109:     function withdraw(uint256 _withdrawId) external override {
110:         if (withdrawIdToWithdrawRequestInfo[_withdrawId].owner != msg.sender)
111:             revert NotOwner();
112:         if (!this.canWithdraw(_withdrawId)) revert WithdrawNotReady();
113: 
114:         if (withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn)
115:             revert AlreadyWithdrawn();
116: 
117:         relock();
118: 
119:         uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId]
120:             .cvxOwed;
121: 
122:         uint256 ethReceived = sellCvx(cvxWithdrawAmount);
123:         cvxUnlockObligations -= cvxWithdrawAmount;
124:         withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn = true;
125: 
126:         // solhint-disable-next-line
127:         (bool sent, ) = msg.sender.call{value: ethReceived}("");
128:         if (!sent) revert FailedToSend();
129:     }

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L250-L265

250:     function sellCvx(
251:         uint256 _cvxAmountIn
252:     ) internal returns (uint256 ethAmountOut) {
253:         address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
254:         // cvx -> eth
255:         uint256 ethBalanceBefore = address(this).balance;
256:         IERC20(CVX_ADDRESS).approve(CVX_ETH_CRV_POOL_ADDRESS, _cvxAmountIn);
257: 
258:         ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying(
259:             1,
260:             0,
261:             _cvxAmountIn,
262:             0 // this is handled at the afEth level
263:         );
264:         ethAmountOut = address(this).balance - ethBalanceBefore;
265:     }

Again, in line 262 we see the same issue as before, the minimum output is configured to zero.

We can see that there is no slippage control at any level, the swap output amount is configured to zero, and there is no check of the number of minted tokens or the amount of ETH sent back to the user.

This implies that direct deposits or withdrawals in the VotiumStrategy contract are susceptible to arbitrary sandwich attacks by MEV bots, which could potentially result in a loss of funds for the user. In the case of a sandwiched deposit, there will be a reduced issuance of VotiumStrategy tokens, as the swap may yield fewer CVX tokens than anticipated. Similarly, a sandwiched withdrawal may lead to a reduced amount of ETH being returned to the user.

Proof of Concept

  1. A user sends a transaction to deposit in VotiumStrategy.
  2. A MEV bot sees the transaction, and swaps a large amount of ETH for CVX in the Curve Pool.
  3. The user's transaction goes through and swap is executed in the manipulated pool.
  4. The MEV bot swaps CVX back to ETH, and pockets the slippage difference.
  5. The user gets less CVX tokens, which means less minted VotiumStrategy tokens.

Recommendation

Add a minimum output parameter to both deposit() and withdraw() in the VotiumStrategy contract.

The AfEth contract can still set these arguments as zero, since it already has slippage control, while allowing direct users of the VotiumStrategy contract to protect against MEV attacks.

-   function deposit() public payable override returns (uint256 mintAmount) {
+   function deposit(uint256 minOut) public payable override returns (uint256 mintAmount) {
        uint256 priceBefore = cvxPerVotium();
        uint256 cvxAmount = buyCvx(msg.value);
        IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
        ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
        mintAmount = ((cvxAmount * 1e18) / priceBefore);
+       require(mintAmount >= minOut);
        _mint(msg.sender, mintAmount);
    }
-   function withdraw(uint256 _withdrawId) external override {
+   function withdraw(uint256 _withdrawId, uint256 minOut) external override {
        if (withdrawIdToWithdrawRequestInfo[_withdrawId].owner != msg.sender)
            revert NotOwner();
        if (!this.canWithdraw(_withdrawId)) revert WithdrawNotReady();

        if (withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn)
            revert AlreadyWithdrawn();

        relock();

        uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId]
            .cvxOwed;

        uint256 ethReceived = sellCvx(cvxWithdrawAmount);
        cvxUnlockObligations -= cvxWithdrawAmount;
        withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn = true;

+       require(ethReceived >= minOut);

        // solhint-disable-next-line
        (bool sent, ) = msg.sender.call{value: ethReceived}("");
        if (!sent) revert FailedToSend();
    }

Assessed type

MEV

#0 - elmutt

2023-09-27T23:36:35Z

@toshiSat Im thinking another solution could be to lock down votiumStrategy so only afEth can interact with it

#1 - c4-judge

2023-09-30T15:13:44Z

0xleastwood marked the issue as selected for report

#2 - c4-judge

2023-09-30T15:14:04Z

0xleastwood marked the issue as not selected for report

#3 - c4-judge

2023-09-30T15:14:10Z

0xleastwood marked the issue as primary issue

#4 - elmutt

2023-10-02T20:01:22Z

https://github.com/asymmetryfinance/afeth/pull/169

we went with locking it down so only afEth deposit or withdraw from votium strategy

#5 - c4-judge

2023-10-04T07:36:46Z

0xleastwood marked the issue as selected for report

#6 - c4-judge

2023-10-04T07:49:43Z

0xleastwood marked the issue as duplicate of #23

#7 - c4-judge

2023-10-04T07:50:02Z

0xleastwood marked the issue as not selected for report

#8 - c4-judge

2023-10-04T07:50:10Z

0xleastwood marked the issue as partial-50

#9 - 0xleastwood

2023-10-04T07:50:39Z

Only giving 50% credit because it misses the depositRewards() edge case.

#10 - 0xleastwood

2023-10-05T07:29:24Z

Giving full credit as paired with #41, it covers all cases outlined in the primary issue.

#11 - c4-judge

2023-10-05T07:29:29Z

0xleastwood marked the issue as full credit

#12 - c4-judge

2023-10-05T07:30:27Z

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
sponsor confirmed
upgraded by judge
duplicate-23

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L272-L293

Vulnerability details

Summary

Deposits to SafEth and VotiumStrategy coming from rewards lack slippage control, making them susceptible to sandwich attacks by MEV bots, which can result in a loss of funds for the protocol.

Impact

Rewards coming from the VotiumStrategy contract are compounded and deposited back to the protocol using the depositRewards() function:

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L272-L293

272:     function depositRewards(uint256 _amount) public payable {
273:         IVotiumStrategy votiumStrategy = IVotiumStrategy(vEthAddress);
274:         uint256 feeAmount = (_amount * protocolFee) / 1e18;
275:         if (feeAmount > 0) {
276:             // solhint-disable-next-line
277:             (bool sent, ) = feeAddress.call{value: feeAmount}("");
278:             if (!sent) revert FailedToSend();
279:         }
280:         uint256 amount = _amount - feeAmount;
281:         uint256 safEthTvl = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) *
282:             safEthBalanceMinusPending()) / 1e18;
283:         uint256 votiumTvl = ((votiumStrategy.cvxPerVotium() *
284:             votiumStrategy.ethPerCvx(true)) *
285:             IERC20(vEthAddress).balanceOf(address(this))) / 1e36;
286:         uint256 totalTvl = (safEthTvl + votiumTvl);
287:         uint256 safEthRatio = (safEthTvl * 1e18) / totalTvl;
288:         if (safEthRatio < ratio) {
289:             ISafEth(SAF_ETH_ADDRESS).stake{value: amount}(0);
290:         } else {
291:             votiumStrategy.depositRewards{value: amount}(amount);
292:         }
293:     }

As we can see in lines 289 and 291, depending on the current TVL ratio between both collaterals, rewards will be either staked in SafEth or deposited back to VotiumStrategy.

Both scenarios experience the same lack of slippage control. The call to stake() sets zero as the _minOut argument in SafEth. Similarly, in the depositRewards() function of VotiumStrategy, the process involves using a Curve Pool to exchange ETH for CVX tokens with a hardcoded min_dy parameter set to zero.

In any case, the reward compounding transaction could be sandwiched by a MEV bot, causing less output received and a loss of funds to the protocol in general.

Recommendation

Add slippage control to the deposit rewards flow in order to allow the rewarded role to specify a minimum output of assets when rewards are either staked in SafEth, or swapped for CVX in VotiumStrategy.

Assessed type

MEV

#0 - 0xleastwood

2023-10-03T12:59:15Z

One part of this issue (slippage in depositRewards()) is a duplicate of #39 and the implementation of ISafEth(SAF_ETH_ADDRESS).stake() does not allow for any form of sandwich attack as far as I can see but will leave as primary for the sponsor to confirm. Keep in mind, there is no provided POC to showcase how this might be exploited in the case of the SAF_ETH_ADDRESS contract.

#1 - c4-judge

2023-10-03T12:59:21Z

0xleastwood marked the issue as primary issue

#2 - c4-judge

2023-10-04T07:56:36Z

0xleastwood marked the issue as satisfactory

#3 - c4-judge

2023-10-04T07:56:42Z

0xleastwood marked the issue as selected for report

#4 - c4-judge

2023-10-04T07:56:52Z

0xleastwood removed the grade

#5 - elmutt

2023-10-04T13:52:28Z

One part of this issue (slippage in depositRewards()) is a duplicate of #39 and the implementation of ISafEth(SAF_ETH_ADDRESS).stake() does not allow for any form of sandwich attack as far as I can see but will leave as primary for the sponsor to confirm. Keep in mind, there is no provided POC to showcase how this might be exploited in the case of the SAF_ETH_ADDRESS contract.

I believe our solution for #39 (https://github.com/asymmetryfinance/afeth/pull/169) should solve this but please let us know if you see any way this can still be exploited

#6 - c4-sponsor

2023-10-04T14:35:56Z

elmutt (sponsor) confirmed

#7 - 0xleastwood

2023-10-04T18:37:42Z

Technically this will fix the issue, but I guess I wanted to confirm if it is possible to sandwich attack the ISafEth(SAF_ETH_ADDRESS).stake{value: amount}(0) call in the first place? @elmutt

#8 - elmutt

2023-10-05T05:46:48Z

Technically this will fix the issue, but I guess I wanted to confirm if it is possible to sandwich attack the ISafEth(SAF_ETH_ADDRESS).stake{value: amount}(0) call in the first place? @elmutt

When you call stake() on safEth its buying 6 different LSD derivatives (reth, lido, ankr, swell, stafi, ankr) and will revert if the price paid for any of them is 1% or higher from the oracle price. You can see the revert in finalChecks() in derivativeBase.sol here: https://etherscan.io/address/0xb3e64c481f0fc82344a7045592284fddb9905b8b#code

Im not really an expert on sandwich attacks, would this mean it could be sandwiched for up to 1% if no minout is passed?

#9 - 0xleastwood

2023-10-05T07:23:48Z

Right, I see. You need some tolerance for slippage to allow for a trade to execute in the first place. I think this function is being restricted anyway, so no way to sandwich attack. But it seems possible to extract value still.

#10 - c4-judge

2023-10-05T07:29:42Z

0xleastwood marked the issue as duplicate of #23

#11 - c4-judge

2023-10-05T07:30:02Z

0xleastwood changed the severity to 3 (High Risk)

#12 - c4-judge

2023-10-05T07:30:11Z

0xleastwood marked the issue as not selected for report

#13 - c4-judge

2023-10-05T07:30:20Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
selected for report
sponsor acknowledged
M-01

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L90-L92

Vulnerability details

Summary

The AfEth ratio between the collaterals can be modified but there is no direct way to balance the assets to follow the new ratio.

Impact

The AfEth contract contains a configurable parameter ratio that indicates the intended balance between the two collaterals, SafEth and the Votium strategy. For example, a value of 3e17 means that 30% of the TVL should go to SafEth, and 70% should go to Votium.

This ratio is followed when new deposits are made. The deposited ETH is splitted according to the ratio and channeled in proportion to each collateral. The ratio is also checked when rewards are deposited to direct them to the proper collateral.

The ratio can also be modified by the admins of the protocol using the setRatio() function.

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L90-L92

90:     function setRatio(uint256 _newRatio) public onlyOwner {
91:         ratio = _newRatio;
92:     }

However, there is no way to balance the assets once a new ratio is defined. The implementation will need to rely on new deposits and reward compounding to slowly correct the offset, which may take a lot of time and may be impractical for most cases.

Proof of Concept

Let's assume the protocol is deployed with a ratio of 3e17.

  1. Deposits follow the configured ratio and split the TVL in 30% for SafEth and 70% for Votium.
  2. At some point, the protocol decides to switch to 50%-50% and sets a new ratio of 5e17.
  3. New deposits will follow the new ratio and split 50% for each collateral, but these have potentially accumulated a large amount of TVL with the previous split. The existing difference will continue, new deposits can't correct this offset.

Recommendation

Similar to how it is done in SafEth, the AfEth contract could have a rebalancing function which withdraws the proper amount from one collateral and deposits it in the other collateral, in order to correct the offset and target the new configured ratio. This function should be admin controlled, and support slippage control to correctly handle potentially large amounts of swaps.

An alternative could be to also correct a potential deviation in the ratio using new deposits. This could help speed up the correction by not only relying on rewards, but will also endure a delay in the convergence.

Assessed type

Other

#0 - c4-judge

2023-10-03T18:43:25Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T10:04:16Z

0xleastwood marked the issue as selected for report

#2 - elmutt

2023-10-04T14:57:38Z

Its hard to rebalance because of the locked convex. We have discussed this internally and consider it an acceptable risk for now so im acknowledging this issue instead of confirming

#3 - c4-sponsor

2023-10-04T14:57:43Z

elmutt marked the issue as disagree with severity

#4 - c4-sponsor

2023-10-04T14:57:48Z

elmutt (sponsor) acknowledged

#5 - c4-judge

2023-10-04T18:50:38Z

0xleastwood marked the issue as not selected for report

#6 - 0xleastwood

2023-10-05T07:22:30Z

Leaving as is because I don't think this is typical behaviour.

#7 - c4-judge

2023-10-05T07:22:37Z

0xleastwood marked the issue as selected for report

#8 - Rassska

2023-10-05T12:21:06Z

If the Asymmetry decides to reset the ratio = 5e17 from ratio == 7e17, they would probably set it to some small acceptable percentage, like 1e17 and then, when the tvl come close to the desired ratio, the reset to ratio = 5e17 should happen. This is just to speed up the rebalancing.

#9 - romeroadrian

2023-10-05T13:50:40Z

If the Asymmetry decides to reset the ratio = 5e17 from ratio == 7e17, they would probably set it to some small acceptable percentage, like 1e17 and then, when the tvl come close to the desired ratio, the reset to ratio = 5e17 should happen. This is just to speed up the rebalancing.

I don't think the CVX locking constraint takes away the issue. Note that the same is implemented in SafEth, with derivatives having different constraints in their respective LSD token.

For the specific concern in the Votium strategy and the CVX locking, it could be implemented by increasing the cvxUnlockObligations. Note that AfEth contract is a client of VotiumStrategy after all!

#10 - 0xleastwood

2023-10-05T15:56:01Z

I mean you could implement instant rebalancing but that probably adds too much complexity and isn't necessary, so instead this design choice was decided as acceptable. Don't think it invalidates the issue.

#11 - d3e4

2023-10-05T17:11:45Z

What is the actual impact of this? A forced rebalance would be associated with trading losses, so letting it rebalance organically seems like a good design choice. What is the harm in staying in the old ratio for a while? That was the exposure that was first deposited into after all, so it seems fair that when they then withdraw that they withdraw in about the same ratio. Rather it seems that the ratio can be seen as an agreement with the user on how his funds are to be invested. Suddenly rebalancing these would break this agreement.

As for how fast it converges to the new ratio, if the small amounts are deposited and withdrawn equally, the higher balance will converge as (startBalance - targetBalance) * exp(-x) + targetBalance, where x is the total amount both withdrawn and deposited so far. (In the report's example this is 0.2 * exp(-x) + 0.5). So to reduce the ratio error to a tenth (to turn (30%, 70%) into (48%, 52%)) then ln(10) ≈ 2.30 times the balances must turned over. This does not seem necessarily inappropriately slow.

I talked about this "issue" in my analysis #71.

#12 - romeroadrian

2023-10-05T17:49:24Z

organically seems like a good design choice

Same as #48, the ratio is a protocol feature.

I strongly disagree with "so letting it rebalance organically seems like a good design choice". Even if we discard situations where the shift in balance is a strategic choice (which I still don't agree with a organic rebalance), a critical scenario in which the exposure to any of the strategies must be reduced will clearly cause an issue.

#13 - d3e4

2023-10-05T18:23:45Z

organically seems like a good design choice

Same as #48, the ratio is a protocol feature.

I strongly disagree with "so letting it rebalance organically seems like a good design choice". Even if we discard situations where the shift in balance is a strategic choice (which I still don't agree with a organic rebalance), a critical scenario in which the exposure to any of the strategies must be reduced will clearly cause an issue.

The report doesn't demonstrate any such critical scenario, so then there is insufficient evidence. (I also cannot imagine what such a scenario would even be.)

As for the ratio's being a protocol feature, see my comment on #48.

#14 - romeroadrian

2023-10-05T18:39:08Z

organically seems like a good design choice

Same as #48, the ratio is a protocol feature. I strongly disagree with "so letting it rebalance organically seems like a good design choice". Even if we discard situations where the shift in balance is a strategic choice (which I still don't agree with a organic rebalance), a critical scenario in which the exposure to any of the strategies must be reduced will clearly cause an issue.

The report doesn't demonstrate any such critical scenario, so then there is insufficient evidence. (I also cannot imagine what such a scenario would even be.)

As for the ratio's being a protocol feature, see my comment on #48.

I don't know what to say, honestly. First it was the lack of impact, then the organic rebalance that was a design choice, now it's a lack of evidence. 🤷

#15 - 0xleastwood

2023-10-07T12:57:40Z

I agree that organic rebalancing is a good feature but this can be easily circumvented by depositing into the protocol under the new ratio, withdrawing and rinsing and repeating to bring it closer to the new ratio at an accelerated rate. But I also understand that instantly rebalancing to the new ratio would retroactively impact existing users which again is not ideal. I don't think either approach is valid and I think the design can either be reconsidered or considered an acceptable risk by the protocol team for which they have. Keeping this as is because imo it is still highlighting an important design flaw.

#16 - d3e4

2023-10-07T21:39:52Z

I agree that organic rebalancing is a good feature but this can be easily circumvented by depositing into the protocol under the new ratio, withdrawing and rinsing and repeating to bring it closer to the new ratio at an accelerated rate. But I also understand that instantly rebalancing to the new ratio would retroactively impact existing users which again is not ideal. I don't think either approach is valid and I think the design can either be reconsidered or considered an acceptable risk by the protocol team for which they have. Keeping this as is because imo it is still highlighting an important design flaw.

From this I gather that both organic rebalancing and instant rebalancing have drawbacks, and that the current design can either be reconsidered or accepted. So this report achieves nothing but "highlighting an important design flaw", which sounds exactly like Analysis to me, with no security risks demonstrated.

In any case, I would like to remind of the duplicate of this issue in my Analysis #71 under Ratio mechanism.

#17 - romeroadrian

2023-10-07T22:23:34Z

I agree that organic rebalancing is a good feature but this can be easily circumvented by depositing into the protocol under the new ratio, withdrawing and rinsing and repeating to bring it closer to the new ratio at an accelerated rate. But I also understand that instantly rebalancing to the new ratio would retroactively impact existing users which again is not ideal. I don't think either approach is valid and I think the design can either be reconsidered or considered an acceptable risk by the protocol team for which they have. Keeping this as is because imo it is still highlighting an important design flaw.

From this I gather that both organic rebalancing and instant rebalancing have drawbacks, and that the current design can either be reconsidered or accepted. So this report achieves nothing but "highlighting an important design flaw", which sounds exactly like Analysis to me, with no security risks demonstrated.

In any case, I would like to remind of the duplicate of this issue in my Analysis #71 under Ratio mechanism.

Ok so you have changed your mind again, now it's not a lack of evidence anymore but a lack of impact, and even so you are asking for grading based on part of your analysis. Unbelievable.

#18 - d3e4

2023-10-07T23:06:29Z

No, they have always gone hand in hand @adriro: there should be evidence for an impact.

#19 - 0xleastwood

2023-10-08T11:35:09Z

Moving into analysis report for the same reasons as #48.

#20 - c4-judge

2023-10-08T11:54:53Z

0xleastwood marked the issue as not selected for report

#21 - c4-judge

2023-10-08T11:54:58Z

0xleastwood changed the severity to QA (Quality Assurance)

#22 - romeroadrian

2023-10-08T13:53:02Z

@0xleastwood Sorry, but I have to say that I feel the shift in decision is a product of the constant pressure of a warden that keeps changing his arguments because he doesn't like the outcome. Nevertheless, I'll provide more information and try to address every point.

First, I consider both #55 and #48 issues that have nothing to do with C4 analysis. Citing https://docs.code4rena.com/awarding/incentive-model-and-awards#analyses

Each warden is encouraged to submit an Analysis alongside their findings for each audit, to share high-level advice and insights from their review of the code.

Comments in d3e4 analysis should be taken as an insight of the protocol/design, which doesn't imply that my reported issues have the same intention, nor that they belong to that category. I think this is another rhetoric movement by this person to downgrade the issue or eventually gain grade from it as a duplicate, shamelessly stated here https://github.com/code-423n4/2023-09-asymmetry-findings/issues/55#issuecomment-1751823937

Second, regarding the issues itself, I believe both are valid and have the proper severity. The ratio is a core feature of the contract, it measures the exposure to the underlying assets, which ultimately affects depositors. I believe both reports are well structured, providing both impact and evidence.

I think that, between the two, #55 is more important and has a greater latent impact. Any change to the ratio cannot be implemented in practice. This becomes more important in a critical scenario in which the protocol needs to move away from any of the underlying strategies, a change in the ratio will do exactly nothing.

Regarding the instantly rebalancing, this is precisely what SafEth implements, and what my report proposes as a recommendation. It doesn't need to be enforced during every ratio change, but would allow correction with both proper access control given to protocol government, and also proper slippage control to minimize impact (again both concerns highlighted in the report). Note that SafEth compounds itself internally, and only Votium rewards are compounded into AfEth, but these rewards only represent a 20% APR of just the Votium TVL. And this is without considering a shift in price of ETH/CVX, which needs to be factored in since one strategy is essentially ETH/LSD and the other is CVX.

#23 - romeroadrian

2023-10-08T14:04:58Z

It's also suspiciously curious that the same warden mentions, in this comment, that:

This does not seem necessarily inappropriately slow.

But in his own issue, which interestingly is also based on the unbalanced ratio, says that:

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.

It appears that the facts differ when he discusses his own issues compared to when he attempts to criticize others.

#24 - d3e4

2023-10-08T15:26:36Z

Let me first address the personal attacks.

a warden that keeps changing his arguments because he doesn't like the outcome.

I have already addressed the error in what you have explicitly stated are changes to my arguments. However else you think my comments uses different arguments are due to their being individually incomplete, inadequately expressed, or simply due to the fact the they each are written in response to a specific comment. In any case, the only thing that matters is the validity of my arguments. Any other criticism of my arguments is, itself, rhetoric.

It's also suspiciously curious that the same warden mentions, in this comment, that:

This does not seem necessarily inappropriately slow.

But in his own issue, which interestingly is also based on the unbalanced ratio, says that:

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.

It appears that the facts differ when he discusses his own issues compared to when he attempts to criticize others.

"Not necessarily inappropriately slow" does not contradict "quite slow". I never claimed it is too slow, in the sense that it has an impact. In my issue that you refer to, the persistence of the imbalance does indeed play a role in an impact. But that is not the core of the issue, and it is not an impact that you mentioned here.

On to the issue itself. I can see what you mean by the ratio as a core feature. I agree that it is. But it is your own invention that being able to immediately rebalance is a core feature. Let me summarise your report. Your evidence is that the only way to rebalance is by letting it converge from deposits and withdrawals. (There is also the adjustment from the rewards deposit, which you don't fully mention but would probably agree with me that the effect is likely insufficient.) So the core feature is fulfilled; it is possible to rebalance to a new ratio. They chose this mechanism for rebalancing and it works. The question is then only whether it works well enough, that is, whether it doesn't take an unreasonably long time. You simply state that it will "take a lot of time and may be impractical for most cases", without justification. In my Analysis I at least estimated the speed as "Several times the balances have to be deposited and withdrawn in order to reach close to the new ratio." In my comment above I quantified my estimate and applied it to your example, showing that it doesn't actually take quite that long to converge. So if "take a lot of time" is an impact, then it is arguably not true, nor have you provided any evidence for it. The issue boils down to the claim that "there is no direct way to balance the assets to follow the new ratio" because there is no direct way to balance the assets to follow the new ratio (!). Had you been right, that it takes a clearly unacceptably long time to rebalance, then your report would still lack evidence, but it would at least have reported an actual issue.

#25 - romeroadrian

2023-10-08T16:08:33Z

Let me first address the personal attacks.

Not at all, quite the opposite. I'm raising, with links, cites and evidence, a clear inconsistent attitude that I consider disrespectful towards the work I do.

#26 - 0xleastwood

2023-10-08T19:14:57Z

I will make my final decision on this shortly, but I would like to point out that the post-judging QA is over and my decision will be final.

#27 - 0xleastwood

2023-10-09T09:36:31Z

I'm going to reiterate my understanding of the analysis section, I actually don't think any information which is worthy of being included in this section automatically invalidates any related issues. It makes sense to me that there would be overlap. Ideally architectural/design decisions are highlighted but the impact of those decisions can be brought to light in the form of formal issues too.

In this instance, the implementation of the ratio mechanism with respect to rebalancing between vEth and safEth is a design decision that has flaws and potentially needs to be redesigned. However, the warden has identified that the current implementation does not enforce the ratio as what might be expected from users. For example, once the ratio has been changed, it takes some time for this to be fully in effect and as a result, users may be depositing into the protocol with the expectation of some ratio but that isn't what is currently in place. Performing instant rebalances is a design decision done by safEth so maybe this is the right way to be consistent? Even if users are potentially retroactively affected, provided the delay is sufficient, users should be able to exit the protocol if they do not agree with the new ratio.

For these reasons, I would still like to keep this issue as medium severity because I think this mechanism is ultimately flawed and can be realistically improved. My decision is final on this.

#28 - c4-judge

2023-10-09T09:36:40Z

This previously downgraded issue has been upgraded by 0xleastwood

#29 - c4-judge

2023-10-09T09:36:47Z

0xleastwood marked the issue as selected for report

#30 - d3e4

2023-10-09T13:48:32Z

@0xleastwood I understand your decision is final, and I can agree with your reasoning. But you are missing an important point. That the rebalancing takes some time is NOT in itself an impact. This is by deliberate design. What might be an appreciable impact is that it takes too long to rebalance. But this report makes no case for neither how long it takes, nor that the time it takes has any impact. It only states the obvious: that it takes some time.

But since you do grade this issue as Medium, then my report on this issue in #71, which reports all of the facts provided here (and a little more), should be upgraded to a duplicate of this issue.

#31 - 0xleastwood

2023-10-09T14:21:54Z

@0xleastwood I understand your decision is final, and I can agree with your reasoning. But you are missing an important point. That the rebalancing takes some time is NOT in itself an impact. This is by deliberate design. What might be an appreciable impact is that it takes too long to rebalance. But this report makes no case for neither how long it takes, nor that the time it takes has any impact. It only states the obvious: that it takes some time.

But since you do grade this issue as Medium, then my report on this issue in #71, which reports all of the facts provided here (and a little more), should be upgraded to a duplicate of this issue.

Noted, but my decision remains unchanged and I will not be upgrading a section of your analysis report because it doesn't dive into the impact nor does it provide any remediation to the issue at hand.

#32 - d3e4

2023-10-09T14:41:01Z

@0xleastwood I understand your decision is final, and I can agree with your reasoning. But you are missing an important point. That the rebalancing takes some time is NOT in itself an impact. This is by deliberate design. What might be an appreciable impact is that it takes too long to rebalance. But this report makes no case for neither how long it takes, nor that the time it takes has any impact. It only states the obvious: that it takes some time. But since you do grade this issue as Medium, then my report on this issue in #71, which reports all of the facts provided here (and a little more), should be upgraded to a duplicate of this issue.

Noted, but my decision remains unchanged and I will not be upgrading a section of your analysis report because it doesn't dive into the impact nor does it provide any remediation to the issue at hand.

It is not a requirement for validity that the report contain a recommended mitigation, it is only encouraged.

@0xleastwood, could you please clarify what you consider to be the stated impact in #55 and what part of that impact is not mentioned in #71.

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-02

Awards

Data not available

External Links

Lines of code

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

Vulnerability details

Summary

While the intention is to use the 0x protocol to sell rewards, the implementation doesn't provide any basic guarantee this will correctly happen and grants the rewarder arbitrary control over the tokens held by the strategy.

Impact

Rewards earned in the VotingStrategy contract are exchanged for ETH and deposited back into the protocol. As indicated by the documentation, the intention is to swap these rewards for ETH using the 0x protocol:

Votium rewards are claimed with claimRewards() using merkle proofs published by votium every 2 weeks. applyRewards() sells rewards on 0x and deposits them back into afEth (and ultimately back into the safEth & votium strategies), making the afEth price go up.

However, the implementation of applyRewards() shallowly executes a series of calls to arbitrary targets:

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

272:     function applyRewards(SwapData[] calldata _swapsData) public onlyRewarder {
273:         uint256 ethBalanceBefore = address(this).balance;
274:         for (uint256 i = 0; i < _swapsData.length; i++) {
275:             // Some tokens do not allow approval if allowance already exists
276:             uint256 allowance = IERC20(_swapsData[i].sellToken).allowance(
277:                 address(this),
278:                 address(_swapsData[i].spender)
279:             );
280:             if (allowance != type(uint256).max) {
281:                 if (allowance > 0) {
282:                     IERC20(_swapsData[i].sellToken).approve(
283:                         address(_swapsData[i].spender),
284:                         0
285:                     );
286:                 }
287:                 IERC20(_swapsData[i].sellToken).approve(
288:                     address(_swapsData[i].spender),
289:                     type(uint256).max
290:                 );
291:             }
292:             (bool success, ) = _swapsData[i].swapTarget.call(
293:                 _swapsData[i].swapCallData
294:             );
295:             if (!success) {
296:                 emit FailedToSell(_swapsData[i].sellToken);
297:             }
298:         }
299:         uint256 ethBalanceAfter = address(this).balance;
300:         uint256 ethReceived = ethBalanceAfter - ethBalanceBefore;
301: 
302:         if (address(manager) != address(0))
303:             IAfEth(manager).depositRewards{value: ethReceived}(ethReceived);
304:         else depositRewards(ethReceived);
305:     }

This not only fails to provide any guarantee that 0x will be used (and that it will be used correctly), but grants a lot of power to the rewarder which can be used accidentally or purposely to negatively impact the protocol. The rewarded role can grant any token approval to any spender and execute arbitrary calls on behalf of the VotingStrategy.

Recommendation

Provide better guarantees in the implementation of applyRewards() that 0x will be used to swap rewards, to ensure a more transparent and less error prone solution.

  • Instead of granting arbitrary allowance to any spender, set this to the 0x entrypoint.
  • Change arbitrary calls to the 0x protocol entrypoint.
  • Data sent to the 0x contract could also be validated, for example to ensure the output token is ETH.

Assessed type

Other

#0 - c4-judge

2023-10-03T18:14:15Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T09:32:09Z

0xleastwood marked the issue as selected for report

#2 - c4-sponsor

2023-10-04T14:54:25Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-03

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L135-L149

Vulnerability details

Summary

The VotiumStrategy withdrawal process involves relocking CVX tokens, which can potentially lead to a denial of service and loss of user funds if the underlying vlCVX contract is shutdown.

Impact

When withdrawals are executed in VotiumStrategy, the implementation of withdraw() will call relock() in order to relock any available excess (i.e. expired tokens minus the pending obligations) of CVX tokens to lock them again in the Convex protocol.

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L135-L149

135:     function relock() public {
136:         (, uint256 unlockable, , ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(
137:             address(this)
138:         );
139:         if (unlockable > 0)
140:             ILockedCvx(VLCVX_ADDRESS).processExpiredLocks(false);
141:         uint256 cvxBalance = IERC20(CVX_ADDRESS).balanceOf(address(this));
142:         uint256 cvxAmountToRelock = cvxBalance > cvxUnlockObligations
143:             ? cvxBalance - cvxUnlockObligations
144:             : 0;
145:         if (cvxAmountToRelock > 0) {
146:             IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock);
147:             ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0);
148:         }
149:     }

This seems fine at first, but if we dig into the implementation of lock() we can see that the preconditions of this function requires the contract not to be shutdown:

https://etherscan.io/address/0x72a19342e8F1838460eBFCCEf09F6585e32db86E#code#L1469

521:     function _lock(address _account, uint256 _amount, uint256 _spendRatio, bool _isRelock) internal {
522:         require(_amount > 0, "Cannot stake 0");
523:         require(_spendRatio <= maximumBoostPayment, "over max spend");
524:         require(!isShutdown, "shutdown");

Which means that any call to lock() after the contract is shutdown will revert. This is particularly bad because relock() is called as part of the withdraw process. If the vlCVX contract is shutdown, VotiumStrategy depositors won't be able to withdraw their position, causing a potential loss of funds.

Note that this also will cause a DoS while depositing rewards, since depositRewards() in VotiumStrategy also calls ILockedCvx::lock().

Proof of Concept

  1. vlCVX contract is shutdown.
  2. A user requests a withdrawal using requestWithdrawal()
  3. The user calls withdraw() when the withdrawal epoch is reached.
  4. The implementation will call relock(), which will call ILockedCvx::lock().
  5. The implementation of lock() will throw an error because the vault is already shutdown.
  6. The transaction will be reverted.

Recommendation

Add a condition to check if the contract is shutdown to avoid the call to lock() and the potential denial of service.

    function relock() public {
        (, uint256 unlockable, , ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(
            address(this)
        );
        if (unlockable > 0)
            ILockedCvx(VLCVX_ADDRESS).processExpiredLocks(false);
        uint256 cvxBalance = IERC20(CVX_ADDRESS).balanceOf(address(this));
        uint256 cvxAmountToRelock = cvxBalance > cvxUnlockObligations
            ? cvxBalance - cvxUnlockObligations
            : 0;
-       if (cvxAmountToRelock > 0) {
+       if (cvxAmountToRelock > 0 && !ILockedCvx(VLCVX_ADDRESS).isShutdown()) {
            IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock);
            ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0);
        }
    }

Assessed type

DoS

#1 - c4-judge

2023-10-03T18:25:55Z

0xleastwood marked the issue as primary issue

#2 - c4-judge

2023-10-04T08:36:21Z

0xleastwood marked the issue as selected for report

#3 - c4-sponsor

2023-10-04T14:51:38Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-04

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L54-L103

Vulnerability details

Summary

Withdrawals in VotiumStrategy are executed in queue since CVX tokens are potentially locked in Convex. However, the implementation fails to consider the case where unlocked assets are already enough to cover the withdrawal, leading to different issues.

Impact

VotiumStrategy withdrawals are executed in queue since the underlying CVX tokens may be locked in the Convex platform. Depositors must request a withdrawal and wait in queue until the epoch associated with their withdrawal is reached in order to exit their position. The core of this logic is present in the function requestWithdraw():

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L54-L103

054:     function requestWithdraw(
055:         uint256 _amount
056:     ) public override returns (uint256 withdrawId) {
057:         latestWithdrawId++;
058:         uint256 _priceInCvx = cvxPerVotium();
059: 
060:         _burn(msg.sender, _amount);
061: 
062:         uint256 currentEpoch = ILockedCvx(VLCVX_ADDRESS).findEpochId(
063:             block.timestamp
064:         );
065:         (
066:             ,
067:             uint256 unlockable,
068:             ,
069:             ILockedCvx.LockedBalance[] memory lockedBalances
070:         ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(address(this));
071:         uint256 cvxAmount = (_amount * _priceInCvx) / 1e18;
072:         cvxUnlockObligations += cvxAmount;
073: 
074:         uint256 totalLockedBalancePlusUnlockable = unlockable +
075:             IERC20(CVX_ADDRESS).balanceOf(address(this));
076: 
077:         for (uint256 i = 0; i < lockedBalances.length; i++) {
078:             totalLockedBalancePlusUnlockable += lockedBalances[i].amount;
079:             // we found the epoch at which there is enough to unlock this position
080:             if (totalLockedBalancePlusUnlockable >= cvxUnlockObligations) {
081:                 (, uint32 currentEpochStartingTime) = ILockedCvx(VLCVX_ADDRESS)
082:                     .epochs(currentEpoch);
083:                 uint256 timeDifference = lockedBalances[i].unlockTime -
084:                     currentEpochStartingTime;
085:                 uint256 epochOffset = timeDifference /
086:                     ILockedCvx(VLCVX_ADDRESS).rewardsDuration();
087:                 uint256 withdrawEpoch = currentEpoch + epochOffset;
088:                 withdrawIdToWithdrawRequestInfo[
089:                     latestWithdrawId
090:                 ] = WithdrawRequestInfo({
091:                     cvxOwed: cvxAmount,
092:                     withdrawn: false,
093:                     epoch: withdrawEpoch,
094:                     owner: msg.sender
095:                 });
096: 
097:                 emit WithdrawRequest(msg.sender, cvxAmount, latestWithdrawId);
098:                 return latestWithdrawId;
099:             }
100:         }
101:         // should never get here
102:         revert InvalidLockedAmount();
103:     }

The implementation first considers available tokens that should be ready to be withdrawn. Line 74-75 sets totalLockedBalancePlusUnlockable to the amount of unlockable tokens (expired tokens in Convex that can be withdrawn) plus any available CVX balance in the contract.

However, the implementation fails to consider that this available balance may be already enough to cover the withdrawal, and proceeds to search within the locked balances by epoch. This will lead to different issues:

  • A user that requests a withdrawal of an amount such that there is enough available balance to cover for it, will still need to wait until the end of the next locked cycle. The implementation will start searching the locked balances array and stop at the first match, and set the withdrawal epoch as the end of the matched period. Even if there are enough tokens to cover for the withdrawal, the user is forced into an unnecessary delay.
  • If there are none locked balances, meaning everything is already in an unlockable state, the implementation will cause a denial of service. The for loop in line 77 won't be executed and the execution will be reverted due to the revert in line 102.

Proof of Concept

Let's illustrate the denial of service case. We assume all deposits in VotiumStrategy were done before the 16 weeks period, which means all vlCVX should be in an unlocked state.

  1. User calls requestWithdraw(amount).
  2. The implementation fetches current position from vlCVX contract using ILockedCvx.lockedBalances(). This will return the entire position as unlockable and an empty array for lockedBalances.
  3. The function sets totalLockedBalancePlusUnlockable as unlockable + IERC20(CVX_ADDRESS).balanceOf(address(this)). This should be enough to cover the requested amount by the user.
  4. The implementation continues to search through the lockedBalances, but since this array is empty the for loop is never executed.
  5. The function reaches the end and is reverted with a InvalidLockedAmount() error.

Recommendation

Before searching through the lockedBalances array, check if there available unlocked tokens are enough to cover the withdrawal. If so, the withdrawal can be set for the current epoch. This will fix the unnecessary delay and the potential denial of service.

    function requestWithdraw(
        uint256 _amount
    ) public override returns (uint256 withdrawId) {
        latestWithdrawId++;
        uint256 _priceInCvx = cvxPerVotium();

        _burn(msg.sender, _amount);

        uint256 currentEpoch = ILockedCvx(VLCVX_ADDRESS).findEpochId(
            block.timestamp
        );
        (
            ,
            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));
        
+       if (totalLockedBalancePlusUnlockable >= amount) {
+           withdrawIdToWithdrawRequestInfo[
+               latestWithdrawId
+           ] = WithdrawRequestInfo({
+               cvxOwed: cvxAmount,
+               withdrawn: false,
+               epoch: currentEpoch,
+               owner: msg.sender
+           });
+           emit WithdrawRequest(msg.sender, cvxAmount, latestWithdrawId);
+           return latestWithdrawId;
+       }

        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) {
                (, uint32 currentEpochStartingTime) = ILockedCvx(VLCVX_ADDRESS)
                    .epochs(currentEpoch);
                uint256 timeDifference = lockedBalances[i].unlockTime -
                    currentEpochStartingTime;
                uint256 epochOffset = timeDifference /
                    ILockedCvx(VLCVX_ADDRESS).rewardsDuration();
                uint256 withdrawEpoch = currentEpoch + epochOffset;
                withdrawIdToWithdrawRequestInfo[
                    latestWithdrawId
                ] = WithdrawRequestInfo({
                    cvxOwed: cvxAmount,
                    withdrawn: false,
                    epoch: withdrawEpoch,
                    owner: msg.sender
                });

                emit WithdrawRequest(msg.sender, cvxAmount, latestWithdrawId);
                return latestWithdrawId;
            }
        }
        // should never get here
        revert InvalidLockedAmount();
    }

Assessed type

Other

#0 - c4-judge

2023-10-03T18:38:29Z

0xleastwood marked the issue as duplicate of #63

#1 - 0xleastwood

2023-10-04T08:46:11Z

It seems pretty severe that requestWithdraw() would fail when the lockedBalances array is empty right?

#2 - c4-judge

2023-10-04T08:49:18Z

0xleastwood marked the issue as selected for report

#3 - c4-sponsor

2023-10-04T14:51:02Z

elmutt (sponsor) confirmed

#4 - elmutt

2023-10-05T16:24:01Z

It seems pretty severe that requestWithdraw() would fail when the lockedBalances array is empty right?

Absolutely agree. we are working on a fix to address this issue and will post it here in the coming days when ready

#5 - romeroadrian

2023-10-07T13:24:48Z

It seems pretty severe that requestWithdraw() would fail when the lockedBalances array is empty right?

Giving the potential DoS here, do you think this could be upgraded to a high?

#6 - 0xleastwood

2023-10-08T12:39:28Z

Giving the potential DoS here, do you think this could be upgraded to a high?

So I think this DoS is recoverable. If all locks are unlocked/expired, then it will not be possible to request a withdrawal. However, if relock() is called, then additional requests to withdraw can be processed and the final impact is that the request is delayed more than it needs to be.

#7 - 0xleastwood

2023-10-08T12:41:23Z

It's not possible to prevent withdrawal finalisation by calling relock() first either because cvxUnlockObligations is used to reserve cvx that is owed to existing withdrawal requests.

#8 - 0xleastwood

2023-10-08T12:42:04Z

I think I will leave it as is unless there is a way to brick funds by preventing users from finalising their withdrawals

Findings Information

🌟 Selected for report: adriro

Also found by: rvierdiiev

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-05

Awards

Data not available

External Links

Lines of code

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

Vulnerability details

Summary

The reward system in VotiumStrategy can be potentially gamed by users to enter just before rewards are deposited and request an exit after that. Depending on the withdrawal queue, users may exit as early as the next epoch and avoid waiting the normal 16 weeks of vote locked CVX.

Impact

Voting in the Convex protocol requires a commitment of at least 16 weeks. Holders of CVX tokens can lock their tokens into vlCVX, which grants them voting power in Curve gauges.

The same mechanism is applied internally in the VotiumStrategy contract. Deposited ETH is swapped to CVX and locked for vlCVX. Withdrawals are executed in a queued fashion, by reserving tokens that will eventually expire in coming epochs. A user exiting the strategy may have enough tokens to exit their position as early as the next epoch.

This means that, under the right circumstances, a user may deposit in VotiumStrategy and withdraw from it in a short period of time. The user just needs to have available expirable tokens coming from previous deposits in the platform, not necessarily related to the ones coming from their deposit. This can potentially reduce the commitment, requiring much less time than the required 16 weeks when using Convex directly.

This would allow users to game the system and enter the protocol just to collect the rewards, with a minimal commitment in the platform.

Proof of Concept

Let's say an attacker is anticipating the claiming of rewards in VotiumStrategy, and let's assume also that there are enough tokens that will be expiring in the next epoch to sufficiently cover their position.

  1. The attacker deposits into the strategy just before rewards are claimed.
  2. Rewarder claims rewards and deposits them back into the strategy, increasing the value for holders.
  3. Right after that, the attacker requests a withdrawal. Since there are enough expirable tokens, the withdrawal is queued for the next epoch.
  4. The attacker just needs to wait for the next epoch to exit their position, along with the rewards.

Recommendation

This is a variation of a common attack in vaults that compound rewards, present in different yield protocols. The usual mitigation is to introduce some delay or penalty to avoid bad intentionally users from depositing just to earn the rewards and leave.

In this case, two possible solutions are:

  • Introduce some kind of minimum permanency delay for depositors. This could be the 16 weeks defined by Convex, or a fraction of it to be more flexible, e.g. 4 weeks.
  • Stream rewards over a period of time. Instead of just depositing back the rewards as an immediate increase of value, have these rewards be linearly unlocked over a period of time. This will cause depositors to stay within the protocol to collect the rewards.

Assessed type

Other

#0 - c4-judge

2023-10-03T18:33:12Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T09:45:32Z

0xleastwood marked the issue as selected for report

#2 - c4-sponsor

2023-10-04T14:40:44Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-06

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L148 https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L243

Vulnerability details

Summary

AfEth main actions execute on-chain swaps and lack an expiration deadline, which enables pending transactions to be maliciously executed at a later point.

Impact

Both AfEth deposits and withdrawals include on-chain swaps in AMM protocols as part of their execution, in order to convert the deposited ETH into the different underlying assets held by SafEth and the Votium strategy.

In the case of SafEth, depending on the derivative, staking may involve swapping ETH for other LSD. For example, the RocketPool derivative implementation uses Balancer to swap between ETH and rETH during deposits:

https://etherscan.io/address/0xb3e64c481f0fc82344a7045592284fddb9905b8b#code#F1#L157

function deposit() external payable onlyOwner returns (uint256) {
    uint256 rethBalanceBefore = IERC20(rethAddress()).balanceOf(
        address(this)
    );
    balancerSwap(msg.value);
    uint256 received = IERC20(rethAddress()).balanceOf(address(this)) -
        rethBalanceBefore;
    underlyingBalance = super.finalChecks(
        ethPerDerivative(true),
        msg.value,
        maxSlippage,
        received,
        true,
        underlyingBalance
    );
    return received;
}

In the case of Votium, deposited ETH is swapped to CVX in order to lock it in Convex. Similarly, when withdrawing, CVX tokens are swapped back to ETH. This is done using a Curve Pool in the buyCvx() and sellCvx() functions:

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L227-L265

227:     function buyCvx(
228:         uint256 _ethAmountIn
229:     ) internal returns (uint256 cvxAmountOut) {
230:         address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
231:         // eth -> cvx
232:         uint256 cvxBalanceBefore = IERC20(CVX_ADDRESS).balanceOf(address(this));
233:         ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying{
234:             value: _ethAmountIn
235:         }(
236:             0,
237:             1,
238:             _ethAmountIn,
239:             0 // this is handled at the afEth level
240:         );
241:         uint256 cvxBalanceAfter = IERC20(CVX_ADDRESS).balanceOf(address(this));
242:         cvxAmountOut = cvxBalanceAfter - cvxBalanceBefore;
243:     }
244: 
245:     /**
246:      * @notice - Internal utility function to sell cvx for eth
247:      * @param _cvxAmountIn - Amount of cvx to sell
248:      * @return ethAmountOut - Amount of eth received
249:      */
250:     function sellCvx(
251:         uint256 _cvxAmountIn
252:     ) internal returns (uint256 ethAmountOut) {
253:         address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
254:         // cvx -> eth
255:         uint256 ethBalanceBefore = address(this).balance;
256:         IERC20(CVX_ADDRESS).approve(CVX_ETH_CRV_POOL_ADDRESS, _cvxAmountIn);
257: 
258:         ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying(
259:             1,
260:             0,
261:             _cvxAmountIn,
262:             0 // this is handled at the afEth level
263:         );
264:         ethAmountOut = address(this).balance - ethBalanceBefore;
265:     }

While both actions in AfEth, deposit() and withdraw(), have a minimum output parameter to control slippage, this doesn't offer protection against when the transaction is actually executed. If the price of the underlying assets drops while the transaction is pending, then the minimum output can still be fulfilled, but the user will get a bad rate due to the stale price. The outdated slippage value now allows for a high slippage trade in detriment of the user.

This can be also attacked by MEV bots which can still sandwich the transaction to profit on the difference. See this issue for an excellent reference on the topic (the author runs a MEV bot).

Proof of Concept

  1. A user submits a transaction to deposit in AfEth.
  2. The transaction sits in the mempool without being included in a block.
  3. The price of CVX/ETH drops.
  4. The transaction gets executed by the blockchain.
  5. Since the price of CVX has dropped, the user will still get the minimum output expected but this will still represent less tokens than it would expect since the transaction has been delayed and the original price is now stale.

Recommendation

Add a deadline timestamp to the deposit() and withdraw() functions, and revert if this timestamp has passed.

Note also that the same should be applied to the VotiumStrategy contract if deposits and withdrawals are made directly there, without going through AfEth.

-   function deposit(uint256 _minout) external payable virtual {
+   function deposit(uint256 _minout, uint256 deadline) external payable virtual {
            if (pauseDeposit) revert Paused();
+           if (block.timestamp > deadline) revert StaleAction();
    function withdraw(
        uint256 _withdrawId,
-       uint256 _minout
+       uint256 _minout,
+       uint256 deadline
    ) external virtual onlyWithdrawIdOwner(_withdrawId) {
        if (pauseWithdraw) revert Paused();
+       if (block.timestamp > deadline) revert StaleAction();

Assessed type

MEV

#0 - c4-judge

2023-10-03T18:44:02Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-10-04T07:31:57Z

0xleastwood marked the issue as satisfactory

#2 - c4-judge

2023-10-04T09:49:03Z

0xleastwood marked the issue as selected for report

#3 - c4-judge

2023-10-04T09:49:10Z

0xleastwood removed the grade

#4 - c4-sponsor

2023-10-04T14:37:56Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
edited-by-warden
M-07

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L203 https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L272

Vulnerability details

Summary

Some functions that are part of the Votium reward flow are left unprotected and can be accessed by anyone to spend resources held by the contract.

Impact

Rewards coming from the Votium protocol are claimed and compounded back in AfEth. This flow consists of two parts, both controlled and initiated by the rewarder role: first, rewards are claimed in Votium and Convex using claimRewards(), second, those rewards are swapped to ETH and deposited back in the protocol using applyRewards().

reward

After rewards are swapped, the VotiumStrategy will call AfEth to manage the deposited rewards, which eventually calls back the VotiumStrategy. These interactions are represented in the previous diagram as steps 5 and 6.

However, both of the functions that implement these steps are publicly accessible and don't have any validation over the amount of ETH sent. Let's first see the case of AfEth::depositRewards():

272:     function depositRewards(uint256 _amount) public payable {
273:        IVotiumStrategy votiumStrategy = IVotiumStrategy(vEthAddress);
274:         uint256 feeAmount = (_amount * protocolFee) / 1e18;
275:         if (feeAmount > 0) {
276:             // solhint-disable-next-line
277:             (bool sent, ) = feeAddress.call{value: feeAmount}("");
278:             if (!sent) revert FailedToSend();
279:         }
280:         uint256 amount = _amount - feeAmount;
281:         uint256 safEthTvl = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) *
282:             safEthBalanceMinusPending()) / 1e18;
283:         uint256 votiumTvl = ((votiumStrategy.cvxPerVotium() *
284:             votiumStrategy.ethPerCvx(true)) *
285:             IERC20(vEthAddress).balanceOf(address(this))) / 1e36;
286:         uint256 totalTvl = (safEthTvl + votiumTvl);
287:         uint256 safEthRatio = (safEthTvl * 1e18) / totalTvl;
288:         if (safEthRatio < ratio) {
289:             ISafEth(SAF_ETH_ADDRESS).stake{value: amount}(0);
290:         } else {
291:             votiumStrategy.depositRewards{value: amount}(amount);
292:         }
293:     }

As we can see in the previous snippet of code, the function doesn't have any access control and doesn't check if the _amount parameter matches the amount of ETH being sent (msg.value). Anyone can call this function with any amount value without actually sending any ETH value.

The implementation of depositRewards() in VotiumStrategyCore has the same issue:

203:     function depositRewards(uint256 _amount) public payable {
204:         uint256 cvxAmount = buyCvx(_amount);
205:         IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
206:         ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
207:         emit DepositReward(cvxPerVotium(), _amount, cvxAmount);
208:     }

Any ETH held in these two contracts can be arbitrarily spent by any unauthorized account. The caller cannot remove value from here, unless sandwiching the trade or benefitting via a third-party call, but can use these functions to grief and unauthorizedly spend any ETH present in these contracts.

Recommendation

If these functions are indeed intended to be publicly accessible, then add a validation to ensure that the amount argument matches the callvalue sent, i.e. require(_amount == msg.value).

On the other hand, if these should only be part of the reward flow initiated by the rewarder role, then validate that AfEth::depositRewards() is called from the Votium Strategy (vEthAddress), and validate that VotiumStrategy::depositRewards() is called either from AfEth (manager) or internally through applyRewards().

Assessed type

Access Control

#0 - elmutt

2023-10-02T19:53:01Z

@toshiSat I believe best solution is to get rid of _amount and only use msg.value here

#1 - c4-judge

2023-10-03T13:49:16Z

0xleastwood marked the issue as primary issue

#2 - c4-judge

2023-10-04T08:00:44Z

0xleastwood marked the issue as duplicate of #33

#3 - c4-judge

2023-10-04T08:00:49Z

0xleastwood marked the issue as satisfactory

#4 - c4-judge

2023-10-04T08:01:02Z

0xleastwood changed the severity to 2 (Med Risk)

#5 - c4-judge

2023-10-07T12:49:06Z

0xleastwood changed the severity to QA (Quality Assurance)

#6 - romeroadrian

2023-10-07T13:16:39Z

@0xleastwood Liam, sorry, may I ask why this issue was downgraded? It seems it was linked somehow to #33 which got downgraded recently. The exposed functions here are part of both core contracts.

#7 - 0xleastwood

2023-10-08T10:45:01Z

@0xleastwood Liam, sorry, may I ask why this issue was downgraded? It seems it was linked somehow to #33 which got downgraded recently. The exposed functions here are part of both core contracts.

Oops, I have no idea what happened here. This was not meant to be downgraded. Apologies.

#8 - c4-judge

2023-10-08T10:45:23Z

This previously downgraded issue has been upgraded by 0xleastwood

#9 - 0xleastwood

2023-10-08T10:47:23Z

Oh wait this was a dupe of an issue I downgraded. Let me confirm.

#10 - 0xleastwood

2023-10-08T11:02:38Z

This is valid and I think should have been a standalone issue, it seems like anyone can arbitrarily spend native ether in exchange for locked cvx tokens. It is unclear how this might be abused because applyRewards() will only transfer out new native ether which was received from its swaps.

#11 - c4-judge

2023-10-08T11:04:49Z

0xleastwood marked the issue as unsatisfactory: Invalid

#12 - c4-judge

2023-10-08T11:04:51Z

0xleastwood changed the severity to QA (Quality Assurance)

#13 - c4-judge

2023-10-08T11:05:05Z

This previously downgraded issue has been upgraded by 0xleastwood

#14 - c4-judge

2023-10-08T11:05:27Z

0xleastwood changed the severity to QA (Quality Assurance)

#15 - c4-judge

2023-10-08T11:07:13Z

This previously downgraded issue has been upgraded by 0xleastwood

#16 - c4-judge

2023-10-08T11:07:20Z

0xleastwood marked the issue as not a duplicate

#17 - c4-judge

2023-10-08T11:07:42Z

0xleastwood removed the grade

#18 - c4-judge

2023-10-08T11:08:04Z

0xleastwood marked the issue as selected for report

#19 - c4-judge

2023-10-08T11:09:08Z

0xleastwood marked the issue as satisfactory

#20 - c4-judge

2023-10-08T11:09:23Z

0xleastwood removed the grade

#21 - c4-judge

2023-10-08T11:09:28Z

0xleastwood marked the issue as primary issue

#22 - d3e4

2023-10-08T17:05:24Z

@0xleastwood I mentioned this issue in L-08.

However, note that the contracts do not hold any ETH (except dust or if sent there by mistake) so calling these functions directly wouldn't really do anything. And if there is any ETH in the contract it is just deposited as rewards, which is the only way it should be spent; it cannot be made to send it elsewhere.

#23 - 0xleastwood

2023-10-08T20:02:05Z

@0xleastwood I mentioned this issue in L-08.

However, note that the contracts do not hold any ETH (except dust or if sent there by mistake) so calling these functions directly wouldn't really do anything. And if there is any ETH in the contract it is just deposited as rewards, which is the only way it should be spent; it cannot be made to send it elsewhere.

These are not equivalent, you talk about the fact that _amount == msg.value should be checked but fail to highlight the impact of not having this check. Plus this issue is more than that, the depositRewards() function is missing access control.

#24 - d3e4

2023-10-09T13:19:01Z

@0xleastwood I mentioned this issue in L-08. However, note that the contracts do not hold any ETH (except dust or if sent there by mistake) so calling these functions directly wouldn't really do anything. And if there is any ETH in the contract it is just deposited as rewards, which is the only way it should be spent; it cannot be made to send it elsewhere.

These are not equivalent, you talk about the fact that _amount == msg.value should be checked but fail to highlight the impact of not having this check. Plus this issue is more than that, the depositRewards() function is missing access control.

And what is the impact according to #38? The impact of not checking that _amount == msg.value is that "any discrepancy is lost". Otherwise, there is no ether in the contracts, so calling them does nothing. But if someone for some reason sends ether to the contract, then it is beneficial to the protocol if these functions are called so that the ether is deposited as rewards. Why should the public not be able to do this, which only benefits the protocol, on behalf of the owner? So what is the impact of the missing access control?

#25 - romeroadrian

2023-10-09T13:28:55Z

@0xleastwood I mentioned this issue in L-08. However, note that the contracts do not hold any ETH (except dust or if sent there by mistake) so calling these functions directly wouldn't really do anything. And if there is any ETH in the contract it is just deposited as rewards, which is the only way it should be spent; it cannot be made to send it elsewhere.

These are not equivalent, you talk about the fact that _amount == msg.value should be checked but fail to highlight the impact of not having this check. Plus this issue is more than that, the depositRewards() function is missing access control.

And what is the impact according to #38? The impact of not checking that _amount == msg.value is that "any discrepancy is lost". Otherwise, there is no ether in the contracts, so calling them does nothing. But if someone for some reason sends ether to the contract, then it is beneficial to the protocol if these functions are called so that the ether is deposited as rewards. Why should the public not be able to do this, which only benefits the protocol, on behalf of the owner? So what is the impact of the missing access control?

I'm not stating any direct theft of funds (in fact it is mentioned in the report), hence the med severity. It's the combination of both issues that conflicts with the protocol specs:

depositRewards() is called by the votium strategy upon claiming rewards to make the afEth price go up by distributing funds into both strategies according to ratio.

Rewarder - Address of wallet that will handle calling the reward functions and swapping the tokens out

#26 - d3e4

2023-10-09T14:09:39Z

It's the combination of both issues that conflicts with the protocol specs:

Conflicting with protocol specs is QA.

#27 - romeroadrian

2023-10-09T14:18:34Z

It's the combination of both issues that conflicts with the protocol specs:

Conflicting with protocol specs is QA.

No, it's medium

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

#28 - 0xleastwood

2023-10-09T14:24:23Z

Agree, it is medium.

#29 - d3e4

2023-10-09T14:36:56Z

@romeroadrian, "the function of the protocol or its availability could be impacted" is not the same a conflicting with specs; function and availability is not specs. In what way do you mean that the protocol stops working?

#30 - elmutt

2023-10-09T16:17:23Z

I believe this solves it based on original authors recommendation:

https://github.com/asymmetryfinance/afeth/pull/169

also

https://github.com/asymmetryfinance/afeth/pull/193

#31 - c4-sponsor

2023-10-09T16:19:15Z

elmutt (sponsor) confirmed

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-08

Awards

Data not available

External Links

Lines of code

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

Vulnerability details

Summary

The VotiumStrategy contract is susceptible to the Inflation Attack, in which the first depositor can be front-runned by an attacker to steal their deposit.

Impact

Both AfEth and VotiumStrategy acts as vaults: accounts deposit some tokens and get back another token (share) that represents their participation in the vault.

These types of contracts are potentially vulnerable to the inflation attack: an attacker can front-run the initial deposit to the vault to inflate the value of a share and render the front-runned deposit worthless.

In AfEth, this is successfully mitigated by the slippage control. Any attack that inflates the value of a share to decrease the number of minted shares is rejected due to the validation of minimum output:

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L166-L167

166:         uint256 amountToMint = totalValue / priceBeforeDeposit;
167:         if (amountToMint < _minout) revert BelowMinOut();

However, this is not the case of VotiumStrategy. In this contract, no validation is done in the number of minted tokens. This means that an attacker can execute the attack by front-running the initial deposit, which may be from AfEth or from any other account that interacts with the contract. See Proof of Concept for a detailed walkthrough of the issue.

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategy.sol#L39-L46

39:     function deposit() public payable override returns (uint256 mintAmount) {
40:         uint256 priceBefore = cvxPerVotium();
41:         uint256 cvxAmount = buyCvx(msg.value);
42:         IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
43:         ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
44:         mintAmount = ((cvxAmount * 1e18) / priceBefore);
45:         _mint(msg.sender, mintAmount);
46:     }

Proof of Concept

Let's say a user wants to deposit in VotiumStrategy and calls deposit() sending an ETH amount such as it is expected to buy X tokens of CVX. Attacker will front-run the transaction and execute the following:

  1. Initial state is empty contract, assets = 0 and supply = 0.
  2. Attacker calls deposit with an amount of ETH such as to buy 1e18 CVX tokens, this makes assets = 1e18 and supply = 1e18.
  3. Attacker calls requestWithdraw(1e18 - 1) so that supply = 1, assume also 1e18 - 1 CVX tokens are withdrawn so that cvxUnlockObligations = 1e18 - 1.
  4. Attacker transfers (donates) X amount of CVX to VotiumStrategy contract.
  5. At this point, priceBefore = cvxPerVotium() = (totalCvx - cvxUnlockObligations) * 1e18 / supply = (X + 1e18 - (1e18 - 1)) * 1e18 / 1 = (X + 1) * 1e18
  6. User transaction gets through and deposit() buys X amount of CVX. Minted tokens will be mintAmount = X * 1e18 / priceBefore = X * 1e18 / (X + 1) * 1e18 = X / (X + 1) = 0.
  7. User is then minted zero VotiumStrategy tokens.
  8. Attacker calls requestWithdraw() again to queue withdrawal to remove all CVX balance from the contract, including the tokens deposited by the user.

Recommendation

There are multiple ways of solving the issue:

  1. Similar to AfEth, add a minimum output check to ensure the amount of minted shares.
  2. Track asset balances internally so an attacker cannot donate assets to inflate shares.
  3. Mint an initial number of "dead shares", similar to how UniswapV2 does.

A very good discussion of these can be found here.

Assessed type

Other

#0 - c4-judge

2023-10-03T13:12:28Z

0xleastwood marked the issue as primary issue

#1 - 0xleastwood

2023-10-04T07:34:31Z

Downgrading this to medium severity because the _minOut parameter should actually prevent this attack as long as it's non-zero, but I agree this is of concern if users do not set this parameter. This is a stated assumption.

#2 - c4-judge

2023-10-04T07:34:39Z

0xleastwood changed the severity to 2 (Med Risk)

#3 - 0xleastwood

2023-10-04T07:35:18Z

Oops, my bad, I mis-interpreted the first part of the issue.

#4 - c4-judge

2023-10-04T07:35:24Z

0xleastwood changed the severity to 3 (High Risk)

#5 - c4-judge

2023-10-04T07:35:56Z

0xleastwood marked the issue as selected for report

#6 - c4-sponsor

2023-10-04T14:47:05Z

elmutt (sponsor) confirmed

#7 - 0xleastwood

2023-10-08T12:14:09Z

Upon further investigation, AfEth.deposit() is not vulnerable to the deposit front-running. This is only an issue if we are interacting with the votium strategy contract directly which is atypical behaviour. However, funds are still at risk even with these stated assumptions so I believe medium severity to be more correct.

#8 - c4-judge

2023-10-08T12:14:20Z

0xleastwood changed the severity to 2 (Med Risk)

#9 - MiloTruck

2023-10-08T19:01:42Z

@0xleastwood apologies for commenting after post-judging QA, but isn't the inflation attack still a problem even if users only interact with the AfEth contract?

AfEth.deposit() calls VotiumStrategy's deposit() function, so if a user calls AfEth.deposit() after VotiumStrategy's state has been manipulated, vMinted will be 0, causing him to lose the portion of his ETH that was deposited into VotiumStrategy.

Unless I'm missing something here...

#10 - 0xleastwood

2023-10-08T19:06:33Z

@0xleastwood apologies for commenting after post-judging QA, but isn't the inflation attack still a problem even if users only interact with the AfEth contract?

AfEth.deposit() calls VotiumStrategy's deposit() function, so if a user calls AfEth.deposit() after VotiumStrategy's state has been manipulated, vMinted will be 0, causing him to lose the portion of his ETH that was deposited into VotiumStrategy.

Unless I'm missing something here...

Ultimately, I do believe _minOut to be sufficient in detecting such an attack.

#11 - 0xleastwood

2023-10-08T19:36:04Z

Actually, I don't even think this attack is honestly feasible, for the same reasons outlined in #58.

#12 - c4-judge

2023-10-08T19:38:08Z

0xleastwood marked the issue as not selected for report

#13 - c4-judge

2023-10-08T19:38:19Z

0xleastwood changed the severity to QA (Quality Assurance)

#14 - c4-judge

2023-10-08T19:39:09Z

This previously downgraded issue has been upgraded by 0xleastwood

#15 - c4-judge

2023-10-08T19:39:30Z

0xleastwood changed the severity to QA (Quality Assurance)

#16 - 0xleastwood

2023-10-08T19:54:56Z

Actually, I'd like to retract this, this is clearly possible when following the steps outlined in the POC.

#17 - c4-judge

2023-10-08T19:55:04Z

This previously downgraded issue has been upgraded by 0xleastwood

#18 - c4-judge

2023-10-08T19:55:08Z

0xleastwood marked the issue as selected for report

#19 - elmutt

2023-10-09T22:24:02Z

Findings Information

🌟 Selected for report: adriro

Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev

Labels

bug
grade-a
QA (Quality Assurance)
selected for report
sponsor confirmed
Q-03

Awards

Data not available

External Links

Report

Summary

Low Issues

Total of 18 issues:

IDIssue
L-1Hardcoded addresses may not be compatible with a multi-chain deployment
L-2Use Ownable2Step instead of Ownable for access control
L-3Missing name and symbol for AfEth token
L-4Validate ratio argument in setRatio()
L-5Price calculations may experience precision loss due to division before multiplication
L-6Validate withdrawals in AfEth have been already executed
L-7Prevent AfEth token transfers to its own contract
L-8Public relock function can be used to grief withdrawal requests
L-9Missing call to base initializers
L-10Missing check for zero address value in constructor or setter
L-11Function canWithdraw() in VotiumStrategy doesn't check if withdrawal has been already executed
L-12VotiumStrategy allows to recover ERC20 tokens but not native ETH
L-13Missing usage of safe wrappers to handle ERC20 operations
L-14Zero token allowance can cause denial of service in applyRewards()
L-15Low level calls to account with no code will not fail
L-16Protocol fees are not collected when rewards are not routed through AfEth
L-17Protocol doesn't collect fees from SafEth
L-18Potential rounding to zero issue in AfEth deposit could cause loss of value

Non Critical Issues

Total of 4 issues:

IDIssue
NC-1Remove debug symbols
NC-2Missing event for important parameter change
NC-3Unused constants
NC-4Use constants for literal or magic values

Informational Issues

Total of 1 issues:

IDIssue
INFO-1Consider using the ERC4626 standard

Low Issues

<a name="L-1"></a>[L-1] Hardcoded addresses may not be compatible with a multi-chain deployment

The codebase is full of hardcoded addresses that refer to other parts of the protocol (SafEth, for example) or third-party protocols (e.g. Votium, Convex, Curve Pools).

Assumptions of the presence of third-party protocol, their deployment addresses and/or their arguments will prevent or complicate deployments in layer 2 chains. This is stated as a possibility in the documentation:

This will only be deployed to Ethereum Mainnet, with the chance of being deployed on L2's on a future date

<a name="L-2"></a>[L-2] Use Ownable2Step instead of Ownable for access control

Use the Ownable2Step variant of the Ownable contract to better safeguard against accidental transfers of access control.

Instances (2):

<a name="L-3"></a>[L-3] Missing name and symbol for AfEth token

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L72

The AfEth contract inherits from ERC20Upgradeable but doesn't call its base initializer, which is in charge of setting the name and symbol for the ERC20 token.

<a name="L-4"></a>[L-4] Validate ratio argument in setRatio()

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L90

The ratio configuration parameter in AfEth measures the portion of deposited value that goes into the SafEth contract. This value is intended to range between 0 (0%) and 1e18 (100%).

The setRatio() function should validate that the new value is within bounds, i.e. require(_newRatio <= 1e18).

<a name="L-5"></a>[L-5] Price calculations may experience precision loss due to division before multiplication

There are several places across the codebase in which intermediate results that depend on a division are then used as part of calculations that involve a multiple.

For example, in requestWithdraw() the calculation of votiumWithdrawAmount depends on the intermediate result of withdrawRatio. The expression can be simplified as:

uint256 votiumWithdrawAmount = (_amount * votiumBalance) / (totalSupply() - afEthBalance);

And similarly, safEthWithdrawAmount:

uint256 safEthWithdrawAmount = (_amount * safEthBalance) / (totalSupply() - afEthBalance);

<a name="L-6"></a>[L-6] Validate withdrawals in AfEth have been already executed

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L243

Withdrawals in AfEth are first enqueued when requested, and executed later when the vAfEth tokens are ready to be withdrawn. The withdraw() function in AfEth validates that the withdrawal is ready (by using canWithdraw()) but doesn't validate if the withdrawal has been already executed.

This isn't currently exploitable since the withdrawal in AfEth also depends on the withdrawal in VotiumStrategy, which correctly checks if the withdrawal associated to the vEthWithdrawId has been already claimed. Consider adding a similar check to AfEth::withdraw().

<a name="L-7"></a>[L-7] Prevent AfEth token transfers to its own contract

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

When a user requests a withdrawal in AfEth, their tokens are transferred to the contract and "locked" until the withdrawal is made effective, at which point the tokens are burned.

Given this mechanism, AfEth tokens held by the same contract are considered as locked tokens but anyone can technically transfer tokens to the contract by calling transfer() or transferFrom().

Consider adding a check to the base ERC20 functionality to prevent AfEth tokens from being sent to the contract by external actors.

<a name="L-8"></a>[L-8] Public relock function can be used to grief withdrawal requests

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

A malicious user can call relock() to front-run a transaction to requestWithdraw().

Relocking will take any available CVX in the contract and expired locks in CvxLocker and relock them. Griefed users will need to wait more, as any of the available balance that could have been used for the withdrawal has been relocked as part of the front-running.

<a name="L-9"></a>[L-9] Missing call to base initializers

Upgradeable contracts that inherit from other base contracts should call the corresponding base initializers during initialization.

Instances (4):

<a name="L-10"></a>[L-10] Missing check for zero address value in constructor or setter

Address parameters should be validated to guard against the default value address(0).

Instances (6):

<a name="L-11"></a>[L-11] Function canWithdraw() in VotiumStrategy doesn't check if withdrawal has been already executed

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

The implementation of canWithdraw() in the VotiumStrategy contract just checks if the required epoch has been reached, but doesn't validate if the request has been already fulfilled (WithdrawRequestInfo.withdrawn).

Consider also checking for the withdrawn condition as part of the implementation of canWithdraw().

function canWithdraw(
    uint256 _withdrawId
) external view virtual override returns (bool) {
    uint256 currentEpoch = ILockedCvx(VLCVX_ADDRESS).findEpochId(
        block.timestamp
    );
    return
        withdrawIdToWithdrawRequestInfo[_withdrawId].epoch <= currentEpoch && !withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn;
}

<a name="L-12"></a>[L-12] VotiumStrategy allows to recover ERC20 tokens but not native ETH

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

The implementation of withdrawStuckTokens() allows the owner of the protocol to recover any ERC20 token, but fails to consider native ETH transfers.

<a name="L-13"></a>[L-13] Missing usage of safe wrappers to handle ERC20 operations

ERC20 operations on arbitrary tokens should be safely wrapped to account for incompatible implementations.

<a name="L-14"></a>[L-14] Zero token allowance can cause denial of service in applyRewards()

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L282-L285

IERC20(_swapsData[i].sellToken).approve(
    address(_swapsData[i].spender),
    0
);

During applyRewards(), token allowances are first reset to zero before being increased to infinity. This could cause issues with some ERC20 implementations that revert on zero value approvals, such as BNB.

<a name="L-15"></a>[L-15] Low level calls to account with no code will not fail

Low level calls (i.e. address.call(...)) to account with no code will silently succeed without reverting or throwing any error. Quoting the reference for the CALL opcode in evm.codes:

Creates a new sub context and execute the code of the given account, then resumes the current one. Note that an account with no code will return success as true.

<a name="L-16"></a>[L-16] Protocol fees are not collected when rewards are not routed through AfEth

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/strategies/votium/VotiumStrategyCore.sol#L302-L304

Protocol fees are collected in AfEth::depositRewards(), just before rewards are being compounded in the protocol.

The reward flow is initiated in VotiumStrategyCore::applyRewards(), and will deposit rewards in AfEth only if the manager address is defined:

302:         if (address(manager) != address(0))
303:             IAfEth(manager).depositRewards{value: ethReceived}(ethReceived);
304:         else depositRewards(ethReceived);

It is important to note that if rewards aren't channeled through AfEth, the protocol will not receive any fees.

<a name="L-17"></a>[L-17] Protocol doesn't collect fees from SafEth

https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L272

Protocol fees are only collected as part of the deposited rewards coming from the VotiumStrategy contract. Votium and Convex rewards are claimed and deposited back in the protocol as an explicit compound action.

On the other hand, SafEth accrues value by the passive appreciation of the underlying LSD tokens backing the protocol. There is no explicit process for claiming or compounding this increase in SafEth token value. The protocol isn't collecting fees from this side of the split.

It is not clear if this is by design or a potential oversight in the implementation.

<a name="L-18"></a>[L-18] Potential rounding to zero issue in AfEth deposit could cause loss of value

Deposits in the AfEth contract are split based on a configured ratio. One portion of the split goes to SafEth, while the other is deposited in the Votium strategy.

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

148:     function deposit(uint256 _minout) external payable virtual {
149:         if (pauseDeposit) revert Paused();
150:         uint256 amount = msg.value;
151:         uint256 priceBeforeDeposit = price();
152:         uint256 totalValue;
153: 
154:         AbstractStrategy vStrategy = AbstractStrategy(vEthAddress);
155: 
156:         uint256 sValue = (amount * ratio) / 1e18;
157:         uint256 sMinted = sValue > 0
158:             ? ISafEth(SAF_ETH_ADDRESS).stake{value: sValue}(0)
159:             : 0;
160:         uint256 vValue = (amount * (1e18 - ratio)) / 1e18;
161:         uint256 vMinted = vValue > 0 ? vStrategy.deposit{value: vValue}() : 0;
162:         totalValue +=
163:             (sMinted * ISafEth(SAF_ETH_ADDRESS).approxPrice(true)) +
164:             (vMinted * vStrategy.price());
165:         if (totalValue == 0) revert FailedToDeposit();
166:         uint256 amountToMint = totalValue / priceBeforeDeposit;
167:         if (amountToMint < _minout) revert BelowMinOut();
168:         _mint(msg.sender, amountToMint);
169:     }

The amounts that go into each are calculated in lines 156 and 160. Both sValue and vValue calculations could be rounded down to zero if the numerator is lower than the denominator.

Even with a low ratio value, the amounts lost are negligible, hence the low severity.

For example, given a small ratio of 1% (i.e. 1e16) we have:

amount * ratio < 1e18 amount < 1e18 / ratio amount < 1e18 / 1e16 amount < 100

Amount should be lower than 100 wei in order to be rounded down to zero.

Non Critical Issues

<a name="NC-1"></a>[NC-1] Remove debug symbols

Remove any code related to debug functionality.

<a name="NC-2"></a>[NC-2] Missing event for important parameter change

Important parameter or configuration changes should trigger an event to allow being tracked off-chain.

Instances (8):

<a name="NC-3"></a>[NC-3] Unused constants

Unreferenced private constants in contracts can be removed.

Instances (2):

<a name="NC-4"></a>[NC-4] Use constants for literal or magic values

Consider defining constants for literal or magic values as it improves readability and prevents duplication of config values.

Instances (9):

Informational Issues

<a name="INFO-1"></a>[INFO-1] Consider using the ERC4626 standard

Both AfEth and VotiumStrategy behave like vaults. They take deposits, mint ERC20 tokens in representation of this deposit, and have a withdraw function to recover the assets back.

This is exactly the use case of the ERC4626 standard. Consider using this standard to improve composability.

#0 - 0xleastwood

2023-10-04T06:16:09Z

L-1 - valid L-2 - valid L-3 - valid L-4 - valid L-5 - valid L-6 - AbstractStrategy(vEthAddress).withdraw() will revert if withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn holds true. L-7 - valid L-8 - valid L-9 - valid L-10 - should be non-critical L-11 - same reason as L-6 L-12 - valid L-13 - valid L-14 - valid L-15 - valid L-16 - valid L-17 - valid

NC-1 - valid NC-2 - valid NC-3 - valid NC-4 - valid

INFO-1 - valid

#1 - c4-judge

2023-10-04T06:33:47Z

0xleastwood marked the issue as grade-a

#2 - c4-sponsor

2023-10-04T15:00:35Z

elmutt (sponsor) confirmed

#3 - c4-judge

2023-10-05T10:30:02Z

0xleastwood marked the issue as selected for report

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4, m_Rassska, rvierdiiev

Labels

bug
G (Gas Optimization)
grade-a
selected for report
sponsor confirmed
G-02

Awards

Data not available

External Links

AfEth contract

AbstractStrategy contract

VotiumStrategy contract

VotiumStrategyCore contract

#0 - c4-judge

2023-10-04T05:59:47Z

0xleastwood marked the issue as selected for report

#1 - c4-sponsor

2023-10-04T15:02:19Z

elmutt (sponsor) confirmed

#2 - c4-judge

2023-10-04T18:55:06Z

0xleastwood marked the issue as grade-a

Findings Information

🌟 Selected for report: m_Rassska

Also found by: adriro, d3e4

Labels

analysis-advanced
grade-a
A-02

Awards

Data not available

External Links

Protocol

AfEth is a new protocol that is part of the Asymmetry Finance ecosystem. This new product is essentially a vault, collateralized by two strategies, SafEth and Votium.

SafEth is an existing product of Asymmetry Finance. It provides a diversified Ethereum staking through difference LSD (Liquid Staking Derivatives) protocols. Supported derivatives are:

  • Lido Finance
  • Rocket Pool
  • Frax Finance
  • Swell Network
  • ANKR
  • staFI

The Votium strategy is a new strategy introduced in AfEth. This contract holds Convex tokens (CVX) and delegates them to the Votium protocol, which provides rewards coming from incentives to direct voting power to certain Curve Gauges.

Deposits in AfEth are split according to a configurable ratio. Users deposit ETH and the protocol will stake a percentage of it in SafEth, and deposit the rest in the Votium strategy.

Contracts

There are 4 core contracts:

  • AfEth.sol: main contract and main entrypoint for the AfEth protocol.
  • AbstractStrategy.sol: base abstract contract for creating strategies.
  • VotiumStrategyCore.sol: implements the core interactions with Votium and Convex.
  • VotiumStrategy.sol: strategy implementation to handle deposit, withdrawals and rewards in Votium.

The main interactions are:

  • Deposit: users deposit ETH into the protocol and get AfEth tokens.
  • Request Withdraw: holders of AfEth can request a withdrawal to remove their funds. This action needs to undergo a waiting period in the underlying assets can be withdrawn from the protocols beneath AfEth.
  • Withdraw: after waiting until the release time, users can execute the queued withdrawal and get back the ETH.
  • Rewards: the rewarded can claim rewards from Votium and Convex and deposit them back into the protocol, compounding the assets held by the contract and increasing value for holders.

The VotiumStrategy contract can also act as a vault on its own, meaning users can deposit and interact with it directly, not necessarily going through AfEth.

All these contracts are upgradeable and follow the Transparent Proxy pattern.

class-diagram

Actors

  • Owner: controlled by the DAO multisig. Has access to modify system configuration. Can withdraw stuck tokens from the Votium strategy.
  • Rewarder: privileged role that claims rewards from Votium and Convex, swaps them to ETH and compounds the assets back into the protocol. This role wil probably be managed by a Gelato bot.
  • End User: deposits ETH into AfEth, can withdraw by following a queue process and recover ETH from their position.

Flows

The are 3 main flows in the system that govern most of the interactions in the contracts:

  • Deposit
  • Withdraw
  • Rewards

Deposit

deposit

Withdraw

withdraw

Reward

reward

Risks

  • Oracle: the system heavily relies on oracles to determine prices which are used in deposits and withdrawals. Invalid or stale data here can be critical to the protocol.
  • Swaps and MEV: the Votium strategy depends on exchanging tokens to manage deposits, withdrawals and rewards. Improper handling of swap operations can cause damage to end users or the protocol in general.
  • Integrations: there are multiple integrations in the design of the protocol. SafEth on multiple LSD protocols. Chainlink for price feeds. The Votium strategy depends on Convex, Votium and Curve Pools. Any unexpected precondition or misunderstood postcondition while interfacing with any third-party function can break the integration, cause a denial of service or end in unexpected results for the protocol.

Time spent:

35 hours

#0 - c4-judge

2023-10-04T05:55:37Z

0xleastwood marked the issue as grade-b

#1 - romeroadrian

2023-10-07T13:20:19Z

@0xleastwood I'd like to ask if you could please review the grading here, seems a bit unfair this one got the same grade as #71. Thanks!

#2 - 0xleastwood

2023-10-08T11:22:23Z

Linking #48 into this.

#3 - 0xleastwood

2023-10-08T11:28:45Z

My reasoning for the initial grade was that analysis reports are meant to detail high-level findings/insights into the codebase and not exactly provide an overview on what the codebase does. But I do think there is a lot of overlap with insights and along with #48 to be graded higher.

#4 - c4-judge

2023-10-08T11:28:49Z

0xleastwood marked the issue as grade-a

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