返回博客

关于反射令牌(Reflection Tokens)的安全性思考

Phalcon
2024年6月6日
阅读约 26 分钟

2024年6月14日更新:一位社区成员仔细审查了这篇博客,并提供了关于ADU事件的信息,这是一种我们之前分类中未涉及的新形式。感谢您,欢迎所有有见地的反馈!

为了增强市场稳定性,反射代币(又称奖励代币)被设计用来为投资者提供额外的收入途径。这鼓励投资者持有代币而不是交易它们。在著名的2021年meme币季节中,反射代币成为了一种不可或缺的机制,在DxSale等平台上推出后迅速吸引了市场关注(例如SafeMoon V1)。

尽管2023年狂热逐渐消退、市场趋于冷静,但我们的系统仍检测到数万起利用此类代币机制的黑客事件。这些"收割式"攻击虽然与其他类型的DeFi攻击相比金额相对较小,但仍给用户资产造成了不可忽视的损失。

在这篇博客中,我们主要关注分享我们研究中与安全相关的见解。具体来说,我们将首先简要介绍反射代币的机制。随后,我们将回顾与反射代币相关的安全事件,重点关注那些利用反射代币机制的事件。然后,我们将从理论上讨论一个潜在的安全问题。最后,我们将分享一些关于缓解措施和解决方案的思考。

0x1 反射代币的机制

据我们所知,这一机制最早由Reflect Finance引入,旨在以非交易方式将交易金额的一定百分比作为费用分配给所有代币持有者。2021年3月,著名的SafeMoon V1在BNB链上发布,进一步推动了反射代币的普及。

0x1.1 基本概念

在深入细节之前,需要先介绍一些基本概念以便更好地理解。

存在两种空间:r空间和t空间,分别读作反射空间和真实空间。这两个空间的加密货币根据相对流通量存在汇率关系。此外,r空间中的货币是通缩的,即每次交易都会销毁一定比例,因此销毁的数量会从流通量中扣除。

假设Alice、Bob和Eve都能够在两个空间中进行交易,如下图所示。如果Alice和Bob在t空间中相互转账,Eve将不会获得任何奖励。然而,如果三者先将各自的代币转换到r空间,然后让Alice和Bob互相转账,Eve最终会通过将其代币转换回t空间来获得被动收入。这就是反射代币机制的基本理念。

请注意,并非所有账户都能在r空间中进行交易,例如代币的流动性提供池,即某些账户需要被排除在r空间之外。

0x1.2 合约层面的解释

现在让我们深入研究Reflect Finance的REFLECT合约,以探索这一机制。

该合约首先为账户管理定义了若干变量:

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函数为两个空间计算相应的amount、transferAmount和fee(即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函数

除了在转账过程中被动触发反射代币机制外,用户还可以主动调用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 被攻破的反射代币事后分析

如前所述,我们的主要关注点是利用反射代币机制的攻击。因此,与该机制无关的事件,例如SafeMoon V2攻击(一种常见的ERC20公开销毁问题)和最近的ZongZi攻击(与利用现货价格的老式价格操纵漏洞相关),不在本文讨论范围之内。

我们对这些攻击进行了深入分析,以揭示其根本原因。您可以参阅此处查看所有这些事件的列表。我们发现,其中大多数是由代码漏洞或不当管理操作导致的普通安全事件。然而,也有少数事件相当可疑(例如存在后门),我们将其称为异常安全事件。在接下来的小节中,我们将首先介绍普通安全事件,然后详细讨论异常事件。

0x2.1 普通安全事件

我们的调查表明,这些事件源于两种类型的问题,即代码层面的问题和操作层面的问题,可能单独出现,也可能组合出现。

  1. 代码层面的问题。这源于合约的糙糙实现,可能是由于开发人员没有完全理解反射代币的机制,导致代币的实际供应量与记录的totalSupply值之间不一致,这可以被用来操纵rate:

    • 1.1 零成本销毁

    • 1.2 代币转账过程中从rSupply中额外扣除

    • 1.3 r空间和t空间数值混淆(伴随精度损失以获利)

  2. 操作层面的问题。这源于管理员的不当操作。具体来说,在这些事件中,这指的是AMM交易对地址配置不当,未被正确排除。

值得注意的是,所有列出的代码层面问题都可能导致实际供应量与totalSupply不一致,从而使合约变得脆弱。然而,这并不一定意味着这些漏洞是可利用的,或者更确切地说,是有足够利润值得利用的,因为攻击者操纵rate可能需要付出成本。为简化起见,下文中我们将使用"可利用"一词来表示"有足够利润值得利用"。因此,在某些情况下,需要一个操作层面的问题才能使这些漏洞变得可利用。

具体来说,问题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内部有两种变体:Type-II-a(即问题1.2与问题2结合)和Type-II-b(即问题1.3与问题2结合)。此外,对于属于Type-II-b类别的类SHEEP事件,其漏洞利用手法表明攻击者(例如这个)可能使用自动化方法来识别具有类似漏洞的合约。具体细节将在后续小节中探讨。

0x2.1.1 Type-I:仅代码层面问题(问题1.1)

只有一个事件属于Type-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都按比例大幅减少:

rate可以通过调用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中,除了原有的一部分外,费用还被划分为两个额外部分:销毁和慈善。

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的值将小于池中代币的供应量。

纯理论描述可能有点抽象,因此让我们用一个例子来说明这个过程。假设Alice想要向Bob转移10个代币,扣除了3个代币,具体如下:1个作为费用,1个用于销毁,1个用于慈善。由于慈善部分同时被反射和销毁,实际的分解是2个代币被反射(1个费用+1个慈善)和2个代币被销毁(1个销毁+1个慈善)。加上剩余7个转给Bob的代币,此过程共涉及11个代币,这是有问题的。

但为什么这种不一致可以被利用来获利呢?下面,我们将挥动数学魔杖来推导其后果。

假设我们之前从池中(即PancakeSwap交易对)获取了一些代币,在r空间记为rAmount,在t空间记为tAmount。由于该池未被排除,我们将_rOwned[pair]记为rReserve,在t空间对应的值也记为tReserve。于是我们有:

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

回顾"余额查询函数"部分(0x1.2.1),当前rate可以使用以下公式计算:

此时,如果我们通过reflect函数(在该合约中被重命名为deliver函数)反射我们所持有的代币,rate将变为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 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);
}

我们可以看到,转账时应该扣除税费和团队费用。然而,在_getTvalues函数中,tTransferAmount同时减去了tFee和tTeam,而在_getRValues函数中,只减去了rFee。这种差异导致了前面提到的不一致问题,且随着更多代币转账的发生而恶化。

由于该代币中交易对也未被排除,这个代币是可被利用的。具体来说,攻击者在调用deliver函数后,可以使用类似BEVO的漏洞利用手法,通过交易对的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 Type-II-b:问题1.3与问题2的结合

Type-II-b事件涉及两个问题的结合:

  • 问题1.3:r空间和t空间数值混淆(注意,为了获利,还必须存在精度损失)。
  • 问题2:AMM交易对未被排除。

问题1.3源于内部_burn函数实现过程中r空间和t空间数值处理不当。

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空间值的一个较大倍数。因此,使用与tSupply同一数量级的_value调用burn函数将显著抬高rate。

然而,与Type-I的情况不同,调用者无法在销毁代币的同时保持自身余额不变。换句话说,攻击者很难(甚至几乎不可能)借此收获更多代币。那么,Type-II-b的情况是如何可以被利用的呢?

以SHEEP代币事件为例。攻击者持有的SHEEP代币的价值可以表示为:

其中SHEEP的价格可以用PancakeSwap交易对中的现货价格表示,计算如下:

那么Value可以进一步表示为:

攻击者随后反复执行burn函数,并最终同步交易对。由于攻击者和交易对都未被排除,两者的余额都会因我们上面提到的rate抬高而下降。因此比率调整为:

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

其中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。

通过分析交易对sync函数内的trace,我们可以计算出攻击者和交易对的理论余额实际上分别为27.523和2.972,比率为9.26。然而,由于精度损失,余额被向下舍入为27和2,将比率抬高到13.50。结果,Profit变成了正值。

最后,攻击者可以通过执行反向swap来获利。

0x2.2 异常安全事件

在这一小节中,我们将分享调查FDP和DBALL代币的发现。我们的分析表明,FDP和DBALL代币的管理者都调用了有问题的特权函数,实际上充当了后门,这使项目面临风险,并最终导致了攻击。具体来说,在DBALL项目中,我们发现代币所有者进行了一系列可疑交易,这些交易提供了明确证据,表明这是一次卷款跑路(rug pull)。

针对这两种代币的攻击手法与0x2.1.2节中'Type-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);
}

调用此函数的交易汇总在下表中:

0x2.2.2 DBALL事件

DBALL案例情况变得比较棘手。使用MetaSleuth分析DBALL所有者的资金流向,我们观察到该地址DBALL代币的流入和流出存在不平衡,这笔交易的资金来源没有被记录。

通过查询链上历史状态,我们最终确认,所有者的DBALL余额在这笔交易前后发生了变化。我们可以观察到,所有者调用了特权函数manualDevBurn,在t空间销毁了1个代币。该函数的实现如下:

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。这一细微的操作使所有者能够改变自己的余额,同时也导致了代币供应量的不一致。

这只是一次意外失误吗?我们的调查表明,这更可能是一次故意的卷款跑路。原因总结如下:

  1. 所有者仅向manualDevBurn函数传入了1个代币,但在半小时内,一笔相当于总供应量的DBALL数量通过这笔交易转移到了一个关联地址。

  2. 该关联地址立即在PancakeSwap交易对中进行了兑换,获得了约56个WBNB。

  1. 分析这两个地址的资金流向显示,两者最终都通过Tornado.Cash转移了BNB。

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减少,rate也会相应下降。如果在某笔交易之后,rSupply低于初始rate,当前rate将会跳变,导致所有非排除用户的余额损失。此外,理论上还存在这样一种可能性:

这会由于精度损失导致rate变为零,从而可能触发除零panic。

0x4 缓解措施与解决方案

反射代币机制通过激励投资者持有代币而非交易以获取额外奖励,提供了一种增强市场稳定性的方式。然而,它也带来了新的安全挑战和潜在风险,例如r空间和t空间数值的混淆。因此,区块链开发者和投资者对这一机制及其潜在风险有更深入的理解,并寻求解决方案,至关重要。

BlockSec为项目上线前和上线后阶段都提供安全服务和产品。我们的安全审计服务进行全面审查,以确保代码安全性和透明度。我们的Phalcon产品提供持续的安全监控和攻击检测能力,使运营者和投资者能够监控项目,并在检测到安全风险时自动采取行动。

相关阅读


关于BlockSec

BlockSec是一家全栈式Web3安全服务提供商。公司致力于提升新兴Web3世界的安全性和可用性,以促进其大规模普及。为此,BlockSec提供智能合约和EVM链安全审计服务、用于安全开发和主动阻止威胁的Phalcon平台、用于资金追踪和调查的MetaSleuth平台,以及供web3建设者高效畲游于加密世界的MetaSuites插件。

迄今为止,公司已服务超过1000家客户,如Uniswap Foundation、Compound、Forta和PancakeSwap,并从Matrix Partners、Vitalbridge Capital和Fenbushi Capital等杰出投资者处获得了两轮总计数千万美元的融资。