Summary
Donated WETH and leftover swap residue cannot be converted back to alETH, allowing an attacker to donate WETH into the contract. This impacts the Report function in the main BASE STRATEGY, as the donated amount is counted as profit but cannot be withdrawn. Users will pay the protocol the alETH equivalent of the WETH, leading to the last person withdrawing not receiving their full balance.
Vulnerability Details
Donated and residue WETH can not be swapped backed to alETH, there are no functions to handle this
/\*\*\
\* @dev Internal function to harvest all rewards, redeploy any idle\
\* funds and return an accurate accounting of all funds currently\
\* held by the Strategy.\
\*\
\* This should do any needed harvesting, rewards selling, accrual,\
\* redepositing etc. to get the most accurate view of current assets.\
\*\
\* NOTE: All applicable assets including loose assets should be\
\* accounted for in this function.\
\*\
\* Care should be taken when relying on oracles or swap values rather\
\* than actual amounts as all Strategy profit/loss accounting will\
\* be done based on this returned value.\
\*\
\* This can still be called post a shutdown, a strategist can check\
\* `TokenizedStrategy.isShutdown()` to decide if funds should be\
\* redeployed or simply realize any profits/losses.\
\*\
\* @return \_totalAssets A trusted and accurate account for the total\
\* amount of 'asset' the strategy currently holds including idle funds.\
\*/\
function _harvestAndReport()
internal
override
returns (uint256 _totalAssets)
{
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
}
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
@audit >>>>
@audit >>>> uint256 underlyingBalance = underlying.balanceOf(address(this));
@audit >>>> _totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}
OPTIONAL TO OVERRIDE BY STRATEGIST
* @notice Gets the max amount of `asset` that can be withdrawn.
* @dev Defaults to an unlimited amount for any address. But can
* be overridden by strategists.
*
* This function will be called before any withdraw or redeem to enforce
* any limits desired by the strategist. This can be used for illiquid
* or sandwichable strategies.
*
* EX:
* return asset.balanceOf(yieldSource);
*
* This does not need to take into account the `_owner`'s share balance
* or conversion rates from shares to assets.
*
* @param . The address that is withdrawing from the strategy.
* @return . The available amount that can be withdrawn in terms of `asset`
*/
The claim function does not swap the entire underlying WETH present in the contract but we swap the amount claimed hence there is no way to swap the residue and donated WETH.
* @dev Function called by keeper to claim WETH from transmuter & swap to alETH at premium
* we ensure that we are always swapping at a premium (i.e. keeper cannot swap at a loss)
* @param _amountClaim The amount of WETH to claim from the transmuter
* @param _minOut The minimum amount of alETH to receive after swap
* @param _path The path to swap WETH to alETH (via Ramses Router)
*/
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IRamsesRouter.route[] calldata _path) external onlyKeepers {
@audit>>> transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
@audit>>> _swapUnderlyingToAsset(_amountClaim, _minOut, _path);
uint256 balAfter = asset.balanceOf(address(this));
@audit>>> require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
Impact
The impact of this issue can be found in harvest function on the main BASE STRATEGY. Calls to obtained the total asset in the contract adds the WETH balance in address(this) allowing an attacker to donate and influence the profit calculation causing an imbalance that can never be rectified since the WETH address(this).balance cannot be independently swapped back to alETH
function report()
external
nonReentrant
onlyKeepers
returns (uint256 profit, uint256 loss)
{
StrategyData storage S = \_strategyStorage();
@audit>>>> uint256 newTotalAssets = IBaseStrategy(address(this))
.harvestAndReport();
uint256 oldTotalAssets = _totalAssets(S);
uint256 sharesToBurn = _unlockedShares(S);
uint256 totalFees;
uint256 protocolFees;
uint256 sharesToLock;
uint256 _profitMaxUnlockTime = S.profitMaxUnlockTime;
if (newTotalAssets > oldTotalAssets) {
unchecked {
@audit>>>> profit = newTotalAssets - oldTotalAssets;
}
sharesToLock = _convertToShares(S, profit, Math.Rounding.Down);
uint16 fee = S.performanceFee;
uint256 totalFeeShares;
if (fee != 0) {
unchecked {
@audit>>>> totalFees = (profit * fee) / MAX_BPS;
@audit>>>> totalFeeShares = (sharesToLock * fee) / MAX_BPS;
}
@audit>>>> (
uint16 protocolFeeBps,
address protocolFeesRecipient
) = IFactory(FACTORY).protocol_fee_config();
uint256 protocolFeeShares;
if (protocolFeeBps != 0) {
unchecked {
@audit>>>> protocolFeeShares =
(totalFeeShares * protocolFeeBps) /
MAX_BPS;
@audit>>>> protocolFees = (totalFees * protocolFeeBps) / MAX_BPS;
}
@audit>>>> _mint(S, protocolFeesRecipient, protocolFeeShares);
}
unchecked {
@audit>>>> _mint(
S,
S.performanceFeeRecipient,
totalFeeShares - protocolFeeShares
);
}
}
if (_profitMaxUnlockTime != 0) {
unchecked {
sharesToLock -= totalFeeShares;
}
if (sharesToBurn > sharesToLock) {
unchecked {
_burn(S, address(this), sharesToBurn - sharesToLock);
}
} else if (sharesToLock > sharesToBurn) {
unchecked {
_mint(S, address(this), sharesToLock - sharesToBurn);
}
}
}
} else {
unchecked {
loss = oldTotalAssets - newTotalAssets;
}
if (loss != 0) {
sharesToBurn = Math.min(
S.balances[address(this)],
_convertToShares(S, loss, Math.Rounding.Down) + sharesToBurn
);
}
if (sharesToBurn != 0) {
_burn(S, address(this), sharesToBurn);
}
}
uint256 totalLockedShares = S.balances[address(this)];
if (totalLockedShares != 0) {
uint256 previouslyLockedTime;
uint96 _fullProfitUnlockDate = S.fullProfitUnlockDate;
if (_fullProfitUnlockDate > block.timestamp) {
unchecked {
previouslyLockedTime =
(_fullProfitUnlockDate - block.timestamp) *
(totalLockedShares - sharesToLock);
}
}
uint256 newProfitLockingPeriod = (previouslyLockedTime +
sharesToLock *
_profitMaxUnlockTime) / totalLockedShares;
S.profitUnlockingRate =
(totalLockedShares * MAX_BPS_EXTENDED) /
newProfitLockingPeriod;
S.fullProfitUnlockDate = uint96(
block.timestamp + newProfitLockingPeriod
);
} else {
S.fullProfitUnlockDate = 0;
}
S.totalAssets = newTotalAssets;
S.lastReport = uint96(block.timestamp);
emit Reported(
profit,
loss,
protocolFees,
totalFees - protocolFees
);
}
Tools Used
Manual Review
Recommendations
Implement a way to swap the entire address(this) WETH balance of the contract to alETH.