DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: medium
Valid

Locked underlyingBalance is included in profits calculation but no function to swap it to alETH, breaking the rewards accounting of Strategy and locking users alETH.

Summary

In the _harvestAndReport functions the reported _totalAssets is given by
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance; which includes even the alETH and WETH directly held by the Strategy contract.

This _totalAssets is used by the report function to determine profit/loss for the strategy.
Which means the strategy considers every alETH or WETH directly sent to the contract to be rewards and include them in the _totalAssets calculation.

The problem is underlyingBalance is already considered as rewards but is still unwithdrawable/un-swappable i.e. locked forever.

Vulnerability Details

alETH is automatically converted to rewards by the TokenizedStrategy but for WETH we need a swap function which is missing in the Strategy.

The only conversion of WETH to ETH happens in the claimAndSwap function, but this function can only swap what is already in the transmuter contact and not those held directly by the Strategy contract.

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IRamsesRouter.route[] calldata _path) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IRamsesRouter.route[] calldata _path) internal {
// TODO : we swap WETH to ALETH -> need to check that price is better than 1:1
// uint256 oraclePrice = 1e18 * 101 / 100;
require(minOut > _amount, "minOut too low");
uint256 underlyingBalance = underlying.balanceOf(address(this));
require(underlyingBalance >= _amount, "not enough underlying balance");
IRamsesRouter(router).swapExactTokensForTokens(_amount, minOut, _path, address(this), block.timestamp);
}

It simply checks for to balance but only swap the WETH amount claimed from the transmuter. Which means they are un-swappable through the claimAndSwap function and needs a separate function(or modify this function to include underlyingBalance).

Now coming to _harvestAndReport function;

function _harvestAndReport()
internal
override
returns (uint256 _totalAssets)
{
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
// transmuter.claim(claimable, address(this));
}
// NOTE : we can do this in harvest or can do seperately in tend
// if (underlying.balanceOf(address(this)) > 0) {
// _swapUnderlyingToAsset(underlying.balanceOf(address(this)));
// }
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
// NOTE : possible some dormant WETH that isn't swapped yet (although we can restrict to only claim & swap in one tx)
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

as we can see from the function that underlyingBalance is included, but since there is no function to swap it to alETH, it is locked in the contract forever, and must not be considered as rewards.

The commented out code actually tried to do the swap, which could have prevented this issue. Either the developer forgot to add a new swap function where the keeper can directly swap dormant WETH held in the contract or he forgot to remove underlyingBalance from the _totalAssets calculation.

Since this return value _totalAssets is used to calculate profit/loss.
Now an attacker can directly send some WETH to the contract and when report() is called it will update a false/unwithdrawable amount as profits which will affect the share to asset ratio and give unfair profits to early withdrawers and lock late withdrawers' assets.

Example(simplified):

  • share to asset is 1:1 i.e. yalETH : alETH, with Bob and Alice as depositors with 50 shares each

  • total shares = 100, total assets = 100

  • Bob directly sends 10 WETH to the contract

  • when report is called new totalAssets = 110, because of directly sent WETH

  • then Strategy assumes 10 tokens as profits

  • share to asset ratio is now 1:1.1

  • Bob redeems all his shares amd gets 55 alETH back

  • Alice tries to withdraw but can only do so upto 45 alETH and the remaining 5 tokens of alice are stuck in the contract forever.

  • 10 WETH directly sent in the contract is also stuck forever.

Please note this is just a simple example which excludes the locking period, fees,etc. and other accounting made by the TokenizedStrategy.

Impact

Likelyhood: Low, since Bob(in the example) will lose tokens, but this could also be anyone.

Impact: High, because it is permanent, and it affects all the share holders(some get more, some lose).

Overall severity: Medium

Tools Used

manual

Recommendations

Introduce a new function:

function swapDormantWETH() external onlyKeepers {
// swap underlying.balanceOf(address(this)) using the router
}

the exact implementation will be different for the three Strategy contracts as they use different routers, but should look something like this in general.

With this function in place, even if Bob directly sends WETH then it can be considered as free rewards and Alice would not be hurt.

OR

We could abandon all WETH sent to the contract by removing the underlyingBalance from the _totalAsset calculations:
In StrategyMainnet, StrategyOp and StrategyArb contracts, the _harvestAndReport function should be changed to:

- _totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
+ _totalAssets = unexchanged + asset.balanceOf(address(this));

but this also means that any WETH held by Strategy will be locked and cannot be considered for rewards.

Updates

Appeal created

inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

_harvestAndReport should not contain the underlying balance to prevent donations having an impact.

goran Auditor
8 months ago
dobrevaleri Auditor
8 months ago
inallhonesty Lead Judge
8 months ago
inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Dormant WETH is not properly treated

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.