Безопасность в 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