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

Inconsistent Shutdown Enforcement Allows Asset Deployment Post-Shutdown

01. Relevant GitHub Links

02. Summary

According to the Tokenized Strategies documentation, once a strategy is shut down, no new assets should be deployed into the system. However, in StrategyArb, StrategyMainnet, and StrategyOp contracts, even after the strategy is shut down, a keeper can still call the claimAndSwap function. This function claims underlying assets and reintroduces them into the transmuter, effectively redeploying new assets post-shutdown, contrary to the intended behavior.

03. Vulnerability Details

The Tokenized Strategies documentation clearly states that upon shutdown, the strategy should prevent the deployment of any new assets. Yet, these contracts allow claimAndSwap calls after shutdown. While deposits and mints are blocked as intended, the claimAndSwap function lets a keeper claim underlying tokens and swap them back into the asset, depositing them into the transmuter. This unintentionally enables asset reinsertion into the system even after it is considered terminated.

function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}

In the canonical _harvestAndReport function shown in the documentation, safeguards exist to ensure no redeployment occurs once the strategy is shutdown. The current implementations in the provided strategies lack a similar check, allowing claim and swap operations that effectively circumvent the shutdown condition.

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// Only harvest and redeploy if the strategy is not shutdown.
if(!TokenizedStrategy.isShutdown()) {
// Claim all rewards and sell to asset.
_claimAndSellRewards();
// Check how much we can re-deploy into the yield source.
uint256 toDeploy = Math.min(
asset.balanceOf(address(this)),
availableDepositLimit(address(this))
);
// If greater than 0.
if (toDeploy > 0) {
// Deposit the sold amount back into the yield source.
_deployFunds(toDeploy)
}
}
// Return full balance no matter what.
_totalAssets = yieldSource.balanceOf(address(this)) + asset.balanceOf(address(this));
}

04. Impact

  1. Financial Manipulation: Continuous asset deployment post-shutdown can be leveraged to manipulate the system’s financial state, potentially leading to unexpected losses or imbalances.

  2. System Instability: Persistent redeployment may cause inconsistencies in asset tracking, reporting, and overall strategy performance, making it difficult to maintain accurate records and recover funds if needed.

  3. Trust and Reputation Damage: Stakeholders rely on the assurance that a shutdown effectively halts all operations. Failure to enforce this can lead to loss of confidence, adversely affecting user engagement and platform reputation.

  4. Regulatory Concerns: Inconsistent behavior post-shutdown might attract regulatory scrutiny, especially if it leads to financial discrepancies or perceived negligence in safeguarding assets.

Given these factors, the vulnerability’s impact extends beyond mere operational inconsistencies, posing significant risks to financial integrity and stakeholder trust.

05. Proof of Concept

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";
contract ShutdownTest is Setup {
function setUp() public virtual override {
super.setUp();
}
function test_poc_claimAndSwap_with_shutdown(uint256 _amount) public {
vm.assume(_amount > minFuzzAmount && _amount < maxFuzzAmount);
// Deposit into strategy
mintAndDepositIntoStrategy(strategy, user, _amount);
assertEq(strategy.totalAssets(), _amount, "!totalAssets");
// Earn Interest
skip(1 days);
// Shutdown the strategy
vm.prank(management);
strategy.shutdownStrategy();
// claimandswap
console.log("===================================");
console.log("Amount deposited:", _amount);
console.log("Total Assets:", strategy.totalAssets());
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Buffered:", transmuter.totalBuffered());
assertApproxEq(strategy.totalAssets(), _amount, _amount / 500);
vm.roll(1);
console.log("===================================");
console.log("Total Assets in Strategy:", strategy.totalAssets());
deployMockYieldToken();
console.log("Deployed Mock Yield Token");
addMockYieldToken();
console.log("Added Mock Yield Token");
depositToAlchemist(_amount);
console.log("Deposited to Alchemist");
airdropToMockYield(_amount / 2);
console.log("Airdropped to Mock Yield");
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));
skip(7 days);
vm.roll(5);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
console.log("===================================");
console.log("Total Assets in Strategy:", strategy.totalAssets());
console.log("Skip 7 days");
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Buffered:", transmuter.totalBuffered());
assertGt(strategy.claimableBalance(), 0, "!claimableBalance");
assertEq(strategy.totalAssets(), _amount);
uint256 claimable = strategy.claimableBalance();
skip(1 seconds);
vm.roll(1);
vm.prank(keeper);
if (block.chainid == 1) {
IStrategyInterface(address(strategy)).claimAndSwap(
claimable,
claimable * 101 / 100,
0
);
} else if (block.chainid == 10) {
IVeloRouter.route[] memory veloRoute = new IVeloRouter.route[]();
veloRoute[0] = IVeloRouter.route(address(underlying), address(asset), true, 0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a);
IStrategyInterfaceVelo(address(strategy)).claimAndSwap(claimable, claimable * 103 / 100, veloRoute);
} else if (block.chainid == 42161) {
IRamsesRouter.route[] memory ramsesRoute = new IRamsesRouter.route[]();
address eFrax = 0x178412e79c25968a32e89b11f63B33F733770c2A;
ramsesRoute[0] = IRamsesRouter.route(address(underlying), eFrax, true);
ramsesRoute[1] = IRamsesRouter.route(eFrax, address(asset), true);
IStrategyInterfaceRamses(address(strategy)).claimAndSwap(claimable, claimable * 103 / 100, ramsesRoute);
} else {
revert("Chain ID not supported");
}
console.log("===================================");
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Assets in Strategy:", strategy.totalAssets());
console.log("Free Assets in Strategy:", asset.balanceOf(address(strategy)));
console.log("Underlying in Strategy:", underlying.balanceOf(address(strategy)));
vm.prank(keeper);
(uint256 profit, uint256 loss) = strategy.report();
assertEq(strategy.claimableBalance(), 0, "!claimableBalance");
assertGt(strategy.totalAssets(), _amount, "!totalAssets");
}
}
(hackenv) bshyuunn@hyuunn-MacBook-Air 2024-12-alchemix % forge test --mt test_poc_claimAndSwap_with_shutdown --fork-url <https://rpc.ankr.com/eth> -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for src/test/Shutdown.t.sol:ShutdownTest
[PASS] test_poc_claimAndSwap_with_shutdown(uint256) (runs: 257, μ: 3958995, ~: 3939483)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 25.94s (22.20s CPU time)
Ran 1 test suite in 26.98s (25.94s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
++ if(!TokenizedStrategy.isShutdown()) {
uint256 balBefore = asset.balanceOf(address(this));
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
++ }
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
bshyuunn Submitter
10 months ago
bshyuunn Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

deposits during shutdown

Support

FAQs

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