Контроль доступа и tx.origin

Improper access control — взломщик №1 в смарт-контрактах. Чаще всего дыра — это просто забытая или неверная проверка «кто вызвал».
Использовать tx.origin для авторизации — всё равно что пускать в дом любого, кого привёл ваш знакомый. Достаточно одной фишинговой ссылки.

Контроль доступа решает, кому можно вызывать опасные функции: минт, вывод средств, смену параметров. Базовый паттерн — Ownable: один владелец и модификатор onlyOwner. Для более сложных систем — ролевой доступ (AccessControl): роли вроде MINTER_ROLE, PAUSER_ROLE, выдаваемые конкретным адресам.

msg.sender против tx.origin

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

   ФИШИНГ ЧЕРЕЗ tx.origin
   ======================
   Владелец -> вызывает  Evil.attack()
                              |
                              | Evil вызывает Victim.withdraw()
                              v
   Victim проверяет tx.origin == owner  -> ЭТО ВЛАДЕЛЕЦ! (обман)
            проверка msg.sender == owner -> это Evil, REVERT (правильно)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/AccessControl.sol";

contract Treasury is AccessControl {
    bytes32 public constant MANAGER = keccak256("MANAGER");

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MANAGER, msg.sender);
    }

    // ПРАВИЛЬНО: msg.sender + ролевая проверка
    function payout(address to, uint256 amount) external onlyRole(MANAGER) {
        // ... выплата ...
    }
    // НИКОГДА: require(tx.origin == owner) — уязвимо к фишингу
}

Как работает под капотом (EVM/газ)

В стеке вызовов msg.sender меняется на каждом «прыжке» (контракт A зовёт B — для B msg.sender это A). А tx.origin один на всю транзакцию — это исходный EOA. Поэтому проверка по tx.origin «не видит» промежуточный вредоносный контракт. Ролевой AccessControl хранит маппинг «роль → адрес → есть ли» и проверяет его в модификаторе onlyRole — это обычные дешёвые чтения storage.

# Та же логика на Python: msg.sender надёжен, tx.origin обманывают
def victim_withdraw(msg_sender, tx_origin, owner, mode):
    if mode == "tx.origin":
        return tx_origin == owner          # УЯЗВИМО
    return msg_sender == owner             # БЕЗОПАСНО

owner = "0xOWNER"
# фишинг: владелец дернул Evil, Evil дернул Victim
# msg.sender == Evil, но tx.origin == owner
print("tx.origin проверка пропускает Evil?",
      victim_withdraw("0xEVIL", owner, owner, "tx.origin"))   # True — плохо
print("msg.sender проверка пропускает Evil?",
      victim_withdraw("0xEVIL", owner, owner, "msg.sender"))  # False — хорошо

«Та же логика на Python ▶». Проверка tx.origin ложно признаёт владельцем вредоносный контракт-посредник; msg.sender видит реального вызывающего и отклоняет его.

Частые ошибки и уязвимости

  • Авторизация по tx.origin — открывает фишинг через промежуточный контракт.
  • Забытый модификатор доступа на функции минта/вывода/настройки — кто угодно её вызовет.
  • Один всемогущий владелец без таймлока — точка отказа и риск, если ключ скомпрометирован.

Best practices

  • Авторизуйте только по msg.sender, никогда по tx.origin.
  • Применяйте принцип наименьших привилегий и ролевой доступ (AccessControl) вместо одного владельца, где это уместно.
  • Для критичных прав используйте мультисиг и таймлок, чтобы один ключ не решал всё.

Двухшаговая передача владения и таймлок

Даже корректная проверка msg.sender не спасает от человеческой ошибки. Классическая беда — передача владения: если просто записать нового владельца, опечатка в адресе навсегда заблокирует все административные функции, ведь исправить уже некому. Поэтому используют двухшаговую передачу (паттерн Ownable2Step у OpenZeppelin): текущий владелец предлагает нового, а тот должен явно принять владение отдельной транзакцией. Опечатка просто не будет принята, и контроль останется у прежнего владельца. Для критичных параметров протокола добавляют таймлок: изменение не применяется сразу, а ставится в очередь с задержкой (например, 48 часов), за которую сообщество успевает заметить подозрительное действие и среагировать. Вместе с мультисигом (когда для действия нужны подписи нескольких ключей) это превращает «всемогущего владельца» из единой точки отказа в управляемый, прозрачный процесс.

Итоги

Контроль доступа — главный фронт безопасности. Проверяйте msg.sender, дробите права по ролям и не доверяйте tx.origin. Дальше — об арифметике, oracle-зависимостях и финальном чеклисте.

Проверьте себя
1. Почему авторизация через tx.origin небезопасна?
Atx.origin всегда равен нулю
BПромежуточный вредоносный контракт сохраняет tx.origin владельца, и проверка ложно проходит
Ctx.origin стоит слишком много газа
Dtx.origin недоступен в 0.8
2. Что показывает msg.sender внутри функции, вызванной контрактом A?
AИсходный EOA транзакции
BАдрес непосредственного вызывающего — контракта A
CАдрес владельца
DНулевой адрес