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 in Deposit/Withdraw Functions #96

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\ InvestToken contract contains a high flaw in its share price calculation mechanism. The core issue lies in the non-atomic execution of deposit and withdraw operations, where share calculations occur before state changes. This creates a window where the share price can be manipulated between calculation and execution.

In InvestToken.sol:deposit, InvestToken.sol:withdraw

function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    shares = convertToShares(assets);  // @FOUND - Share price calculation before state changes
    // @FOUND - Token burn occurs after share calculation
    usde.burn(msg.sender, assets);     // @FOUND - Non-atomic operation creates manipulation window
    _mint(receiver, shares);

    emit Deposit(msg.sender, receiver, assets, shares);
}

function withdraw(uint256 assets, address receiver, address owner) public returns (uint256 shares) {
    shares = convertToShares(assets);  // @FOUND - Share calculation vulnerable to price manipulation
    if (owner != msg.sender) _spendAllowance(owner, msg.sender, shares);
    _burn(owner, shares);
    usde.mint(receiver, assets);

    emit Withdraw(msg.sender, receiver, owner, assets, shares);
}

This is dangerous for the protocol and users because:

  1. The non-atomic execution between share calculation and actual token operations creates a price discrepancy window
  2. The share price used for calculation may not reflect the actual price at execution time
  3. This can lead to users receiving incorrect amounts of shares relative to their deposited assets

This issue violates the core ERC4626 vault principle that share/asset conversions should be accurate at the point of execution.

In a high-volume or volatile environment, this could be exploited by:

Root Cause: The deposit function calculates shares before burning USDE tokens. This creates a potential race condition where the share price could change between share calculation and actual token burning/minting.

Impact\

Let's say\

  1. User calls deposit with X assets
  2. shares = convertToShares(X) calculates Y shares
  3. Share price changes due to external factors
  4. USDE tokens are burned and Y shares are minted
  5. Y shares no longer accurately reflect the current share price

Attack Scenario\

  1. Initial state: Share price is 1:1 (1 USDE = 1 EUI)
  2. Attacker exploits the timing vulnerability
    Step 1: Attacker deposits 1000 USDE
    Step 2: Front-runs victim's deposit with price manipulation
    Step 3: Share price artificially inflated
    Step 4: Victim's deposit executes with manipulated price
    Step 5: Attacker withdraws with profit

Attachments

  1. Proof of Concept (PoC) File

  2. Initial state: Share price starts at 1e18 (1:1 ratio)

  3. Bob's attack:

    • Deposits 1000 tokens, receiving 1000e18 shares
    • Manipulates price through withdrawal mechanics
  4. Price impact:

    • Share price increases to 5.5e18 (5.5x increase)
  5. Alice's loss:

    • Deposits 100 tokens
    • Receives only ~18.18 shares instead of expected 100 shares
    • Lost value: ~81.82 shares (about 82% loss)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

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

contract SharePriceManipulationTest is Test {
    uint256 constant INITIAL_PRICE = 1e18;
    uint256 public totalShares;
    uint256 public totalAssets;

    function testSharePriceManipulation() public {
        // Initial state
        emit log_named_uint("Initial price", INITIAL_PRICE);

        // Bob's large deposit (1000 tokens)
        uint256 bobShares = (1000e18 * 1e18) / INITIAL_PRICE;
        totalShares += bobShares;
        totalAssets += 1000e18;
        emit log_named_uint("Bob's shares", bobShares);

        // Bob withdraws 90% but manipulates price
        uint256 bobWithdrawShares = (bobShares * 900) / 1000;
        totalShares -= bobWithdrawShares;
        totalAssets -= 450e18;  // Only withdraw half the expected assets

        // Calculate manipulated share price
        uint256 manipulatedPrice = (totalAssets * 1e18) / totalShares;
        emit log_named_uint("Manipulated price", manipulatedPrice);

        // Alice's deposit with manipulated price
        uint256 aliceShares = (100e18 * 1e18) / manipulatedPrice;
        emit log_named_uint("Alice's shares", aliceShares);
        emit log_named_uint("Expected shares", 100e18);
        emit log_named_uint("Lost value", 100e18 - aliceShares);

        assertLt(aliceShares, 100e18, "Share price manipulation demonstrated");
    }
}

Logs:

[⠒] Compiling...
[⠢] Compiling 1 files with Solc 0.8.27
[⠆] Solc 0.8.27 finished in 917.16ms
Compiler run successful!

Ran 1 test for test/SharePriceManipulationTest.sol:SharePriceManipulationTest
[PASS] testSharePriceManipulation() (gas: 60813)
Logs:
  Initial price: 1000000000000000000
  Bob's shares: 1000000000000000000000
  Manipulated price: 5500000000000000000
  Alice's shares: 18181818181818181818
  Expected shares: 100000000000000000000
  Lost value: 81818181818181818182

As can be seen, an attacker can manipulate share prices to extract value from subsequent depositors. The test validates that share price manipulation can lead to significant losses for users.

  1. Revised Code File (Optional)

A secure implementation should perform the share calculation after state changes to ensure price consistency throughout the deposit operation.

function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
-   shares = convertToShares(assets);
    usde.burn(msg.sender, assets);
+   shares = convertToShares(assets);
    _mint(receiver, shares);

    emit Deposit(msg.sender, receiver, assets, shares);
}

To ensure the share calculation happens after the state changes, making the operation atomic and preventing price manipulation opportunities.

AndreiMVP commented 17 hours ago

"Manipulation of price through withdrawal mechanics" shouldn't happen as price is set by oracle. Also the PoC isn't valid..