XSURGE 闪电贷攻击事件简单分析

0x01 简介

8 月 17 日,有消息爆出 BSC 上 DeFi 协议 XSURGE 遭到闪电贷攻击,被盗金额价值 500 万美金。对此展开漏洞原理的分析。

图片

0x02 交易追踪

攻击交易信息

攻击交易截图

image-20210818174143280

截屏2021-08-18 17.42.05

截止事件分析时,该地址已经被标记为XSUGRE Hack

本以为只是一起简单的闪电贷套利攻击,在分析交易数额发现似乎不是那么回事,尝试对合约进行分析,在浏览器上找到了合约的源码

0x03 源码分析

好家伙,分析源码发现居然是重入攻击,牛的牛的。咱一步一步来看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/** 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());

// send BNB to Seller
(bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}("");
if (successful) {
// subtract full amount from sender
_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;
}

函数功能分析

  • 检查卖家售卖的余额是否小于等于卖家持有的金额
  • 计算售卖的费率并扣除
  • 发送剩余的款项
  • 更新卖家的状态
  • 记录事件

重点在这行代码

1
(bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}(""); 

使用call函数发送代币,还给了40000gas,更为关健的是未使用checks-effects-interactions模式。这不妥妥的给你安排,但是sell函数函数有nonReentrant关键词修饰,来看一下它的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor () {
_status = _NOT_ENTERED;
}

modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
}

关键字功能分析

  • 检查_status是否不等于_ENTERED

  • 先定义_status状态为_ENTERED

  • 执行被修饰函数主体
  • 更改_status状态为_NOT_ENTERED

可以看到nonReentrant可以很好的防御重入攻击,显然我们没有办法二次调用sell函数。是不是会有点可惜,没有办法攻击了。我们再来看另一个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

函数功能分析

  • 购买量不能超过本合约持有量
  • 被购买后剩余持有量是否为零
  • 计算可以兑换的Surge数量
  • 给目标账户增发Surge
  • 记录事件

在看一个函数

1
2
3
4
5
receive() external payable {
uint256 val = msg.value;
address buyer = msg.sender;
purchase(buyer, val);
}

经过上面的分析,我们没有办法实现二次sell,但是我们可以调用purchase函数啊,purchase函数未被nonReentrant修饰,显然我们可以在攻击合约的fallback函数中去调用受害合约的receive()函数从而调用purchase函数,此时攻击账户的信息还没有更新,我们就可以实现购买,相当于我们把钱花出去了,但是账户的余额没有更新,我们进行售卖的时候,受害合约依旧会给我们转账。

0x04 总结

没有想到多年以后还会有重入漏洞出现,这次是以另一种形式。如果开始者不使用call或者限定为较少的gas,那么这次的攻击事件就不会发生。

修复建议:

  • 使用安全代币发送函数如tansfer
  • 限制call的gas仅能支持一次转账
  • 限制参与的用户不为合约账户

安全建议:

  • 在项目上线之前,找专业的第三方安全企业进行全面的安全审计,而且可以找多家进行交叉审计;
  • 可以发布漏洞赏金计划,发送社区白帽子帮助找问题,先于黑客找到漏洞;
  • 加强对项目的安全监测和预警,尽量做到在黑客发动攻击之前发布预警从而保护项目安全。

另外,今年BSC链上的合约屡屡遭受闪电贷攻击,对整个DEFI生态造成了严重的影响。