DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: medium
Invalid

Price Validation Architecture Flaw in KeeperProxy - Findings Report

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:

// Phase 1: Price Validation
function _validatePrice(address perpVault, MarketPrices memory prices) internal view {
// Sequencer status check
(/*roundID*/, int256 answer, uint256 startedAt,, ) = sequencerUptimeFeed.latestRoundData();
bool isSequencerUp = answer == 0;
require(isSequencerUp, "sequencer is down");
// Chainlink price validation
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);
// Additional price checks...
}
// Phase 2: Trade Execution
function run(
address perpVault,
bool isOpen,
bool isLong,
MarketPrices memory prices,
bytes[] memory _swapData
) external onlyKeeper {
_validatePrice(perpVault, prices); // Phase 1
// Potential multi-block gap
IPerpetualVault(perpVault).run(isOpen, isLong, prices, _swapData); // Phase 2
}

Key Vulnerability Points:

  1. Time Gap Exploitation: Multiple blocks can pass between validation and execution

  2. Price Source Mismatch: Validation uses keeper-provided prices while execution uses GMX on-chain prices

  3. Lack of Execution-Time Validation: No price re-validation during actual trade execution

  4. Threshold Bypass: Price movement during the gap can exceed configured thresholds

Impact

The vulnerability can lead to:

  1. Financial Loss: Users may experience trades at unfavorable prices

  2. Threshold Violation: Trades executing outside of configured safety bounds

  3. Protocol Instability: Potential for cascading liquidations due to unexpected price executions

  4. Market Manipulation: Malicious keepers could exploit price movements

Proof of Concept

The following comprehensive PoC demonstrates the vulnerability:

// SPDX-License-Identifier: MIT
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; // 5% threshold
function setUp() public {
// Deploy contracts
keeper = new KeeperProxy();
vault = new MockPerpVault();
oracle = new MockOracle();
// Initialize
keeper.initialize();
keeper.setDataFeed(WETH, address(oracle), 3600, PRICE_THRESHOLD);
// Setup mock data
vault.setMarket(WETH);
oracle.setPrice(1000e8); // Initial ETH price $1000
}
function testPriceValidationFlaw() public {
// Create prices for validation
MarketPrices memory prices = MarketPrices({
indexTokenPrice: PriceProps({min: 1000e8, max: 1000e8}),
longTokenPrice: PriceProps({min: 1000e8, max: 1000e8}),
shortTokenPrice: PriceProps({min: 1000e8, max: 1000e8})
});
// Step 1: Initial validation passes
vm.prank(address(keeper));
keeper.run(address(vault), true, true, prices, new bytes[](0));
// Step 2: Price moves significantly (10% up)
oracle.setPrice(1100e8);
// Step 3: Move forward few blocks
vm.roll(block.number + 3);
// Step 4: Execution occurs at new price
// Note: Execution price (1100) is outside threshold (1000 ± 5%)
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:

  1. Initial price validation passes with ETH at $1000

  2. Price moves to $1100 (10% increase)

  3. Trade executes at $1100 despite 5% threshold

  4. No validation occurs during execution phase

Tools Used

  • Manual code review

  • Foundry testing framework

  • Solidity visual debugger

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; // blocks
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"
);
// Verify prices match commitment
require(
keccak256(abi.encode(prices)) == pc.priceHash,
"Price mismatch"
);
// Validate current prices are within slippage
_validateCurrentPrices(prices, pc.maxSlippage);
// Mark as executed
pc.executed = true;
// Execute trade
IPerpetualVault(perpVault).run(isOpen, isLong, prices, swapData);
}
}

This solution ensures:

  1. Price commitments are time-bounded

  2. Execution prices match validation prices

  3. Slippage protection during execution

  4. Prevention of duplicate executions

Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.