更新于 2024 年 6 月 14 日:一位社区成员仔细审查了这篇博客,并提供了关于 ADU 事件 的信息,这是一种我们之前分类中未涵盖的新形式。感谢,并欢迎所有有见地的反馈!
为增强市场稳定性,反思代币(又称奖励代币)被设计用于为投资者提供额外的收入途径。这鼓励投资者持有而非交易他们的代币。在臭名昭著的 2021 年 meme 币季节期间,反思代币成为不可或缺的机制,在 DxSale 等平台上启动后迅速吸引了市场关注(例如,SafeMoon V1)。
尽管狂热有所减退,市场在 2023 年趋于冷静,但我们的系统却检测到数万起利用此类代币机制的黑客事件。这些收割者式攻击,虽然与 DeFi 攻击的其他类型相比规模相对较小,但导致用户资产遭受了不可忽略的损失。
在这篇博客中,我们将主要重点放在分享我们研究中的安全相关见解。具体而言,我们将首先简要介绍反思代币的机制。在此之后,我们将回顾与反思代币相关的安全事件,重点关注那些利用反思代币机制的事件。然后,我们将从理论上讨论一个潜在的安全问题。最后,我们将分享一些关于缓解和解决方案的思考。
0x1 反思代币的机制
据我们所知,该机制最初由 Reflect Finance 推出,旨在以非交易的方式将交易金额的一部分作为费用分配给所有代币持有者。2021 年 3 月,著名的 SafeMoon V1 在 BNB 链上发布,进一步普及了反思代币。
0x1.1 基本概念
在深入细节之前,应先介绍一些基本概念以获得更好的理解。
有两种空间:r-space(反思空间)和 t-space(真实空间)。两种空间的加密货币之间的汇率基于相对的流通量。此外,r-space 中的货币是通缩的,也就是说,每笔交易都会销毁一定百分比的代币,销毁量将从流通量中扣除。
假设 Alice、Bob 和 Eve 都能在两个空间中进行交易,如下图所示。如果 Alice 和 Bob 在 t-space 中进行两两转账,Eve 将不会收到任何奖励。但是,如果他们三个人都先将代币转换为 r-space,然后让 Alice 和 Bob 之间进行转账,Eve 最终将通过将其代币转换回 t-space 来获得被动收入。这就是反思代币机制的基本思想。
请注意,并非所有账户,例如代币的流动性提供池,都能够进行 r-space 交易,即某些账户需要被排除在 r-space 之外。
0x1.2 合约层级解释
现在让我们深入了解 Reflect Finance 的 REFLECT 合约 来探索这个机制。
该合约首先定义了几个用于账户管理的变量:
mapping (address => uint256) private _rOwned; // 用户持有的反思代币
mapping (address => uint256) private _tOwned; // 用户持有的真实代币
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // 用户是否被排除在 r-space 之外
address[] private _excluded; // 被排除在 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));
从功能和用户交互的角度来看,该合约的函数可分为以下三类:余额查询和代币转账,以及独特的反思函数。前两者兼容 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);
}
这可以用以下公式表示:

上述公式中的汇率是通过调用 _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-space 转账资产到 t-space 的实现,其中 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-space 中反映费用。具体来说,_rTotal会因 r-space 中的费用(即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], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
上面提供的代码构成了该机制的核心。不同的代币可能会包含特定的附加功能来定制其实现。例如,有些代币可能会使用交易费用来驱动“交换并液化”功能,以防止巨鲸决定出售其代币时出现挤兑。
0x2 Rekt 反思代币的案例分析
如前所述,我们的主要重点是利用反思代币机制的攻击。因此,与此机制无关的事件,例如 SafeMoon V2 攻击(一个常见的 ERC20 公共销毁问题)和最近的 ZongZi 攻击(与利用现货价格的老式价格操纵有关),均不在此讨论范围内。
我们对这些攻击进行了深入分析,以揭示其根本原因。您可以参考 此处 查看所有这些事件的列表。我们发现大多数事件是由于代码漏洞或不当的管理操作引起的正常安全事件。然而,少数事件相当可疑(例如,存在后门),我们称之为异常安全事件。在接下来的子节中,我们将首先介绍正常安全事件,然后详细介绍异常事件。
0x2.1 正常安全事件
我们的调查表明,这些事件源于两种类型的问题,即代码层级问题和操作层级问题,两者单独或结合出现。
-
代码层级问题。这是由于合约的蹩脚实现造成的,可能是因为开发人员未能完全理解反思代币的机制,导致实际代币供应量与记录的
totalSupply值之间不一致,这可以用来操纵汇率:-
1.1 零成本销毁
-
1.2 代币转账期间从
rSupply中额外扣除 -
1.3 r-space 和 t-space 值之间的混淆(存在精度损失以获利)
-
-
操作层级问题。这是由于管理员操作不当造成的。具体来说,在这些事件中,这指的是 AMM 配对地址的配置不当,未能正确排除。
值得注意的是,所有列出的代码层级问题都可能导致实际供应量与 totalSupply 之间出现不一致,使合约容易受到攻击。然而,这并不一定意味着这些漏洞可以被利用,或者更准确地说,利润足够高到值得利用,因为攻击者操纵汇率可能存在成本。为简化起见,下文将使用“可利用”一词表示“利润足够高到值得利用”。因此,在某些情况下,操作层级问题对于使这些漏洞可被利用是必要的。
具体而言,问题 1.1 可以直接利用,而问题 1.2 和 1.3 则需要与问题 2 结合才能利用。因此,基于这些观察,本次讨论的事件可分为两类:I 类和II 类。下表概述了相关数据:
| 类型 | 事件(s) | 根本原因(s) | 数量 (%) | |:----:|:------------------:|:------------------:|:----:|:----:| | 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)。此外,对于 II-b 类中的 SHEEP 型事件,攻击表明攻击者(例如,此攻击)可能正在使用自动化方法来识别类似的易受攻击合约。后续章节将详细探讨具体细节。
0x2.1.1 I 类:仅代码层级问题(问题 1.1)
只有一起事件属于 I 类,即 CATOSHI 事件,这是一起影响总供应量的零成本销毁。
首先,让我们看一下 CATOSHI 合约 中的 burnOf 函数:
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 II-a 类:问题 1.2 和问题 2 的结合
II-a 类事件涉及两个问题的结合:
- 问题 1.2:在代币转账期间从
rSupply中额外扣除。 - 问题 2:AMM 配对未被排除。
在 II-a 类中,有三起攻击事件,根据问题 1.2 中的漏洞形式,它们可以进一步细分为两个子类别,如下所示:
1. _reflectFee 函数中的额外反思
有两个事件属于此子类别,即 BEVO 事件 和 FETA 事件。下文将以 BEVO 合约 为例进行说明。
如“代币转账函数”(0x1.2.2 节)中所述,每次代币转账都会触发一部分交易费用的反思。在 BEVO 中,除了原始费用外,费用还分为两部分:销毁和慈善。
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rChairty 从 _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 的值将小于池中的代币供应量。
纯理论描述可能有些抽象,让我们用一个例子来阐明这个过程。假设 Alice 想向 Bob 转账 10 个代币,并扣除 3 个代币,如下所示:1 个用于费用,1 个用于销毁,1 个用于慈善。由于慈善部分同时被反射和销毁,实际细分是 2 个代币被反射(1 个费用 + 1 个慈善)和 2 个代币被销毁(1 个销毁 + 1 个慈善)。连同剩余的 7 个代币转账给 Bob,总共 11 个代币参与了这个过程,这是错误的。
但为什么这种不一致可以被利用来获利呢?下面,我们将施展我们的数学魔力来推导其后果。
假设我们之前已从池(即 PancakeSwap 配对)中获得了一些代币,表示为 r-space 中的 rAmount 和 t-space 中的 tAmount。由于池尚未被排除,我们将池的 _rOwned[pair] 表示为 rReserve,其在 t-space 中的对应值为 tReserve。则我们有:

由于额外扣除,rSupply 现在小于池中的代币供应量:

回想“余额查询函数”部分(0x1.2.1),当前汇率可以使用以下公式计算:

此时,如果我们通过 reflect 函数(在此合约中重命名为 deliver 函数)反射我们持有的代币,汇率将变为 rate':

因为

那么我们有

结合公式 1、3 和 6,我们可以得出以下不等式:

这意味着我们可以直接从池中(通过 skim 函数)收获的代币数量甚至大于我们已交付的数量,使其有利可图,因为调用 reflect 函数的成本可以被覆盖。之后,攻击者可以将收获的代币兑换成有价值的代币(本例中为 WBNB)以获利。
请注意,BEVO 合约也容易受到问题 1.3 的影响,但尚未在此次攻击中被利用。
2. _getRValues 函数中不正确的 rTransferAmount 计算
只有一起事件属于此形式,即 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); // 然而,rAmount 没有扣除 rTeam
return (rAmount, rTransferAmount, rFee);
}
我们可以看到,在转账期间,税费和团队费用都应该被扣除。然而,在 _getTvalues 函数中,tTransferAmount 同时减去了 tFee 和 tTeam,而在 _getRValues 函数中,只减去了 rFee。这种差异导致了前面提到的不一致问题,随着代币转账次数的增加,该问题变得更加严重。
由于代币的配对也未被排除,该代币是可利用的。具体来说,攻击者可以使用类似的 BEVO 攻击,在调用 deliver 函数后,通过配对的 skim 函数来获取更多 ADU 代币。
然而,鉴于当时链上状态,攻击者不可能将获取的 ADU 代币兑换成 WBNB 来获利(由于 tokenFromReflection 函数中的 require 语句)。因此,攻击者需要采用更复杂的利用策略来获利,此处不作详细介绍。
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-space 和 t-space 值之间的混淆(注意,必须存在精度损失才能获利)。
- 问题 2:AMM 配对未被排除。
问题 1.3 源于在实现内部 _burn 函数期间,对 r-space 和 t-space 之间值的错误处理。
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-space 值
_tTotal = _tTotal.sub(_value); // _tTotal 应该减去 t-space 值
// 根据 burn 函数的语义,_rTotal 也应该减去一个 r-space 值。
emit Transfer(_who, address(0), _value);
}
考虑到合约的基本常量,r-space 值通常是 t-space 值的较大倍数。因此,使用与 tSupply 同等数量级的 _value 来调用 burn 函数将显著抬高汇率。
然而,与 I 类情况不同,调用者不能在不改变自身余额的情况下销毁代币。换句话说,攻击者很难(甚至不可能)获取更多代币。那么,II-b 类案件是如何被利用的呢?
以 SHEEP 代币事件 为例。攻击者持有的 SHEEP 代币的价值可表示为:

其中 SHEEP 的价格可以通过 PancakeSwap 配对中的现货价格表示,计算如下:

那么价值可以进一步表示为:

然后攻击者反复执行 burn 函数,并最终同步配对。由于攻击者和配对均未被排除,它们的余额因我们提到的汇率膨胀而下降。因此,比率调整为:

根据之前的定义,我们可以进一步将这些比率表示为:

其中 X 代表烧毁的 _value 的总和。
神奇之处在于:如果进一步简化 8 和 9 式,后者比前者小,这让我们感到困惑,因为在这种情况下利润将是负值:

事实上,攻击者利用了反思代币中的精度损失问题。对于非排除用户,根据我们提供的公式,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。
通过分析配对同步函数中的追踪,我们可以计算出理论上攻击者和配对的余额实际上分别为 27.523 和 2.972,比率为 9.26。然而,由于精度损失,余额分别向下取整为 27 和 2,将比率提高到 13.50。因此,利润变为正值。
最后,攻击者可以通过执行反向兑换来获利。
0x2.2 异常安全事件
在本小节中,我们将分享我们在调查 FDP 和 DBALL 代币时获得的发现。我们的分析表明,FDP 和 DBALL 代币的管理者调用了存在问题的特权函数,实际上充当了后门,将项目置于风险之中并最终导致了攻击。具体而言,在 DBALL 项目中,我们识别出了一系列可疑的代币所有者交易,这为将其视为拉盘提供了明确证据。
针对这两种代币的利用方式与 0x2.1.2 节中“II-a 类:问题 1.2 和问题 2 的结合”部分讨论的非常相似。然而,在分析实际代币供应量可能与 totalSupply 产生差异的原因时,出现了一些可疑活动。
0x2.2.1 FDP 事件
FDP 事件中实际代币供应量与 totalSupply 之间的差异源于调用 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-space 代币。该函数的实现如下:
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()] 时会发生算术下溢,从 0 变为几乎 type(uint256).max。这种细微的操纵允许所有者更改其余额,但也会导致代币供应量的不一致。
这仅仅是意外错误吗?我们的调查表明,这更可能是一次有意的拉盘。原因总结如下:
-
所有者在
manualDevBurn函数中只传入了 1 个代币,但在不到半小时内,通过此交易,与总供应量相等的 DBALL 代币数量被转移到了关联地址。 -
该关联地址立即在 PancakeSwap 配对中进行了兑换,获得了约 56 个 WBNB。
- 分析这两个地址的资金流 显示,两者最终都通过 Tornado.Cash 转移了 BNB。
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-space 和 t-space 值之间的混淆。因此,区块链开发者和投资者必须更好地理解该机制及其潜在风险并寻求解决方案至关重要。
BlockSec 为发布前和发布后阶段提供安全服务和产品。我们的安全审计服务进行彻底审查,以确保代码安全性和透明度。我们的 Phalcon 产品提供持续的安全监控和攻击检测功能,使运营商和投资者能够监控项目并在检测到安全风险时采取自动措施。
相关阅读
关于 BlockSec
BlockSec 是一家全栈 Web3 安全服务提供商。公司致力于提升新兴 Web3 世界的安全性和可用性,以促进其大规模采用。为此,BlockSec 提供智能合约和 EVM 链安全审计服务,用于安全开发和主动阻止威胁的Phalcon 平台,用于资金追踪和调查的MetaSleuth 平台,以及供 Web3 构建者在加密世界中高效冲浪的MetaSuites 扩展。
迄今为止,该公司已为 Uniswap Foundation、Compound、Forta 和 PancakeSwap 等 300 多家客户提供服务,并从 Matrix Partners、Vitalbridge Capital 和 Fenbushi Capital 等知名投资者那里完成了两轮融资,总额达数千万美元。
-
邮箱:contact@blocksec.com
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth:https://metasleuth.io/
-
MetaSuites:https://blocksec.com/metasuites



