在智能合约的开发过程中,一个需要考虑的重要问题即是合约是否有可能遭受重入攻击。一个最经典的重入攻击案例即为2016年的DAO项目所经历的攻击,最终造成约360万个以太币被盗窃,并直接导致了Ethereum从Ethereum Classic的硬分叉。
重入攻击的原理很简单:以太坊上的智能合约彼此之间可以相互调用。假设在一个合约A执行过程中发生了一次外部的合约B调用,并且合约B是由黑客所控制的,合约B的调用过程中可以重新进入合约A的调用。如果合约A在执行外部合约调用之前并未完成自己的内部状态更新,则有可能会被合约B利用从而盗取资产。
以下面的合约C为例:
contract C{
function deposit() external{
....
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
}
合约C提供了以太币的质押和提取接口,用户可以将一定数量的以太币质押到该合约,以使用合约提供的其他功能,如获取利息、投票等。在需要退出时,可以调用提取接口将自己原先质押的资产取回。
可以看到,合约C的withdraw
方法首先读取了当前交易者质押的资金总量,随后调用call
方法将该数量的以太币转给交易者账户。但由于msg.sender
有可能是一个合约账户,合约的fallback
方法在收到以太币后被触发,并且在fallback
方法中再次调用了合约C的withdraw
方法。当再次进入合约C的withdraw
方法时,再次读取交易者质押的资金总量,与上次完全一样。因此,恶意合约再次收到了该数量的以太币,再次进入合约C的withdraw
方法,直到合约C的余额耗尽。
另外,还有一种是跨函数的重入攻击。即在一个合约的两个方法之间共享了某些内部状态,从一个方法中调用其他合约,其他合约再次冲入到该合约的另外一个方法,最终造成类似的效果。
防止重入攻击的方法也很简单,一个是利用锁机制,另一个是编码时遵循Checks-effects-interactions
模式。
锁机制在合约内部加了一个状态变量,该状态变量用来标识合约是否被重入。在合约的关键方法被调用时,首先会判断是否已经处于锁定状态,如果是则回退所有的状态更改。如果否则将锁锁上,在执行完当前方法所有逻辑后再将锁打开。因此当外部合约再次重入到合约的内部时,合约已经被锁定,攻击无法继续。
仍然以合约C为例,采用锁机制的解决方法如下:在合约中增加了unlocked
变量,合约构造时需要将该变量初始化为true。并且为withdraw
方法都增加了修饰符onlyUnlocked
,仅在合约当前处于非重入的状态下才会真正执行资产的提取操作。
contract C{
bool unlocked;
modifier onlyUnlocked{
require(unlocked,"contract is already locked");
unlocked = false;
_;
unlocked = true;
}
function deposit() external{
....
}
function withdraw() external onlyUnlocked{
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
}
值得注意的是,上述机制定义了锁变量unlocked
来标识当前状态是否是解锁状态,同样也可以定义一个locked
变量来实现同样的功能。两者的区别在于unlocked
变量在构造时需要被设置为true,而locked
变量则节省了这一步操作。
Checks-effects-interactions
模式指的是在开发人员在编码时应遵循先检查,再更改内部状态,最后与外部合约进行交互的规范。以合约C为例,采用该模式的合约写法如下:
contract C{
function deposit() external{
....
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount != 0,"account balance is zero");
balances[msg.sender] = 0;
require(msg.sender.call.value(amount)());
}
}
在进行资产提取时,首先判断账户余额是否为0。如果是则回退状态更改,如果否则先将账户余额设置为0,最后调用call
方法进行外部转账。此时,恶意合约的fallback
方法再次调用withdraw
方法时,由于账户余额已经被清零,攻击者无法提取更多的资产。
另外,还有一种情况下无需特别考虑重入攻击问题,即合约没有保存任何内部状态。此时,即使当前合约被重入,也不存在前述的状态部分更改的问题,因此不会带来之前的严重后果。Oneswap项目中的Router
合约即属于这种类别,Router
合约本身仅负责对一些计算工作和对 Pair
合约的调用转发,即使该合约调用过程中被重入也仅仅是重复进行一些计算和调用,因此相比于Router
合约而言,Pair
合约如何防止重入攻击则显得更加重要。
相比内部账户而言,对外部账户进行转账充满了各种风险,主要的原因在于被调用的外部合约代码是不可控的。看到这里,或许你会想到可以通过extcodesize
指令将外部账户和内部账户区分开来,检查一下接收方账户的合约字节码是否为0,如果是则说明这是一个内部账户,反之则为外部账户。但事实上,即使extcodesize
指令返回值为0也无法确认这并非一个合约账户。原因在于,extcodesize
指令获取的是当前调用时相应账户的合约代码大小,而合约在初始化构造时也没有可用的运行时代码,因此此时extcodesize
指令返回值同样为0。这就说明,如果extcodesize
指令返回值不为0,那么该账户一定是一个外部账户,反之并不说明该账户就是一个内部账户。
总结
本文介绍了智能合约可能面临的重入攻击的原理、防御措施,并以Oneswap项目的Router
合约为例说明了重入攻击可能不会造成严重破坏的情况,尽管如此,开发人员需时刻保持警惕,在编码过程中尽量通过使用锁机制或Checks-effects-interactions
模式来杜绝重入攻击的一切可能入口,杜绝后患。
原文:《OneSwap Series 8 - The Evil Has a Name: Re-entrancy》
链接:https://oneswap.medium.com/oneswap-series-8-the-evil-has-a-name-re-entrancy-b293429f2d0c