Обновлено 14 июня 2024 г.: Член сообщества внимательно изучил этот блог и предоставил информацию об инциденте с ADU, который представляет собой новую форму, не охваченную нашей предыдущей классификацией. Спасибо, мы приветствуем любые содержательные отзывы!
Для повышения стабильности рынка токены отражения (также известные как токены вознаграждения) создаются, чтобы предложить инвесторам дополнительный способ получения дохода. Это побуждает инвесторов держать свои токены вместо того, чтобы торговать ими. Во время печально известного сезона мем-коинов 2021 года «токены отражения» стали незаменимым механизмом, быстро завоевавшим внимание рынка после запуска на таких платформах, как DxSale (например, SafeMoon V1).
Несмотря на то, что к 2023 году ажиотаж утих, а рынок остыл, наша система обнаружила десятки тысяч хакерских инцидентов, эксплуатирующих такие механизмы токенов в реальных условиях. Эти атаки в стиле «жнеца» (reaper-style), хотя и относительно небольшие по масштабу суммы по сравнению с другими типами DeFi-атак, привели к значительным потерям активов пользователей.
В этом блоге мы сосредоточимся на обмене идеями по безопасности, полученными в результате наших исследований. В частности, мы сначала кратко представим механизм токена отражения. После этого мы рассмотрим инциденты безопасности, связанные с токенами отражения, уделив особое внимание тем, которые эксплуатируют механизм токена отражения. Затем мы обсудим потенциальную проблему безопасности с теоретической точки зрения. Наконец, мы поделимся некоторыми мыслями о мерах по смягчению последствий и решениях.
0x1 Механизм токена отражения
Насколько нам известно, этот механизм был впервые представлен компанией Reflect Finance и разработан для распределения процента от суммы транзакции в качестве комиссии всем держателям токенов нетразакционным способом. В марте 2021 года на блокчейне BNB был выпущен известный проект SafeMoon V1, что способствовало дальнейшей популяризации токенов отражения.
0x1.1 Основные концепции
Прежде чем углубляться в детали, стоит представить некоторые фундаментальные концепции для лучшего понимания.
Существует два вида пространств: r-пространство и t-пространство, которые читаются как «отраженное пространство» (reflected space) и «истинное пространство» (true space) соответственно. Криптовалюты в этих двух пространствах имеют обменные курсы, основанные на относительном объеме обращения. Кроме того, валюта в r-пространстве является дефляционной, что означает, что при каждой транзакции определенный процент будет сжигаться, и в результате сжигаемая сумма вычитается из объема обращения.
Представьте, что Алиса, Боб и Ева могут совершать транзакции в обоих пространствах, как показано на рисунке ниже. Если Алиса и Боб совершают парные переводы в t-пространстве, Ева не получит никаких вознаграждений. Однако, если все трое сначала конвертируют свои токены в r-пространство, а затем позволят Алисе и Бобу переводить токены друг другу, Ева в конечном итоге получит пассивный доход, конвертировав свои токены обратно в t-пространство. Это и есть базовая идея механизма токена отражения.
Обратите внимание, что не все аккаунты, такие как пул ликвидности токена, способны торговать в r-пространстве, т.е. определенные аккаунты должны быть исключены из r-пространства.
0x1.2 Объяснение на уровне контракта
Теперь давайте углубимся в контракт REFLECT от Reflect Finance, чтобы изучить этот механизм.
Этот контракт сначала определяет несколько переменных для управления аккаунтами:
mapping (address => uint256) private _rOwned; // отраженный токен, удерживаемый пользователем
mapping (address => uint256) private _tOwned; // истинный токен, удерживаемый пользователем
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // исключен ли пользователь из r-пространства
address[] private _excluded; // аккаунты, исключенные из r-пространства
Затем он определяет основные константы контракта. Можно заметить, что _rTotal устанавливается как определенное кратное _tTotal (т.е. totalSupply токена, который используется в качестве возвращаемого значения функции totalSupply):
uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));
С точки зрения функциональности и взаимодействия с пользователем, функции этого контракта делятся на следующие три категории: запрос баланса и перевод токенов, а также характерная функция reflect. Первые две совместимы со стандартом ERC-20, однако внутренняя логика отличается от других токенов ERC-20. Каждая из этих функций будет подробно описана ниже.
0x1.2.1 Функции для запроса баланса
Расчет баланса отличается для исключенных и неисключенных пользователей:
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Сумма должна быть меньше общего объема отражений");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Это можно выразить следующей формулой:

Курс (rate) в формуле выше рассчитывается путем вызова функции _getRate, которая фактически вычисляется на основе возвращаемого значения функции _getCurrentSupply внутри контракта.
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
Нетрудно вывести соответствующую формулу из приведенного выше фрагмента кода:

0x1.2.2 Функции для перевода токенов
В целом, существует четыре сценария перевода активов:
function _transfer(address sender, address recipient, uint256 amount) private {
require(sender != address(0), "ERC20: перевод с нулевого адреса");
require(recipient != address(0), "ERC20: перевод на нулевой адрес");
require(amount > 0, "Сумма перевода должна быть больше нуля");
if (_isExcluded[sender] && !_isExcluded[recipient]) {
_transferFromExcluded(sender, recipient, amount); // t-пространство -> r-пространство
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r-пространство -> t-пространство
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r-пространство -> r-пространство
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t-пространство -> t-пространство
} else {
_transferStandard(sender, recipient, amount); // r-пространство -> r-пространство
}
}
Для исключенных аккаунтов как _rOwned, так и _tOwned должны быть добавлены или вычтены из соответствующего пространства. Для неисключенных аккаунтов учитывается только _rOwned. Например, фрагмент кода ниже показывает реализацию передачи активов из r-пространства в t-пространство, где sender — неисключенный аккаунт, а recipient — исключенный аккаунт.
function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
-
Функция
_getValuesвычисляет соответствующую сумму, сумму перевода и комиссию (т.е. сумма = сумма перевода + комиссия) для обоих пространств.function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) { (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount); uint256 currentRate = _getRate(); (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate); return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee); } -
Функция
_reflectFeeотражает комиссию в r-пространстве. В частности,_rTotalуменьшается на сумму комиссии в r-пространстве (т.е.rFee), что, в свою очередь, снижает курс. Согласно формуле расчета balanceOf(user), комиссии таким образом отражаются на всех неисключенных держателях токенов.function _reflectFee(uint256 rFee, uint256 tFee) private { _rTotal = _rTotal.sub(rFee); _tFeeTotal = _tFeeTotal.add(tFee); }
0x1.2.3 Функция reflect
В дополнение к пассивному запуску механизма токена отражения во время процесса перевода, пользователи могут активно вызывать функцию reflect для запуска этого механизма. В частности, если пользователь расходует свои токены для вызова этой функции, курс будет снижаться по мере уменьшения rSupply, тем самым принося пользу другим держателям токенов. Другими словами, принося себя в жертву ради других. Делая это, администраторы проекта могут стимулировать держателей токенов с помощью использования этой функции.
function reflect(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Исключенные адреса не могут вызывать эту функцию");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
Приведенный выше код формирует ядро механизма. Разные токены могут включать дополнительные специфические функции для настройки своей реализации. Например, некоторые могут использовать комиссии за транзакции для питания функции «swap and liquify» (обмен и обеспечение ликвидности), чтобы предотвратить панику, когда «киты» решают продать свои токены.
0x2 Постфактум о взломанных токенах отражения
Как упоминалось ранее, наше основное внимание сосредоточено на атаках, использующих механизм токена отражения. Поэтому инциденты, не связанные с этим механизмом, такие как атака на SafeMoon V2 (распространенная проблема публичного сжигания ERC20) и недавняя атака на ZongZi (связанная с манипуляцией ценой старого типа, использующей спотовую цену), здесь не рассматриваются.
Мы провели углубленный анализ этих атак, чтобы выявить их первопричины. Вы можете обратиться сюда за списком всех этих инцидентов. Мы обнаружили, что большинство из них — это нормальные инциденты безопасности, вызванные либо уязвимостями в коде, либо ненадлежащими административными действиями. Однако некоторые из них довольно подозрительны (например, наличие бэкдора), что мы называем аномальными инцидентами безопасности. В следующих подразделах мы сначала представим нормальные инциденты безопасности, а затем подробно рассмотрим аномальные.
0x2.1 Нормальные инциденты безопасности
Наше расследование предполагает, что эти инциденты вызваны двумя типами проблем: проблемами на уровне кода и проблемами на уровне операций, индивидуально или в сочетании.
-
Проблемы на уровне кода. Это возникает из-за плохой реализации контракта, вероятно, из-за того, что разработчики не полностью понимают механизм токена отражения, что приводит к несоответствию между фактическим предложением токенов и записанным значением
totalSupply, которое можно использовать для манипулирования курсом:-
1.1 Сжигание с нулевой стоимостью
-
1.2 Дополнительное вычитание из
rSupplyво время перевода токенов -
1.3 Путаница между значениями r-пространства и t-пространства (с потерей точности для получения прибыли)
-
-
Проблемы на уровне операций. Это результат ненадлежащей эксплуатации администраторами. В частности, в этих инцидентах речь идет о неправильной конфигурации адресов пар AMM, которые не были должным образом исключены.
Стоит отметить, что ВСЕ перечисленные проблемы на уровне кода могут привести к несоответствиям между фактическим предложением и totalSupply, делая контракты уязвимыми. Однако это не обязательно означает, что эти уязвимости являются эксплуатируемыми или, точнее, достаточно прибыльными, чтобы стоило их использовать, так как манипулирование курсом может стоить расходов атакующему. Для простоты далее мы будем использовать термин «эксплуатируемый» в значении «достаточно прибыльный, чтобы стоить эксплуатации». Как результат, в некоторых сценариях необходима проблема на уровне операций, чтобы сделать эти уязвимости эксплуатируемыми.
В частности, проблему 1.1 можно эксплуатировать напрямую, тогда как проблемы 1.2 и 1.3 требуют сочетания с проблемой 2, чтобы стать эксплуатируемыми. Таким образом, обсуждаемые инциденты можно разделить на два типа: Type-I и Type-II, основываясь на этих наблюдениях. Ниже приведена таблица с соответствующими данными:
| Тип | Инцидент(ы) | Первопричина(ы) | # (%) |
|---|---|---|---|
| I | CATOSHI | Только проблема на уровне кода (1.1) | 1 (0.79%) |
| II (a) | BEVO, FETA, ADU | Комбинация обоих (1.2 и 2) | 3 (2.38%) |
| II (b) | SHEEP и 120+ других | Комбинация обоих (1.3 и 2) | 122 (96.83%) |
Таблица показывает, что инциденты Type-II составляют значительную долю. В частности, внутри Type-II есть 2 вариации: Type-II-a (т.е. проблема 1.2 с проблемой 2) и Type-II-b (т.е. проблема 1.3 с проблемой 2). Кроме того, в случае инцидентов типа SHEEP категории Type-II-b, эксплойты предполагают, что атакующие (например, вот этот) могут использовать автоматизированные методы для выявления аналогично уязвимых контрактов. Конкретные детали будут рассмотрены в последующих подразделах.
0x2.1.1 Type-I: Только проблема на уровне кода (Проблема 1.1)
Только один инцидент относится к Type-I, а именно инцидент с CATOSHI — сжигание с нулевой стоимостью, затрагивающее общий объем предложения.
Давайте сначала взглянем на функцию burnOf в контракте CATOSHI:
function burnOf(uint256 tAmount) public {
uint256 currentRate = _getRate();
uint256 rAmount = tAmount.mul(currentRate);
_tTotal = _tTotal.sub(tAmount);
_rTotal = _rTotal.sub(rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
Очевидно, что сумма, сожженная этой функцией, не вычитается из баланса вызывающего (т.е. msg.sender). Однако _rOwned[msg.sender] должен быть уменьшен на rAmount, и если аккаунт исключен, _tOwned[msg.sender] также должен быть уменьшен на tAmount.
Из-за этого упущения атакующие могут сначала сжечь большое количество токенов с нулевыми затратами, а затем вызвать функцию reflect контракта. Поскольку и _tTotal, и _rTotal были значительно и пропорционально уменьшены:

Курс можно легко манипулировать в сторону снижения путем вызова функции reflect, что приводит к существенному увеличению balanceOf(attacker). Это позволяет атакующим извлечь выгоду из раздутого баланса.

Почему? Обратите внимание, что новый баланс атакующего рассчитывается следующим образом:

Отношение между balanceOf(attacker) и balanceOf(attacker)' составляет:

Поскольку

Следовательно

Это означает, что атакующий получает больше токенов, которые можно обменять на ценные токены (в данном случае WETH), чтобы получить прибыль.
0x2.1.2 Type-II-a: Комбинация проблемы 1.2 и проблемы 2
Инциденты Type-II-a включают комбинацию двух проблем:
- Проблема 1.2: Дополнительное вычитание из
rSupplyво время перевода токенов. - Проблема 2: Пара AMM не исключена.
В Type-II-a есть три инцидента атаки, которые можно далее разделить на две подкатегории в соответствии с формами уязвимости в проблеме 1.2:
1. Дополнительное отражение в функции _reflectFee
Два инцидента относятся к этой подкатегории, а именно инцидент с BEVO и инцидент с FETA. Ниже мы будем использовать контракт BEVO для иллюстрации.
Как было представлено в разделе «Функции для перевода токенов» (0x1.2.2), каждый перевод токена запускает отражение части комиссии за транзакцию. В BEVO комиссии делятся на две дополнительные части: burn (сжигание) и charity (благотворительность), помимо оригинальной.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity вычитается из _rTotal
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
Обратите внимание, что благотворительный аккаунт исключен, что означает, что сумма, отправленная на этот аккаунт, сжигается, как показано в функции _sendToCharity.
function _sendToCharity(uint256 tCharity, address sender) private {
uint256 currentRate = _getRate();
uint256 rCharity = tCharity.mul(currentRate);
address currentCharity = _charity[0];
_rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
_tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // так как благотворительный аккаунт исключен, часть для благотворительности сжигается
emit Transfer(sender, currentCharity, tCharity);
}
Из приведенного выше фрагмента кода мы видим, что существует два места для отражения и сжигания части для благотворительности, что приводит к несоответствию фактического предложения токенов totalSupply во время переводов. По мере того, как передается больше токенов, значение rSupply будет меньше, чем предложение токенов в пуле, из-за дополнительного уменьшения.
Чисто теоретическое описание может быть немного абстрактным, поэтому давайте используем пример, чтобы прояснить процесс. Предположим, Алиса хочет перевести Бобу 10 токенов, и 3 токена вычитаются следующим образом: 1 за комиссию, 1 за сжигание и 1 на благотворительность. Поскольку часть для благотворительности одновременно отражается и сжигается, фактическое распределение составляет: 2 токена отражено (1 комиссия + 1 благотворительность) и 2 токена сожжено (1 сжигание + 1 благотворительность). Вместе с оставшимися 7 токенами, которые должны быть переведены Бобу, в этом процессе участвует в общей сложности 11 токенов, что является ошибкой.
Но почему это несоответствие можно использовать для получения прибыли? Ниже мы взмахнем нашей математической волшебной палочкой, чтобы вывести последствия.
Предположим, мы ранее приобрели некоторые токены из пула (т.е. пара PancakeSwap), обозначенные как rAmount в r-пространстве и tAmount в t-пространстве. Поскольку пул не был исключен, давайте обозначим _rOwned[pair] как rReserve, а соответствующее значение в t-пространстве также обозначим как tReserve. Тогда мы имеем:

Из-за дополнительного уменьшения rSupply теперь меньше, чем предложение токенов в пуле:

Вспомните раздел «Функции для запроса баланса» (0x1.2.1), текущий курс можно рассчитать по следующей формуле:

В этот момент, если мы отразим токены, которые удерживаем, через функцию reflect (которая в этом контракте переименована в функцию deliver), курс становится rate':

Так как

Тогда мы имеем

Объединяя формулы 1, 3 и 6, мы можем вывести следующее неравенство:

Это означает, что количество токенов, которые мы можем собрать непосредственно из пула (через функцию skim), даже больше, чем то, что мы доставили, что делает это прибыльным, так как стоимость вызова функции reflect может быть покрыта. После этого атакующий может обменять собранные токены на ценные токены (в данном случае WBNB) для получения прибыли.
Обратите внимание, что контракт BEVO также уязвим для проблемы 1.3, которая не была использована в атаке.
2. Неправильный расчет rTransferAmount в функции _getRValues
Только один инцидент относится к этой форме, а именно инцидент с ADU. Давайте сначала взглянем на приведенный ниже фрагмент кода.
function _transferStandard(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_takeTeam(tTeam);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
(uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
uint256 currentRate = _getRate();
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}
function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
uint256 tFee = tAmount.mul(taxFee).div(100);
uint256 tTeam = tAmount.mul(teamFee).div(100);
uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam вычитается из tAmount
return (tTransferAmount, tFee, tTeam);
}
function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
uint256 rAmount = tAmount.mul(currentRate);
uint256 rFee = tFee.mul(currentRate);
uint256 rTransferAmount = rAmount.sub(rFee); // Однако rTeam не вычитается из rAmount
return (rAmount, rTransferAmount, rFee);
}
Мы видим, что как налоговая комиссия, так и комиссия команды должны вычитаться во время переводов. Однако в функции _getTvalues, tTransferAmount вычитается и на tFee, и на tTeam, тогда как в функции _getRValues вычитается только rFee. Это несоответствие приводит к упомянутой ранее проблеме несоответствия, которая усугубляется по мере совершения новых переводов токенов.
Поскольку пара также не исключена в этом токене, этот токен является эксплуатируемым. Конкретнее, атакующий мог бы использовать эксплойты, подобные BEVO, чтобы получить больше токенов ADU через функцию skim пары после вызова функции deliver.
Однако, учитывая состояние ончейн на тот момент, атакующему было невозможно обменять собранные токены ADU на WBNB, чтобы получить прибыль (из-за оператора require в функции tokenFromReflection). Поэтому атакующему пришлось бы использовать более сложную стратегию эксплуатации для получения прибыли, которая здесь не будет подробно описана.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Сумма должна быть меньше общего объема отражений");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
0x2.1.3 Type-II-b: Комбинация проблемы 1.3 и проблемы 2
Инциденты Type-II-b включают комбинацию двух проблем:
- Проблема 1.3: Путаница между значениями r-пространства и t-пространства (отметим, что также должна присутствовать потеря точности для получения прибыли).
- Проблема 2: Пара AMM не исключена.
Проблема 1.3 возникает из-за неправильного обращения со значениями между r-пространством и t-пространством во время реализации внутренней функции _burn.
function burn(uint256 _value) public {
_burn(msg.sender, _value);
}
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]);
_rOwned[_who] = _rOwned[_who].sub(_value); // _rOwned должен быть уменьшен на значение r-пространства
_tTotal = _tTotal.sub(_value); // _tTotal должен быть уменьшен на значение t-пространства
// Для семантики функции burn, _rTotal также должен быть уменьшен на значение r-пространства.
emit Transfer(_who, address(0), _value);
}
Учитывая основные константы контракта, значение r-пространства, как правило, является большим кратным значения t-пространства. Поэтому вызов функции burn со значением _value, которое имеет тот же порядок величины, что и tSupply, значительно раздует курс.
Однако, в отличие от случаев Type-I, вызывающий не может сжигать токены, сохраняя свой собственный баланс неизменным. Другими словами, атакующему трудно, если не невозможно, собрать больше токенов. Следовательно, как случаи Type-II-b могут быть эксплуатируемыми?
Возьмем в качестве примера инцидент с токеном SHEEP. Стоимость токенов SHEEP, удерживаемых атакующим, можно обозначить как:

Где цену SHEEP можно выразить спотовой ценой в паре PancakeSwap, рассчитанной как:

Тогда Стоимость (Value) можно далее выразить как:

Затем атакующий неоднократно выполняет функцию burn и в конечном итоге синхронизирует пару. Поскольку ни атакующий, ни пара не исключены, их балансы падают из-за раздувания курса, о котором мы упоминали выше. Таким образом, соотношение корректируется до:

Основываясь на предыдущих определениях, мы можем далее выразить эти соотношения как:

Где X представляет сумму сожженных _value.
А вот и магия: последнее соотношение явно меньше прежнего, если мы упростим 8 и 9 далее, что озадачивает нас, потому что прибыль будет отрицательной в этом случае:

На самом деле атакующий воспользовался проблемой потери точности в токене отражения. Для неисключенных пользователей, согласно формуле, которую мы предоставили, расчет баланса на самом деле округляется в меньшую сторону в функции tokenFromReflection. Таким образом, возвращаемое значение запроса balanceOf может быть меньше его теоретического значения. То есть соотношение' может быть больше, чем соотношение, если мы примем во внимание эту проблему.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Сумма должна быть меньше общего объема отражений");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Отлаживая транзакцию атаки, мы можем рассчитать теоретический баланс атакующего и пары до и после этих манипуляций. Результаты наших расчетов представлены в таблице ниже:
Δ в таблице — это чрезвычайно малое значение, намного меньше 1.
Анализируя трассировку внутри функции синхронизации пары, мы можем рассчитать, что теоретические балансы атакующего и пары составляют 27.523 и 2.972 соответственно, что дает соотношение 9.26. Однако из-за потери точности балансы округляются в меньшую сторону до 27 и 2 соответственно, что раздувает соотношение до 13.50. В результате Прибыль становится положительной величиной.
Наконец, атакующий может получить прибыль, выполнив обратный обмен.
0x2.2 Аномальные инциденты безопасности
В этом подразделе мы поделимся нашими выводами из расследования токенов FDP и DBALL. Наш анализ показывает, что менеджеры токенов FDP и DBALL вызывали проблемные привилегированные функции, фактически действуя как бэкдоры, что подвергало проекты риску и в конечном итоге привело к атакам. В частности, в проекте DBALL мы выявили серию подозрительных транзакций владельцем токена, что дает четкие основания считать это rug pull (мошенничеством).
Эксплуатации, направленные на эти два токена, тесно напоминают те, что обсуждались в разделе «Type-II-a: Комбинация проблемы 1.2 и проблемы 2» в 0x2.1.2. Однако при анализе причин, по которым фактическое предложение токенов может отклоняться от totalSupply, всплывают некоторые подозрительные действия.
0x2.2.1 Инцидент с FDP
Расхождение между фактическим предложением токенов и totalSupply в случае FDP обусловлено вызовом функции transferOwnership, привилегированной функции, которую может вызвать только владелец контракта. Как следует из названия, эта функция должна изменять владельца контракта. Однако в контракте FDP эта функция не имеет ничего общего с передачей владения. Вместо этого она увеличивает _rOwned[newOwner], не изменяя totalSupply. Это явно нарушает принципы проектирования нормального процесса выпуска токенов.
function transferOwnership(address newOwner) public virtual onlyOwner {
(, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
_rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);
emit Transfer(address(0), newOwner, tTransferAmount);
}
Транзакции, вызвавшие эту функцию, суммированы в следующей таблице:
| Временная метка | Хэш транзакции | Вызывающий | newOwner |
|---|---|---|---|
| 2021-06-05 17:23:57 | 0x46fa1f97...4606d9bc | 0xef309c...262586 | 0x9e96af...24481a |
| 2021-06-05 17:24:06 | 0x686e0d82...d6ebfb62 | 0xef309c...262586 | 0xb0c426...a72063 |
| 2021-06-05 17:26:12 | 0x44285339...7a526320 | 0xef309c...262586 | 0xef309c...262586 |
| 2021-06-05 19:39:00 | 0xaff7a688...dfe3f344 | 0xef309c...262586 | 0x9e96af...24481a |
| 2022-06-05 18:08:10 | 0x2c413604...f7718f25 | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 18:49:16 | 0x8f4309ca...97d4bcec | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 23:33:44 | 0xaa029544...3ff7b629 | 0xef309c...262586 | 0xef309c...262586 |
0x2.2.2 Инцидент с DBALL
В случае с DBALL все становится сложнее. Используя MetaSleuth для анализа потока средств владельца DBALL, мы наблюдали дисбаланс во входящих и исходящих потоках токенов DBALL на этот адрес, при этом источник средств для этой транзакции не был записан.
Запрашивая исторические состояния в блокчейне, мы наконец идентифицировали, что баланс DBALL владельца изменился до и после этой транзакции. Мы можем наблюдать, что владелец вызвал привилегированную функцию manualDevBurn, чтобы сжечь 1 токен в t-пространстве. Реализация этой функции выглядит следующим образом:
function manualDevBurn (uint256 tAmount) public onlyOwner() {
uint256 rAmount = tAmount.mul(_getRate());
if (_isExcluded[_msgSender()]) {
_tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
}
_rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
_tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
_rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
На первый взгляд, все кажется в порядке. Однако, поскольку контракт указывает версию компилятора ниже 0.8, во время вычитания _rOwned[_msgSender()] происходит арифметическое переполнение (underflow), переходящее от 0 к почти type(uint256).max. Эта тонкая манипуляция позволяет владельцу изменить свой баланс, но она также приводит к несоответствию в предложении токенов.
Является ли это просто случайной ошибкой? Наше расследование предполагает, что это более вероятно преднамеренный rug pull. Причины суммированы следующим образом:
-
Владелец передал всего 1 токен в функцию
manualDevBurn, однако через полчаса количество DBALL, равное общему объему предложения, было переведено на связанный адрес через эту транзакцию. -
Этот связанный адрес немедленно обменял токены в паре PancakeSwap и получил около 56 WBNB.
- Анализ фондовых потоков этих двух адресов показывает, что оба в конечном итоге перевели BNB через Tornado.Cash.
0x3 Потенциальная проблема при расчете курса
Помимо инцидентов, которые мы обсуждали ранее, мы также обнаружили, что теоретически существует потенциальная проблема при расчете баланса неисключенных пользователей, заслуживающая дальнейшего обсуждения. Эта проблема может возникнуть во время расчета курса.
Давайте взглянем на функцию _getCurrentSupply. В этой функции заключительный оператор if определяет, меньше ли rSupply начального курса (т.е. _rTotal / _tTotal).
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
В течение жизненного цикла от развертывания контракта до запуска проекта, если это утверждение истинно, балансы всех неисключенных пользователей будут равны нулю. Однако после запуска проекта и начала транзакций первоначальное намерение оператора if было потеряно.
Поскольку rSupply будет уменьшаться из-за механизма токена отражения, курс будет снижаться соответствующим образом. Если после определенной транзакции rSupply упадет ниже начального курса, текущий курс подскочит, что приведет к убыткам баланса у всех неисключенных держателей токенов. Кроме того, теоретически возможно, что

Это приведет к тому, что курс станет равным нулю из-за потери точности, потенциально вызывая панику при делении на ноль.
0x4 Меры по смягчению и решения
Механизм токена отражения предлагает способ повышения стабильности рынка путем стимулирования инвесторов удерживать свои токены вместо того, чтобы торговать для получения дополнительных вознаграждений. Однако он также создает новые проблемы безопасности и потенциальные риски, такие как путаница между значениями r-пространства и t-пространства. Поэтому для блокчейн-разработчиков и инвесторов крайне важно лучше понять механизм и его потенциальные риски, а также искать решения.
BlockSec предоставляет услуги и продукты по безопасности как для этапа перед, так и после запуска. Наши услуги по аудиту безопасности проводят тщательные проверки для обеспечения безопасности кода и прозрачности. Наш продукт Phalcon предлагает непрерывный мониторинг безопасности и возможности обнаружения атак, позволяя операторам и инвесторам контролировать проекты и предпринимать автоматические действия при обнаружении рисков безопасности.
Рекомендуемое чтение
- Как блокчейны L2 могут лучше защитить своих пользователей
- Топ-10 «потрясающих» инцидентов безопасности в 2023 году
- Полный концептуальный анализ: Рост Биткоина с Inscription
О компании BlockSec
BlockSec — поставщик полностековых услуг безопасности Web3. Компания стремится повысить безопасность и удобство использования для развивающегося мира Web3, чтобы способствовать его массовому внедрению. С этой целью BlockSec предоставляет услуги аудита безопасности смарт-контрактов и EVM-чейнов, платформу Phalcon для разработки безопасности и проактивного блокирования угроз, платформу MetaSleuth для отслеживания фондов и расследований, и расширение MetaSuites для эффективного серфинга криптомира строителями Web3.
На сегодняшний день компания обслужила более 300 клиентов, таких как Uniswap Foundation, Compound, Forta и PancakeSwap, и получила десятки миллионов долларов США в двух раундах финансирования от выдающихся инвесторов, включая Matrix Partners, Vitalbridge Capital и Fenbushi Capital.
-
Веб-сайт: https://blocksec.com/
-
Email: [email protected]
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



