Разработка системы защиты от oracle manipulation
Oracle manipulation — один из самых прибыльных векторов атак в DeFi. Суть: злоумышленник искусственно изменяет цену в источнике данных (oracle), которым пользуется протокол, получает выгоду от некорректной цены, затем цена возвращается в норму. В отличие от классических эксплойтов кода, здесь не обязательно взламывать смарт-контракт — достаточно временно повлиять на данные, которым контракт доверяет.
Mango Markets (октябрь 2022): $114 млн украдено через манипуляцию собственным MNGO токеном в Mango oracle. CREAM Finance: $130 млн через flash loan + manipulation цены yUSD. Это не edge cases — это системный риск при неверной архитектуре price feeds.
Типы oracle и их векторы атак
Spot price из AMM (наихудший вариант)
Использование getReserves() от Uniswap V2 пары как источника цены — прямой путь к flash loan атаке.
// КРИТИЧЕСКИ УЯЗВИМО
function getPrice(address token) public view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
// Spot price = reserve1 / reserve0
// Flash loan может изменить reserves в 100x за одну транзакцию
return uint256(reserve1) * 1e18 / uint256(reserve0);
}
Атака занимает одну транзакцию: flash loan → swap искажает reserves → вызов уязвимого протокола → repay flash loan. Никаких следов, никакого риска для атакующего.
TWAP (Time-Weighted Average Price)
TWAP — стандартная защита от flash loan атак. Средняя цена за период времени не может быть значительно изменена одной транзакцией.
// Uniswap V3 TWAP через OracleLibrary
import "@uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol";
contract TWAPOracle {
address public immutable pool; // Uniswap V3 pool
uint32 public constant TWAP_PERIOD = 30 minutes;
function getTWAP() public view returns (uint256 price) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = TWAP_PERIOD;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(pool).observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 arithmeticMeanTick = int24(tickCumulativesDelta / int56(uint56(TWAP_PERIOD)));
// Конвертируем tick в цену
uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(arithmeticMeanTick);
price = FullMath.mulDiv(
uint256(sqrtRatioX96) * uint256(sqrtRatioX96),
1e18,
2 ** 192
);
}
}
Ограничения TWAP: 30-минутный TWAP можно сдвинуть постепенной манипуляцией в течение 30 минут. Для токенов с малой ликвидностью — стоимость атаки снижается. TWAP не подходит как единственная защита для volatile assets или low-liquidity pools.
Chainlink Price Feeds
Chainlink — децентрализованная сеть оракулов. Цена агрегируется от десятков независимых node operators, обновляется при отклонении более чем на deviation threshold (обычно 0.5% или 1%) или по heartbeat (раз в 24 часа).
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract ChainlinkOracleConsumer {
AggregatorV3Interface public immutable priceFeed;
// Максимальное время с последнего обновления
uint256 public constant STALENESS_THRESHOLD = 3600; // 1 час для большинства feeds
constructor(address _priceFeed) {
priceFeed = AggregatorV3Interface(_priceFeed);
}
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// Проверка staleness — цена не устарела?
require(
block.timestamp - updatedAt <= STALENESS_THRESHOLD,
"Oracle: stale price"
);
// Проверка round completeness
require(answeredInRound >= roundId, "Oracle: incomplete round");
// Проверка на отрицательную цену (не должно быть, но проверяем)
require(answer > 0, "Oracle: invalid price");
// Нормализуем до 18 decimals
uint8 decimals = priceFeed.decimals();
uint256 normalizedPrice = uint256(answer);
if (decimals < 18) {
normalizedPrice = normalizedPrice * 10 ** (18 - decimals);
} else if (decimals > 18) {
normalizedPrice = normalizedPrice / 10 ** (decimals - 18);
}
return normalizedPrice;
}
}
Типичные ошибки при использовании Chainlink:
- Не проверять
updatedAt— принять устаревшую цену во время downtime Chainlink - Не проверять
answeredInRound >= roundId— принять цену из неполного раунда - Hardcode
STALENESS_THRESHOLDбез учёта конкретного feed (heartbeat у ETH/USD = 1 час, у экзотических = 24 часа)
Multi-oracle система: медианная агрегация
Лучшая защита — несколько независимых источников с агрегацией через медиану. Манипуляция одного источника не влияет на медиану из трёх.
contract MultiOracleAggregator {
struct OracleConfig {
address oracle;
uint256 stalenessThreshold;
uint256 weight; // вес при взвешенном среднем (если нужно)
bool active;
}
OracleConfig[] public oracles;
// Максимальное допустимое отклонение между оракулами (basis points)
uint256 public constant MAX_DEVIATION = 500; // 5%
function getPrice() external view returns (uint256 price, bool isValid) {
uint256[] memory prices = new uint256[](oracles.length);
uint256 validCount = 0;
for (uint256 i = 0; i < oracles.length; i++) {
if (!oracles[i].active) continue;
try IOracle(oracles[i].oracle).getPrice() returns (uint256 p, bool valid) {
if (valid && p > 0) {
prices[validCount++] = p;
}
} catch {
// Oracle недоступен — продолжаем с остальными
}
}
// Нужно минимум 2 валидных источника
require(validCount >= 2, "Insufficient oracle responses");
// Сортируем и берём медиану
uint256 median = _getMedian(prices, validCount);
// Проверяем что все цены в пределах MAX_DEVIATION от медианы
bool allWithinDeviation = true;
for (uint256 i = 0; i < validCount; i++) {
uint256 deviation = _absDiff(prices[i], median) * 10000 / median;
if (deviation > MAX_DEVIATION) {
allWithinDeviation = false;
break;
}
}
return (median, allWithinDeviation);
}
function _getMedian(uint256[] memory arr, uint256 length) internal pure returns (uint256) {
// Insertion sort для небольших массивов (обычно 3-5 оракулов)
for (uint256 i = 1; i < length; i++) {
uint256 key = arr[i];
int256 j = int256(i) - 1;
while (j >= 0 && arr[uint256(j)] > key) {
arr[uint256(j + 1)] = arr[uint256(j)];
j--;
}
arr[uint256(j + 1)] = key;
}
if (length % 2 == 1) {
return arr[length / 2];
} else {
return (arr[length / 2 - 1] + arr[length / 2]) / 2;
}
}
}
Circuit breaker при аномальных ценах
При резком отклонении цены — автоматическая пауза протокола:
contract PriceCircuitBreaker {
uint256 public lastValidPrice;
uint256 public lastUpdateTime;
// Максимальное изменение цены за один update
uint256 public constant MAX_PRICE_CHANGE_BPS = 1000; // 10% за update
// Максимальное изменение за 1 час
uint256 public constant MAX_HOURLY_CHANGE_BPS = 2000; // 20% за час
bool public circuitBreakerTripped;
struct PriceHistory {
uint256 price;
uint256 timestamp;
}
PriceHistory[10] public priceHistory; // Ring buffer последних 10 цен
uint256 public historyIndex;
function updatePrice(uint256 newPrice) external onlyOracle {
require(!circuitBreakerTripped, "Circuit breaker active");
if (lastValidPrice > 0) {
uint256 change = _absDiff(newPrice, lastValidPrice) * 10000 / lastValidPrice;
// Резкое изменение за один update
if (change > MAX_PRICE_CHANGE_BPS) {
circuitBreakerTripped = true;
emit CircuitBreakerTripped(lastValidPrice, newPrice, change);
return;
}
// Изменение за час
uint256 hourlyChange = _calculateHourlyChange(newPrice);
if (hourlyChange > MAX_HOURLY_CHANGE_BPS) {
circuitBreakerTripped = true;
emit CircuitBreakerTripped(lastValidPrice, newPrice, hourlyChange);
return;
}
}
// Сохраняем в историю
priceHistory[historyIndex % 10] = PriceHistory(newPrice, block.timestamp);
historyIndex++;
lastValidPrice = newPrice;
lastUpdateTime = block.timestamp;
emit PriceUpdated(newPrice, block.timestamp);
}
function resetCircuitBreaker() external onlyGovernance {
// Сброс только через governance после анализа причины
circuitBreakerTripped = false;
emit CircuitBreakerReset(msg.sender);
}
}
Уязвимость к flash loan: детекция и защита
Read-only reentrancy
Специфическая атака для Balancer/Curve стиля пулов: злоумышленник использует flash loan callback чтобы вызвать другой протокол пока балансы уже изменены но state protection (reentrancy guard) ещё активен в оригинальном пуле.
// Balancer V2 re-entrancy lock check
interface IVault {
function getReserves() external view returns (uint256, uint256);
// Vault locked → getReserves() вернёт некорректные данные во время callback
}
contract BalancerOracleConsumer {
IVault public immutable balancerVault;
modifier ensureNotLocked() {
// Проверяем что Balancer vault не заблокирован (не в flash loan)
// Пытаемся вызвать operationKind — если vault locked, revertnет
(, bytes memory data) = address(balancerVault).staticcall(
abi.encodeWithSignature("getAuthorizer()")
);
require(data.length > 0, "Balancer vault locked");
_;
}
function getBalancerPrice() external view ensureNotLocked returns (uint256) {
// Безопасный вызов только когда vault не в flash loan
(uint256 reserve0, uint256 reserve1) = balancerVault.getReserves();
return reserve1 * 1e18 / reserve0;
}
}
Flash loan detection на уровне транзакции
contract FlashLoanAwareProtocol {
// Mapping от block.number → true если в этом блоке был flash loan
// через известные flash loan провайдеры
mapping(uint256 => bool) private _flashLoanInBlock;
// Callback от flash loan провайдеров (AAVE, Uniswap, Balancer)
// Мы получаем уведомление если flash loan используется в нашем протоколе
function markFlashLoanBlock() external onlyFlashLoanProvider {
_flashLoanInBlock[block.number] = true;
}
modifier noFlashLoan() {
require(!_flashLoanInBlock[block.number], "Flash loan detected in block");
_;
}
// Ценочувствительные операции только когда нет flash loan в блоке
function borrow(address asset, uint256 amount) external noFlashLoan {
uint256 price = oracle.getPrice(asset);
// ...
}
}
Pyth Network и push oracle модель
Chainlink использует push модель: данные обновляются автоматически при deviation или heartbeat. Pyth использует pull модель: пользователь сам обновляет цену перед транзакцией, предоставляя signed price attestation от сети.
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
contract PythOracleConsumer {
IPyth public immutable pyth;
bytes32 public immutable priceId;
// Максимальное время жизни цены (60 секунд для большинства assets)
uint256 public constant PRICE_MAX_AGE = 60;
// updatePriceFeeds вызывается в той же транзакции что и операция
function borrowWithPythPrice(
uint256 borrowAmount,
bytes[] calldata priceUpdateData // данные от Pyth
) external payable {
// Обновляем цену (пользователь платит fee ~$0.001)
uint256 updateFee = pyth.getUpdateFee(priceUpdateData);
pyth.updatePriceFeeds{value: updateFee}(priceUpdateData);
// Получаем обновлённую цену
PythStructs.Price memory price = pyth.getPriceNoOlderThan(priceId, PRICE_MAX_AGE);
require(price.price > 0, "Invalid price");
require(price.conf < uint64(price.price) / 10, "Price confidence too low");
uint256 normalizedPrice = _normalizePrice(price.price, price.expo);
_executeBorrow(msg.sender, borrowAmount, normalizedPrice);
}
function _normalizePrice(int64 price, int32 expo) internal pure returns (uint256) {
if (expo >= 0) {
return uint256(uint64(price)) * 10 ** uint32(expo);
} else {
return uint256(uint64(price)) / 10 ** uint32(-expo);
}
}
}
Confidence interval в Pyth — важный параметр: conf показывает неопределённость цены. Если conf / price > 10% — данные ненадёжны и операцию лучше отклонить.
Мониторинг oracle аномалий
interface PriceAnomaly {
asset: string;
currentPrice: bigint;
historicalMedian: bigint;
deviationPct: number;
timestamp: number;
oracleSource: string;
}
class OracleMonitor {
private priceHistory: Map<string, bigint[]> = new Map();
async detectAnomalies(
assets: string[],
oracleAddresses: string[]
): Promise<PriceAnomaly[]> {
const anomalies: PriceAnomaly[] = [];
for (let i = 0; i < assets.length; i++) {
const currentPrice = await this.getCurrentPrice(oracleAddresses[i]);
const history = this.priceHistory.get(assets[i]) ?? [];
if (history.length >= 10) {
// Медиана последних 10 цен
const sorted = [...history].sort((a, b) => (a > b ? 1 : -1));
const median = sorted[Math.floor(sorted.length / 2)];
const deviation = Math.abs(
Number((currentPrice - median) * 10000n / median)
) / 100;
if (deviation > 15) { // 15% отклонение от медианы
anomalies.push({
asset: assets[i],
currentPrice,
historicalMedian: median,
deviationPct: deviation,
timestamp: Date.now(),
oracleSource: oracleAddresses[i]
});
// Отправляем алерт немедленно
await this.sendAlert(anomalies[anomalies.length - 1]);
}
}
// Обновляем историю
history.push(currentPrice);
if (history.length > 100) history.shift();
this.priceHistory.set(assets[i], history);
}
return anomalies;
}
}
Рекомендации по выбору oracle стратегии
| Сценарий | Рекомендуемое решение |
|---|---|
| Major assets (ETH, BTC, stablecoins) | Chainlink + TWAP как fallback |
| Long-tail DeFi токены | Uniswap V3 TWAP 30min + Circuit breaker |
| Cross-chain приложения | Pyth (быстрое обновление) + Chainlink verification |
| High-frequency протоколы | Pyth pull model (актуальная цена в каждой tx) |
| Low-liquidity assets | Multi-oracle с высоким deviation threshold |
Этапы разработки
| Фаза | Содержание | Срок |
|---|---|---|
| Аудит текущих oracle | Анализ уязвимостей существующей интеграции | 1–2 нед |
| Дизайн multi-oracle | Выбор источников, веса, агрегация логика | 1–2 нед |
| Смарт-контракты | Aggregator, circuit breaker, anomaly detection | 3–4 нед |
| Интеграция | Chainlink, Pyth, TWAP подключение | 2–3 нед |
| Мониторинг система | Off-chain alerting, dashboard | 2–3 нед |
| Тестирование | Fork tests с симуляцией атак, fuzz | 2–3 нед |
| Аудит | 2–3 нед |







