Нажмите ESC, чтобы закрыть

Уязвимости стандартов ERC: Руководство для архитектора

Привет! Раз уж ты читаешь этот материал, значит, тебе либо чертовски интересна безопасность смарт-контрактов, либо ты прямо сейчас проектируешь архитектуру нового DeFi-протокола и у тебя слегка подрагивают поджилки от мысли, что завтра всё это улетит в трубу из-за какой-нибудь глупой уязвимости.

Слушай, я в этой теме уже давно. Прошел путь от хакатонов, где мы на коленке собирали эксплойты под пиццу и энергетики, до кресла CTO криптобиржи. И знаешь, что я тебе скажу? Большинство жестких взломов, которые я видел и расследовал (а некоторые и предотвращал на этапе аудита), происходят не потому, что криптография сломалась. И не потому, что компилятор Solidity сошел с ума. Они происходят из-за фундаментального непонимания того, как стандарты ERC ведут себя на стыке взаимодействия друг с другом.

Мы привыкли думать, что стандарты - это закон и безопасность. Но дьявол кроется в деталях реализации и скрытых побочных эффектах. Давай разберем, где архитекторы чаще всего наступают на грабли, и как спроектировать систему так, чтобы спать спокойно.

1. Скрытая угроза ERC-20: Опасная классика

Казалось бы, ERC-20 изучен вдоль и поперек. Что там может пойти не так? Да всё, если ты интегрируешь в свой протокол чужие токены без жесткой проверки.

Проблема отсутствия возвращаемого значения (The No-Return Dilemma)

По спецификации transfer и transferFrom должны возвращать bool. Но в реальности куча старых и авторитетных токенов (привет, USDT и BNB на некоторых старых контрактах) этого не делают. Они просто не возвращают ничего в случае успеха.

Если твой контракт ожидает bool через стандартный интерфейс:

// Так делать нельзя, если работаешь с произвольными токенами!
IERC20(token).transferFrom(msg.sender, address(this), amount);

То при взаимодействии с USDT транзакция тупо упадет (revert), потому что EVM будет искать возвращаемое значение на стеке, а его там нет. Или, что еще хуже, если ты не проверяешь результат (token.transfer(...) вместо require(token.transfer(...))), то некоторые токены при ошибке возвращают false вместо реверта, и твой контракт продолжит выполнение, как будто всё ок. Результат? Юзер нарисовал себе баланс из воздуха.

Решение: Навсегда забудь про прямой вызов transfer и transferFrom. Используй библиотеку SafeERC20 от OpenZeppelin и её методы safeTransfer / safeTransferFrom. Она под капотом проверяет низкоуровневый возврат и корректно обрабатывает контракты-инвалиды.

Weird ERC-20 Tokens: Когда стандарт превращается в тыкву

Вот тебе небольшая таблица-шпаргалка по токенам, которые ведут себя не так, как написано в учебниках. Архитектор обязан закладывать это в логику пулов ликвидности.

Тип токена (Weird ERC-20)В чем прикол?Чем опасно для архитектуры?
Deflationary / Fee-on-Transfer (напр., STA, PAXG)Снимают комиссию прямо во время перевода.Ты думал, что контракт получил 100 токенов, а на баланс пришло 99. Внутренний учет ликвидности ломается, возникает дефицит.
Upgradable Proxies (напр., USDC, USDT)Логика токена может измениться в любой момент админами.Появление черных списков (Blacklists). Если адрес твоего контракта заблокируют, вся ликвидность застрянет внутри.
Rebasing Tokens (напр., AMPL)Баланс на кошельках динамически меняется (supply меняется для стабилизации цены).Баланс контракта может уменьшиться или увеличиться сам по себе, без вызова функций перевода.

2. ERC-721 и ERC-1155: Ловушка onERC721Received и Reentrancy

О, это моя "любимая" тема. Сколько NFT-маркетплейсов и лендингов погорело на функциях безопасного перевода!

Когда ты вызываешь safeTransferFrom в ERC-721 или ERC-1155, контракт токена проверяет, является ли получатель смарт-контрактом. Если да, он вызывает у получателя хук onERC721Received или onERC1155Received.

Зачем? Чтобы убедиться, что контракт умеет работать с NFT и они там не застрянут.

В чем подвох? Этот хук передает управление внешнему неконтролируемому коду прямо в середине твоей транзакции, до того, как ты обновил внутреннее состояние своего контракта!

Код уязвимого минтинга / маркетплейса

Посмотри на этот кусок. Я специально написал его так, чтобы показать классическую архитектурную ошибку - нарушение паттерна Checks-Effects-Interactions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract VulnerableNFCLending {
    // Храним информацию о залогах: Пользователь => ID токена => Активен ли залог
    mapping(address => mapping(uint256 => bool)) public hasCollateral;
    IERC721 public nftToken;
    constructor(address _nft) {
        nftToken = IERC721(_nft);
    }
    // Юзер хочет взять кредит под залог NFT
    function depositCollateral(uint256 tokenId) external {
        // 1. Interactions: Переводим NFT на контракт
        // safeTransferFrom триггерит хук onERC721Received на контракте получателя? 
        // Подожди, нет, получатель тут МЫ. Но если контракт вызывает safeMint... 
        // Стоп, давай перепишем контекст под ситуацию, когда мы отдаем NFT или когда злоумышленник перехватывает управление.
        
        // Перепишем сценарий: Контракт отдает NFT назад (например, при выводе залога) 
        // или это контракт минтинга, который сначала переводит, а потом обновляет состояние.
    }
}

Давай лучше покажу чистый пример с уязвимым минтингом, тут нагляднее. Допустим, у нас есть контракт, который позволяет минтить только 1 NFT в одни руки бесплатно.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract VulnerableMint is ERC721 {
    mapping(address => bool) public hasMinted;
    uint256 public currentTokenId;
    constructor() ERC721("DangerNFT", "DNFT") {}
    function freeMint() external {
        // Checks
        require(!hasMinted[msg.sender], "Brother, you already have one!");
        // Interactions (Внутри _safeMint зашит вызов внешнего контракта!)
        _safeMint(msg.sender, currentTokenId);
        currentTokenId++;
        // Effects (Обновление состояния происходит СЛИШКОМ ПОЗДНО)
        hasMinted[msg.sender] = true;
    }
}

А теперь контракт атакующего. Он просто ловит этот хук, видит, что hasMinted на основном контракте все еще false, и вызывает freeMint снова и снова, пока не выжрет весь лимит или не закончится газ.


    Правило архитектора: Сначала обновляй стейт (hasMinted[msg.sender] = true;), и только потом вызывай любые методы минтинга или перевода. И всегда, слышишь, всегда вешай модификатор nonReentrant от OpenZeppelin на функции, работающие с NFT-трансферами.
  

Погнали дальше. Раз уж мы вскрыли тему Reentrancy через хуки, давай раскопаем более свежую и изощренную проблему, о которой мало кто задумывается на этапе проектирования архитектуры, пока гром не грянет.

3. ERC-2612 (Permit): Фантомные аппрувы и Front-running атаки

Стандарт ERC-2612 принес в мир Web3 огромное облегчение - функцию permit. Она позволяет пользователям подписывать сообщение (EIP-712) о выдаче аппрува на трату токенов в оффлайне, а плату за газ перекладывать на релеер или сам протокол. UX вырос космически: вместо двух транзакций (approve + transferFrom) юзер делает одну.

Но архитекторы часто забывают, как именно работает эта подпись под капотом, и совершают фатальные ошибки в логике.

Фронтраннинг подписи (Signature Front-running)

Представь классическую функцию депозита в смарт-контракт с использованием permit:

function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    // Сначала выполняем permit по переданной подписи пользователя
    IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s);
    
    // Затем забираем токены
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    
    // Начисляем внутренние поинты/доли пула
    _mintShares(msg.sender, amount);
}

Где тут уязвимость? Любой мемпул-бот (MEV) видит эту транзакцию в публичном пуле. Бот может вытащить валидную подпись (v, r, s) и параметры из твоей транзакции, создать свою транзакцию с вызовом token.permit(...) напрямую на контракте токена и выставить цену газа (Gas Price) выше, чем у пользователя.

Его транзакция выполнится первой. Подпись успешно применится, аппрув выставится. Затем выполнится транзакция честного пользователя. Но поскольку подпись уже была использована, nonce пользователя в контракте токена увеличился! Вызов permit внутри depositWithPermit вызовет revert, так как подпись станет невалидной.

Результат? Пользовательская транзакция падает, он тратит газ впустую, его UX сломан, а если это был критический долив маржи в лонг-позицию, его позицию ликвидирует из-за задержки.

Как защитить архитектуру?

Оборачивай вызов permit в блок try/catch. Если подпись уже была отправлена в сеть фронтраннером, контракт токена уже имеет нужный allowance. Твой контракт должен просто проигнорировать ошибку дублирования подписи и продолжить выполнение transferFrom.

try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {} 
catch {
    // Если упало — возможно, подпись уже зафронтранили. 
    // Проверяем, достаточно ли текущего allowance для проведения операции.
    require(IERC20(token).allowance(msg.sender, address(this)) >= amount, "Permit failed and allowance insufficient");
}

Проблема "Фантомного Пермита" (Phantom Permit)

А вот это уже чисто инсайдерская боль, о которой мало где пишут. Что будет, если твой DeFi-протокол поддерживает любые токены, и пользователь пытается вызвать depositWithPermit для токена, который НЕ поддерживает ERC-2612?

Ты думаешь: "Ну, контракт упадет, ведь функции permit там нет". А вот и не всегда!

Если контракт токена имеет встроенную функцию fallback() или receive(), которая не ревертит транзакцию при вызове неизвестного селектора (как это сделано в некоторых прокси-контрактах или старых токенах WETH), то вызов permit завершится успешно (возвратит success = true), но никакого аппрува на самом деле не произойдет.

Дальше контракт пойдет выполнять transferFrom, который упадет, если у контракта не было старого аппрува. Но если уязвимость совместить с логикой, где аппрув проверяется по-другому, можно знатно огрести. Всегда проверяй, что целевой токен реально поддерживает IERC20Permit, проверяя его интерфейс через ERC-165, либо жестко контролируй вайтлист токенов.

4. ERC-3156 (Flash Loans): Опасность манипуляции балансами внутри одной транзакции

Флешлоны - мощнейший инструмент, но они ломают тайминги, к которым привыкли традиционные архитекторы. Внутри одной транзакции злоумышленник может взять взаймы миллионы долларов, провернуть черную схему и вернуть их обратно.

Самая жесткая архитектурная ошибка здесь - это использование функции balanceOf(address(this)) для расчета стоимости долей пула или цены актива.

// КАТАСТРОФИЧЕСКАЯ ОШИБКА В АРХИТЕКТУРЕ
function getSharePrice() public view returns (uint256) {
    // Цена доли зависит от текущего баланса токенов на контракте
    return token.balanceOf(address(this)) / totalShares;
}

Если твой контракт позволяет брать Flash Loan этих же токенов, то в момент, когда заемщик забирает токены, баланс контракта падает практически до нуля. Если в этот же момент (внутри хука onFlashLoan) твой протокол позволяет совершать другие операции (например, ликвидации или расчет наград), то цена акций будет искажена в разы.

Хакер берет флешлон -> баланс пула падает -> цена доли падает -> хакер с другого кошелька дешево скупает доли пула -> возвращает флешлон -> баланс пула восстанавливается -> хакер продает доли по нормальной цене. Всё, пул пустой.

Золотое правило: Никогда не полагайся на balanceOf(address(this)) для критических математических расчетов, если этот баланс можно временно изменить без изменения логического состояния системы. Используй внутренний учет (internal accounting) через uint256 internalReserve, который обновляется только при контролируемых депозитах и выводах.

Ладно, давай перейдем к вещам, от которых у меня, как у человека, отвечающего за безопасность биржевой инфраструктуры, реально шевелятся волосы на затылке. Поговорим про новые и относительно свежие стандарты, где грабли еще не так сильно притоптаны ногами сотен разработчиков.

5. ERC-4337 (Account Abstraction): Ловушки на уровне пакетных транзакций и Paymaster

Абстракция аккаунта - это круто, спору нет. Мы уходим от EOAs (обычных кошельков) к смарт-контрактам в качестве кошельков пользователей. Больше никаких сид-фраз, можно делать социальное восстановление и платить за газ в стейблкоинах через паймейстеры (Paymasters).

Но с точки зрения архитектора протокола, который интегрируется с ERC-4337, здесь открывается целая бездна специфических векторов атак.

Сигнатурная уязвимость validateUserOp

В ERC-4337 ключевая точка кастомной валидации кошелька - метод validateUserOp. Он должен проверить подпись транзакции и вернуть специальный статус.

// Упрощенный пример логики валидации в смарт-кошельке
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    // Критическая ошибка: Мы доверяем вызову со стороны ANY адреса?
    // Нет, Bundler вызывает это через EntryPoint. Но если мы забыли проверку...
    require(msg.sender == entryPoint, "Only EntryPoint can trigger validation");
    // Собственная валидация подписи
    if (_verifySignature(userOp, userOpHash)) {
        // Возвращаем 0, если валидация успешна
        return 0; 
    }
    
    // Возвращаем SIG_VALIDATION_FAILED (обычно 1) при ошибке
    return 1; 
}

Подожди, видишь, в чем прикол? По спецификации ERC-4337, если валидация подписи провалена, функция не должна делать revert. Она должна вернуть специальное упакованное значение (константу ошибки), чтобы EntryPoint (главный контракт-дирижер) понял, что транзакция невалидна, и не списывал газ с кошелька, а просто отбросил операцию на уровне Bundler'а.

Если ты, как архитектор, по привычке бахнешь туда require(isValid, "Invalid signature");, то при массовой отправке пакета транзакций (batch) одна упавшая транзакция из-за жесткого revert сломает весь пакет для Bundler'а. В итоге твой кошелек или твой паймейстер попадет в черный список (ban) у бундлеров, и твои пользователи вообще не смогут отправлять транзакции. Логика валидации должна быть атомарной и строго следовать математике возвращаемых значений ERC-4337, а не классическим паттернам Solidity.

Атака на Paymaster (Gas Drain)

Если твой DeFi-протокол выступает в роли Paymaster'а (например, ты субсидируешь газ для своих пользователей, чтобы они торговали без комиссий), ты обязан намертво изолировать стадию валидации от внешнего мира.

В контракте Paymaster есть метод validatePaymasterUserOp. Внутри этого метода запрещено использовать динамическое состояние, которое может измениться между моментом симуляции транзакции бундлером и моментом включения её в блок. Например, нельзя вызывать оракулы цены (Chainlink) прямо внутри валидации паймейстера, чтобы посчитать, сколько токенов списать с юзера за газ.

Почему? Злоумышленник может отправить транзакцию, во время симуляции оракул покажет одну цену (валидация пройдет), а перед самым включением в блок хакер сам манипулирует ценой оракула (флешлоном или быстрым трейдом). Валидация в блоке начнет падать, но бундлер уже взял транзакцию в работу и потратил газ. Деньги спишутся с твоего паймейстера, а юзер ничего не заплатит. Твой баланс газа тупо выжрут за несколько часов.

6. ERC-4626 (Tokenized Vaults): Фронтраннинг первого депозита (Inflation Attack)

ERC-4626 - это стандарт для токенизированных хранилищ (стейкинг, пулы доходности, лендинги). Он стандартизировал методы deposit, mint, withdraw и redeem. Это гениально, потому что теперь любой агрегатор доходности вроде Yearn может интегрировать любой новый пул за 5 минут.

Но в самом математическом дизайне стандарта заложена мина замедленного действия, известная как Inflation Attack (Атака инфляции актива). Она бьет по пулам в тот момент, когда они только-только разворачиваются в сети и их баланс равен нулю.

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

Формула расчета количества долей (shares), которые получает пользователь при депозите активов (assets), обычно выглядит так:

$$\text{shares} = \frac{\text{assets} \times \text{totalShares}}{\text{totalAssets}}$$

Если пул пустой (totalShares == 0), то shares == assets. То есть соотношение 1 к 1.

А теперь смотри за руками хакера:

  • Честный пользователь отправляет транзакцию deposit на сумму 1000 USDC в совершенно новый, пустой ERC-4626 пул.
  • Хакер видит это в мемпуле и фронтранит (выставляет газ выше). Он депонирует в пул всего 1 wei USDC. Пул минтит ему 1 wei долей. Теперь totalShares = 1, totalAssets = 1.
  • Затем, в этой же транзакции, хакер делает прямой перевод (через обычный transfer, а не через deposit) огромной суммы — например, 10 000 USDC — на адрес контракта пула.
  • Что произошло с математикой пула? Теперь totalShares = 1, но totalAssets = 10 001 USDC (из-за прямого перевода баланс контракта вырос, а количество долей — нет). Цена одной доли пула стала космической.
  • Наконец, выполняется транзакция честного юзера на 1000 USDC. Контракт считает доли по формуле:

    $$\text{shares} = \frac{1000 \times 1}{10\,001} = 0$$

    Из-за округления вниз в Solidity (целочисленное деление), пользователь получает 0 долей! Но его 1000 USDC успешно уходят на баланс пула.

  • Хакер делает withdraw своей единственной доли (1 wei shares) и забирает из пула абсолютно всё: свои 10 000 USDC, свой 1 wei и 1000 USDC обманутого пользователя.

Архитектурное решение: Защититься от этого можно двумя путями. Первый - при создании пула принудительно минтить "виртуальные ликвидности" (dead shares) на нулевой адрес (залочить там первые 1000 wei долей, как это сделано в Uniswap V2). Второй - использовать обновленные библиотеки OpenZeppelin, где встроена защита через виртуальные оффсеты (virtual assets и virtual shares), которые не дают знаменателю дроби превратиться в ноль или единицу при манипуляциях.

Слушай, а давай теперь поднимемся на уровень выше. Мы поговорили про конкретные токены, NFT, пермиты и хранилища. Но как всё это увязать в единую архитектуру и не сойти с ума во время интеграции?

Когда ты проектируешь крупную систему, например, агрегатор доходности или кросс-чейн мост, тебе приходится работать со всеми этими стандартами одновременно. И тут возникает синергетический эффект уязвимостей, когда две безопасные по отдельности фичи вместе дают фатальную дыру.

7. Сводная матрица архитектурных рисков при проектировании систем

Чтобы у тебя перед глазами была четкая картина, я набросал эту таблицу. Это буквально чек-лист для твоего следующего архитектурного ревью (Architecture Review). Положи её себе в Notion или распечатай.

Стандарт ERCГлавная скрытая угрозаПроявление в логикеКак лечить на уровне дизайна?
ERC-20Отсутствие возврата / Нестандартный трансферПадение транзакции или тихий пропуск ошибкиИспользовать исключительно SafeERC20 (OpenZeppelin).
ERC-20 (Weird)Fee-on-Transfer / Изменение баланса (Rebase)Расхождение между внутренним учетом системы и реальным балансом на контрактеСчитать разницу балансов balanceAfter - balanceBefore вместо доверия аргументу amount.
ERC-721 / 1155Перехват управления через хуки onERC...ReceivedReentrancy (Повторный вход) до обновления внутреннего стейтаСтрогое следование паттерну Checks-Effects-Interactions + модификатор nonReentrant.
ERC-2612Фронтраннинг подписей в мемпулеОтказ в обслуживании (DoS) для легитимного пользователяОборачивать вызовы permit в блоки try/catch.
ERC-3156Временное осушение пула (Flash Loan)Искажение спотовых цен, завязанных на balanceOfИспользовать внутренние накопительные переменные (internal reserves) вместо прямого баланса.
ERC-4337Жесткий revert при пакетной валидацииБан контракта / кошелька в бундлерахВозвращать магические константы ошибок вместо падения транзакции через require.
ERC-4626Inflation Attack (Атака на первый депозит)Округление долей до нуля, кража средств первого вкладчикаМинтить "мертвые доли" на address(0) при инициализации или использовать виртуальные оффсеты.

8. Мысли вслух и золотые правила безопасной архитектуры

Знаешь, за три года в кресле CTO я понял одну вещь. Самый безопасный код - это тот, который не написан. Чем сложнее твоя архитектурная схема, тем больше там скрытых связей и тем выше шанс, что какой-нибудь гений с хакатона найдет лазейку, о которой ты даже не думал, когда пил свой утренний кофе.

Если бы мне нужно было выдать тебе всего три правила, которые спасут твой проект от заголовков в Rekt News, они звучали бы так:

  • Никогда не доверяй внешним контрактам. Даже если это самый популярный токен в мире. Завтра его админы обновят прокси, добавят черные списки, и твоя система встанет колом. Пиши код с расчетом на то, что внешний токен ведет себя как самый злобный и непредсказуемый актор в сети.
  • Сначала стейт - потом переводы. Я не устану это повторять. Это база, которую вдалбливают на первом курсе любого нормального курса по смарт-контрактам, но люди с упорством маньяков продолжают отправлять токены до того, как перепишут циферки в своих маппингах. Сначала ты забираешь или отдаешь права внутри своего контракта, фиксируешь это в блокчейне, и только самым последним действием вызываешь внешние transfer, safeMint или call.
  • Изолируй математику от внешнего баланса. Баланс твоего контракта в EVM - это публичная и легко манипулируемая штука. Кто угодно может заслать тебе миллион долларов флешлоном или просто "самоликвидировать" контракт через selfdestruct, насильно закинув эфир на твой адрес. Если твоя логика начисления наград или расчета цены доли зависит от того, сколько токенов сейчас лежит на адресе контракта, ты уже проиграл. Твой внутренний учет должен быть изолирован, как кабина пилота.

Пожалуй, на этом этапе мы прошлись по всем ключевым болевым точкам стандартов, которые могут свести на нет усилия даже сильной команды разработчиков, если архитектор вовремя не подставил костыль (в хорошем смысле этого слова) в нужные места.

Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...

Поделитесь своим мнением

Ваш e-mail не будет опубликован. Обязательные поля отмечены *