Deep EVM #4: Примитивы безопасности — msg.sender, контроль доступа и реентрабельность
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.