Back to Blog

反思反射令牌:安全视角

June 6, 2024
26 min read

更新于 2024 年 6 月 14 日:一位社区成员仔细审查了这篇博文,并提供了有关 ADU 事件 的信息,这是一种我们之前未涵盖的新形式。感谢并欢迎所有富有洞察力的反馈!

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

尽管狂热在 2023 年有所减退,市场也趋于平缓,但我们的系统检测到数万起在实际环境中利用此类代币机制的黑客攻击事件。这些收割者式攻击,虽然与 DeFi 的其他类型攻击相比规模相对较小,但对用户资产造成了不可忽略的损失。

在本博文中,我们的主要重点是分享我们研究中的安全相关见解。具体来说,我们将首先简要介绍反弹代币的机制。之后,我们将回顾与反弹代币相关的安全事件,重点关注利用反弹代币机制的事件。然后,我们将从理论上讨论一个潜在的安全问题。最后,我们将分享一些关于缓解和解决方案的想法。

0x1 反弹代币的机制

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

0x1.1 基本概念

在深入探讨细节之前,需要介绍一些基本概念以获得更好的理解。

有两种空间:r-spacet-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 正常安全事件

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

  1. 代码层面问题。这源于合约的糟糕实现,可能是因为开发人员没有完全理解反弹代币的机制,导致代币的实际供应与记录的 totalSupply 值之间不一致,这可用于操纵费率:

    • 1.1 零成本销毁

    • 1.2 代币转账期间 rSupply 的额外扣除

    • 1.3 r-space 和 t-space 值之间的混淆(存在精度损失以获取利润)

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

值得注意的是,所有列出的代码层面问题都可能导致实际供应与 totalSupply 之间的不一致,使合约易受攻击。然而,这并不一定意味着这些漏洞可以被利用,或者更确切地说,有利可图到值得利用,因为攻击者可能需要成本来操纵费率。为简单起见,在以下内容中,我们将使用“可利用”来表示“有利可图到值得利用”。因此,在某些情况下,操作层面的问题对于使这些漏洞可利用是必需的。

具体来说,问题 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)。此外,对于 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 想将 10 个代币转账给 Bob,其中 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 减去了 tFeetTeam,而在 _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 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。

通过分析对的 sync 函数中的跟踪,我们可以计算出攻击者和对的理论余额实际上分别为 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);
}

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

0x2.2.2 DBALL 事件

DBALL 的情况比较棘手。使用MetaSleuth 分析DBALL 所有者的资金流,我们观察到该地址的 DBALL 代币流入和流出失衡,此交易的资金来源未被记录。

通过查询链上的历史状态,我们最终确定 DBALL 所有者的余额在此交易之前和之后发生了变化。我们可以观察到所有者调用了特权函数 manualDevBurn 来销毁 t-space 中的 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 计算费率的潜在问题

除了我们之前讨论的事件之外,我们还发现,从理论上讲,非排除用户的余额计算存在一个值得进一步讨论的潜在问题。这个问题可能发生在费率的计算过程中。

让我们看一下 _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 平台用于资金跟踪和调查,以及MetaSuites 扩展,供 web3 构建者在加密世界中高效冲浪。

迄今为止,公司已为 Uniswap Foundation、Compound、Forta 和 PancakeSwap 等 300 多家客户提供服务,并在两轮融资中从 Matrix Partners、Vitalbridge Capital 和 Fenbushi Capital 等领先投资者那里获得了数千万美元的投资。

Sign up for the latest updates
The Decentralization Dilemma: Cascading Risk and Emergency Power in the KelpDAO Crisis
Security Insights

The Decentralization Dilemma: Cascading Risk and Emergency Power in the KelpDAO Crisis

This BlockSec deep-dive analyzes the KelpDAO $290M rsETH cross-chain bridge exploit (April 18, 2026), attributed to the Lazarus Group, tracing a causal chain across three layers: how a single-point DVN dependency enabled the attack, how DeFi composability cascaded the damage through Aave V3 lending markets to freeze WETH liquidity exceeding $6.7B across Ethereum, Arbitrum, Base, Mantle, and Linea, and how the crisis forced decentralized governance to exercise centralized emergency powers. The article examines three parameters that shaped the cascade's severity (LTV, pool depth, and cross-chain deployment count) and provides an exclusive technical breakdown of Arbitrum Security Council's forced state transition, an atomic contract upgrade that moved 30,766 ETH without the holder's signature.

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026

This BlockSec weekly security report covers four attack incidents detected between April 13 and April 19, 2026, across multiple chains such as Ethereum, Unichain, Arbitrum, and NEAR, with total estimated losses of approximately $310M. The highlighted incident is the $290M KelpDAO rsETH bridge exploit, where an attacker poisoned the RPC infrastructure of the sole LayerZero DVN to fabricate a cross-chain message, triggering a cascading WETH freeze across five chains and an Arbitrum Security Council forced state transition that raises questions about the actual trust boundaries of decentralized systems. Other incidents include a $242K MMR proof forgery on Hyperbridge, a $1.5M signed integer abuse on Dango, and an $18.4M circular swap path exploit on Rhea Finance's Burrowland protocol.

Weekly Web3 Security Incident Roundup | Apr 6 – Apr 12, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Apr 6 – Apr 12, 2026

This BlockSec weekly security report covers four DeFi attack incidents detected between April 6 and April 12, 2026, across Linea, BNB Chain, Arbitrum, Optimism, Avalanche, and Base, with total estimated losses of approximately $928.6K. Notable incidents include a $517K approval-related exploit where a user mistakenly approved a permissionless SquidMulticall contract enabling arbitrary external calls, a $193K business logic flaw in the HB token's reward-settlement logic that allowed direct AMM reserve manipulation, a $165.6K exploit in Denaria's perpetual DEX caused by a rounding asymmetry compounded with an unsafe cast, and a $53K access control issue in XBITVault caused by an initialization-dependent check that failed open. The report provides detailed vulnerability analysis and attack transaction breakdowns for each incident.