[经典攻击事件分析]xSurge事件中的重入漏洞+套利的完美组合利用

2022-11-07 10:19:19 浏览数 (1)

本文作者:小驹[1]

1. 事件介绍

xSurge 被攻击事件发生在 2021-08-16 日,距离今天已经近 1 年了,为什么还会选择这个事件进行分析?主要是这个攻击过程很有意思,有以下的几点思考

  • 使用nonReentrant防止重入,又在代码中又不遵循检查-生效-交互模式(checks-effects-interactions)时,要怎么利用?
  • 该漏洞利用的代码是重入漏洞的典型代码,但利用过程却不是重入。应该是”最像重入漏洞的套利漏洞”。
  • 随着 ERC20 合约的规范化,ERC20 的合约漏洞越来越少。xSurge 会不会是 ERC20 合约最后的漏洞?
  • 价格计算机制既然可以从数学上证明存在漏洞,能不能用数学的方法来挖掘此类的漏洞?
  • 该攻击过程中与闪电贷的完美结合。所有的攻击成本只有 gas 费,漏洞利用的大资金从闪电贷中获得,攻击获利后归还闪电贷的本息。闪电贷使攻击的收益损失比大大大大地提高。

xSurge 是一个基于bsc链的 Defi 的生态系统,其代币为xSurgeToken,用户可以通过持有 xSurgeToken 获得高收益回报,同时可以将 xSurgeToken 用于其 Defi 生态中的。xSurgeToken 遵循ERC20协议,初始供应量为 10 亿枚,随着用户的对 xSurgeToken 的转入转出,xSurgeToken 的价格会动态进行调整。

在 2021-08-16 日,黑客通过xSurgeToken合约代码中的漏洞,窃取了xSurgeToken合约中的 12161 个 BNB。具体来说黑客采用闪电贷出的 10000 WBNB 做为初始资金,通过代码漏洞套利,淘空了xSurgeToken合约的 BNB 余额,同时套利使xSurgeToken的价格下降了7千多倍。

本文将重点分析和复现 xSurge 的攻击过程。

2. 漏洞分析

漏洞可以被利用主要在于两点:

  1. sell 函数中先转了 xSurge 代币,后进行了余额的减操作,导致余额会在短暂时间内留存,这笔余额被黑客用来再次购买了 xSurge。
  2. xSurge 的价格计算过程中,在有更多的买入时,会导致xSurge的价格变低,xSurge 的价格变低,又会导致可以购买更多的 xSurge,类似于滚雪球,越滚越大,最终把 xSurge 合约掏空。同时,叠加黑客利用闪电贷,贷到 10000 个 WBNB 进行攻击,这么大一笔钱对 xSurge 价格影响更大,从而导致循环调用 8 次就可以掏空 xSurge 合约。

对这前两点进行分析说明

2.1 漏洞代码分析

漏洞代码链接如下:

https://bscscan.com/address/0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21#code[2]

漏洞涉及到三个函数:

  1. sell() :卖出 xSurge,把卖出得到的 BNB 发送给出售者(也就是函数调用者)
  2. purchase() :买入指定数量的 xSurge。该函数为 internal 类型,只能由下面的 recive()函数调用。
  3. receive() :receive 不是普通函数,是 solidity 的回调函数。当 xSurge 合约收到 BNB 时,该函数会自动执行,在该函数中调用了 purchase()函数。

漏洞点在于:sell()函数中调用了call函数后才进行 balance的减法操作,攻击者通过 call 函数获得代码控制权后,在 balance 还没减掉的情况下,攻击者调用 purchase 方法进行购买,达到了类似套利的效果。

众所周知,“更高成本带来更高的收益”,黑客看到这种机会,也期望着有更多的投入来套取更大的收益,于是,攻击者为了扩大这种攻击效果,使用了闪电贷,从Pancake中贷出来 10000 个BNB进行攻击,在一个区块中经过 8 次的“套利”,获得了 12161 个 BNB。

在代码中,看到攻击利用的关键点

代码语言:javascript复制
/** Purchases SURGE Tokens and Deposits Them in Sender's Address*/
// 利用关键点3:这里调用了mint时,balance还是没有减之前的,所以mint出来的肯定会多一些。
    function purchase(address buyer, uint256 bnbAmount) internal returns (bool) {
        // make sure we don't buy more than the bnb in this contract
        require(bnbAmount <= address(this).balance, 'purchase not included in balance');
        // previous amount of BNB before we received any
        uint256 prevBNBAmount = (address(this).balance).sub(bnbAmount);
        // if this is the first purchase, use current balance
        prevBNBAmount = prevBNBAmount == 0 ? address(this).balance : prevBNBAmount;
        // find the number of tokens we should mint to keep up with the current price
        uint256 nShouldPurchase = hyperInflatePrice ? _totalSupply.mul(bnbAmount).div(address(this).balance) : _totalSupply.mul(bnbAmount).div(prevBNBAmount);
        // apply our spread to tokens to inflate price relative to total supply
        uint256 tokensToSend = nShouldPurchase.mul(spreadDivisor).div(10**2);
        // revert if under 1
        if (tokensToSend < 1) {
            revert('Must Buy More Than One Surge');
        }

        // mint the tokens we need to the buyer
        mint(buyer, tokensToSend);  **// 到这里就成功了。**
        emit Transfer(address(this), buyer, tokensToSend);
        return true;
    }

    /** Sells SURGE Tokens And Deposits the BNB into Seller's Address */
    function sell(uint256 tokenAmount) public nonReentrant returns (bool) {

        address seller = msg.sender;

        // make sure seller has this balance
        require(_balances[seller] >= tokenAmount, 'cannot sell above token amount');

        // calculate the sell fee from this transaction
        uint256 tokensToSwap = tokenAmount.mul(sellFee).div(10**2);

        // how much BNB are these tokens worth?
        uint256 amountBNB = tokensToSwap.mul(calculatePrice());

        // 利用关键点1:漏洞地方,在call中程序的控制权被转移到攻击者手里。但是在攻击balance的数量却还没有少
        (bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}("");
        if (successful) {
            // subtract full amount from sender,在这里才开始减掉balance的数量
            _balances[seller] = _balances[seller].sub(tokenAmount, 'sender does not have this amount to sell');
            // if successful, remove tokens from supply
            _totalSupply = _totalSupply.sub(tokenAmount);
        } else {
            revert();
        }
        emit Transfer(seller, address(this), tokenAmount);
        return true;
    }

// 利用关键点2:攻击合约得到程序控制权后,直接向xsurge合约进行转账,从而触发xsurge合约的receive函数的调用。这里会调用purchase函数
receive() external payable {
        uint256 val = msg.value;
        address buyer = msg.sender;
        purchase(buyer, val);
    }

2.2 xSurgeToken 价格计算方式+闪电贷

从 xSurge 的合约代码可知,xSurgeToken的基础数量为10亿,在sell时,_totalSupply会减,在purchase时,_totalSupply会增加。产生的影响是:黑客买入更多的 xSurgeToken 时,xSurgeToken 的价格会降低。

xSurge 的价格计算方法如下:xSurge 合约.balance/_totalSupply

代码语言:javascript复制
// 计算xsurge的价格,xsurge价格=this.balance/_totalSupply
    function calculatePrice() public view returns (uint256) {
        return ((address(this).balance).div(_totalSupply));
    }

可以把复现过程中,xSurge 的价格,xSurge 合约的 BNB 余额(下面 quantity 的值),xSurge 的供应量打印出来(下面_totalSupply 的值)。

代码语言:javascript复制
2 times sell
cur price:40431697 = quantity(23614362876275166045082) / _totalSupply(584055694626796)

[recevie ]AttackContract xsurge balance:290602026064411, BNB balance
 11044561081497012206562
3 times sell
cur price:30436844 = quantity(23614362876275166045082) / _totalSupply(775847945216500)

[recevie ]AttackContract xsurge balance:482394273111949, BNB balance
 13801605682969677247808
4 times sell
cur price:17900419 = quantity(23614362876275166045082) / _totalSupply(1319207269744644)

[recevie ]AttackContract xsurge balance:1025753540789277, BNB balance
 17259733080609943264480
5 times sell
cur price:6449277 = quantity(23614362876275166045082) / _totalSupply(3661551965635088)
…………

在整个攻击过程中,从 xSurge 合约的角度看,xSurge合约中的BNB没有变化(一直都是 23614362876275166045082),但xSurge供应量_totalSupply一直在增加xSurge的价格一路走低。(注意这句话中三个变量的顺序,这也是黑客攻击过程中的各个变量的变化趋势:xSurge 合约中的 BNB→xSurge 供应量_totalSupply→xSurge 的价格)。

从 sell 方法中,输入的是 xSurgeToken,输出的是 BNB,计算 BNB 的公式如下:

bnb = (0.94 * balance * xSurge) / totalSupply

公式 1

在 purchase 方法中,输入的是 BNB,输出的是 xSurgeToken,计算 xSurgeToken 的公式如下:

xSurge = (0.94*totalSupply*bnb)/(balance-bnb)

转化成

bnb=(xSurge*balance)/(0.94*totalSupply xSurge)

公式 2

在这个 sell 方法 purchase 方法调用过程中,实现套利,sell 得到的 bnb(也就是公式 1)需要大于 purchase 时输入的 bnb(也就是公式 2),所以公式 1 的右边>公式 2 的右边,得到下面的不等式.

(0.94 * balance * xSurge) / totalSupply>(xSurge*balance)/(0.94*totalSupply xSurge)
(0.94 * balance * xSurge) / totalSupply

计算得出

xSuger<((1-0.94*0.94)/0.94)*totalSupply =0.1238*totalSupply

也就是,当购买xSurgeToken投入的资本量大于0.1238*totalSupply时,可以实现套利。

我们打印出黑客攻击时的xSurgeToken的价格totalSupply,分别为 46393569, 293453665448225

需要投入的最少的 xSurge 数量为:0.1238*293453665448225 = 36329563782490.255。

所以攻击所需要的最低成本大约为:数量价格=36329563782490.25546393569 = 1685.4e18 = 1685个BNB。如果攻击时,使用少于 1685 个 BNB 的话,会导致入不敷出。

我们通过使用 1000 个 BNB 进行攻击来模拟下入不敷出的场景,在这种场景下,**xSurge 的价格会越来越高(如下图)**,最终导致连 1000 个 BNB 的本金都会亏损(在调用 11 次 sell 后,1000 个 BNB 只会剩下 426 个)。

攻击者因此采用了闪电贷的方式获得 10000 个 BNB 到 xSurge 合约后进行攻击,换出一大笔 xSurgeToken,在 xSurge 合约 sell 中代币余额没有减的漏洞,在调用 xSurge 的 purchase 时,_totalSupply 会增大,导致价格减少,循环下去,_totalSupply 越来越大,xSurge 价格越来越小。

价格越来越小,就导致在这 10000 个 BNB 的本钱下,可换出来的 xSurge 越来越多。

3.攻击过程分析

根据交易的 tx: 0x7e2a6ec08464e8e0118368cb933dc64ed9ce36445ecf9c49cacb970ea78531d2 ,可以看到黑客通过部署了攻击合约,并调用攻击合约进行攻击,在攻击合约中使用了 Pancake 的闪电贷功能,所有的攻击过程都在 PancakePair.swap 的闪电贷方法中进行。

在闪电贷中,通过查看调用 swap 方法时的参数,其中的 data 数据就是闪电货的回调函数,攻击合约贷出来的币会在该回调函数中进行套利使用。

重点看下闪电贷中的回调函数操作:

主要操作如下:

  1. 将闪电贷中贷出来的 10000 BNB 转移至攻击合约中。做为攻击时套利时的本钱。
  2. 开始利用漏洞套利。
    1. 计算当前 xSurge 的价格。
    2. 攻击合约将所有的 BNB 买入 xSurge。(买入了202,610,205,836,752 xSurge)
    3. 攻击合约调用 xSurge 的 sell 方法,将 xSurge 卖掉,同时得到 BNB(得到了9066.40333453581 BNB,

      0 人点赞