MEDIUM severity: Unsafe asset removal without borrow validation. The removeAsset function in VaultLogic allows removing an asset from the vault's list without checking if there are outstanding borrows. This can lead to a state where borrowers cannot repay loans because the asset is no longer recognized by the system. The root cause is the absence of a pre-removal...

Vulnerable code:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { IVault } from "../../interfaces/IVault.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/// @title Vault for storing the backing for cTokens
/// @author kexley, @capLabs
/// @notice Tokens are supplied by cToken minters and borrowed by covered agents
/// @dev Supplies, borrows and utilization rates are tracked. Interest rates should be computed and
/// charged on the external contracts, only the principle amount is counted on this contract.
library VaultLogic {
    using SafeERC20 for IERC20;

    /// @dev Timestamp is past the deadline
    error PastDeadline();

    /// @dev Amount out is less than required
    error Slippage(address asset, uint256 amountOut, uint256 minAmountOut);

    /// @dev Paused assets cannot be supplied or borrowed
    error AssetPaused(address asset);

    /// @dev Only whitelisted assets can be supplied or borrowed
    error AssetNotSupported(address asset);

    /// @dev Asset is already listed
    error AssetAlreadySupported(address asset);

    /// @dev Only non-supported assets can be rescued
    error AssetNotRescuable(address asset);

    /// @dev Invalid min amounts out as they dont match the number of assets
    error InvalidMinAmountsOut();

    /// @dev Insufficient reserves
    error InsufficientReserves(address asset, uint256 balanceBefore, uint256 amount);

    /// @dev Cap token minted
    event Mint(address indexed minter, address receiver, address indexed asset, uint256 amountIn, uint256 amountOut);

    /// @dev Cap token burned
    event Burn(address indexed burner, address receiver, address indexed asset, uint256 amountIn, uint256 amountOut);

    /// @dev Cap token redeemed
    event Redeem(address indexed redeemer, address receiver, uint256 amountIn, uint256[] amountsOut);

    /// @dev Borrow made
    event Borrow(address indexed borrower, address indexed asset, uint256 amount);

    /// @dev Repayment made
    event Repay(address indexed repayer, address indexed asset, uint256 amount);

    /// @dev Add asset
    event AddAsset(address asset);

    /// @dev Remove asset
    event RemoveAsset(address asset);

    /// @dev Asset paused
    event PauseAsset(address asset);

    /// @dev Asset unpaused
    event UnpauseAsset(address asset);

    /// @dev Rescue unsupported ERC20 tokens
    event RescueERC20(address asset, address receiver);

    /// @dev Modifier to only allow supplies and borrows when not paused
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    modifier whenNotPaused(IVault.VaultStorage storage $, address _asset) {
        _whenNotPaused($, _asset);
        _;
    }

    /// @dev Modifier to update the utilization index
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    modifier updateIndex(IVault.VaultStorage storage $, address _asset) {
        _updateIndex($, _asset);
        _;
    }

    /// @notice Mint the cap token using an asset
    /// @dev This contract must have approval to move asset from msg.sender
    /// @param $ Vault storage pointer
    /// @param params Mint parameters
    function mint(IVault.VaultStorage storage $, IVault.MintBurnParams memory params)
        external
        whenNotPaused($, params.asset)
        updateIndex($, params.asset)
    {
        if (params.deadline < block.timestamp) revert PastDeadline();
        if (params.amountOut < params.minAmountOut) {
            revert Slippage(address(this), params.amountOut, params.minAmountOut);
        }

        $.totalSupplies[params.asset] += params.amountIn;

        IERC20(params.asset).safeTransferFrom(msg.sender, address(this), params.amountIn);

        emit Mint(msg.sender, params.receiver, params.asset, params.amountIn, params.amountOut);
    }

    /// @notice Burn the cap token for an asset
    /// @dev Can only withdraw up to the amount remaining on this contract
    /// @param $ Vault storage pointer
    /// @param params Burn parameters
    function burn(IVault.VaultStorage storage $, IVault.MintBurnParams memory params)
        external
        updateIndex($, params.asset)
    {
        if (params.deadline < block.timestamp) revert PastDeadline();
        if (params.amountOut < params.minAmountOut) {
            revert Slippage(params.asset, params.amountOut, params.minAmountOut);
        }

        $.totalSupplies[params.asset] -= params.amountOut;

        IERC20(params.asset).safeTransfer(params.receiver, params.amountOut);

        emit Burn(msg.sender, params.receiver, params.asset, params.amountIn, params.amountOut);
    }

    /// @notice Redeem the Cap token for a bundle of assets
    /// @dev Can only withdraw up to the amount remaining on this contract
    /// @param $ Vault storage pointer
    /// @param params Redeem parameters
    function redeem(IVault.VaultStorage storage $, IVault.RedeemParams memory params) external {
        if (params.amountsOut.length != $.assets.length) revert InvalidMinAmountsOut();
        if (params.deadline < block.timestamp) revert PastDeadline();

        address[] memory cachedAssets = $.assets;
        for (uint256 i; i < cachedAssets.length; ++i) {
            if (params.amountsOut[i] < params.minAmountsOut[i]) {
                revert Slippage(cachedAssets[i], params.amountsOut[i], params.minAmountsOut[i]);
            }
            _updateIndex($, cachedAssets[i]);
            $.totalSupplies[cachedAssets[i]] -= params.amountsOut[i];
            IERC20(cachedAssets[i]).safeTransfer(params.receiver, params.amountsOut[i]);
        }

        emit Redeem(msg.sender, params.receiver, params.amountIn, params.amountsOut);
    }

    /// @notice Borrow an asset
    /// @dev Whitelisted agents can borrow any amount, LTV is handled by Agent contracts
    /// @param $ Vault storage pointer
    /// @param params Borrow parameters
    function borrow(IVault.VaultStorage storage $, IVault.BorrowParams memory params)
        external
        whenNotPaused($, params.asset)
        updateIndex($, params.asset)
    {
        uint256 balanceBefore = IERC20(params.asset).balanceOf(address(this));
        if (balanceBefore < params.amount) revert InsufficientReserves(params.asset, balanceBefore, params.amount);

        $.totalBorrows[params.asset] += params.amount;
        IERC20(params.asset).safeTransfer(params.receiver, params.amount);

        emit Borrow(msg.sender, params.asset, params.amount);
    }

    /// @notice Repay an asset
    /// @param $ Vault storage pointer
    /// @param params Repay parameters
    function repay(IVault.VaultStorage storage $, IVault.RepayParams memory params)
        external
        updateIndex($, params.asset)
    {
        $.totalBorrows[params.asset] -= params.amount;
        IERC20(params.asset).safeTransferFrom(msg.sender, address(this), params.amount);

        emit Repay(msg.sender, params.asset, params.amount);
    }

    /// @notice Add an asset to the vault list
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    function addAsset(IVault.VaultStorage storage $, address _asset) external {
        if (_listed($, _asset)) revert AssetAlreadySupported(_asset);

        $.assets.push(_asset);
        emit AddAsset(_asset);
    }

    /// @notice Remove an asset from the vault list
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    function removeAsset(IVault.VaultStorage storage $, address _asset) external {
        address[] memory cachedAssets = $.assets;
        uint256 length = cachedAssets.length;
        bool removed;
        for (uint256 i; i < length; ++i) {
            if (_asset == cachedAssets[i]) {
                $.assets[i] = cachedAssets[length - 1];
                $.assets.pop();
                removed = true;
                break;
            }
        }

        if (!removed) revert AssetNotSupported(_asset);

        emit RemoveAsset(_asset);
    }

    /// @notice Pause an asset
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    function pause(IVault.VaultStorage storage $, address _asset) external {
        $.paused[_asset] = true;
        emit PauseAsset(_asset);
    }

    /// @notice Unpause an asset
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    function unpause(IVault.VaultStorage storage $, address _asset) external {
        $.paused[_asset] = false;
        emit UnpauseAsset(_asset);
    }

    /// @notice Rescue an unsupported asset
    /// @param $ Vault storage pointer
    /// @param _asset Asset to rescue
    /// @param _receiver Receiver of the rescue
    function rescueERC20(IVault.VaultStorage storage $, address _asset, address _receiver) external {
        if (_listed($, _asset)) revert AssetNotRescuable(_asset);
        IERC20(_asset).safeTransfer(_receiver, IERC20(_asset).balanceOf(address(this)));
        emit RescueERC20(_asset, _receiver);
    }

    /// @notice Calculate the utilization ratio of an asset
    /// @dev Returns the ratio of borrowed assets to total supply, scaled to ray (1e27)
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    /// @return ratio Utilization ratio in ray (1e27)
    function utilization(IVault.VaultStorage storage $, address _asset) public view returns (uint256 ratio) {
        ratio = $.totalSupplies[_asset] != 0 ? $.totalBorrows[_asset] * 1e27 / $.totalSupplies[_asset] : 0;
    }

    /// @notice Up to date cumulative utilization index of an asset
    /// @dev Utilization and index are both scaled in ray (1e27)
    /// @param $ Vault storage pointer
    /// @param _asset Utilized asset
    /// @return index Utilization ratio index in ray (1e27)
    function currentUtilizationIndex(IVault.VaultStorage storage $, address _asset)
        external
        view
        returns (uint256 index)
    {
        index = $.utilizationIndex[_asset] + (utilization($, _asset) * (block.timestamp - $.lastUpdate[_asset]));
    }

    /// @notice Validate that an asset is listed
    /// @param $ Vault storage pointer
    /// @param _asset Asset to check
    /// @return isListed Asset is listed or not
    function _listed(IVault.VaultStorage storage $, address _asset) internal view returns (bool isListed) {
        address[] memory cachedAssets = $.assets;
        uint256 length = cachedAssets.length;
        for (uint256 i; i < length; ++i) {
            if (_asset == cachedAssets[i]) {
                isListed = true;
                break;
            }
        }
    }

    /// @dev Only allow supplies and borrows when not paused
    /// @param $ Vault storage pointer
    /// @param _asset Asset address
    function _whenNotPaused(IVault.VaultStorage storage $, address _asset) private view {
        if ($.paused[_asset]) revert AssetPaused(_asset);
    }

    /// @dev Update the cumulative utilization index of an asset
    /// @param $ Vault storage pointer
    /// @param _asset Utilized asset
    function _updateIndex(IVault.VaultStorage storage $, address _asset) internal {
        if (!_listed($, _asset)) revert AssetNotSupported(_asset);
        $.utilizationIndex[_asset] += utilization($, _asset) * (block.timestamp - $.lastUpdate[_asset]);
        $.lastUpdate[_asset] = block.timestamp;
    }
}

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { Access } from "../access/Access.sol";

import { IVault } from "../interfaces/IVault.sol";
import { VaultStorageUtils } from "../storage/VaultStorageUtils.sol";
import { FractionalReserve } from "./FractionalReserve.sol";
import { Minter } from "./Minter.sol";
import { VaultLogic } from "./libraries/VaultLogic.sol";
import { ERC20PermitUpgradeable } from
    "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol";

/// @title Vault for storing the backing for cTokens
/// @author kexley, @capLabs
/// @notice Tokens are supplied by cToken minters and borrowed by covered agents
/// @dev Supplies, borrows and utilization rates are tracked. Interest rates should be computed and
/// charged on the external contracts, only the principle amount is counted on this contract.
contract Vault is IVault, ERC20PermitUpgradeable, Access, Minter, FractionalReserve, VaultStorageUtils {
    /// @dev Initialize the assets
    /// @param _name Name of the cap token
    /// @param _symbol Symbol of the cap token
    /// @param _accessControl Access control address
    /// @param _feeAuction Fee auction address
    /// @param _oracle Oracle address
    /// @param _assets Asset addresses
    function __Vault_init(
        string memory _name,
        string memory _symbol,
        address _accessControl,
        address _feeAuction,
        address _oracle,
        address[] calldata _assets
    ) internal onlyInitializing {
        __ERC20_init(_name, _symbol);
        __ERC20Permit_init(_name);
        __Access_init(_accessControl);
        __FractionalReserve_init_unchained(_feeAuction);
        __Minter_init_unchained(_oracle);
        __Vault_init_unchained(_assets);
    }

    /// @dev Initialize unchained
    /// @param _assets Asset addresses
    function __Vault_init_unchained(address[] calldata _assets) internal onlyInitializing {
        getVaultStorage().assets = _assets;
    }

    /// @notice Mint the cap token using an asset
    /// @dev This contract must have approval to move asset from msg.sender
    /// @param _asset Whitelisted asset to deposit
    /// @param _amountIn Amount of asset to use in the minting
    /// @param _minAmountOut Minimum amount to mint
    /// @param _receiver Receiver of the minting
    /// @param _deadline Deadline of the tx
    function mint(address _asset, uint256 _amountIn, uint256 _minAmountOut, address _receiver, uint256 _deadline)
        external
        returns (uint256 amountOut)
    {
        amountOut = getMintAmount(_asset, _amountIn);
        VaultLogic.mint(
            getVaultStorage(),
            MintBurnParams({
                asset: _asset,
                amountIn: _amountIn,
                amountOut: amountOut,
                minAmountOut: _minAmountOut,
                receiver: _receiver,
                deadline: _deadline
            })
        );
        _mint(_receiver, amountOut);
    }

    /// @notice Burn the cap token for an asset
    /// @dev Asset is withdrawn from the reserve or divested from the underlying vault
    /// @param _asset Asset to withdraw
    /// @param _amountIn Amount of cap token to burn
    /// @param _minAmountOut Minimum amount out to receive
    /// @param _receiver Receiver of the withdrawal
    /// @param _deadline Deadline of the tx
    function burn(address _asset, uint256 _amountIn, uint256 _minAmountOut, address _receiver, uint256 _deadline)
        external
        returns (uint256 amountOut)
    {
        amountOut = getBurnAmount(_asset, _amountIn);
        divest(_asset, amountOut);
        VaultLogic.burn(
            getVaultStorage(),
            MintBurnParams({
                asset: _asset,
                amountIn: _amountIn,
                amountOut: amountOut,
                minAmountOut: _minAmountOut,
                receiver: _receiver,
                deadline: _deadline
            })
        );
        _burn(msg.sender, _amountIn);
    }

    /// @notice Redeem the Cap token for a bundle of assets
    /// @dev Assets are withdrawn from the reserve or divested from the underlying vault
    /// @param _amountIn Amount of Cap token to burn
    /// @param _minAmountsOut Minimum amounts of assets to withdraw
    /// @param _receiver Receiver of the withdrawal
    /// @param _deadline Deadline of the tx
    /// @return amountsOut Amount of assets withdrawn
    function redeem(uint256 _amountIn, uint256[] calldata _minAmountsOut, address _receiver, uint256 _deadline)
        external
        returns (uint256[] memory amountsOut)
    {
        amountsOut = getRedeemAmount(_amountIn);
        divestMany(assets(), amountsOut);
        VaultLogic.redeem(
            getVaultStorage(),
            RedeemParams({
                amountIn: _amountIn,
                amountsOut: amountsOut,
                minAmountsOut: _minAmountsOut,
                receiver: _receiver,
                deadline: _deadline
            })
        );
        _burn(msg.sender, _amountIn);
    }

    /// @notice Borrow an asset
    /// @dev Whitelisted agents can borrow any amount, LTV is handled by Agent contracts
    /// @param _asset Asset to borrow
    /// @param _amount Amount of asset to borrow
    /// @param _receiver Receiver of the borrow
    function borrow(address _asset, uint256 _amount, address _receiver) external checkAccess(this.borrow.selector) {
        divest(_asset, _amount);
        VaultLogic.borrow(getVaultStorage(), BorrowParams({ asset: _asset, amount: _amount, receiver: _receiver }));
    }

    /// @notice Repay an asset
    /// @param _asset Asset to repay
    /// @param _amount Amount of asset to repay
    function repay(address _asset, uint256 _amount) external checkAccess(this.repay.selector) {
        VaultLogic.repay(getVaultStorage(), RepayParams({ asset: _asset, amount: _amount }));
    }

    /// @notice Add an asset to the vault list
    /// @param _asset Asset address
    function addAsset(address _asset) external checkAccess(this.addAsset.selector) {
        VaultLogic.addAsset(getVaultStorage(), _asset);
    }

    /// @notice Remove an asset from the vault list
    /// @param _asset Asset address
    function removeAsset(address _asset) external checkAccess(this.removeAsset.selector) {
        VaultLogic.removeAsset(getVaultStorage(), _asset);
    }

    /// @notice Pause an asset
    /// @param _asset Asset address
    function pause(address _asset) external checkAccess(this.pause.selector) {
        VaultLogic.pause(getVaultStorage(), _asset);
    }

    /// @notice Unpause an asset
    /// @param _asset Asset address
    function unpause(address _asset) external checkAccess(this.unpause.selector) {
        VaultLogic.unpause(getVaultStorage(), _asset);
    }

    /// @notice Rescue an unsupported asset
    /// @param _asset Asset to rescue
    /// @param _receiver Receiver of the rescue
    function rescueERC20(address _asset, address _receiver) external checkAccess(this.rescueERC20.selector) {
        VaultLogic.rescueERC20(getVaultStorage(), _asset, _receiver);
    }

    /// @notice Get the list of assets supported by the vault
    /// @return assetList List of assets
    function assets() public view returns (address[] memory assetList) {
        assetList = getVaultStorage().assets;
    }

    /// @notice Get the total supplies of an asset
    /// @param _asset Asset address
    /// @return totalSupply Total supply
    function totalSupplies(address _asset) external view returns (uint256 totalSupply) {
        totalSupply = getVaultStorage().totalSupplies[_asset];
    }

    /// @notice Get the total borrows of an asset
    /// @param _asset Asset address
    /// @return totalBorrow Total borrow
    function totalBorrows(address _asset) external view returns (uint256 totalBorrow) {
        totalBorrow = getVaultStorage().totalBorrows[_asset];
    }

    /// @notice Get the pause state of an asset
    /// @param _asset Asset address
    /// @return isPaused Pause state
    function paused(address _asset) external view returns (bool isPaused) {
        isPaused = getVaultStorage().paused[_asset];
    }

    /// @notice Available balance to borrow
    /// @param _asset Asset to borrow
    /// @return amount Amount available
    function availableBalance(address _asset) external view returns (uint256 amount) {
        VaultStorage storage $ = getVaultStorage();
        amount = $.totalSupplies[_asset] - $.totalBorrows[_asset];
    }

    /// @notice Utilization rate of an asset
    /// @dev Utilization scaled by 1e27
    /// @param _asset Utilized asset
    /// @return ratio Utilization ratio
    function utilization(address _asset) external view returns (uint256 ratio) {
        ratio = VaultLogic.utilization(getVaultStorage(), _asset);
    }

    /// @notice Up to date cumulative utilization index of an asset
    /// @dev Utilization scaled by 1e27
    /// @param _asset Utilized asset
    /// @return index Utilization ratio index
    function currentUtilizationIndex(address _asset) external view returns (uint256 index) {
        index = VaultLogic.currentUtilizationIndex(getVaultStorage(), _asset);
    }
}


Vulnerability type: CWE-664|CWE-404