合约安全[3]: BurgerSwap重入漏洞
2021-05-29

前言

2020年10月22日,Zengo团队的Oded Leiba,发表了他在BurgerSwap中发现的重入漏洞
本文将翻译并解读漏洞的原理。

基础知识

AMM

自动做市商(AMM)是一种即时兑换模式。
通常的交易所(中心化/去中心化),需要买卖双方对价格达成一致,才能完成交易;
而AMM 只需买方认可流通池中的Token价格即可立刻完成交易。

流动性

用户将资产转入交易平台之中获得收益,而在平台中的总资产额度,就构成了流通池。
流通池中代币总额越多,深度越好,大额交易带来的影响也就更小,不至于因为一笔几十万美元的交易就让价格产生大幅波动。
Defi的一个风险点就是流通池不够大,容易产生较大滑点。

币对

币对是用于记录流通池相关信息的变量。
例如,用户想使用A币换取B币,那么就会涉及到币对 A-B
用户将一些A币转入币对A-B中,并从中取出若干B币,即可完成兑换。
币对A-B中可被取出的A币和B币总量,就对应着币对的流动性
剩余币总量越大,流动性越大。

WETH

为了让ETH能跟其他ERC20标准的Token进行交易,
实现了满足ERC20标准的WETH合约,作为ETH和Token之间兑换的桥梁。

漏洞概述

这是一个典型的重入漏洞
攻击者只需付出少量的ETH、一点点WETH-BSC流动性,即可掏空WETH-BSC币对中所有的ETH。

漏洞原理

漏洞出现在withdrawFromBSC方法中,
该方法用于赎回TokenETH

    function withdrawFromBSC(bytes calldata _signature, bytes32 _paybackId, address _token, uint _amount) external payable {
        require(executedMap[_paybackId] == false, "ALREADY_EXECUTED");
        
        require(_amount > 0, "NOTHING_TO_WITHDRAW");
        require(msg.value == developFee, "INSUFFICIENT_VALUE");
        
        bytes32 message = keccak256(abi.encodePacked(_paybackId, _token, msg.sender, _amount));
        require(_verify(message, _signature), "INVALID_SIGNATURE");
        
        if(_token == WETH) {
            IWETH(WETH).withdraw(_amount);
            TransferHelper.safeTransferETH(msg.sender, _amount);
        } else {
            TransferHelper.safeTransfer(_token, msg.sender, _amount);
        }
        totalFee = totalFee.add(developFee);
        
        executedMap[_paybackId] = true;
        
        emit Withdraw(_paybackId, msg.sender, _token, _amount);
    }
  1. 函数先检查了executedMap[_paybackId]是否为false,从而判断这笔赎回交易是否已经被处理。
  2. 然后检查了用户所支付的feeValue数量和提供的签名是否有效。
  3. 接着便是调用TransferHelper进行转账操作。
  4. 最后更改executedMap[_paybackId]的值为true,以表明该赎回交易处理完成。

到这里,漏洞的本质就浮出水面了。
withdrawFromBSC方法的实现,没有遵循Checks-Effects-Interactions编码规范。

根据CEI规范合约函数在实现时,应该

  1. 先检查输入是否符合条件
  2. 再修改合约状态
  3. 最后和用户交互
    举个正确的例子:
    function withdraw(uint amount) public {
        require(balances[msg.sender] >= amount);

        balances[msg.sender] -= amount;

        msg.sender.transfer(amount);
    }

很明显,对于withdrawFromBSC方法
调用TransferHelper进行转账的操作, 应发生在executedMap[_paybackId] = true;之后。

由于TransferHelper.safeTransferETH的实现,使用的是原生的.call.value方法。

    function safeTransferETH(address to, uint value) internal {
        (bool success,) = to.call{value:value}(new bytes(0));
        require(success, 'TransferHelper: ETH_TRANSFER_FAILED');
    }

函数调用者的fallback方法将会被调用,
攻击者合约可以通过实现fallback方法,并在其中再次调用withdrawFromBSC来实现重入攻击。
转走BurgerSwap合约中所有的ETH

修复方案

Checks-Effects-Interactions 编码规范

最有效、简单的解决方案,就是将withdrawFromBSC方法中的

        if(_token == WETH) {
            IWETH(WETH).withdraw(_amount);
            TransferHelper.safeTransferETH(msg.sender, _amount);
        } else {
            TransferHelper.safeTransfer(_token, msg.sender, _amount);
        }

放到executedMap[_paybackId] = true;之后。
只要符合CEI规范,攻击合约就会因为通不过check,而无法改变函数控制流。

函数锁 lock modifier

通过实现一个lock modifier来添加函数锁概念。
当在交易中第一次进入函数时,
更新全局变量lock的值,使得modifier验证无法通过
从而限制函数之间的相互调用。

简单举例如下:

pragma solidity ^0.4.0;

contract SingleCall {
  address public lock = false;

  modifier onlyOnce {
    if (this.lock != false) throw;
    _;
  }

  function withdrawFromBSC(bytes _someParams)
  onlyOnce
  {
    lock = true;
    //some command...;
    lock = false;
  }
}

总结

BurgerSwap合约由于没有遵循Checks-Effects-Interactions 编码规范,导致了严重的重入漏洞。
作为合约的开发人员,应以此为戒,把用户交互放到修改合约状态后面。
同时,不要盲目相信市面上第三方代码的实现。
也许底层函数本身没有问题,却由于开发时不安全地调用,引入了风险。

另一方面,合约项目方可以通过赏金计划、漏洞平台等方式吸引社区中的白帽黑客来发现项目中的安全漏洞
帮助项目的完善和安全性的提升。
最后,项目方可以联系国内有区块链合约安全相关服务的厂家(比如长亭科技)来支持代码审计工作,对项目进行系统、全面的安全性检查。

引用