Oracle manipulation
Как атакующий манипулирует spot-ценой оракула, почему нельзя брать цену из одного AMM-пула и как проверять внешний price feed.
Что такое oracle manipulation
Oracle manipulation — это атака, при которой контракт получает неправильную цену из оракула и принимает неверное финансовое решение.
Частый случай — lending-протокол берёт цену залога из одного AMM-пула. Атакующий временно двигает цену swap-ом, берёт займ по завышенной стоимости залога и оставляет протокол с плохим долгом.
Воспроизводим манипуляцию цены
Создайте файл contracts/OracleManipulationDemo.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IPriceFeed {
function latestAnswer() external view returns (uint256);
function updatedAt() external view returns (uint256);
}
contract DemoToken {
string public constant name = "Demo Token";
string public constant symbol = "DEMO";
uint8 public constant decimals = 18;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
error NotEnoughBalance();
error NotEnoughAllowance();
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed < amount) {
revert NotEnoughAllowance();
}
allowance[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}
function _transfer(address from, address to, uint256 amount) private {
if (balanceOf[from] < amount) {
revert NotEnoughBalance();
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
}
}
contract SpotPricePool {
DemoToken public immutable token;
uint256 public ethReserve;
uint256 public tokenReserve;
constructor(address tokenAddress) payable {
token = DemoToken(tokenAddress);
ethReserve = msg.value;
}
function seed(uint256 tokenAmount) external {
token.transferFrom(msg.sender, address(this), tokenAmount);
tokenReserve += tokenAmount;
}
function buyTokens() external payable returns (uint256 tokensOut) {
tokensOut = (tokenReserve * msg.value) / (ethReserve + msg.value);
ethReserve += msg.value;
tokenReserve -= tokensOut;
token.transfer(msg.sender, tokensOut);
}
function spotPrice() external view returns (uint256) {
// Цена 1 DEMO в ETH, масштаб 1e18.
return (ethReserve * 1e18) / tokenReserve;
}
}
contract VulnerableLending {
DemoToken public immutable token;
SpotPricePool public immutable pool;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
error NotEnoughCollateral();
error EthTransferFailed();
constructor(address tokenAddress, address poolAddress) payable {
token = DemoToken(tokenAddress);
pool = SpotPricePool(poolAddress);
}
function depositCollateral(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
collateral[msg.sender] += amount;
}
function borrow(uint256 ethAmount) external {
uint256 tokenPrice = pool.spotPrice();
uint256 collateralValue = (collateral[msg.sender] * tokenPrice) / 1e18;
uint256 maxDebt = collateralValue / 2;
if (debt[msg.sender] + ethAmount > maxDebt) {
revert NotEnoughCollateral();
}
debt[msg.sender] += ethAmount;
(bool success,) = msg.sender.call{value: ethAmount}("");
if (!success) {
revert EthTransferFailed();
}
}
}
contract MockPriceFeed is IPriceFeed {
uint256 private answer;
uint256 private timestamp;
function setPrice(uint256 newAnswer) external {
answer = newAnswer;
timestamp = block.timestamp;
}
function latestAnswer() external view returns (uint256) {
return answer;
}
function updatedAt() external view returns (uint256) {
return timestamp;
}
}
contract SafeLending {
DemoToken public immutable token;
IPriceFeed public immutable priceFeed;
uint256 public constant MAX_PRICE_AGE = 1 hours;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
error StalePrice();
error InvalidPrice();
error NotEnoughCollateral();
error EthTransferFailed();
constructor(address tokenAddress, address priceFeedAddress) payable {
token = DemoToken(tokenAddress);
priceFeed = IPriceFeed(priceFeedAddress);
}
function depositCollateral(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
collateral[msg.sender] += amount;
}
function borrow(uint256 ethAmount) external {
uint256 tokenPrice = _readPrice();
uint256 collateralValue = (collateral[msg.sender] * tokenPrice) / 1e18;
uint256 maxDebt = collateralValue / 2;
if (debt[msg.sender] + ethAmount > maxDebt) {
revert NotEnoughCollateral();
}
debt[msg.sender] += ethAmount;
(bool success,) = msg.sender.call{value: ethAmount}("");
if (!success) {
revert EthTransferFailed();
}
}
function _readPrice() private view returns (uint256) {
uint256 price = priceFeed.latestAnswer();
uint256 timestamp = priceFeed.updatedAt();
if (price == 0) {
revert InvalidPrice();
}
if (block.timestamp - timestamp > MAX_PRICE_AGE) {
revert StalePrice();
}
return price;
}
}Создайте файл scripts/oracle-manipulation.ts:
import { network } from "hardhat";
const { ethers } = await network.create();
const [deployer, attacker] = await ethers.getSigners();
const token = await ethers.deployContract("DemoToken");
await token.waitForDeployment();
const tokenAddress = await token.getAddress();
const pool = await ethers.deployContract("SpotPricePool", [tokenAddress], {
value: ethers.parseEther("100"),
});
await pool.waitForDeployment();
const poolAddress = await pool.getAddress();
await token.mint(deployer.address, ethers.parseEther("1000"));
await token.approve(poolAddress, ethers.parseEther("1000"));
await pool.seed(ethers.parseEther("1000"));
const vulnerable = await ethers.deployContract("VulnerableLending", [tokenAddress, poolAddress], {
value: ethers.parseEther("20"),
});
await vulnerable.waitForDeployment();
const vulnerableAddress = await vulnerable.getAddress();
console.log("Цена до атаки:", ethers.formatEther(await pool.spotPrice()), "ETH за DEMO");
await pool.connect(attacker).buyTokens({
value: ethers.parseEther("50"),
});
console.log("Цена после swap:", ethers.formatEther(await pool.spotPrice()), "ETH за DEMO");
const attackerTokens = await token.balanceOf(attacker.address);
await token.connect(attacker).approve(vulnerableAddress, attackerTokens);
await vulnerable.connect(attacker).depositCollateral(attackerTokens);
await vulnerable.connect(attacker).borrow(ethers.parseEther("10"));
console.log(
"Долг attacker:",
ethers.formatEther(await vulnerable.debt(attacker.address)),
"ETH",
);
const feed = await ethers.deployContract("MockPriceFeed");
await feed.waitForDeployment();
await feed.setPrice(ethers.parseEther("0.1"));
const safe = await ethers.deployContract("SafeLending", [tokenAddress, await feed.getAddress()], {
value: ethers.parseEther("20"),
});
await safe.waitForDeployment();
console.log("SafeLending читает цену из feed:", ethers.formatEther(await feed.latestAnswer()));Запуск:
npx hardhat run scripts/oracle-manipulation.tsПосле крупного swap цена DEMO в SpotPricePool вырастает. VulnerableLending принимает эту временную spot-цену как честную рыночную цену и разрешает занять больше ETH.
Как это работает
spotPrice() — цена прямо из текущих резервов AMM. Её можно изменить одной транзакцией, если в пуле достаточно мало ликвидности.
buyTokens() — атакующий покупает DEMO за ETH. ETH-резерв растёт, token-резерв падает, поэтому цена DEMO в ETH становится выше.
borrow() в VulnerableLending — уязвимая точка. Контракт считает стоимость залога по цене, которую атакующий только что сдвинул.
SafeLending — не читает цену из торгового пула. Он использует отдельный feed и проверяет, что цена не нулевая и не устаревшая.
Как защищаться
Не используйте spot-цену одного AMM-пула как оракул для займов, ликвидаций, mint/burn и расчёта залога.
Используйте Chainlink-подобные price feeds, TWAP-оракулы или медиану нескольких источников. Цена должна быть дорогой для манипуляции и устойчивой к одному swap.
Проверяйте updatedAt, нулевую цену, decimals и максимальное допустимое отклонение. Оракул может быть честным, но устаревшим.
Частые ошибки
Взять getReserves() и посчитать цену → это spot-цена, её можно сдвинуть на один блок.
Не проверять свежесть ответа → stale price может быть хуже манипуляции, потому что контракт продолжит работать с устаревшим рынком.
Считать большую ликвидность полной защитой → манипуляция становится дороже, но не исчезает. Для критичных решений нужен отдельный oracle design.
Что дальше
- Batch транзакции через Multicall — как атакующий может объединять swap и borrow в одну транзакцию
- Фронтраннинг — как работает и защита — почему порядок транзакций влияет на цену и исполнение
- Вызов view-функций контракта — как читать данные оракула и проверять ответ перед использованием