Reentrancy, CEI-паттерн, tx.origin, integer overflow, front-running, ReentrancyGuard
Уязвимость в смарт-контракте — это не ошибка, которую можно исправить патчем. Это дыра в неизменяемом коде с деньгами внутри
Код смарт-контракта неизменяем после деплоя (если не используется proxy). Ошибки в контрактах стоили миллиарды долларов: взлом DAO в 2016 ($60M), Parity wallet bug 2017 ($150M заморожены навсегда), Ronin Bridge 2022 ($625M). Понимание атак — обязательное условие для любого разработчика Web3.
Самая известная уязвимость блокчейна, именно она привела к взлому DAO.
Суть атаки: контракт-жертва отправляет ETH атакующему до обновления своего состояния. Атакующий в receive() снова вызывает уязвимую функцию — и так по кругу, пока баланс жертвы не опустеет.
Уязвимый контракт:
// УЯЗВИМЫЙ КОД — не использовать в production!
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// УЯЗВИМОСТЬ: ETH отправляется ПЕРЕД обновлением баланса
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// Сначала отправляем ETH...
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// ...потом обновляем баланс (ПОЗДНО!)
balances[msg.sender] = 0;
}
}Атакующий контракт вызывает withdraw(), получает ETH, и в момент получения (в receive()) снова вызывает withdraw() — баланс ещё не обнулён, поэтому проверка проходит.
CEI — главный паттерн защиты от reentrancy. Порядок действий в функции:
// ПРАВИЛЬНЫЙ КОД — паттерн CEI
contract SecureBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
// 1. Checks
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// 2. Effects — обновляем состояние ДО отправки ETH
balances[msg.sender] = 0;
// 3. Interactions — теперь безопасно отправлять
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}Дополнительная защита — OpenZeppelin ReentrancyGuard:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
function withdraw() external nonReentrant {
// nonReentrant устанавливает lock перед выполнением
// и снимает после — повторный вызов из receive() откатится
}
}ReentrancyGuard использует bool флаг в storage: перед вызовом устанавливает _entered = true, после — сбрасывает. Повторный вызов увидит флаг и выбросит revert. Используйте вместе с CEI для двойной защиты.
tx.origin — адрес EOA, который инициировал цепочку вызовов (самая первая подпись в цепочке).
msg.sender — непосредственный вызывающий текущей функции (может быть другой контракт).
Использование tx.origin для авторизации — уязвимость:
// УЯЗВИМЫЙ КОД — фишинговая атака!
contract VulnerableWallet {
address public owner;
function transfer(address to, uint256 amount) external {
require(tx.origin == owner, "Not owner"); // ОПАСНО!
payable(to).transfer(amount);
}
}Атакующий разворачивает вредоносный контракт и убеждает владельца вызвать его функцию. Вредоносный контракт переадресует вызов в VulnerableWallet.transfer(attacker, balance). tx.origin = owner (он подписал транзакцию), проверка пройдена, средства украдены.
Правило: никогда не используйте tx.origin для авторизации. Только msg.sender.
До Solidity 0.8.0 переполнение не вызывало revert:
// В Solidity 0.7.x: uint8(255) + 1 == 0
// В Solidity 0.8.x: revert с Panic(0x11)Исторически это была частая уязвимость. Решение в старом коде — библиотека SafeMath от OpenZeppelin. В Solidity 0.8+ защита встроена. Если нужен wrap-around (кольцевой счётчик) — используйте unchecked { } явно.
Front-running — атака, при которой злоумышленник видит pending транзакцию в mempool и отправляет свою транзакцию с более высоким газом, чтобы она выполнилась раньше.
Уязвимый сценарий: контракт лотереи, где первый угадавший число выигрывает. Атакующий видит в mempool транзакцию с правильным числом, копирует его и отправляет с большим gasPrice.
Защиты: commit-reveal scheme (сначала отправить хэш ответа, потом раскрыть), использование Flashbots для приватных транзакций, проектирование контрактов так, чтобы front-running не давал преимущества.
Неправильная авторизация — частая уязвимость. Рекомендации:
Используйте OpenZeppelin Ownable или AccessControl вместо ручных проверок. AccessControl поддерживает ролевую модель — MINTER_ROLE, PAUSER_ROLE и т.д.
import "@openzeppelin/contracts/access/AccessControl.sol";
contract ManagedToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// только адреса с MINTER_ROLE могут минтить
}
}Всегда ограничивайте административные функции (pause, upgrade, withdraw) конкретными ролями. Не давайте одному адресу все права — используйте multisig (Gnosis Safe) для критических операций.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.