Summary
A architectural flaw exists in KeeperProxy's price validation mechanism where a significant time gap between price validation and execution phases allows trades to execute at prices that would have failed initial validation checks, potentially leading to significant financial losses.
Vulnerability Details
The vulnerability stems from a fundamental design flaw in how KeeperProxy handles price validation and trade execution. The system operates in two distinct phases:
function _validatePrice(address perpVault, MarketPrices memory prices) internal view {
(, int256 answer, uint256 startedAt,, ) = sequencerUptimeFeed.latestRoundData();
bool isSequencerUp = answer == 0;
require(isSequencerUp, "sequencer is down");
address market = IPerpetualVault(perpVault).market();
IVaultReader reader = IPerpetualVault(perpVault).vaultReader();
MarketProps memory marketData = reader.getMarket(market);
_check(marketData.indexToken, prices.indexTokenPrice.min);
_check(marketData.indexToken, prices.indexTokenPrice.max);
}
function run(
address perpVault,
bool isOpen,
bool isLong,
MarketPrices memory prices,
bytes[] memory _swapData
) external onlyKeeper {
_validatePrice(perpVault, prices);
IPerpetualVault(perpVault).run(isOpen, isLong, prices, _swapData);
}
Key Vulnerability Points:
Time Gap Exploitation: Multiple blocks can pass between validation and execution
Price Source Mismatch: Validation uses keeper-provided prices while execution uses GMX on-chain prices
Lack of Execution-Time Validation: No price re-validation during actual trade execution
Threshold Bypass: Price movement during the gap can exceed configured thresholds
Impact
The vulnerability can lead to:
Financial Loss: Users may experience trades at unfavorable prices
Threshold Violation: Trades executing outside of configured safety bounds
Protocol Instability: Potential for cascading liquidations due to unexpected price executions
Market Manipulation: Malicious keepers could exploit price movements
Proof of Concept
The following comprehensive PoC demonstrates the vulnerability:
pragma solidity ^0.8.4;
import {Test} from "forge-std/Test.sol";
import {KeeperProxy} from "../../contracts/KeeperProxy.sol";
import {IPerpetualVault} from "../../contracts/interfaces/IPerpetualVault.sol";
import {MarketPrices, PriceProps} from "../../contracts/libraries/StructData.sol";
contract PriceValidationFlawTest is Test {
KeeperProxy public keeper;
MockPerpVault public vault;
MockOracle public oracle;
address public constant WETH = address(0x1);
uint256 public constant PRICE_THRESHOLD = 500;
function setUp() public {
keeper = new KeeperProxy();
vault = new MockPerpVault();
oracle = new MockOracle();
keeper.initialize();
keeper.setDataFeed(WETH, address(oracle), 3600, PRICE_THRESHOLD);
vault.setMarket(WETH);
oracle.setPrice(1000e8);
}
function testPriceValidationFlaw() public {
MarketPrices memory prices = MarketPrices({
indexTokenPrice: PriceProps({min: 1000e8, max: 1000e8}),
longTokenPrice: PriceProps({min: 1000e8, max: 1000e8}),
shortTokenPrice: PriceProps({min: 1000e8, max: 1000e8})
});
vm.prank(address(keeper));
keeper.run(address(vault), true, true, prices, new bytes[](0));
oracle.setPrice(1100e8);
vm.roll(block.number + 3);
assertEq(vault.getLastExecutionPrice(), 1100e8);
assertGt(
vault.getLastExecutionPrice(),
prices.indexTokenPrice.max * (10000 + PRICE_THRESHOLD) / 10000,
"Trade executed outside threshold"
);
}
}
contract MockPerpVault is IPerpetualVault {
address public market;
uint256 public lastExecutionPrice;
function setMarket(address _market) external {
market = _market;
}
function run(bool, bool, MarketPrices memory prices, bytes[] memory) external {
lastExecutionPrice = prices.indexTokenPrice.max;
}
function getLastExecutionPrice() external view returns (uint256) {
return lastExecutionPrice;
}
}
contract MockOracle {
int256 private price;
function setPrice(int256 _price) external {
price = _price;
}
function latestRoundData() external view returns (
uint80, int256, uint256, uint256, uint80
) {
return (0, price, block.timestamp, block.timestamp, 0);
}
}
This PoC demonstrates how:
Initial price validation passes with ETH at $1000
Price moves to $1100 (10% increase)
Trade executes at $1100 despite 5% threshold
No validation occurs during execution phase
Tools Used
Recommended Mitigation
Implement a price commitment scheme with bounded execution time:
contract KeeperProxy {
struct PriceCommitment {
bytes32 priceHash;
uint256 timestamp;
uint256 maxSlippage;
bool executed;
}
mapping(bytes32 => PriceCommitment) public priceCommitments;
uint256 public constant MAX_EXECUTION_DELAY = 3;
function createPriceCommitment(
MarketPrices memory prices,
uint256 maxSlippage
) external returns (bytes32) {
bytes32 commitment = keccak256(abi.encode(prices));
priceCommitments[commitment] = PriceCommitment({
priceHash: commitment,
timestamp: block.timestamp,
maxSlippage: maxSlippage,
executed: false
});
return commitment;
}
function executeWithCommitment(
bytes32 commitment,
MarketPrices memory prices,
address perpVault,
bool isOpen,
bool isLong,
bytes[] memory swapData
) external {
PriceCommitment storage pc = priceCommitments[commitment];
require(!pc.executed, "Already executed");
require(
block.number <= pc.timestamp + MAX_EXECUTION_DELAY,
"Expired commitment"
);
require(
keccak256(abi.encode(prices)) == pc.priceHash,
"Price mismatch"
);
_validateCurrentPrices(prices, pc.maxSlippage);
pc.executed = true;
IPerpetualVault(perpVault).run(isOpen, isLong, prices, swapData);
}
}
This solution ensures:
Price commitments are time-bounded
Execution prices match validation prices
Slippage protection during execution
Prevention of duplicate executions