反思反射令牌:安全视角

反思反射令牌:安全视角

更新于 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 结合才能被利用。因此,根据这些观察,正在讨论的事件可以分为两种类型,Type-IType-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)。此外,对于 Category 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 都按比例大大减少:

通过调用 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 从 _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 Type-II-b:问题 1.3 和问题 2 的组合

Type-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 函数将显著提高汇率

然而,与 Type-I 的情况不同,调用者不能在保持自身余额不变的情况下燃烧代币。换句话说,攻击者很难(如果不是不可能)获得更多代币。因此,Type-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 节中讨论的“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-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. 所有者仅将 1 个代币传入 manualDevBurn 函数,但在半小时内,相当于总供应量的 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
Weekly Web3 Security Incident Roundup | Feb 9 – Feb 15, 2026

Weekly Web3 Security Incident Roundup | Feb 9 – Feb 15, 2026

During the week of February 9 to February 15, 2026, three blockchain security incidents were reported with total losses of ~$657K. All incidents occurred on the BNB Smart Chain and involved flawed business logic in DeFi token contracts. The primary causes included an unchecked balance withdrawal from an intermediary contract that allowed donation-based inflation of a liquidity addition targeted by a sandwich attack, a post-swap deflationary clawback that returned sold tokens to the caller while draining pool reserves to create a repeatable price-manipulation primitive, and a token transfer override that burned tokens directly from a Uniswap V2 pair's balance and force-synced reserves within the same transaction to artificially inflate the token price.

Top 10 "Awesome" Security Incidents in 2025

Top 10 "Awesome" Security Incidents in 2025

To help the community learn from what happened, BlockSec selected ten incidents that stood out most this year. These cases were chosen not only for the scale of loss, but also for the distinct techniques involved, the unexpected twists in execution, and the new or underexplored attack surfaces they revealed.

#10 Panoptic Incident: XOR Linearity Breaks the Position Fingerprint Scheme

#10 Panoptic Incident: XOR Linearity Breaks the Position Fingerprint Scheme

On August 29, 2025, Panoptic disclosed a Cantina bounty finding and confirmed that, with support from Cantina and Seal911, it executed a rescue operation on August 25 to secure roughly $400K in funds. The issue stemmed from a flaw in Panoptic’s position fingerprint calculation algorithm, which could have enabled incorrect position identification and downstream fund risk.