Розробка системи захисту від маніпуляції оракулами
Маніпуляція оракулом — один з найприбутковіших векторів атак у DeFi. Суть: зловмисник штучно змінює ціну в джерелі даних (oracle), яке використовує протокол, отримує прибуток від неправильної ціни, потім ціна повертається в норму. На відміну від класичних експлойтів коду, вам не обов'язково потрібно зламувати смарт-контракт—просто тимчасово вплинути на дані, яким довіряє контракт.
Mango Markets (жовтень 2022): $114M вкрадено через маніпуляцію токеном MNGO в Mango oracle. CREAM Finance: $130M через flash loan + маніпуляцію ціною yUSD. Це не граничні випадки—це системний ризик при неправильній архітектурі price feeds.
Типи оракулів та їх вектори атак
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 → виклик вразливого протоколу → повернення 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 не підходить як єдиний захист для волатильних активів або пулів з низькою ліквідністю.
Chainlink Price Feeds
Chainlink—децентралізована мережа оракулів. Ціна агрегується від десятків незалежних node operators, оновлюється при відхиленні більше ніж на поріг (зазвичай 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"
);
// Перевірка повноти раунду
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—прийняти застарілу ціну під час простою Chainlink - Не перевіряти
answeredInRound >= roundId—прийняти ціну з неповного раунду - Hardcode
STALENESS_THRESHOLDбез урахування конкретного feed (heartbeat для ETH/USD = 1 година, для екзотичних = 24 години)
Система мультиоракула: медіанна агрегація
Найкращий захист—кілька незалежних джерел з агрегацією через медіану. Маніпуляція одного джерела не впливає на медіану з трьох.
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 {
// Оракул недоступний—продовжуємо з іншими
}
}
// Потрібно мінімум 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;
// Максимальна зміна ціни за одне оновлення
uint256 public constant MAX_PRICE_CHANGE_BPS = 1000; // 10% за оновлення
// Максимальна зміна за 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;
// Різка зміна за одне оновлення
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 заблокований, 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 детекція на рівні транзакції
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 та pull oracle модель
Chainlink використовує push модель: дані оновлюються автоматично при відхиленні або heartbeat. Pyth використовує pull модель: користувач сам оновлює ціну перед транзакцією, надаючи підписану 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 секунд для більшості активів)
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%—дані ненадійні і операцію краще відхилити.
Мониторинг аномалій оракула
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]
});
// Відправляємо alert негайно
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 активи | 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 тиж |







