китаев.tech

Oracle manipulation

Как атакующий манипулирует spot-ценой оракула, почему нельзя брать цену из одного AMM-пула и как проверять внешний price feed.

Что такое oracle manipulation

Oracle manipulation — это атака, при которой контракт получает неправильную цену из оракула и принимает неверное финансовое решение.

Частый случай — lending-протокол берёт цену залога из одного AMM-пула. Атакующий временно двигает цену swap-ом, берёт займ по завышенной стоимости залога и оставляет протокол с плохим долгом.


Воспроизводим манипуляцию цены

Создайте файл contracts/OracleManipulationDemo.sol:

solidity
// 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:

typescript
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()));

Запуск:

bash
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.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.