通缩项目在业务设计的时候一定要考虑到与 pair 交互的情况,自身的通缩机制是否会对 pair 产生影响。
封面:Photo by Dynamic Wang on Unsplash
近期 Beosin 安全团队研究发现,通缩代币引起的安全事件依然频发,造成众多项目方资金的损失,因此,Beosin 安全团队准备了这篇详解通缩代币的文章,与大家分享。
本文将对通缩代币与 pair 结合过程中容易出现的问题以及历史发生的真实通缩代币安全事件两个方面进行介绍,通过本文,我们将彻底搞清楚通缩代币是什么意思以及通缩代币发生安全问题所涉及的原理,使我们在之后的项目中避坑。
1 通缩代币是哪种类型的币?
通缩代币是一种在交易过程中会进行相关比例销毁的代币,这是一种很好的激励用户持有代币的方式。
在代币交易过程中,会扣除部分代币用于手续费、奖励以及销毁,而随着代币的销毁,总供应量便会不断减少,就能使得用户持有代币所占比例增加,从而使得用户更愿意持有代币来被动获取更高的收益。
看似完美的金融方案,但在代码实现上并不像预想的那么完美。代码中存在销毁过程,此过程将绕过 swap 过程直接修改地址余额,这种情况与 pair 相结合,便会出现一些意想不到的问题。
2 通缩代币存在哪些问题?
(1)添加流动性问题
通缩代币在转账时会收取一定比例的手续费给当前合约,并在手续费达到某个阈值(当前代币数量大于等于合约设置的某个变量)时会调用 pair 合约进行 swap、addLiquidity 或 sync 等操作。
如果在通缩代币交易过程中,没有排除 to 地址等于 pair 合约地址,并且该通缩代币在 pair 中为 TokenB 时,那么在进行 TokenA 与 TokenB 添加流动性的操作中可能导致失败。
为什么会出现交易失败的问题呢?添加流动性是将 TokenA 与 TokenB 两种代币打入 pair 合约,然后调用 pair 合约的 mint 函数(下方详情),该函数会根据本合约的当前余额与储备量的差值来判断用户传入了多少代币。
用户将 TokenA 的代币发送至 pair 后,进行 TokenB 代币转账,当收取的手续费正好达到上述的阈值时,代币合约调用 pair 的 swap、mint 或 sync 函数,这几个函数都会调用 pair 的_update 函数,从而将用户最开始发送至 pair 的 TokenA 更新为 reserve。
最后,用户再调用 mint 函数,会导致 TokenA 的 balance 和 reserve 是相等的,结果将导致该笔交易失败。
Mint 函数代码如下:
function mint(address to) external lock returns (uint liquidity)
{
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); //获取储备量
uint balance0 = IERC20(token0).balanceOf(address(this));//获取当前合约 TokenA 余额
uint balance1 = IERC20(token1).balanceOf(address(this));//获取当前合约 TokenB 余额
uint amount0 = balance0.sub(_reserve0);//差值计算结果为 0
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');//此处会导致交易失败
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn)
kLast = uint(reserve0).mul(reserve1);
emit Mint(msg.sender, amount0, amount1);
}
整个调用过程如下:
(2)Skim 问题
Pair 合约拥有一个 skim 函数(下方详情),该函数会将 pair 合约中超出储备量的代币发送到调用者指定地址,数量计算方式是根据 pair 合约所拥有的代币数量与储备量之间的差值来实现的,这本身是一个平衡 pair 供应量的功能,但遇到其中一个代币为通缩代币,便可能出现问题。
通缩代币在交易过程中会扣取一部分的费用,那么如果在 skim 函数中代币转账过程扣取的费用是由 from“买单”,会出现什么问题呢?
此时扣取的费用将会是 pair 的供应量,这样就能提前向 pair 中转入代币,通过不断的 skim 函数与 sync 函数消耗掉 pair 的供应量,使得该种代币在 pair 中的价格不断飙升,最终使用少部分该通缩代币就能兑换出大量的另一种代币(一般为 usdt、eth 等价值币)。
Skim 函数代码如下:
function skim(address to) external lock {
address _token0 = token0;
address _token1 = token1;
_safeTransfer(_token0,to,IERC20(_token0).balanceOf(address(this)).sub(reserve0)); _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
整个调用过程如下:
(3)销毁问题
该问题主要出现在使用 “映射” 机制的通缩代币中,这种代币的机制是存在两种代币余额存储变量,分别为 tOwned 和 rOwned,而 tOwned 存储的是实际代币数量,rOwned 存储的是通过 currentRate 变量放大映射之后的值。
rOwned 的作用是什么呢?在文章开始说过,通缩代币能激励用户持有代币,这种激励目的使用的方式便是对交易者扣除 rOwned 值,同时扣除 rTotal,这样其他用户 rOwned 所占 rTotal 的比例就会被动增加,实现被动收益。(rOwned 与 rTotal 可理解为用户的股份以及总股份)
用户查询余额的方式有两种情况,一种是除外地址,直接返回 tOwned 的值,另一种是非除外地址,返回 rOwned/currentRate,而 currentRate 计算方式为 rTotal/tTotal。如果有办法使得 rTotal 减小,那么用户查询出的实际余额将变大,而如果 pair 查询余额变大,则可以通过 skim 函数将多余的代币转移出去。
而该类通缩代币存在一个 deliver() 函数,非除外地址可调用,该函数会将调用者的 rOwned 销毁,并销毁相同数量的_rTotal,使得所有非除外地址的余额查询增加,pair 如果非除外的话,便可使用上述方式套利攻击。
3 通缩代币相关安全事件剖析
(1)AES 安全事件
北京时间 2023 年 1 月 30 日,Beosin 旗下 Beosin EagleEye 安全风险监控、预警与阻断平台监测到,AES 遭受到黑客攻击,该项目便存在上述的 Skim 问题。
AES-USDT pair 合约有一个 skim 函数,该函数可以强制平衡 pair 的供应量,将多余资金发送给指定地址。
攻击者在本次攻击过程中,首先向 pair 里面直接转入了部分 AES 代币,导致供应量不平衡,从而攻击者调用 skim 函数时,会将多余的这部分代币转到攻击者指定地址,而攻击者在此处指定了 pair 合约为接收地址,使得多余的 AES 又发送到了 pair 合约,导致强制平衡之后 pair 合约依然处于不平衡状态,攻击者便可重复调用强制平衡函数,而 AES 发送过程会调用到 AES 合约的 transfer 函数,如下图。
另外一点,当调用 AES 代币合约的 transfer 函数时,若发送者为合约设置的 pair 合约时,会将一部分费用记录在 swapFeeTotal 之中(如上图过程),在最后的时候可以统一调用 distributeFee 函数(如下图)将 swapFeeTotal 记录的费用从 pair 中转出,这里相比上述的过程,攻击者可以不用做 sync 函数调用操作,而是在最后将费用转移出去之后调用一次 sync 函数即可。
攻击者经过反复的强制平衡操作,费用记录变得异常大,基本接近 pair 的总余额,最后攻击者调用 distributeFee 函数将 pair 里面的 AES 转出,pair 的 AES 余额变得非常少,导致攻击者利用少量 AES 兑换了大量的 USDT。
(2)BevoToken 安全事件
北京时间 2023 年 1 月 30 日,Beosin 旗下 Beosin EagleEye 安全风险监控、预警与阻断平台监测到,BevoToken 遭受到闪电贷攻击,该项目便是上面所说的 “映射” 机制通缩代币。
由于 BevoToken 合约的 balanceOf 函数(如下图)并非 ERC20 标准的函数,该函数在经过一些计算处理后再返回余额,而转账或其他操作可能使前后计算返回的余额不一致,当攻击者在 swap 操作前后可凭借这个问题来操控 pair 合约的余额,从而 skim 出多余的代币。
攻击者首先在 pancake 贷出 192.5 个 BNB,之后换成约 302,877 个 BEVO 代币,再调用被攻击合约的 deliver 函数(如下图),此时_rTotal 的值减小,_rTotal 的值减小会导致_getRate 中计算的值偏小,此时 balanceOf 返回的余额则会偏大,导致攻击者能 skim 出多余的 BEVO。
之后,攻击者再将 skim 出的代币进行 deliver,此时_rTotal 的值已经很小了,在进行_getRate 计算时,会减去除外地址的 rOwned(如下图),此值固定且被攻击者在之前通过 burn 异常放大的,在最开始_rTotal 正常的时候,减去该值对结果的影响不大,但是现在_rTotal 被攻击者操控得异常小,再减去这个异常放大的固定值后,对结果产生了巨大的影响,第一次 deliver 导致 pair 计算结果偏大 3 倍,而第二次 deliver 之后,pair 计算结果则偏大了数百倍,这也是为什么攻击者获得的代币要比自己销毁的代币多得多的原因。
4 Beosin 总结
通缩项目在业务设计的时候一定要考虑到与 pair 交互的情况,自身的通缩机制是否会对 pair 产生影响。我们也建议相关项目上线前寻找专业的安全审计机构进行全面的代码以及业务的安全审计工作。
免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。