这篇文章旨在作为一篇相对深入且最新的入门文章,详细介绍了 Solidity 开发者过去犯下的错误,以防止未来的开发者重蹈覆辙。
以太坊智能合约的特性之一,是能够调用并使用其他外部合约的代码。合约通常也会处理 ether,因此经常会将 ether 发送到各种外部用户地址。调用外部合约或向地址发送 ether 的操作,要求合约提交一次外部调用。这些外部调用可能被攻击者劫持,从而迫使合约执行更多代码(例如通过 fallback 函数),包括对自身的回调。因此,代码执行会“重入”合约。这类攻击被用在了臭名昭著的 DAO 攻击中。
关于重入攻击的进一步阅读,请参阅 智能合约重入攻击 和 Consensus - 以太坊智能合约最佳实践。
当合约向未知地址发送 ether 时,就可能发生这种攻击。攻击者可以在外部地址上精心构建一个包含恶意代码的合约,这些恶意代码位于 fallback 函数 中。因此,当合约向该地址发送 ether 时,就会触发恶意代码。通常,恶意代码会在易受攻击的合约上执行某个函数,执行开发者未预期的操作。“重入”这个名字源于这样一个事实:外部恶意合约回调易受攻击合约上的某个函数,并在易受攻击合约的任意位置“重新进入”代码执行。
为了说明这一点,考虑下面这个简单的易受攻击合约,它充当一个以太坊金库,允许存款人每周仅提取 1 ether。
EtherStore.sol:```solidity 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 数量。只有当请求提取的金额小于 1 ether 且在过去一周内没有发生过提取时,它才会成功。真的是这样吗?...
漏洞位于第 \[17\] 行,我们在那里向用户发送其请求的 ether 数量。考虑一个恶意攻击者创建如下合约,
Attack.sol:```solidity
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// initialise 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合约。攻击者会创建上述合约(假设地址为0x0...123),并将EtherStore的合约地址作为构造函数参数传入。这将初始化公共变量etherStore,使其指向我们想要攻击的合约。
然后,攻击者会调用pwnEtherStore()函数,并传入一定数量的以太币(大于或等于1),在此示例中我们假设为1 ether。在此示例中,我们假设许多其他用户已向该合约存入以太币,使其当前余额为10 ether。随后将发生以下情况:
Attack.sol - 第 [15] 行 - 将以msg.value为1 ether(以及大量gas)调用EtherStore合约的depositFunds()函数。发送者(msg.sender)将是我们恶意合约(0x0...123)。因此,balances[0x0..123] = 1 ether。
Attack.sol - 第 [17] 行 - 恶意合约随后将以参数1 ether调用EtherStore合约的withdrawFunds()函数。由于我们之前没有进行过任何提款,这将通过所有要求(EtherStore合约的第 [12]-[16] 行)。
EtherStore.sol - 第 [17] 行 - 合约随后会将1 ether发送回恶意合约。
Attack.sol - 第 [25] 行 - 发送给恶意合约的以太币随后将执行回退函数。
Attack.sol - 第 [26] 行 - EtherStore合约的总余额此前为10 ether,现在为9 ether,因此此if语句通过。
Attack.sol - 第 [27] 行 - 回退函数随后再次调用EtherStore的withdrawFunds()函数,并“重新进入”EtherStore合约。
EtherStore.sol - 第 [11] 行 - 在第二次调用withdrawFunds()时,由于第 [18] 行尚未执行,我们的余额仍然是1 ether。因此,我们仍然有balances[0x0..123] = 1 ether。lastWithdrawTime变量也是如此。同样,我们通过了所有要求。
EtherStore.sol - 第 [17] 行 - 我们再次提取1 ether。
步骤 4-8 将重复 - 直到EtherStore.balance >= 1不再成立,如Attack.sol中第 [26] 行所规定。
Attack.sol - 第 [26] 行 - 一旦EtherStore合约中剩余的以太币少于1(或等于1),此if语句将失败。这将允许执行EtherStore合约的第 [18] 和 [19] 行(对应于每次调用withdrawFunds()函数)。
EtherStore.sol - 第 [18] 和 [19] 行 - balances和lastWithdrawTime映射将被更新,执行结束。
最终结果是,攻击者通过单笔交易,瞬间从EtherStore合约中提取了所有(除1外)以太币。
有多种常见技术有助于避免智能合约中的潜在重入漏洞。第一种是(尽可能)在向外部合约发送以太币时使用内置的 transfer() 函数。transfer函数仅随外部调用发送2300 gas,这不足以让目标地址/合约再调用另一个合约(即重新进入发送合约)。
第二种技术是确保所有更改状态变量的逻辑都在以太币被发送出合约(或任何外部调用)之前完成。在EtherStore示例中,EtherStore.sol的第 [18] 和 [19] 行应放在第 [17] 行之前。将任何对未知地址执行外部调用的代码放在本地化函数或代码执行的最后一步是一种良好实践。这被称为 checks-effects-interactions 模式。
第三种技术是引入互斥锁。即添加一个状态变量,在代码执行期间锁定合约,从而防止重入调用。
将所有这些技术(三种并非全部必需,但出于演示目的均采用)应用于EtherStore.sol,得到以下无重入漏洞的合约:```solidity
contract EtherStore {