Обновлено 14 июня 2024 г.: участник сообщества внимательно изучил этот блог и предоставил информацию о инциденте с ADU, который представляет собой новую форму, не охваченную нашей предыдущей классификацией. Спасибо, и мы приветствуем любые полезные отзывы!
Для повышения стабильности рынка reflection-токены (также известные как reward-токены) созданы для того, чтобы предоставить инвесторам дополнительный способ получения дохода. Это стимулирует инвесторов удерживать свои токены вместо того, чтобы торговать ими. Во время печально известного сезона мем-коинов 2021 года reflection-токены стали незаменимым механизмом, быстро привлекая внимание рынка после запуска на таких платформах, как DxSale (например, SafeMoon V1).
Несмотря на то, что ажиотаж утих, а рынок остыл в 2023 году, наша система обнаружила десятки тысяч инцидентов взлома, эксплуатирующих подобные механизмы токенов в реальных условиях. Эти атаки в стиле «жнеца», хотя и относительно небольшие по масштабу по сравнению с другими типами атак на DeFi, привели к немалым потерям пользовательских активов.
В этом блоге наше основное внимание сосредоточено на том, чтобы поделиться выводами, связанными с безопасностью, полученными в ходе нашего исследования. В частности, мы сначала дадим краткое введение в механизм reflection-токена. После этого мы рассмотрим инциденты безопасности, связанные с reflection-токенами, уделяя особое внимание тем, которые эксплуатируют механизм reflection-токена. Затем мы теоретически обсудим потенциальную проблему безопасности. Наконец, мы поделимся некоторыми мыслями о смягчении последствий и решениях.
0x1 Механизм reflection-токена
Насколько нам известно, этот механизм был впервые представлен Reflect Finance и разработан для распределения процента от суммы транзакции в виде комиссий среди всех держателей токенов нетранзакционным способом. В марте 2021 года на блокчейне BNB был выпущен известный SafeMoon V1, что сделало reflection-токен еще более популярным.
0x1.1 Основные понятия
Прежде чем углубляться в детали, следует ввести некоторые фундаментальные понятия для лучшего понимания.
Существует два вида пространств: r-пространство и t-пространство, читаемые как reflected space (отраженное пространство) и true space (истинное пространство) соответственно. Криптовалюты этих двух пространств имеют обменные курсы, основанные на относительном объеме обращения. Кроме того, валюта в r-пространстве является дефляционной, то есть при каждой транзакции определенный процент сжигается, и в результате сожженная сумма вычитается из объема обращения.
Предположим, что Алиса, Боб и Ева все способны совершать транзакции в обоих пространствах, как показано на рисунке ниже. Если Алиса и Боб проводят взаимные переводы в t-пространстве, Ева не получит никаких вознаграждений. Однако, если все трое сначала конвертируют свои токены в r-пространство, а затем Алиса и Боб переводят друг другу, Ева в конечном итоге получит пассивный доход, конвертировав свои токены обратно в t-пространство. Это и есть основная идея механизма reflection-токена.
Обратите внимание, что не все аккаунты, например пул ликвидности токена, способны торговать в r-пространстве, т.е. определенные аккаунты должны быть исключены из r-пространства.
0x1.2 Объяснение на уровне контракта
Теперь давайте углубимся в контракт REFLECT от Reflect Finance, чтобы изучить этот механизм.
Этот контракт сначала определяет несколько переменных для управления аккаунтами:
mapping (address => uint256) private _rOwned; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space
Затем он определяет основные константы контракта. Можно заметить, что _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, "Amount must be less than total reflections");
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: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
require(amount > 0, "Transfer amount must be greater than zero");
if (_isExcluded[sender] && !_isExcluded[recipient]) {
_transferFromExcluded(sender, recipient, amount); // t-space -> r-space
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r-space -> t-space
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r-space -> r-space
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t-space -> t-space
} else {
_transferStandard(sender, recipient, amount); // r-space -> r-space
}
}
Для исключенных аккаунтов необходимо добавлять или вычитать значения как из _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вычисляет соответствующую сумму, transferAmount и комиссию (т.е. amount = transferAmount + fee) для обоих пространств.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), что, в свою очередь, снижает rate. Согласно формуле расчета balanceOf(user), комиссии таким образом отражаются на всех не исключенных держателях токенов.function _reflectFee(uint256 rFee, uint256 tFee) private { _rTotal = _rTotal.sub(rFee); _tFeeTotal = _tFeeTotal.add(tFee); }
0x1.2.3 Функция reflect
Помимо пассивного срабатывания механизма reflection-токена во время процесса перевода, пользователи могут активно вызывать функцию reflect, чтобы инициировать этот механизм. В частности, если пользователь тратит принадлежащие ему токены на вызов этой функции, rate снизится по мере уменьшения rSupply, тем самым принося выгоду другим держателям токенов. Иными словами, жертвуя собой ради других. Таким образом, разработчики проекта могут стимулировать держателей токенов с помощью этой функции.
function reflect(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
Приведенный выше код формирует ядро механизма. Различные токены могут включать специфические дополнительные функции для адаптации своей реализации. Например, некоторые могут использовать транзакционные комиссии для функции «swap and liquify», чтобы предотвратить давку, когда киты решают продать свои токены.
0x2 Постмортем взломанных reflection-токенов
Как упоминалось ранее, наше основное внимание сосредоточено на атаках, эксплуатирующих механизм reflection-токена. Поэтому инциденты, не связанные с этим механизмом, такие как атака на SafeMoon V2 (распространенная проблема публичного сжигания ERC20) и недавняя атака на ZongZi (связанная со старой манипуляцией ценой, эксплуатирующей спотовую цену), не рассматриваются.
Мы провели глубокий анализ этих атак, чтобы демистифицировать их первопричины. Вы можете обратиться сюда, чтобы увидеть список всех этих инцидентов. Мы обнаружили, что большинство из них являются обычными инцидентами безопасности, вызванными либо уязвимостями кода, либо неправильными административными операциями. Однако несколько из них выглядят довольно подозрительно (например, присутствие бэкдора), которые мы называем аномальными инцидентами безопасности. В следующих подразделах мы сначала представим обычные инциденты безопасности, а затем подробно рассмотрим аномальные.
0x2.1 Обычные инциденты безопасности
Наше исследование показывает, что эти инциденты возникают из-за двух типов проблем, т.е. проблем на уровне кода и проблем на уровне операций, либо по отдельности, либо в сочетании.
-
Проблемы на уровне кода. Это возникает из-за некачественной реализации контракта, вероятно, из-за того, что разработчики не полностью понимают механизм reflection-токена, что приводит к несоответствию между фактическим предложением токенов и записанным значением
totalSupply, что можно использовать для манипулирования rate:-
1.1 Сжигание с нулевой стоимостью
-
1.2 Дополнительное вычитание из
rSupplyво время переводов токенов -
1.3 Путаница между значениями r-пространства и t-пространства (с потерей точности для получения прибыли)
-
-
Проблемы на уровне операций. Это результат неправильных действий администраторов. В частности, в этих инцидентах речь идет о неправильной настройке адресов пар AMM, которые не были должным образом исключены.
Стоит отметить, что ВСЕ перечисленные проблемы на уровне кода могут привести к несоответствиям между фактическим предложением и totalSupply, делая контракты уязвимыми. Однако это не обязательно означает, что эти уязвимости эксплуатируемы или, точнее, достаточно прибыльны, чтобы их стоило эксплуатировать, поскольку у злоумышленника могут быть затраты на манипулирование rate. Для простоты далее мы будем использовать термин «эксплуатируемый» в значении «достаточно прибыльный, чтобы его стоило эксплуатировать». В результате проблема на уровне операций в некоторых сценариях необходима, чтобы сделать эти уязвимости эксплуатируемыми.
В частности, проблема 1.1 может быть эксплуатирована напрямую, тогда как проблемы 1.2 и 1.3 требуют сочетания с проблемой 2, чтобы стать эксплуатируемыми. Поэтому рассматриваемые инциденты можно разделить на два типа, Тип-I и Тип-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%) |
Таблица показывает, что инциденты Типа-II составляют значительную долю. В частности, внутри Типа-II существует 2 разновидности: Тип-II-a (т.е. проблема 1.2 вместе с проблемой 2) и Тип-II-b (т.е. проблема 1.3 вместе с проблемой 2). Кроме того, для инцидентов в стиле SHEEP категории Тип-II-b, эксплойты позволяют предположить, что злоумышленники (например, этот) могут использовать автоматизированные методы для выявления похожих уязвимых контрактов. Конкретные детали будут рассмотрены в последующих подразделах.
0x2.1.1 Тип-I: Только проблема на уровне кода (Проблема 1.1)
К Типу-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 значительно снизились пропорционально:

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

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

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

Так как

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

Это означает, что злоумышленник собирает больше токенов, которые можно обменять на ценные токены (в данном случае WETH), чтобы получить прибыль.
0x2.1.2 Тип-II-a: Сочетание проблемы 1.2 и проблемы 2
Инциденты Типа-II-a включают сочетание двух проблем:
- Проблема 1.2: Дополнительное вычитание из
rSupplyво время переводов токенов. - Проблема 2: Пара AMM не исключена.
В Типе-II-a присутствуют три атаки, которые можно далее разделить на две подкатегории в соответствии с формами уязвимости в проблеме 1.2, как показано ниже:
1. Дополнительный reflect в функции _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); // rChairty is deducted from _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); // since charity account is excluded, the charity part is burned
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), текущий rate можно вычислить по следующей формуле:

В этот момент, если мы отразим токены, которые мы держим, через функцию reflect (которая в этом контракте переименована в deliver), rate становится 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 is deducted from 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); // However, there is no rTeam deducted from rAmount
return (rAmount, rTransferAmount, rFee);
}
Мы видим, что во время переводов должны вычитаться и tax fee, и team fee. Однако в функции _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, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
0x2.1.3 Тип-II-b: Сочетание проблемы 1.3 и проблемы 2
Инциденты Типа-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 should be minus a r-space value
_tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
// For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
emit Transfer(_who, address(0), _value);
}
Учитывая основные константы контракта, значение r-пространства обычно во много раз больше значения t-пространства. Поэтому вызов функции burn со значением _value, которое имеет тот же порядок величины, что и tSupply, значительно завысит rate.
Однако, в отличие от случаев Типа-I, вызывающий не может сжечь токены, оставив свой собственный баланс неизменным. Иными словами, злоумышленнику трудно, если вообще возможно, собрать больше токенов. Тогда как случаи Типа-II-b могут быть эксплуатируемы?
Возьмем в качестве примера инцидент с токеном SHEEP. Стоимость токенов SHEEP, принадлежащих злоумышленнику, можно обозначить как:

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

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

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

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

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

На самом деле злоумышленник воспользовался проблемой потери точности в reflection-токене. Для не исключенных пользователей, согласно предоставленной нами формуле, расчет баланса на самом деле округляется вниз в функции tokenFromReflection. Таким образом, возвращаемое значение запроса balanceOf может быть меньше его теоретического значения. То есть, ratio' может быть больше, чем ratio, если мы учтем эту проблему.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Отладив транзакцию атаки, мы можем вычислить теоретический баланс злоумышленника и пары до и после этих манипуляций. Результаты наших расчетов представлены в таблице ниже:
Δ в таблице — чрезвычайно малое значение, намного меньше 1.
Проанализировав трейс в функции sync пары, мы можем рассчитать, что теоретические балансы злоумышленника и пары фактически составляют 27.523 и 2.972 соответственно, что дает соотношение 9.26. Однако из-за потери точности балансы округляются вниз до 27 и 2 соответственно, что завышает соотношение до 13.50. В результате Profit становится положительным значением.
Наконец, злоумышленник может получить прибыль, выполнив обратный своп.
0x2.2 Аномальные инциденты безопасности
В этом подразделе мы поделимся результатами нашего расследования токенов FDP и DBALL. Наш анализ показывает, что менеджеры токенов как FDP, так и DBALL вызывали проблемные привилегированные функции, фактически выступающие в роли бэкдоров, что подвергло проекты риску и в конечном итоге привело к атакам. В частности, в проекте DBALL мы обнаружили серию подозрительных транзакций от владельца токена, которые предоставляют явные доказательства того, что это можно считать rug pull.
Эксплойты, направленные против этих двух токенов, очень напоминают те, что обсуждались в разделе «Тип-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. Причины изложены ниже:
-
Владелец передал в функцию
manualDevBurnвсего лишь 1 токен, однако в течение получаса количество DBALL, равное общему предложению, было переведено на связанный адрес через эту транзакцию. -
Этот связанный адрес немедленно совершил своп в паре PancakeSwap и получил примерно 56 WBNB.
- Анализ движения средств этих двух адресов показывает, что оба в конечном итоге перевели BNB через Tornado.Cash.
0x3 Потенциальная проблема при расчете rate
Помимо инцидентов, которые мы обсудили ранее, мы также обнаружили, что теоретически существует потенциальная проблема в расчете баланса не исключенных пользователей, заслуживающая дальнейшего обсуждения. Эта проблема может возникнуть при расчете rate.
Давайте рассмотрим функцию _getCurrentSupply. В этой функции завершающий оператор if определяет, меньше ли rSupply, чем начальный rate (т.е. _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 будет уменьшаться из-за механизма reflection-токена, rate будет соответственно снижаться. Если после определенной транзакции rSupply упадет ниже начального rate, текущий rate резко скакнет, что приведет к потерям баланса для всех не исключенных пользователей. Кроме того, теоретически возможно, что

Это приводит к тому, что rate становится нулевым из-за потери точности, что потенциально может вызвать панику деления на ноль.
0x4 Смягчение последствий и решения
Механизм reflection-токена предлагает способ повышения стабильности рынка, стимулируя инвесторов удерживать свои токены вместо торговли ими для получения дополнительных вознаграждений. Однако он также вносит новые вызовы безопасности и потенциальные риски, такие как путаница между значениями r-пространства и t-пространства. Поэтому крайне важно, чтобы разработчики блокчейнов и инвесторы лучше понимали этот механизм и его потенциальные риски, а также искали решения.
BlockSec предоставляет услуги безопасности и продукты как для этапа до запуска, так и после него. Наши услуги по аудиту безопасности включают тщательные проверки для обеспечения безопасности и прозрачности кода. Наш продукт Phalcon Security предлагает возможности непрерывного мониторинга безопасности и обнаружения атак, позволяя операторам и инвесторам отслеживать проекты и предпринимать автоматические действия при обнаружении рисков безопасности.
Связанные материалы
- How L2 Blockchains Can Do Better to Protect Their Users
- Top 10 "Awesome" Security Incidents in 2023
- Conceptual Full Analysis A Rise Of Bitcoin With Inscription
О компании BlockSec
BlockSec — полноценный поставщик услуг безопасности Web3. Компания стремится повысить безопасность и удобство использования зарождающегося мира Web3, чтобы способствовать его массовому внедрению. С этой целью BlockSec предоставляет услуги аудита безопасности смарт-контрактов и EVM-совместимых блокчейнов, платформу Phalcon для проактивной разработки с учетом безопасности и блокировки угроз, платформу MetaSleuth для отслеживания средств и расследований, а также расширение MetaSuites для разработчиков web3, эффективно работающих в мире криптовалют.
На сегодняшний день компания обслужила более 1000 клиентов, таких как 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



