Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #4: Примитивы безопасности — msg.sender, контроль доступа и реентрабельность

OS
Open Soft Team

Engineering Team

Безопасность начинается с понимания контекста

Безопасность смарт-контрактов — это не набор правил, которые вы запоминаете. Это глубокое понимание того, как EVM обрабатывает вызовы, кто является вызывающим, и что происходит, когда один контракт вызывает другой. Большинство критических уязвимостей в истории Ethereum были вызваны непониманием этих фундаментальных механизмов.

msg.sender vs tx.origin

На уровне опкодов:

  • CALLER (msg.sender) — адрес, непосредственно вызвавший текущий контракт. Если контракт A вызывает контракт B, то внутри B msg.sender == address(A).
  • ORIGIN (tx.origin) — адрес EOA (внешнего аккаунта), инициировавшего транзакцию. Остаётся неизменным через все уровни вложенных вызовов.
// Пользователь -> Контракт A -> Контракт B -> Контракт C
// Внутри C:
// msg.sender = address(B)   // непосредственный вызывающий
// tx.origin  = Пользователь // инициатор транзакции

Почему tx.origin опасен

Использование tx.origin для авторизации — классическая уязвимость:

// УЯЗВИМЫЙ контракт
contract VulnerableWallet {
    address public owner;

    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner); // ОПАСНО!
        payable(to).transfer(amount);
    }
}

// Атака:
// 1. Злоумышленник создаёт контракт-приманку
// 2. Владелец взаимодействует с приманкой (например, "получить бесплатные NFT")
// 3. Приманка вызывает VulnerableWallet.transfer(attacker, balance)
// 4. tx.origin == owner проходит, потому что владелец инициировал транзакцию!
// 5. Средства украдены

contract Attacker {
    VulnerableWallet wallet;

    function attack() external {
        wallet.transfer(msg.sender, address(wallet).balance);
    }

    // Владелец вызывает эту "безобидную" функцию
    function claimFreeNFT() external {
        // ... якобы логика NFT ...
        this.attack(); // tx.origin всё ещё == владелец
    }
}

Всегда используйте msg.sender для авторизации. Единственное безопасное применение tx.origin — проверка, что вызывающий является EOA (не контрактом): require(msg.sender == tx.origin).

Паттерны контроля доступа

Простой Ownable

Простейший паттерн — единственный владелец:

contract Ownable {
    address public owner;
    error NotOwner();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        owner = newOwner;
    }
}

На уровне EVM onlyOwner компилируется примерно так:

CALLER           // msg.sender на стек
SLOAD(0)         // owner из хранилища
EQ               // сравнение
PUSH2 continue   // адрес продолжения
JUMPI            // если равны — продолжить
REVERT           // иначе — откат
continue:
JUMPDEST

Role-Based Access Control (RBAC)

Для более сложных систем используется ролевой контроль доступа:

contract AccessControl {
    mapping(bytes32 => mapping(address => bool)) private _roles;

    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");
    bytes32 public constant MINTER_ROLE = keccak256("MINTER");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER");

    modifier onlyRole(bytes32 role) {
        require(_roles[role][msg.sender], "AccessControl: unauthorized");
        _;
    }

    function grantRole(bytes32 role, address account) external onlyRole(ADMIN_ROLE) {
        _roles[role][account] = true;
    }

    function revokeRole(bytes32 role, address account) external onlyRole(ADMIN_ROLE) {
        _roles[role][account] = false;
    }
}

В хранилище проверка _roles[role][msg.sender] вычисляется как:

slot = keccak256(msg.sender . keccak256(role . base_slot))
SLOAD(slot) // 2100 газа (холодный) или 100 газа (тёплый)

Реентрабельность: самая опасная уязвимость

Реентрабельность (reentrancy) — уязвимость, при которой внешний вызов позволяет вызываемому контракту повторно войти в вызывающий контракт до завершения текущего выполнения.

Классический пример: DAO Hack

Эта уязвимость стала причиной взлома The DAO в 2016 году (украдено ~$60M), что привело к хардфорку Ethereum.

// УЯЗВИМЫЙ контракт
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0);

        // ОПАСНО: внешний вызов ДО обновления состояния
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // Это выполнится только после возврата из call
        // Но к этому моменту атакующий уже вызвал withdraw повторно!
        balances[msg.sender] = 0;
    }
}

// Контракт атакующего
contract Attacker {
    VulnerableVault vault;

    function attack() external payable {
        vault.deposit{value: 1 ether}();
        vault.withdraw();
    }

    // Вызывается при получении ETH
    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw(); // Повторный вход!
        }
    }
}

Последовательность вызовов:

Attacker.attack()
  -> Vault.withdraw()
    -> Attacker.receive() (получает 1 ETH)
      -> Vault.withdraw() (balances[attacker] всё ещё > 0!)
        -> Attacker.receive() (получает ещё 1 ETH)
          -> ... (повторяется, пока в Vault есть ETH)

Паттерн Checks-Effects-Interactions (CEI)

Главный защитный паттерн от реентрабельности:

function withdraw() external {
    // 1. CHECKS — проверки
    uint256 amount = balances[msg.sender];
    require(amount > 0);

    // 2. EFFECTS — изменение состояния
    balances[msg.sender] = 0;  // Обнуляем ДО внешнего вызова

    // 3. INTERACTIONS — внешние вызовы
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

Теперь при повторном входе balances[msg.sender] уже равен нулю, и require(amount > 0) не пройдёт.

Мьютекс (ReentrancyGuard)

Дополнительный слой защиты — мьютекс-блокировка:

contract ReentrancyGuard {
    uint256 private _status;
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;

    modifier nonReentrant() {
        require(_status != ENTERED, "ReentrancyGuard: reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }
}

Почему _status инициализируется как 1, а не 0? Потому что запись 0->1 (SSTORE нулевое->ненулевое) стоит 20000 газа, а запись 1->2 (ненулевое->ненулевое) стоит только 2900 газа. Использование 1/2 вместо 0/1 экономит ~17000 газа при первом вызове.

Транзиентное хранилище для блокировки

С EIP-1153 блокировка реентрабельности стала намного дешевле:

modifier nonReentrant() {
    assembly {
        if tload(0) { revert(0, 0) }
        tstore(0, 1)
    }
    _;
    assembly {
        tstore(0, 0)
    }
}
// Стоимость: ~200 газа вместо ~6000 газа

Read-Only Reentrancy

Относительно новый вектор атаки, затрагивающий протоколы, которые полагаются на view-функции других контрактов для определения цен или состояния:

// Пул ликвидности
contract Pool {
    function getPrice() external view returns (uint256) {
        return totalAssets / totalShares; // Зависит от состояния
    }

    function withdraw(uint256 shares) external {
        uint256 assets = shares * totalAssets / totalShares;
        totalShares -= shares;
        // ВНЕШНИЙ ВЫЗОВ — состояние временно неконсистентно
        // totalShares уменьшено, но totalAssets ещё нет
        token.transfer(msg.sender, assets);
        totalAssets -= assets; // Обновление после вызова
    }
}

// Атакующий вызывает Pool.getPrice() из receive()
// В этот момент totalShares уменьшено, а totalAssets — нет
// getPrice() вернёт завышенную цену!

Защита: используйте паттерн CEI и для view-функций применяйте блокировку реентрабельности.

Переполнение и недополнение

До Solidity 0.8 арифметические операции не проверяли переполнение:

// Solidity < 0.8
uint8 x = 255;
x += 1; // x == 0 (переполнение!)

uint8 y = 0;
y -= 1; // y == 255 (недополнение!)

С Solidity 0.8+ все арифметические операции автоматически проверяются. Но если вы используете unchecked для оптимизации газа, будьте осторожны:

// Безопасно: i никогда не переполнится при разумной длине массива
for (uint256 i; i < arr.length;) {
    // ...
    unchecked { ++i; }
}

// ОПАСНО: пользовательский ввод в unchecked
unchecked {
    uint256 result = userInput - fee; // Может быть отрицательным!
}

Заключение

Безопасность смарт-контрактов строится на трёх столпах: понимание контекста вызова (msg.sender vs tx.origin), правильный контроль доступа (RBAC, Ownable) и защита от реентрабельности (CEI, мьютекс, транзиентное хранилище). Каждая из этих концепций имеет прямое представление на уровне опкодов EVM.

В следующей статье мы начнём изучать Yul — ассемблерный язык Solidity, который даёт прямой доступ к опкодам EVM.