重入攻擊(Reentrancy Attack)是一種智能合約中的漏洞,當合約在更新自身狀態(如餘額)之前,對外部合約進行調用時,攻擊者可能利用這一點重複調用原始函數,從而執行不當操作,例如多次提領資金。這種攻擊通常發生在合約向外部地址發送以太幣(Ether)或調用外部合約時,攻擊者可通過惡意合約重新進入(re-enter)原始合約,利用未更新的狀態進行惡意操作,最終可能耗盡合約中的所有資金。
範例(存在漏洞的合約)
以下是一個易受重入攻擊的智能合約範例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Solidity_Reentrancy {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// Vulnerability: Ether is sent before updating the user's balance, allowing reentrancy.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Update balance after sending Ether
balances[msg.sender] = 0;
}
}
漏洞分析:
withdraw
函數:- 在發送以太幣(
msg.sender.call
)後才將用戶餘額設置為 0。 - 如果
msg.sender
是一個惡意合約,該合約可以在接收以太幣時立即重新調用withdraw
函數。 - 因為餘額尚未更新(仍為非零),攻擊者可以重複提領資金,直到耗盡合約的以太幣。
- 在發送以太幣(
- 問題:這種「先發送、後更新」的邏輯允許攻擊者在狀態更新前多次執行提領操作。
惡意攻擊合約範例
contract Attack {
Solidity_Reentrancy victim;
constructor(address _victim) {
victim = Solidity_Reentrancy(_victim);
}
receive() external payable {
victim.withdraw();
}
function attack() external {
victim.deposit{value: 1 ether}();
victim.withdraw();
}
}
惡意攻擊合約運作過程:
假設 victim
漏洞合約總共有 10 ETH 的資金,這些資金來自多個用戶
- 攻擊的第一步:
attack()
函式victim.deposit{value: 1 ether}();
:攻擊者先向有漏洞的victim
合約存入 1 Ether。此時,victim
漏洞合約會記錄攻擊者的餘額為 1 Ether。victim.withdraw();
:接著,攻擊者觸發第一次提款。- 漏洞合約的內部狀態如下:
victim
漏洞合約總資金:10 ETHBalances[attacker]
:1 ETH (攻擊者的餘額)
- 漏洞觸發:
victim.withdraw()
函式的執行- 檢查餘額:有漏洞的
withdraw
函式會檢查攻擊者的餘額是否足夠(require(userBalances[msg.sender] >= amount)
)。此時,餘額是 1 Ether,檢查通過。 - 轉帳(外部呼叫):接著,
withdraw
函式會執行msg.sender.call{value: amount}("")
,將 1 Ether 轉帳給攻擊者。 - 漏洞點:此時,
victim
存在漏洞的合約還沒有將攻擊者的餘額清零或減少! - 漏洞合約的內部狀態如下:
victim
漏洞合約總資金:9 ETHBalances[attacker]
:1 ETH (攻擊者的餘額),漏洞合約還沒有將攻擊者的餘額清零或減少!
- 檢查餘額:有漏洞的
- 重入點:
receive()
函式- 當
victim
漏洞合約將 1 Ether 轉給攻擊合約時,攻擊合約的receive()
函式會自動被觸發執行。 receive()
函式內部有一行關鍵程式碼:victim.withdraw();
。- 這行程式碼會讓攻擊合約再次呼叫
victim
漏洞合約的withdraw
函式。
- 當
- 循環攻擊
- 當第二次進入
victim.withdraw()
時,因為存在漏洞合約的狀態還沒有被更新,它依然認為攻擊者的餘額是 1 Ether。 - 檢查餘額通過,
victim
存在漏洞合約再次向攻擊合約轉帳 1 Ether。- 漏洞合約的內部狀態如下:
victim
漏洞合約總資金:8 ETHBalances[attacker]
:1 ETH (攻擊者的餘額),漏洞合約還沒有將攻擊者的餘額清零或減少
- 漏洞合約的內部狀態如下:
- 攻擊合約的
receive()
函式再次被觸發,再次呼叫withdraw
。 - 這個過程會一直重複,直到
victim
存在漏洞合約內的 Ether 被耗盡。- 漏洞合約的內部狀態如下:
victim
漏洞合約總資金:0 ETHBalances[attacker]
:1 ETH (攻擊者的餘額),漏洞合約還沒有將攻擊者的餘額清零或減少
- 漏洞合約的內部狀態如下:
- 當第二次進入
影響
重入攻擊可能導致以下嚴重後果:
- 資金耗盡:攻擊者可通過重複提領耗盡合約中的所有資金。
- 未授權操作:攻擊者可能觸發未經授權的功能調用,導致合約執行非預期的行為。
- 系統破壞:攻擊可能影響與合約相關的系統,造成更廣泛的損害。
修復方法
為防止重入攻擊,開發者應採取以下措施:
- 狀態優先更新:在調用外部合約或發送以太幣之前,完成所有內部狀態的更新(例如,將餘額設為 0)。
- 使用防重入保護:採用函數修飾符(如 OpenZeppelin 的
ReentrancyGuard
)來防止重入攻擊,確保函數在執行期間不會被重複調用。 - 謹慎使用外部調用:盡量減少對外部合約的調用,並確保調用安全。
修復後的範例合約
以下是修復了重入漏洞的合約範例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Solidity_Reentrancy {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// Fix: Update the user's balance before sending Ether
balances[msg.sender] = 0;
// Then send Ether
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
修復內容:
1.狀態更新優先:
- 在
withdraw
函數中,先將用戶餘額 (balances[msg.sender]
) 設為 0,然後才發送以太幣。 - 這確保即使攻擊者重新調用
withdraw
,餘額已經為 0,無法再次提領。
2.安全性提升:
- 這種「先更新、後發送」的順序杜絕了重入攻擊的可能性。
總結
重入攻擊是智能合約中最危險的漏洞之一,可能導致資金被耗盡或合約功能被濫用。通過在外部調用前更新狀態、採用防重入保護機制(如 ReentrancyGuard
)以及謹慎設計外部調用,開發者可以有效防止重入攻擊。修復後的合約展示了如何通過調整狀態更新順序來確保安全性,保護合約和用戶的資金。