Безопасность в Web3: основные уязвимости смарт-контрактов и как их избежать
Привет! 👋 Сегодня поговорим об одной из самых важных тем в Web3 разработке – безопасности смарт-контрактов. За последние годы хакеры украли сотни миллионов долларов, эксплуатируя уязвимости в смарт-контрактах. Давайте разберем основные типы уязвимостей и научимся их предотвращать.
1. Reentrancy-атаки 🔄
Что это?
Reentrancy (повторный вход) происходит, когда вредоносный контракт повторно вызывает функцию целевого контракта до завершения её первого выполнения. Это возможно из-за особенностей работы внешних вызовов в Solidity, когда контракт-получатель может выполнить произвольный код перед возвратом управления.
Типы Reentrancy-атак:
- Single-Function Reentrancy
- Cross-Function Reentrancy
- Повторный вход в другую функцию, которая работает с тем же состоянием
- Сложнее обнаружить, так как затрагивает несколько функций
- Cross-Contract Reentrancy
- Read-Only Reentrancy
Примеры уязвимого кода:
solidityCopycontract VulnerableBank { mapping(address => uint) public balances; function withdraw() public { uint amount = balances[msg.sender]; (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] = 0; // Слишком поздно! } function deposit() public payable { balances[msg.sender] += msg.value; } } // Атакующий контракт contract Attacker { VulnerableBank public bank; constructor(address bankAddress) { bank = VulnerableBank(bankAddress); } // Функция для начала атаки function attack() public payable { bank.deposit{value: msg.value}(); bank.withdraw(); } // Fallback функция для повторного входа receive() external payable { if (address(bank).balance >= msg.value) { bank.withdraw(); } } }
solidityCopycontract VulnerableProtocol { mapping(address => uint) public deposits; mapping(address => bool) public hasBonus; function withdraw() public { uint amount = deposits[msg.sender]; (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); deposits[msg.sender] = 0; } function claimBonus() public { require(!hasBonus[msg.sender], "Bonus already claimed"); require(deposits[msg.sender] > 0, "No deposits"); hasBonus[msg.sender] = true; // Отправка бонуса... } }
Реальные примеры атак:
Методы защиты:
solidityCopycontract SecureBank { mapping(address => uint) public balances; function withdraw() public { uint amount = balances[msg.sender]; // Checks balances[msg.sender] = 0; // Effects (bool success, ) = msg.sender.call{value: amount}(""); // Interactions require(success, "Transfer failed"); } }
solidityCopyimport "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract SecureProtocol is ReentrancyGuard { mapping(address => uint) public balances; function withdraw() public nonReentrant { uint amount = balances[msg.sender]; balances[msg.sender] = 0; (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); } }
solidityCopycontract CustomSecureContract { bool private locked; modifier noReentrant() { require(!locked, "Reentrant call"); locked = true; _; locked = false; } function secureFunction() public noReentrant { // Безопасный код } }
solidityCopycontract PullPayment { mapping(address => uint) private payments; // Асинхронный платёж function asyncPay(address payee, uint amount) internal { payments[payee] += amount; } // Получатель сам забирает средства function withdrawPayment() public { uint payment = payments[msg.sender]; require(payment > 0, "No payment"); payments[msg.sender] = 0; (bool success, ) = msg.sender.call{value: payment}(""); require(success, "Transfer failed"); } }
solidityCopycontract MutexProtection { mapping(address => uint) private balances; mapping(bytes32 => bool) private mutexLocks; modifier mutex(bytes32 lockId) { require(!mutexLocks[lockId], "Operation in progress"); mutexLocks[lockId] = true; _; mutexLocks[lockId] = false; } function complexOperation() public mutex(keccak256("complex")) { // Сложная операция с несколькими внешними вызовами } }
Лучшие практики предотвращения:
- Архитектурные решения
- Использовать паттерн Pull Payment вместо Push Payment
- Минимизировать внешние вызовы
- Группировать связанные операции
- Использовать атомарные транзакции
- Написание кода
- Следовать паттерну Checks-Effects-Interactions
- Использовать ReentrancyGuard
- Добавлять мутексы для сложных операций
- Тщательно документировать порядок операций
- Тестирование
- Писать специфические тесты на reentrancy
- Использовать инструменты статического анализа
- Проводить фаззинг-тестирование
- Тестировать взаимодействие между контрактами
- Аудит и мониторинг
Инструменты защиты:
2. Overflow и Underflow 🔢
Что это?
Целочисленное переполнение (overflow) и антипереполнение (underflow) - это арифметические уязвимости, которые возникают при выходе за пределы допустимого диапазона чисел.
- Overflow происходит, когда результат превышает максимальное значение типа данных:
- Underflow происходит при уменьшении числа ниже минимального значения:
Примеры уязвимого кода:
solidityCopy// Уязвимый контракт с overflow contract VulnerableToken { mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; // Возможен underflow balances[to] += amount; // Возможен overflow } } // Уязвимый контракт с underflow contract VulnerableGame { uint public playerHealth = 100; function takeDamage(uint damage) public { playerHealth -= damage; // Если damage > playerHealth, произойдет underflow } }
Реальные примеры атак:
- Beauty Chain (BEC) Token - в 2018 году хакеры использовали overflow для создания огромного количества токенов
- PoWHC - потеря около $1 млн из-за underflow в функции withdraw
Как защититься:
solidityCopy// Безопасно в Solidity 0.8.0+ contract SafeToken { mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; // Автоматически проверяется на underflow balances[to] += amount; // Автоматически проверяется на overflow } }
solidityCopyimport "@openzeppelin/contracts/utils/math/SafeMath.sol"; contract SafeToken { using SafeMath for uint256; mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { require(balances[msg.sender] >= amount); balances[msg.sender] = balances[msg.sender].sub(amount); balances[to] = balances[to].add(amount); } }
solidityCopycontract SafeGame { uint public constant MAX_HEALTH = 100; uint public playerHealth = MAX_HEALTH; function heal(uint amount) public { require(playerHealth + amount <= MAX_HEALTH, "Health overflow"); playerHealth += amount; } function takeDamage(uint damage) public { require(damage <= playerHealth, "Health underflow"); playerHealth -= damage; } }
solidityCopylibrary SafeMath { function add(uint256 a, uint256 b) internal pure returns (uint256) { uint256 c = a + b; require(c >= a, "SafeMath: addition overflow"); return c; } function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a, "SafeMath: subtraction underflow"); return a - b; } function mul(uint256 a, uint256 b) internal pure returns (uint256) { if (a == 0) return 0; uint256 c = a * b; require(c / a == b, "SafeMath: multiplication overflow"); return c; } }
Лучшие практики предотвращения:
3. Фронт-раннинг (Front-Running) 🏃♂️
Что это?
Front-running – это тип атаки, при которой злоумышленник отслеживает транзакции в mempool и размещает свою транзакцию перед целевой, используя более высокую цену на газ. Это возможно, потому что майнеры обычно включают в блок транзакции с более высокой ценой на газ первыми.
Типы фронт-раннинга:
- Displacement (Замещение)
- Insertion (Вставка)
- Атакующий вставляет свою транзакцию перед целевой
- Пример: покупка токена перед большой сделкой на DEX
- Suppression (Подавление)
Примеры уязвимых ситуаций:
solidityCopycontract VulnerableDEX { function swap(address token, uint amount) public { uint price = getPrice(token); // Цена фиксируется здесь // ... некоторое время проходит ... executeSwap(token, amount, price); // Используется старая цена } }
solidityCopycontract VulnerableNFT { function mintSpecialNFT(uint tokenId) public { require(!_exists(tokenId), "Already minted"); _mint(msg.sender, tokenId); // Можно перехватить редкий tokenId } }
solidityCopycontract VulnerableAuction { function bid() public payable { require(msg.value > highestBid, "Bid too low"); payable(highestBidder).transfer(highestBid); // Возврат предыдущей ставки highestBidder = msg.sender; highestBid = msg.value; } }
Реальные примеры атак:
Как защититься:
solidityCopycontract SafeNFTMint { mapping(address => bytes32) public commits; mapping(bytes32 => bool) public usedCommits; uint public commitDeadline; uint public revealDeadline; // Шаг 1: Пользователь отправляет хэш своего выбора function commit(bytes32 commitHash) public { require(block.timestamp < commitDeadline, "Commit phase ended"); commits[msg.sender] = commitHash; } // Шаг 2: Пользователь раскрывает свой выбор function reveal(uint tokenId, bytes32 salt) public { require(block.timestamp >= commitDeadline, "Commit phase not ended"); require(block.timestamp < revealDeadline, "Reveal phase ended"); bytes32 commitHash = keccak256(abi.encodePacked(tokenId, salt, msg.sender)); require(commits[msg.sender] == commitHash, "Invalid reveal"); require(!usedCommits[commitHash], "Already revealed"); usedCommits[commitHash] = true; _mint(msg.sender, tokenId); } }
solidityCopycontract BatchAuction { struct Bid { address bidder; uint amount; } Bid[] public bids; uint public auctionEnd; bool public finalized; // Все ставки собираются в течение периода аукциона function bid() public payable { require(block.timestamp < auctionEnd, "Auction ended"); bids.push(Bid(msg.sender, msg.value)); } // Победитель определяется после окончания аукциона function finalize() public { require(block.timestamp >= auctionEnd, "Auction not ended"); require(!finalized, "Already finalized"); finalized = true; // Сортировка ставок и определение победителя _settleBids(); } }
solidityCopycontract FlashLoanProtection { bool private _locked; modifier noFlashLoan() { require(!_locked, "No flash loan allowed"); _locked = true; _; _locked = false; } function protectedFunction() public noFlashLoan { // Код защищенной функции } }
solidityCopycontract MEVProtection { // Минимальная задержка между транзакциями uint public constant MIN_DELAY = 1 blocks; mapping(address => uint) public lastActionBlock; modifier mevProtected() { require(block.number >= lastActionBlock[msg.sender] + MIN_DELAY, "Too frequent transactions"); lastActionBlock[msg.sender] = block.number; _; } function protectedSwap() public mevProtected { // Код свапа } }
Лучшие практики предотвращения:
- Архитектурные решения
- Использовать batch-обработку транзакций
- Внедрять временные задержки
- Применять commit-reveal схемы
- Использовать случайность (через oracle или VRF)
- Ценообразование
- Использовать скользящие средние цены
- Внедрять механизмы против манипуляций
- Добавлять проскальзывание (slippage)
- Мониторинг
- Отслеживать необычные паттерны транзакций
- Использовать инструменты анализа mempool
- Внедрять системы алертов
- Дополнительные меры
Инструменты защиты:
4. Неправильное управление доступом 🔐
Что это?
Уязвимости управления доступом возникают, когда смарт-контракт некорректно контролирует доступ к привилегированным функциям или данным. Это может привести к несанкционированному доступу, изменению критических параметров или краже средств.
Типы уязвимостей доступа:
solidityCopy// Уязвимый контракт contract VulnerableContract { address public owner; uint public price; function setPrice(uint newPrice) public { // Нет проверки доступа! price = newPrice; } }
solidityCopycontract VulnerableInit { address public owner; // Забыли инициализировать owner в конструкторе function initialize() public { // Любой может вызвать! owner = msg.sender; } }
solidityCopycontract VulnerableProxy { address public implementation; function upgrade(address newImplementation) public { require(msg.sender == owner); // Проверяем только владельца // Нет проверки, является ли newImplementation контрактом! implementation = newImplementation; } }
Реальные примеры атак:
Методы защиты:
solidityCopyimport "@openzeppelin/contracts/access/Ownable.sol"; contract SecureContract is Ownable { uint public price; function setPrice(uint newPrice) public onlyOwner { price = newPrice; } // Безопасная передача владения function transferOwnership(address newOwner) public override onlyOwner { require(newOwner != address(0), "New owner is zero address"); super.transferOwnership(newOwner); } }
solidityCopyimport "@openzeppelin/contracts/access/AccessControl.sol"; contract SecureProtocol is AccessControl { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); constructor() { _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); _setRoleAdmin(OPERATOR_ROLE, ADMIN_ROLE); _setRoleAdmin(UPGRADER_ROLE, ADMIN_ROLE); } function adminFunction() public onlyRole(ADMIN_ROLE) { // Только админ может вызвать } function operatorFunction() public onlyRole(OPERATOR_ROLE) { // Только оператор может вызвать } function upgradeFunction() public onlyRole(UPGRADER_ROLE) { // Только upgrader может вызвать } }
solidityCopycontract TimelockController { uint public constant DELAY = 2 days; mapping(bytes32 => bool) public pendingOperations; mapping(bytes32 => uint) public operationTimestamps; function scheduleOperation(bytes32 operationId) public onlyOwner { require(!pendingOperations[operationId], "Already scheduled"); pendingOperations[operationId] = true; operationTimestamps[operationId] = block.timestamp + DELAY; } function executeOperation(bytes32 operationId) public onlyOwner { require(pendingOperations[operationId], "Not scheduled"); require(block.timestamp >= operationTimestamps[operationId], "Too early"); pendingOperations[operationId] = false; // Выполнение операции } }
solidityCopycontract MultiSigWallet { address[] public owners; mapping(address => bool) public isOwner; uint public required; struct Transaction { address to; uint value; bytes data; bool executed; mapping(address => bool) confirmations; } Transaction[] public transactions; constructor(address[] memory _owners, uint _required) { require(_owners.length >= _required, "Invalid required number"); require(_required > 0, "Required must be positive"); for (uint i = 0; i < _owners.length; i++) { address owner = _owners[i]; require(owner != address(0), "Invalid owner"); require(!isOwner[owner], "Duplicate owner"); isOwner[owner] = true; owners.push(owner); } required = _required; } function submitTransaction(address to, uint value, bytes memory data) public returns (uint transactionId) { require(isOwner[msg.sender], "Not owner"); transactionId = transactions.length; transactions.push(Transaction({ to: to, value: value, data: data, executed: false })); confirmTransaction(transactionId); } function confirmTransaction(uint transactionId) public { require(isOwner[msg.sender], "Not owner"); Transaction storage transaction = transactions[transactionId]; transaction.confirmations[msg.sender] = true; if (isConfirmed(transactionId)) { executeTransaction(transactionId); } } function isConfirmed(uint transactionId) public view returns (bool) { uint count = 0; for (uint i = 0; i < owners.length; i++) { if (transactions[transactionId].confirmations[owners[i]]) count += 1; if (count >= required) return true; } return false; } function executeTransaction(uint transactionId) public { require(isConfirmed(transactionId), "Not confirmed"); Transaction storage transaction = transactions[transactionId]; require(!transaction.executed, "Already executed"); transaction.executed = true; (bool success,) = transaction.to.call{value: transaction.value}(transaction.data); require(success, "Execution failed"); } }
Лучшие практики:
- Проектирование системы доступа
- Определить все роли заранее
- Следовать принципу наименьших привилегий
- Документировать права доступа
- Использовать многоуровневую систему ролей
- Реализация
- Использовать проверенные библиотеки (OpenZeppelin)
- Внедрять многоподписные механизмы для критических операций
- Добавлять временные задержки
- Реализовать систему логирования доступа
- Безопасная инициализация
- Всегда инициализировать роли в конструкторе
- Проверять корректность адресов
- Использовать initializer для прокси-контрактов
- Добавлять защиту от повторной инициализации
- Мониторинг и аудит
Инструменты и библиотеки:
5. Отсутствие проверок входных данных ⚠️
Что это?
Отсутствие или недостаточная валидация входных параметров функций может привести к неожиданному поведению контракта, потере средств или манипуляциям с состоянием. Эта уязвимость часто становится причиной эксплойтов в комбинации с другими уязвимостями.
Типы уязвимостей входных данных:
solidityCopycontract VulnerableToken { mapping(address => uint) public balances; function transfer(address to, uint amount) public { // Нет проверки нулевого адреса! balances[msg.sender] -= amount; balances[to] += amount; } }
solidityCopycontract VulnerableLending { function borrow(uint amount, uint collateral) public { // Нет проверки соотношения займа к обеспечению! // Нет проверки минимального размера займа! _transferCollateral(msg.sender, collateral); _transferLoan(msg.sender, amount); } }
solidityCopycontract VulnerableAirdrop { function multiSend(address[] memory recipients, uint[] memory amounts) public { // Нет проверки длины массивов! // Нет проверки суммы всех amounts! for(uint i = 0; i < recipients.length; i++) { payable(recipients[i]).transfer(amounts[i]); } } }
Реальные примеры атак:
Методы защиты:
solidityCopycontract SecureToken { mapping(address => uint) public balances; uint public constant MAX_TRANSFER_AMOUNT = 1000000 * 10**18; function transfer(address to, uint amount) public { // Проверка адреса require(to != address(0), "Zero address not allowed"); require(to != address(this), "Cannot transfer to contract"); // Проверка суммы require(amount > 0, "Amount must be positive"); require(amount <= MAX_TRANSFER_AMOUNT, "Amount too large"); require(balances[msg.sender] >= amount, "Insufficient balance"); // Проверка переполнения require(balances[to] + amount >= balances[to], "Overflow check"); balances[msg.sender] -= amount; balances[to] += amount; } }
solidityCopycontract SecureLending { uint public constant MIN_COLLATERAL_RATIO = 150; // 150% uint public constant MIN_LOAN_AMOUNT = 100 * 10**18; // 100 tokens uint public constant MAX_LOAN_AMOUNT = 100000 * 10**18; // 100,000 tokens function borrow(uint amount, uint collateral) public { // Базовые проверки require(amount >= MIN_LOAN_AMOUNT, "Loan too small"); require(amount <= MAX_LOAN_AMOUNT, "Loan too large"); require(collateral > 0, "No collateral provided"); // Проверка соотношения займа к обеспечению uint collateralRatio = (collateral * 100) / amount; require(collateralRatio >= MIN_COLLATERAL_RATIO, "Insufficient collateral ratio"); // Проверка ликвидности протокола require(_getAvailableLiquidity() >= amount, "Insufficient protocol liquidity"); _transferCollateral(msg.sender, collateral); _transferLoan(msg.sender, amount); } }
solidityCopycontract SecureAirdrop { uint public constant MAX_BATCH_SIZE = 200; uint public constant MAX_AMOUNT_PER_TRANSFER = 1000 * 10**18; function multiSend( address[] memory recipients, uint[] memory amounts ) public { // Проверка длины массивов require(recipients.length > 0, "Empty recipients array"); require(recipients.length == amounts.length, "Arrays length mismatch"); require(recipients.length <= MAX_BATCH_SIZE, "Batch too large"); // Проверка суммы и адресов uint totalAmount = 0; for(uint i = 0; i < recipients.length; i++) { require(recipients[i] != address(0), "Zero address in recipients"); require(amounts[i] > 0, "Zero amount in amounts"); require(amounts[i] <= MAX_AMOUNT_PER_TRANSFER, "Amount too large"); totalAmount += amounts[i]; } // Проверка общей суммы require(totalAmount <= balanceOf(msg.sender), "Insufficient balance"); // Выполнение переводов for(uint i = 0; i < recipients.length; i++) { _transfer(msg.sender, recipients[i], amounts[i]); } } }
solidityCopycontract SecureProtocol { modifier validAddress(address addr) { require(addr != address(0), "Zero address not allowed"); require(addr != address(this), "Cannot use contract address"); _; } modifier validAmount(uint amount) { require(amount > 0, "Amount must be positive"); require(amount <= maxAmount, "Amount too large"); _; } modifier validArrays(address[] memory addrs, uint[] memory amounts) { require(addrs.length > 0, "Empty array"); require(addrs.length == amounts.length, "Arrays length mismatch"); require(addrs.length <= maxBatchSize, "Batch too large"); _; } function secureFunction( address to, uint amount ) public validAddress(to) validAmount(amount) { // Код функции } }
Лучшие практики:
- Валидация адресов
- Проверка на нулевой адрес
- Проверка на адрес контракта
- Проверка на специальные адреса (precompiled contracts)
- Валидация контрольной суммы адреса
- Валидация числовых значений
- Проверка на ноль
- Проверка минимальных/максимальных значений
- Проверка бизнес-ограничений
- Защита от переполнения
- Валидация массивов
- Проверка длины
- Проверка соответствия длин связанных массивов
- Ограничение размера батча
- Проверка элементов массива
- Дополнительные проверки
Инструменты и библиотеки:
Заключение 🎯
Ключевые выводы по безопасности смарт-контрактов
- Фундаментальные принципы безопасности:
- Принцип наименьших привилегий
- Безопасность по умолчанию
- Глубокая защита (defense in depth)
- Fail-safe defaults
- Экономическая безопасность
- Процесс разработки безопасных контрактов:
Планирование → Разработка → Тестирование → Аудит → Развертывание → Мониторинг
- Чеклист безопасности при разработке:
Комплексный подход к безопасности
contract SecureContract { // Экстренная остановка bool public paused; modifier whenNotPaused() { require(!paused, "Contract paused"); _; } // Ограничение по времени modifier onlyDuringOperatingHours() { require(block.timestamp % 86400 >= 32400 && // 9:00 AM block.timestamp % 86400 <= 64800, // 6:00 PM "Outside operating hours"); _; } // Ограничение частоты mapping(address => uint) public lastActionTime; modifier rateLimited() { require(block.timestamp >= lastActionTime[msg.sender] + 1 hours, "Rate limited"); lastActionTime[msg.sender] = block.timestamp; _; } }
contract RecoverableContract { // Возможность обновления address public implementation; function upgrade(address newImplementation) external onlyOwner { require(newImplementation.code.length > 0, "Not a contract"); implementation = newImplementation; } // Восстановление активов function recoverTokens( address token, address recipient, uint amount ) external onlyOwner { IERC20(token).transfer(recipient, amount); } }
План действий при обнаружении уязвимости
Непрерывное улучшение безопасности
- Регулярные проверки: plaintextCopy
Ежедневно: - Мониторинг транзакций - Проверка алертов Еженедельно: - Обзор кода - Обновление зависимостей Ежемесячно: - Пентестинг - Обновление документации Ежеквартально: - Внешний аудит - Обновление процедур
- Образование и развитие:
Рекомендуемые ресурсы для изучения
Подпишись !!!
Спасибо за чтение ! Подпишись что бы не пропускать дальнейшие статьи!
Телеграм: https://t.me/one_eyes