Back to Blog

Отчет о тестировании безопасности Radiant V2

Code Auditing
March 23, 2023
28 min read

Манифест отчета

Элемент Описание
Клиент Radiant Capital
Цель Radiant V2

История версий

Версия Дата Описание
1.0 15 марта 2023 г. Первая версия
2.0 21 марта 2023 г. Вторая версия

1. Введение

1.1 О тестировании безопасности

Компания Radiant Capital пригласила нас провести тестирование безопасности (в качестве «красной команды» / red team) смарт-контрактов Radiant V2 для выявления потенциальных рисков. Как ответственная организация, Radiant Capital серьезно относится к безопасности. Поэтому команда решила уделить больше внимания защите этих смарт-контрактов, несмотря на то, что они уже были проверены несколькими аудиторскими компаниями в сфере безопасности ^1.

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

1.2 О целевых контрактах

Информация Описание
Тип Смарт-контракт
Язык Solidity
Подход Статический анализ, динамический анализ, полуавтоматическая и ручная верификация

Целевой репозиторий — Radiant_v2.1.1. Значения SHA коммитов во время тестирования безопасности приведены ниже. Наш отчет отвечает только за начальную версию (т.е. Версию 1), а также за новый код (в последующих версиях) для исправления проблем, указанных в отчете.

Обратите внимание, что данный отчет охватывает только смарт-контракты в папке radiant_v2.1.1/contracts этого репозитория, включая:

  • bounties
  • deployments
  • flashloan
  • leverage
  • lock
  • oracles
  • staking
  • zap
  • eligibility
  • misc
  • oft
  • protocol
  • stargate

После обновления в Версии 8 файлы, охваченные этим тестированием безопасности, включают:

  • lending/AaveOracle.sol
  • lending/AaveProtocolDataProvider.sol
  • lending/ATokensAndRatesHelper.sol
  • lending/StableAndVariableTokensHelper.sol
  • lending/UiPoolDataProviderV2V3.sol
  • lending/UiPoolDataProvider.sol
  • lending/WETHGateway.sol
  • lending/WalletBalanceProvider.sol
  • lending/configuration
  • lending/flashloan
  • lending/lendingpool
  • lending/tokenization
  • radiant/accessories
  • radiant/eligibility
  • radiant/oracles
  • radiant/staking
  • radiant/token
  • radiant/zap

1.3 Модель безопасности

Для оценки риска мы следуем стандартам или рекомендациям, которые широко приняты как в индустрии, так и в научных кругах, включая методологию оценки рисков OWASP ^2 и список общих ошибок программирования (Common Weakness Enumeration) ^3. Общая серьезность риска определяется вероятностью и влиянием. В частности, вероятность используется для оценки того, насколько вероятно, что конкретная уязвимость может быть обнаружена и использована злоумышленником, в то время как влияние используется для оценки последствий успешной эксплуатации.

В этом отчете и вероятность, и влияние классифицируются по двум рейтингам, т.е. высокий и низкий соответственно, а их сочетания показаны в Таблице 1.1.

Таблица 1.1: Классификация серьезности уязвимостей
Таблица 1.1: Классификация серьезности уязвимостей

Соответственно, серьезность, измеряемая в этом отчете, классифицируется на три категории: Высокая, Средняя, Низкая. Для полноты картины также используется категория Не определено для случаев, когда риск невозможно точно определить.

Более того, статус обнаруженного элемента будет относиться к одной из четырех следующих категорий:

  • Не определено Еще нет ответа.

  • Принято к сведению Элемент получен клиентом, но еще не подтвержден.

  • Подтверждено Элемент признан клиентом, но еще не исправлен.

  • Исправлено Элемент подтвержден и исправлен клиентом.

2. Автоматизированное тестирование безопасности

2.1 Автоматизированное статическое тестирование безопасности

Мы используем наш собственный инструмент статического анализа на базе Slither для проверки наличия уязвимостей. После ручной проверки результатов проблем обнаружено не было. Подробные результаты тестирования можно найти в Таблице 4.1 в Приложении.

2.2 Автоматизированное динамическое тестирование безопасности

Мы используем методы фаззинга для проверки надежности, стабильности и точности целевых контрактов. В частности, начальное «зерно» (seed) в процессе фаззинга определяется на основе семантики функций и скриптов тестирования контрактов. Чтобы имитировать ончейн-среду, мы также поддерживаем набор адресов, которые взаимодействовали с контрактами LendingPool и MultiFeeDistribution.

Наш фаззер также учитывает семантику функций при генерации последовательности транзакций. Например, функция stake в контракте MultiFeeDistribution и функция deposit в контракте LendingPool, скорее всего, будут вызваны первыми в последовательности. Мутации параметров функций и последовательностей направляются покрытием кода контракта. Если определенный параметр или последовательность достигают более высокого покрытия кода, они получают более высокий приоритет для мутации в следующем раунде фаззинга. Чтобы исследовать пути, ограниченные «магическими числами», мы собираем значения, считываемые из хранилища (т.е. инструкция SLOAD) во время выполнения, и используем их для генерации параметров функций в процессе мутации.

В общей сложности мы генерируем 100 000 тестовых случаев и используем 31 оракул, которые применяются для обнаружения сбоев. Каждый тест состоит из 30 транзакций с заданным порядком. В конечном итоге мы обнаружили одну критическую проблему (т.е. Раздел 3.2.6), которая также была выявлена в процессе нашего ручного тестирования безопасности. Подробные результаты тестирования можно найти в Таблицах 4.2, 4.3 и 4.4 в Приложении.

3. Ручное тестирование безопасности

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

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

  • Высокий риск: 2

  • Средний риск: 8

  • Низкий риск: 7

  • Рекомендации: 3

  • Замечания: 1

ID Серьезность Описание Категория Статус
1 Средняя Отсутствие зарезервированного интерфейса для сброса указателей функций Software Security Исправлено
2 Средняя Некорректный расчет оракула DeFi Security Исправлено
3 Высокая Потенциальный слив средств через BaseBounty DeFi Security Исправлено
4 Низкая Потенциально недействительные графики эмиссии DeFi Security Исправлено
5 Низкая Пропускаемые графики эмиссии DeFi Security Подтверждено
6 Средняя Изменяемый обменный курс во время миграции DeFi Security Исправлено
7 Высокая Некорректная реализация _transfer() (I) DeFi Security Исправлено
8 Низкая Отсутствие проверки периода в UniV2TwapOracle DeFi Security Исправлено
9 Средняя Невозвратные «пылевые» токены DeFi Security Исправлено
10 Средняя Некорректная реализация _transfer() (II) DeFi Security Исправлено
11 Средняя Манипулируемые вознаграждения за компаундинг DeFi Security Исправлено
12 Средняя Отсутствие контроля доступа в setLeverager() DeFi Security Исправлено
13 Средняя Отсутствие проверки проскальзывания в addLiquidityWETHOnly() DeFi Security Подтверждено
14 Низкая Отсутствие проверки borrowRatio в loopETH() DeFi Security Исправлено
15 Низкая Отсутствие проверки длины между assets и poolIDs в setPoolIDs() DeFi Security Исправлено
16 Низкая Отсутствие отзыва привилегии mint в addBountyContract() DeFi Security Подтверждено
17 Низкая Майнеры могут быть назначены только один раз DeFi Security Подтверждено
18 - Оптимизация газа (zapVestingToLp() в Mfd) Рекомендация Исправлено
19 - Непустой резерв вознаграждений в BountyManager Рекомендация Исправлено
20 - Несогласованное именование в requiredUsdValue() Рекомендация Подтверждено
21 - Заметка о MFDPlus Примечание Подтверждено

Подробности представлены в следующих разделах.

3.1 Software Security

3.1.1 Потенциальная проблема 1: Отсутствие зарезервированного интерфейса для сброса указателей функций

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 7
Введено в Версии 1

Описание Три функции, getLpMfdBounty(), getChefBounty() и getAutoCompoundBounty(), вызываются через указатели функций в контракте BountyManager. В то же время наследование от Ownable Upgradable показывает, что этот контракт является реализацией прокси. Это указывает на то, что контракт реализации может быть обновлен в будущем, что порождает проблему, связанную с указателями функций.

function initialize(
        address _rdnt,
        address _weth,
        address _lpMfd,
        address _mfd,
        address _chef,
        address _priceProvider,
        address _eligibilityDataProvider,
        uint256 _hunterShare,
        uint256 _baseBountyUsdTarget,
        uint256 _maxBaseBounty,
        uint256 _bountyBooster
    ) external initializer {
        require(_rdnt != address(0));
        require(_weth != address(0));
        require(_lpMfd != address(0));
        require(_mfd != address(0));
        require(_chef != address(0));
        require(_priceProvider != address(0));
        require(_eligibilityDataProvider != address(0));
        require(_hunterShare <= 10000);
        require(_baseBountyUsdTarget != 0);
        require(_maxBaseBounty != 0);
 
        rdnt = _rdnt;
        weth = _weth;
        lpMfd = _lpMfd;
        mfd = _mfd;
        chef = _chef;
        priceProvider = _priceProvider;
        eligibilityDataProvider = _eligibilityDataProvider;
 
        HUNTER_SHARE = _hunterShare;
        baseBountyUsdTarget = _baseBountyUsdTarget;
        bountyBooster = _bountyBooster;
        maxBaseBounty = _maxBaseBounty;
 
        bounties[1] = getLpMfdBounty;
        bounties[2] = getChefBounty;
        bounties[3] = getAutoCompoundBounty;
        bountyCount = 3;
 
        slippageLimit = 10;
        minDLPBalance = uint256(5).mul(10 ** 18);
 
 
        __Ownable_init();
        __Pausable_init();
    } 

Листинг 3.1: BountyManager.sol

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

Рекомендация Контракт должен предоставлять интерфейсы для сброса указателей функций.

3.2 DeFi Security

3.2.1 Потенциальная проблема 2: Некорректный расчет оракула

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 11
Введено в Версии 1 и 4

Описание Функция consult() в контракте ComboOracle используется для вычисления средней цены из нескольких источников. В реализации версии 1 используется среднее арифметическое для расчета итоговой цены, которой можно манипулировать, воздействуя на один из оракулов-источников.

function consult() public view override returns (uint256 price) {
        require(sources.length != 0);

        uint256 sum;
        for (uint256 i = 0; i < sources.length; i++) {
            uint256 price = sources[i].consult();
            require(price != 0, "source consult failure");
            sum = sum.add(price);
        }
        price = sum.div(sources.length);
    }

Листинг 3.2: ComboOracle.sol

В реализации версии 4, когда средняя цена больше, чем минимальная цена × 1,025, будет возвращена минимальная цена. Однако возвращаемым значением все равно можно манипулировать, если результат, возвращенный одним из оракулов-источников, является аномально низким.

/**
    * @notice Рассчитанная цена
    * @return price Средняя цена нескольких источников.
    */
   function consult() public view override returns (uint256 price) {
       require(sources.length != 0);

       uint256 sum;
       uint256 lowestPrice;
       for (uint256 i = 0; i < sources.length; i++) {
           uint256 price = sources[i].consult();
           require(price != 0, "source consult failure");
           if (lowestPrice == 0) {
               lowestPrice = price;
           } else {
               lowestPrice = lowestPrice > price ? price : lowestPrice;
           }
           sum = sum.add(price);
       }
       price = sum.div(sources.length);
       price = price > ((lowestPrice * 1025) / 1000) ? lowestPrice : price;
   }

Листинг 3.3: ComboOracle.sol

Влияние Ценой, возвращаемой ComboOracle, можно манипулировать, что позволяет злоумышленнику получить от этого прибыль.

Рекомендация Мы предлагаем использовать медианное значение вместо среднего. Если имеются только два оракула-источника и возникает значительная разница, разумнее отменить транзакцию, когда средняя цена значительно больше минимальной.

Комментарий Будет использоваться только два оракула-источника. Если возникнет значительная разница, мы будем использовать OZ Defender Sentinel для приостановки соответствующих контрактов.

Примечание Контракт ComboOracle удален и больше не используется.

3.2.2 Потенциальная проблема 3: Потенциальный слив средств через BaseBounty

Элемент Описание
Серьезность Высокая
Статус Исправлено в версии 4
Введено в Версии 1

Описание Пользователь может заблокировать токены (например, RDNT) на фиксированный срок, чтобы получать вознаграждения. По истечении срока блокировки другие пользователи могут вызвать функцию executeBounty() для повторной блокировки токенов этого пользователя, чтобы получить BaseBounty, если этот пользователь включил AutoRelock. В процессе повторной блокировки истекшие блокировки будут очищены и снова размещены (restaked) в пуле во внутренней функции _cleanWithdrawableLocks(). Однако существует переменная maxLockWithdrawPerTxn, ограничивающая максимальное количество блокировок, которые могут быть очищены. В этом случае неотмененные истекшие блокировки могут существовать даже после выполнения функции executeBounty(). Это может дополнительно обойти проверку в строке 106 функции claimBounty() в контракте MFDPlus. Переменная issueBaseBounty будет установлена в true и возвращена.

**
    * @notice Снять все заблокированные токены, время разблокировки которых истекло
    */
   function _cleanWithdrawableLocks(
       address user,
       uint256 totalLock,
       uint256 totalLockWithMultiplier
   ) internal returns (uint256 lockAmount, uint256 lockAmountWithMultiplier) {
       LockedBalance[] storage locks = userLocks[user];

       if (locks.length != 0) {
           uint256 length = locks.length <= maxLockWithdrawPerTxn ? locks.length : maxLockWithdrawPerTxn;
           for (uint256 i = 0; i < length; ) {
               if (locks[i].unlockTime <= block.timestamp) {
                   lockAmount = lockAmount.add(locks[i].amount);
                   lockAmountWithMultiplier = lockAmountWithMultiplier.add(
                       locks[i].amount.mul(locks[i].multiplier)
                   );
                   locks[i] = locks[locks.length - 1];
                   locks.pop();
                   length = length - 1;
               } else {
                   i = i + 1;
               }
           }
           if (locks.length == 0) {
               lockAmount = totalLock;
               lockAmountWithMultiplier = totalLockWithMultiplier;
               delete userLocks[user];

               userlist.removeFromList(user);
           }
       }
   }

Листинг 3.4: MultiFeeDistribution.sol

В частности, злоумышленник может поставить на кон 1 wei токена с одинаковым временем истечения много раз, что значительно превышает maxLockWithdrawPerTxn. После этого злоумышленник может установить действие как getLpMfdBounty и многократно вызывать executeBounty(). Поскольку количество очищенных блокировок ограничено maxLockWithdrawPerTxn, злоумышленник может выкачать BaseBounty из контракта BountyManager.

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

Рекомендация Убедитесь, что функция _cleanWithdrawableLocks() может очистить все истекшие блокировки и установите минимальную сумму депозита в функции _stake().

3.2.3 Потенциальная проблема 4: Потенциально недействительные графики эмиссии

Элемент Описание
Серьезность Низкая
Статус Исправлено в версии 10
Введено в Версии 1

Описание В контракте ChefIncentivesController функция setEmissionSchedule() вызывается владельцем для установки графиков различных ставок вознаграждения. В этом случае время начала для каждого графика (_startTimeOffsets[i] + startTime) должно быть проверено на соответствие условию, что оно больше текущего временного штампа. Однако проверяется только первый элемент в _startTimeOffsets, чего недостаточно. Более того, _startTimeOffsets[i] преобразуется из uint256 в uint128 при добавлении в emissionSchedule, что может привести к усечению, если исходный вход слишком велик.

function setEmissionSchedule(
        uint256[] calldata _startTimeOffsets,
        uint256[] calldata _rewardsPerSecond
    ) external onlyOwner {
        uint256 length = _startTimeOffsets.length;
        require(length > 0 && length == _rewardsPerSecond.length, "empty or mismatch params");
        if (startTime > 0) {
            require(_startTimeOffsets[0] > block.timestamp.sub(startTime), "invalid start time");
        }
 
        for (uint256 i = 0; i < length; i++) {
            emissionSchedule.push(
                EmissionPoint({
                    startTimeOffset: uint128(_startTimeOffsets[i]),
                    rewardsPerSecond: uint128(_rewardsPerSecond[i])
                })
            );
        }
        emit EmissionScheduleAppended(_startTimeOffsets, _rewardsPerSecond);
    } 

Листинг 3.5: ChefIncentivesController.sol

Влияние Если _startTimeOffsets не в порядке возрастания, некоторые обещанные вознаграждения не будут распределены пользователям. Если _startTimeOffsets[i] выходит за пределы диапазона uint128, будет добавлен недействительный график эмиссии.

Рекомендация Убедитесь, что _startTimeOffsets расположены в порядке возрастания и все элементы находятся в диапазоне uint128.

3.2.4 Потенциальная проблема 5: Пропускаемые графики эмиссии

Элемент Описание
Серьезность Низкая
Статус Подтверждено
Введено в Версии 1

Описание В контракте ChefIncentivesController функция setScheduleRewardsPerSecond() будет итерировать emissionSchedule, чтобы найти целевой график с наибольшим индексом, который уже начался, и соответствующим образом обновить ставку вознаграждения. Однако в этом случае некоторые графики эмиссии могут быть пропущены.

function setScheduledRewardsPerSecond() internal {
		if (!persistRewardsPerSecond) {
			uint256 length = emissionSchedule.length;
			uint256 i = emissionScheduleIndex;
			uint128 offset = uint128(block.timestamp.sub(startTime));
			for (; i < length && offset >= emissionSchedule[i].startTimeOffset; i++) {}
			if (i > emissionScheduleIndex) {
				emissionScheduleIndex = i;
				_massUpdatePools();
				rewardsPerSecond = uint256(emissionSchedule[i - 1].rewardsPerSecond);
			}
		}
	}

Листинг 3.6: ChefIncentivesController.sol

Влияние Если функция setScheduledRewardsPerSecond() не вызывается долгое время, некоторые обещанные вознаграждения могут не быть распределены пользователям.

Рекомендация Функция setScheduledRewardsPerSecond() вызывается внутри функции claim() и _handleActionAfterForToken(), поэтому единственный способ пропустить графики эмиссий — это отсутствие взаимодействия пользователей с протоколом во время эпохи эмиссий.

3.2.5 Потенциальная проблема 6: Изменяемый обменный курс во время миграции

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 5
Введено в Версии 1

Описание Контракт Migration реализован для обмена пользователями токенов V1 на токены V2 по заданному обменному курсу exchangeRate. Однако в процессе миграции этот параметр exchangeRate все еще может быть изменен владельцем через функцию setExchangeRate().

/**
    * @notice Миграция с V1 на V2
    * @param amount количество токенов V1
    */
   function exchange(uint256 amount) external whenNotPaused {
       uint256 v1Decimals = tokenV1.decimals();
       uint256 v2Decimals = tokenV2.decimals();

       uint256 outAmount = amount.mul(1e4).div(exchangeRate).mul(10**v2Decimals).div(10**v1Decimals);
       tokenV1.safeTransferFrom(_msgSender(), address(this), amount);
       tokenV2.safeTransfer(_msgSender(), outAmount);

       emit Migrate(_msgSender(), amount, outAmount);
   }

Листинг 3.7: Migration.sol

Влияние Это будет несправедливо по отношению к другим пользователям, если exchangeRate будет изменен в процессе миграции.

Рекомендация Как только начинается миграция, exchangeRate должен быть зафиксирован.

3.2.6 Потенциальная проблема 7: Некорректная реализация _transfer() (I)

Элемент Описание
Серьезность Высокая
Статус Исправлено в версии 7
Введено в Версии 1

Описание В контракте IncentivizedERC20 функция _transfer() не учитывает ситуацию, когда отправитель и получатель могут быть одним и тем же аккаунтом (так называемый «самоперевод»). В частности, если отправитель равен получателю, баланс отправителя будет перезаписан при обновлении баланса получателя. В этом случае хакер может бесконечно увеличивать свой собственный баланс, многократно отправляя перевод на свой собственный аккаунт.

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: перевод с нулевого адреса');
        require(recipient != address(0), 'ERC20: перевод на нулевой адрес');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: сумма перевода превышает баланс');
        uint256 recipientBalance = _balances[recipient].add(amount);
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

Листинг 3.8: IncentivizedERC20.sol

Влияние Токены могут быть выпущены бесконечно.

Рекомендация Реализуйте функцию _transfer() должным образом. Например, как стандартную реализацию _transfer() ERC20 в OpenZeppelin.

_balances[sender] = _balances[sender].sub(amount, 'ERC20: сумма перевода превышает баланс');
_balances[recipient] = _balances[recipient].add(amount);

Листинг 3.9: ERC20.sol в OpenZeppelin

3.2.7 Потенциальная проблема 8: Отсутствие проверки периода в UniV2TwapOracle

Элемент Описание
Серьезность Низкая
Статус Исправлено в версии 9
Введено в Версии 1

Описание В контракте UniV2TwapOracle атрибут _period не проходит проверку в функциях initialize() и setPeriod().

function initialize(
        address _pair,
        address _rdnt,
        address _ethChainlinkFeed,
        uint _period,
        uint _consultLeniency,
        bool _allowStaleConsults
    ) external initializer {
        __Ownable_init();

        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
        price0CumulativeLast = pair.price0CumulativeLast(); // Получить текущее накопленное значение цены (1 / 0)
        price1CumulativeLast = pair.price1CumulativeLast(); // Получить текущее накопленное значение цены (0 / 1)
        uint112 reserve0;
        uint112 reserve1;
        (reserve0, reserve1, blockTimestampLast) = pair.getReserves();
        require(reserve0 != 0 && reserve1 != 0, 'UniswapPairOracle: NO_RESERVES'); // Убедиться в наличии ликвидности в паре

        PERIOD = _period;
        CONSULT_LENIENCY = _consultLeniency;
        ALLOW_STALE_CONSULTS = _allowStaleConsults;

        baseInitialize(_rdnt, _ethChainlinkFeed);
    }

    function setPeriod(uint _period) external onlyOwner {
        PERIOD = _period;
    }

Листинг 3.10: UniV2TwapOracle.sol

Влияние В этом случае оракул может вернуть неожиданное значение, если _period слишком мал.

Рекомендация Установите минимальный предел для _period в функциях initialize и setPeriod.

3.2.8 Потенциальная проблема 9: Невозвратные «пылевые» токены

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 5
Введено в Версии 1

Описание В контракте UniswapPoolHelper функция zapWETH() предназначена для того, чтобы помочь пользователю конвертировать токены WETH в LP-токены. Она вызывает функцию addLiquidityWETHOnly() для добавления ликвидности в пул для LP-токенов. В этом процессе могут оставаться «пылевые» токены, которые должны быть возвращены пользователям. Однако UniswapPoolHelper не реализует такую функциональность для обработки этих токенов.

function zapWETH(uint256 amount)
    public
    returns (uint256 liquidity)
{
    IWETH WETH = IWETH(wethAddr);
    WETH.transferFrom(msg.sender, address(liquidityZap), amount);
    liquidity = liquidityZap.addLiquidityWETHOnly(amount, address(this));
    IERC20 lp = IERC20(lpTokenAddr);
    
    liquidity = lp.balanceOf(address(this));
    lp.safeTransfer(msg.sender, liquidity);
}

Листинг 3.11: UniswapPoolHelper.sol

Влияние «Пылевые» токены останутся в контракте, их сможет извлечь кто угодно через функцию zapTokens(0,0).

Рекомендация Реализуйте функцию возврата «пылевых» токенов после добавления ликвидности.

3.2.9 Потенциальная проблема 10: Некорректная реализация _transfer() (II)

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 9
Введено в Версии 7

Описание В контракте IncentivizedERC20 функция _transfer() вызывает функцию handle_ActionAfter() для обновления статуса пользователя в контракте ChefIncentivesController. Однако параметр senderBalance не будет обновлен, если отправитель равен получателю, что является ошибкой.

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: перевод с нулевого адреса');
        require(recipient != address(0), 'ERC20: перевод на нулевой адрес');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: сумма перевода превышает баланс');
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        uint256 recipientBalance = _balances[recipient].add(amount);
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

Листинг 3.12: IncentivizedERC20.sol

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

Рекомендация Исправьте senderBalance в функции handleActionAfter().

3.2.10 Потенциальная проблема 11: Манипулируемые вознаграждения за компаундинг

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 10
Введено в Версии 5

Описание В контракте MFDPlus функция _convertPendingRewardsToWeth() меняет вознаграждения пользователя на WETH через роутер Uniswap для повторной блокировки. Однако после обмена отсутствует проверка на проскальзывание.

IERC20(underlying).safeApprove(uniRouter, removedAmount);
    uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // проскальзывание обрабатывается после этой функции
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

Листинг 3.13: MFDPlus.sol

Влияние Злоумышленник может опередить транзакцию (front-run), чтобы манипулировать ценой и получить прибыль.

Рекомендация Добавьте проверку проскальзывания в функцию claimCompound().

3.2.11 Потенциальная проблема 12: Отсутствие контроля доступа в setLeverager()

Элемент Описание
Серьезность Средняя
Статус Исправлено в версии 9
Введено в Версии 1

Описание Функция setLeverager() в контракте LendingPool не имеет контроля доступа.

uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // проскальзывание обрабатывается после этой функции
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

Листинг 3.14: LendingPool.sol

Влияние Если рычаг (leverager) не был установлен изначально, злоумышленник мог бы установить его на любой адрес, тем самым получив контроль над логикой функции depositWithAutoDLP().

Рекомендация Установите leverager в функции initialize() или добавьте контроль доступа для функции setLeverager().

3.2.12 Потенциальная проблема 13: Отсутствие проверки проскальзывания в addLiquidityWETHOnly()

Элемент Описание
Серьезность Средняя
Статус Подтверждено
Введено в Версии 1

Описание Пользователь может использовать либо заемные токены WETH (или свои собственные токены ETH), либо вестинг-токены RDNT в контрактах MFD для получения LP-токенов (т.е. WETH-RDNT).

Однако при добавлении ликвидности в пул расчет необходимых токенов основан на количестве резервов в пуле, что можно манипулировать. В этом случае, если у пользователя есть только токены WETH, будет вызываться функция addLiquidityWETHOnly() для обмена половины токенов WETH на токены RDNT в несбалансированном пуле без проверки проскальзывания.

function addLiquidityWETHOnly(uint256 _amount, address payable to)
    public
    returns (uint256 liquidity)
{
    require(to != address(0), "LiquidityZAP: Неверный адрес");
    uint256 buyAmount = _amount.div(2);
    require(buyAmount > 0, "LiquidityZAP: Недостаточно ETH");

    (uint256 reserveWeth, uint256 reserveTokens) = getPairReserves();
    uint256 outTokens = UniswapV2Library.getAmountOut(
        buyAmount,
        reserveWeth,
        reserveTokens
    );

    _WETH.transfer(_tokenWETHPair, buyAmount);

    (address token0, address token1) = UniswapV2Library.sortTokens(
        address(_WETH),
        _token
    );
    IUniswapV2Pair(_tokenWETHPair).swap(
        _token == token0 ? outTokens : 0,
        _token == token1 ? outTokens : 0,
        address(this),
        ""
    );

    return _addLiquidity(outTokens, buyAmount, to);
}

Листинг 3.15: LiquidityZap.sol

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
       require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
       require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
       uint amountInWithFee = amountIn.mul(997);
       uint numerator = amountInWithFee.mul(reserveOut);
       uint denominator = reserveIn.mul(1000).add(amountInWithFee);
       amountOut = numerator / denominator;
   }  

Листинг 3.16: UniswapV2Library.sol

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

Рекомендация Проверяйте проскальзывание в функции addLiquidityWETHOnly() или убедитесь, что ее может вызывать только UniswapPoolHelper.

3.2.13 Потенциальная проблема 14: Отсутствие проверки borrowRatio в loopETH()

Элемент Описание
Серьезность Низкая
Статус Исправлено в версии 10
Введено в Версии 1

Описание Функция loopETH() используется для заимствования с кредитным плечом и получает параметр borrowRatio для указания коэффициента заимствования. Однако borrowRatio не проверяется перед началом цикла.

function loopETH(
        uint256 interestRateMode,
        uint256 borrowRatio,
        uint256 loopCount
    ) external payable {
        uint16 referralCode = 0;
        uint256 amount = msg.value;
        if (IERC20(address(weth)).allowance(address(this), address(lendingPool)) == 0) {
            IERC20(address(weth)).safeApprove(address(lendingPool), type(uint256).max);
        }
        if (IERC20(address(weth)).allowance(address(this), address(treasury)) == 0) {
            IERC20(address(weth)).safeApprove(treasury, type(uint256).max);
        }

        uint256 fee = amount.mul(feePercent).div(RATIO_DIVISOR);
        _safeTransferETH(treasury, fee);
        
        amount = amount.sub(fee);

        weth.deposit{value: amount}();
        lendingPool.deposit(address(weth), amount, msg.sender, referralCode);

        for (uint256 i = 0; i < loopCount; i += 1) {
            amount = amount.mul(borrowRatio).div(RATIO_DIVISOR);
            lendingPool.borrow(address(weth), amount, interestRateMode, referralCode, msg.sender);
            weth.withdraw(amount);

            fee = amount.mul(feePercent).div(RATIO_DIVISOR);
            _safeTransferETH(treasury, fee);

            weth.deposit{value: amount.sub(fee)}();
            lendingPool.deposit(address(weth), amount.sub(fee), msg.sender, referralCode);
        }

        zapWETHWithBorrow(wethToZap(msg.sender), msg.sender);
    }

Листинг 3.17: Leverager.sol

Влияние borrowRatio может оказаться выше RATIO_DIVISOR, что не соответствует исходному проекту.

Рекомендация Убедитесь, что borrowRatio меньше или равно RATIO_DIVISOR.

3.2.14 Потенциальная проблема 15: Отсутствие проверки длины между assets и poolIDs в setPoolIDs()

Элемент Описание
Серьезность Низкая
Статус Исправлено в версии 10
Введено в Версии 1

Описание Функция setPoolIDs() позволяет владельцу устанавливать различные poolIDs для различных активов. Однако длины этих двух массивов не проверяются на равенство.

// Установить идентификаторы пулов для активов
    function setPoolIDs(address[] memory assets, uint256[] memory poolIDs) external onlyOwner {
        for (uint256 i = 0; i < assets.length; i += 1) {
            poolIdPerChain[assets[i]] = poolIDs[i];
        }
        emit PoolIDsUpdated(assets, poolIDs);
    } 

Листинг 3.18: StarBorrow.sol

Влияние Активы не будут назначены правильным poolID.

Рекомендация Убедитесь, что длины assets и poolIDs равны.

3.2.15 Потенциальная проблема 16: Отсутствие отзыва привилегии mint в addBountyContract()

Элемент Описание
Серьезность Низкая
Статус Подтверждено
Введено в Версии 1

Описание Функция addBountyContract() используется для установки нового BountyManager. Однако исходный контракт вознаграждений до сих пор сохраняет привилегию выпуска (mint), что противоречит исходному проекту.

function addBountyContract(address _bounty) external onlyOwner {
       BountyManager = _bounty;
       minters[_bounty] = true;
   }

Листинг 3.19: Leverager.sol

Влияние Устаревший BountyManager все еще имеет привилегии выпуска.

Рекомендация Отзовите привилегию выпуска исходного контракта BountyManager.

Комментарий Функция addBountyContract будет вызвана только один раз для инициализации BountyManager.

3.2.16 Потенциальная проблема 17: Майнеры могут быть назначены только один раз

Элемент Описание
Серьезность Низкая
Статус Подтверждено
Введено в Версии 1

Описание minters используется для записи тех, кто имеет разрешение на доступ к функции mint() и addReward(). Однако, когда один из майнеров (например, контракт ChefIncentivesController) обновляется, устаревшие майнеры не могут быть удалены.

function setMinters(address[] memory _minters) external onlyOwner {
        require(!mintersAreSet);
        for (uint256 i; i < _minters.length; i++) {
            minters[_minters[i]] = true;
        }
        mintersAreSet = true;
    }

Листинг 3.20: MultiFeeDistribution.sol

Влияние Устаревшие майнеры не могут быть удалены при их обновлении.

Рекомендация Реализуйте привилегированную функцию для изменения майнеров.

Комментарий Поскольку BountyManager, ChefIncentivesController и MultiFeeDistribution будут обновляемыми, майнеры всегда сохраняют один и тот же адрес прокси.

3.3 Дополнительная рекомендация

3.3.1 Потенциальная проблема 18: Оптимизация газа (zapVestingToLp() в Mfd)

Элемент Описание
Статус Исправлено в версии 10
Введено в Версии 1

Описание Функцию zapVestingToLp() может вызвать только контракт LockZap для вывода заблокированного дохода пользователя. Она итерирует массив earnings пользователя, начиная с индекса 0, и проверяет, превышает ли unlockTime текущий временной штамп. Если да, то этот доход будет удален из массива и переведен. Однако, поскольку unlockTime в массиве увеличивается с индексом, было бы эффективнее начинать итерацию с конца массива. Если unlockTime меньше текущего временного штампа, цикл можно прервать.

function zapVestingToLp(address _user)
        external
        override
        returns (uint256 zapped)
    {
        require(msg.sender == lockZap);

        LockedBalance[] storage earnings = userEarnings[_user];
        uint256 length = earnings.length;

        for (uint256 i = 0; i < length; ) {
            // только вестинг, поэтому смотрим только на заблокированные элементы
            if (earnings[i].unlockTime > block.timestamp) {
                zapped = zapped.add(earnings[i].amount);
                // удалить + сдвинуть размер массива
                earnings[i] = earnings[earnings.length - 1];
                earnings.pop();
                length = length.sub(1);
            } else {
                i = i.add(1);
            }
        }

        rdntToken.safeTransfer(lockZap, zapped);

        Balances storage bal = balances[_user];
        bal.earned = bal.earned.sub(zapped);
        bal.total = bal.total.sub(zapped);

        return zapped;
    }

Листинг 3.21: MultiFeeDistribution.sol

Рекомендация Начинайте итерацию с конца earnings. Если unlockTime меньше текущего временного штампа, цикл можно прервать.

3.3.2 Потенциальная проблема 19: Непустой резерв вознаграждений в BountyManager

Элемент Описание
Статус Исправлено в версии 10
Введено в Версии 1

Описание В функции _sendBounty(), если в контракте BountyManager недостаточно токенов RDNT для перевода, возникнет событие BountyReseveEmpty() и контракт будет приостановлен. Однако возможно, что осталось еще немного токенов RDNT, что не соответствует возникшему событию.

function _sendBounty(address _to, uint256 _amount)
		internal
		returns (uint256)
	{
		if (_amount == 0) {
			return 0;
		}

		uint256 bountyReserve = IERC20(rdnt).balanceOf(address(this));
		if(_amount > bountyReserve) {
			emit BountyReserveEmpty(bountyReserve);
			_pause();
		} else {
			IERC20(rdnt).safeTransfer(address(mfd), _amount);
			IMFDPlus(mfd).mint(_to, _amount, true);
			return _amount;
		}
	}

Листинг 3.22: BountyManager.sol

Рекомендация Переведите оставшиеся токены RDNT, даже если их недостаточно.

3.3.3 Потенциальная проблема 20: Несогласованное именование в requiredUsdValue()

Элемент Описание
Статус Подтверждено
Введено в Версии 1

Описание Функция requiredUsdValue() используется для проверки необходимой заблокированной стоимости пользователя, который хочет получить право на вознаграждения, владея RTokens. Расчет основан на стоимости обеспечения пользователя, которая возвращается функцией getUserAccountData(). Однако возвращаемое значение называется totalCollateralETH, что не согласуется с таковым в функции requiredUsdValue() (т.е. totalCollateralUSD).

Рекомендация Стандартизируйте соглашения об именовании функций с правильным именем токена. Например, переименуйте requiredUsdValue() в requiredEthValue().

Комментарий Мы предпочли сохранить контракты AAVE максимально похожими, поэтому не стали обновлять имя.

3.4 Примечания

3.4.1 Потенциальная проблема 21: Устаревший MFDPlus

Элемент Описание
Статус Подтверждено
Введено в версии 10

Описание Контракт MFDPlus больше не используется. Логика компаундинга перемещена в контракт AutoCompounder, а другая логика — в контракт MiddleFeeDistribution.

4. Приложение

4.1 Результаты автоматизированного статического тестирования безопасности

Таблица 4.1: Результаты автоматизированного статического тестирования. «Found» указывает количество проблем, сообщенных инструментами. «FP» означает количество ложных срабатываний после нашей ручной проверки.

ID Детектор Описание Влияние Found FP Результат
1 arbitrary-send-erc20 Вызов transferFrom с произвольным from High 1 1 Пройдено
... ... ... ... ... ... ...

(Примечание: полная таблица опущена для краткости перевода, сохраняется структура)

4.2 Результаты автоматизированного динамического тестирования безопасности

Таблица 4.2: Протестированные свойства для логики Lending

ID Свойство Результат
1 Вызов deposit никогда не приводит к уменьшению суммы RToken для onBehalfOf Пройдено
... ... ...

5 Уведомления и замечания

5.1 Отказ от ответственности

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

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

Объем этого тестирования безопасности ограничен кодом, упомянутым в Разделе 1.2. Если явно не указано иное, безопасность самого языка (например, Solidity), базовой цепочки инструментов компиляции и вычислительной инфраструктуры не входит в объем.

5.2 Процедура аудита

Мы проводим аудит в соответствии со следующей процедурой.

  • Обнаружение уязвимостей: Мы сначала сканируем смарт-контракты автоматическими анализаторами кода, а затем вручную проверяем (отклоняем или подтверждаем) проблемы, о которых они сообщают.

  • Семантический анализ: Мы изучаем бизнес-логику смарт-контрактов и проводим дальнейшее исследование возможных уязвимостей с использованием автоматического инструмента фаззинга (разработанного нашей исследовательской группой). Мы также вручную анализируем возможные сценарии атак с независимыми аудиторами для перекрестной проверки результата.

  • Рекомендации: Мы предоставляем разработчикам полезные советы с точки зрения хорошей практики программирования, включая оптимизацию газа, стиль кода и т.д.

Мы показываем основные конкретные контрольные точки ниже.

(Перечень контрольных точек сохранен в оригинальной структуре)

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit