The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

Zero Minimum Output in Swap Transactions Exposing to MEV Sandwich Attacks

Summary

The smart contract contains a critical vulnerability in the calculateMinimumAmountOut function, which is used to determine the minimum acceptable amount of output tokens in a swap transaction.

This vulnerability, when combined with the collateral check logic, can lead to every swap being susceptible to sandwich attacks by MEV bots.

Vulnerability Details

The core issue lies in the logic used to calculate minimumAmountOut in the calculateMinimumAmountOut function.

function calculateMinimumAmountOut(bytes32 _inTokenSymbol, bytes32 _outTokenSymbol, uint256 _amount)
private
view
returns (uint256)
{
ISmartVaultManagerV3 _manager = ISmartVaultManagerV3(manager);
uint256 requiredCollateralValue = minted * _manager.collateralRate() / _manager.HUNDRED_PC();
console2.log("requiredCollateralValue: %s", requiredCollateralValue);
uint256 collateralValueMinusSwapValue =
euroCollateral() - calculator.tokenToEur(getToken(_inTokenSymbol), _amount);
console2.log("collateralValueMinusSwapValue: %s", collateralValueMinusSwapValue);
@> return collateralValueMinusSwapValue >= requiredCollateralValue
@> ? 0
: calculator.eurToToken(getToken(_outTokenSymbol), requiredCollateralValue - collateralValueMinusSwapValue);
}

The function returns zero as the minimum amount of output tokens if the post-swap collateral value is deemed sufficient (i.e., collateralValueMinusSwapValue >= requiredCollateralValue).

With or without minting collateral a allowed test will always be potentially attacked by an MEV bot as minimumAmountOut will always be 0.

While this might appear to be a measure to allow swaps that don't jeopardize the collateral's health, it inadvertently exposes every allowed swap to potential sandwich attacks.

In a sandwich attack, an MEV bot can observe a pending swap transaction with zero slippage protection (as minimumAmountOut is set to zero).

The bot can then execute trades that manipulate the market price, leading to the initial swap transaction being executed at a highly unfavorable rate. After the victim's swap is executed, the attacker reverses their trade to profit from the artificially created price slippage.


POC

We can verify the minimumAmountOut by running this POC.

To be able to run the test in this finding follow the following recommendation

INITIAL FOUNDRY SETUP FOR POC

Follow the following tutorial to install foundry in your local repo

https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry

When hardhat-foundry is installed, rename the current test folder to hardhat_test so forge won't look at it.

Create/Update the foundry.toml fil with the current setup, to allow compilation with viaIR

[profile.default]
src = 'contracts'
out = 'out'
libs = ['node_modules', 'lib']
test = 'test'
cache_path = 'cache_forge'
viaIR= true

Create a new test folder, with the following file test contract in it, this contract deploy the whole contract stack.

The smartVaultManager is deployed with the proxy.
NFTMetadataGenerator is replaced by a Dummy address to lower the compilation time.

2 helpers are used in all the pocs:

_deployVault: Used to deploy the user Vault and retrieve the vault address
_send: Is used to send ETH

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "./utils/ERC20Mock.sol";
import "./utils/EUROsMock.sol";
import "./utils/ChainlinkMock.sol";
import "./utils/TokenManagerMock.sol";
import "./utils/SmartVaultDeployerV3.sol";
import "./utils/SwapRouterMock.sol";
import "./utils/SmartVaultIndex.sol";
import "../contracts/LiquidationPoolManager.sol";
import "./utils/SmartVaultManager.sol";
import "../contracts/SmartVaultManagerV5.sol";
import "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {console2} from "forge-std/console2.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract ImplDeployerProxy is ERC1967Proxy {
constructor(address impl, bytes memory data) ERC1967Proxy(impl, data) {}
function upgradeTo(address newImplementation) public {
_upgradeTo(newImplementation);
}
}
contract ContractBTest is Test {
uint256 baseTime = 1710000000;
uint256 DEFAULT_COLLATERAL_RATE = 120000; // 120%
uint256 PROTOCOL_FEE_RATE = 500; // 0.5%
uint32 POOL_FEE_PERCENTAGE = 50000; // 50%
bytes32 immutable WETH = "ETH";
address WETH_ADDRESS = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
address WBTC_ADDRESS = 0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f;
EUROsMock EUROs;
ERC20Mock TST;
ERC20Mock WBTC;
ERC20Mock LUNA;
ChainlinkMock ClEthUsd;
ChainlinkMock ClBtcUsd;
ChainlinkMock ClEurUsd;
ChainlinkMock ClLunaUsd;
SmartVaultManager smartVaultManagerV1;
ImplDeployerProxy proxy;
SmartVaultManagerV5 smartVaultManagerV5;
SmartVaultIndex smartVaultIndex;
SmartVaultV3 smartVault;
LiquidationPoolManager liquidationPoolManager;
LiquidationPool liquidationPool;
TokenManagerMock tokenManagerMock;
address user = makeAddr("user");
address deployer = makeAddr("deployer");
address protocol = makeAddr("protocol");
address userLiquidator = makeAddr("userLiquidator");
address maliciousUser = makeAddr("maliciousUser");
address NFTMetadataGenerator = makeAddr("Dum");
address liquidator = makeAddr("liquidator");
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
function setUp() public {
vm.startPrank(deployer);
address implementationV1 = address(smartVaultManagerV1);
WBTC = new ERC20Mock('Wrapped BTC', 'WBTC', 8);
LUNA = new ERC20Mock('LUNA Ponzi', 'LUNA', 18);
// Set chainlink price Mock to 2200$
ClEthUsd = new ChainlinkMock('ETH / USD');
ClBtcUsd = new ChainlinkMock('BTC / USD');
ClEurUsd = new ChainlinkMock('EUR / USD');
ClLunaUsd = new ChainlinkMock('LUNA / USD');
vm.warp(baseTime);
ClEthUsd.setPrice(220000000000); // 2200$
vm.warp(baseTime);
ClBtcUsd.setPrice(4400000000000); // 44000$
vm.warp(baseTime);
ClEurUsd.setPrice(110000000); // 1.10$
vm.warp(baseTime);
ClLunaUsd.setPrice(2000000000); // 20$
EUROs = new EUROsMock();
tokenManagerMock = new TokenManagerMock(WETH, address(ClEthUsd));
tokenManagerMock.addAcceptedToken(address(WBTC), address(ClBtcUsd));
tokenManagerMock.addAcceptedToken(address(LUNA), address(ClLunaUsd));
address smartVaultDeployerV3 = address(new SmartVaultDeployerV3(WETH, address(ClEurUsd)));
smartVaultIndex = new SmartVaultIndex();
address swapRouterMock = address(new SwapRouterMock());
// Make sure everything is deployed correctly
bytes memory data = abi.encodeWithSignature(
"initialize(uint256,uint256,address,address,address,address,address,address,address)",
DEFAULT_COLLATERAL_RATE,
PROTOCOL_FEE_RATE,
address(EUROs),
protocol,
liquidator,
address(tokenManagerMock),
smartVaultDeployerV3,
address(smartVaultIndex),
NFTMetadataGenerator
);
// Set contract and proxy
proxy = new ImplDeployerProxy(address(new SmartVaultManager()), data);
proxy.upgradeTo(address(new SmartVaultManagerV5()));
smartVaultManagerV5 = SmartVaultManagerV5(address(proxy));
// Settup vault manager
smartVaultIndex.setVaultManager(address(smartVaultManagerV5));
bytes32 adminRole = EUROs.DEFAULT_ADMIN_ROLE();
EUROs.grantRole(adminRole, address(smartVaultManagerV5));
// For testing purpose we allow this contract to mint EUROs
EUROs.grantRole(adminRole, address(this));
EUROs.grantRole(EUROs.MINTER_ROLE(), address(this));
// Liquidation setup
TST = new ERC20Mock('The Standard Token', 'TST', 18);
liquidationPoolManager =
new LiquidationPoolManager(address(TST), address(EUROs), address(smartVaultManagerV5), address(ClEurUsd), payable(protocol), POOL_FEE_PERCENTAGE);
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
smartVaultManagerV5.setLiquidatorAddress(address(liquidationPoolManager));
smartVaultManagerV5.setProtocolAddress(address(liquidationPoolManager));
EUROs.grantRole(EUROs.BURNER_ROLE(), address(liquidationPool));
vm.stopPrank();
}
function _send(address target, uint256 amount) private {
(bool ok,) = address(target).call{value: amount}("");
require(ok, "Send ETH fail");
}
function _deployVault() public returns (address payable vaultAddress) {
vm.recordLogs();
//Mint vault through smartVaultManager and extract address
smartVaultManagerV5.mint();
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 vaultAddressBytes = logs[4].topics[1];
vaultAddress = payable(address(uint160(uint256(vaultAddressBytes))));
smartVault = SmartVaultV3(vaultAddress);
}
function testSetup() public {
startHoax(user, 100 ether);
address vaultAddress = _deployVault();
_send(address(smartVault), 1 ether); // User send 1 ETH to the contract
smartVault.mint(user, 100 ether);
vm.stopPrank();
assert(EUROs.balanceOf(user) == 100 ether);
}

First add console2.log to the SmartVaultV3 contract:

+import {console2} from "forge-std/console2.sol";

Then add the following log in the swap method:

function swap(bytes32 _inToken, bytes32 _outToken, uint256 _amount) external onlyOwner {
uint256 swapFee =
_amount * ISmartVaultManagerV3(manager).swapFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
address inToken = getSwapAddressFor(_inToken);
uint256 minimumAmountOut = calculateMinimumAmountOut(_inToken, _outToken, _amount);
+ console2.log("minimumAmountOut: %s", minimumAmountOut);
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: inToken,
tokenOut: getSwapAddressFor(_outToken),
fee: 3000,
recipient: address(this),
deadline: block.timestamp,
amountIn: _amount - swapFee,
amountOutMinimum: minimumAmountOut,
sqrtPriceLimitX96: 0
});
inToken == ISmartVaultManagerV3(manager).weth()
? executeNativeSwapAndFee(params, swapFee)
: executeERC20SwapAndFee(params, swapFee);
}

Now add the following test to your test file and run forge t --mt 'testSwapIssue' -vvvvvv

function testSwapIssue() public {
// Start prank and mint 1 WBTC to the user
vm.startPrank(user);
WBTC.mint(user, 100000000); // Mint 1 WBTC for the user
address vaultAddress = _deployVault();
console2.log(vaultAddress);
//Send 1 BTC as collateral to allow the user to mint EUROs
WBTC.transfer(address(smartVault), 100000000);
assert(WBTC.balanceOf(user) == 0 ether);
assert(WBTC.balanceOf(address(smartVault)) == 100000000);
smartVault.swap(bytes32(bytes(WBTC.symbol())), bytes32(bytes(LUNA.symbol())), 100000000);
vm.stopPrank();
}

Logs

│ ├─ [0] console::log(minimumAmountOut: %s, 0) [staticcall]
│ │ └─ ← ()

If the pair chosen have a low liquidity, the user could end loosing 0.99999999 BTC.

Impact

This vulnerability can lead to substantial financial losses for users initiating swap transactions. In a sandwich attack scenario, the user's transaction is executed at a manipulated and unfavorable rate, resulting in the loss of asset value. Since every swap allowed by the collateral check is susceptible, the overall integrity and trust in the smart contract are severely compromised.

Tools Used

Manual code analysis.

Recommended Mitigation

To mitigate this vulnerability, the following steps should be considered:

Implement Slippage Protection: Modify the calculateMinimumAmountOut function to include a realistic and protective slippage threshold for every swap. This involves setting a nonzero minimumAmountOut that reflects acceptable market conditions and slippage rates.

Dynamic Slippage Calculation: Introduce a dynamic mechanism to calculate minimumAmountOut based on current liquidity conditions, token volatility, and other market factors.

MEV Protection Strategies: Explore and implement advanced strategies to protect against MEV, such as transaction bundling or using MEV-resistant DeFi protocols like cowSwap.

Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Slippage-issue

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

Slippage-issue

Support

FAQs

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