本文深入探讨了智能合约中重入攻击的原理、危害以及防御方法。文章通过具体的代码示例,详细解释了经典重入攻击和只读重入攻击的利用方式和防范措施,强调了Check-Effects-Interactions模式和重入锁Guard在保障智能合约安全中的重要性。尤其针对view函数在特定情况下可能返回过期数据的问题提出了应对方案。
学习重入攻击如何利用智能合约,以及如何使用 Check-Effects-Interactions 和重入保护来预防它们,并提供清晰的代码示例。
欢迎回到 "Solodit 清单解释" 系列。
今天,我们来探讨 重入攻击。
重入攻击是 智能合约 中最广为人知的攻击向量。它利用了一个漏洞,即在先前的执行完成之前,可以重复调用一个 函数。这使得攻击者能够操纵合约的 状态。
在本文中,我们将剖析与重入攻击相关的两个关键清单项目。我们将探讨代码示例、详细场景和经过验证的缓解技术。
这是 "Solodit 清单解释" 系列的一部分。你可以在这里找到之前的文章:
为了获得最佳体验,请打开一个包含 Solodit 清单 的标签页,以便在阅读时参考它。示例可在我的 GitHub 上找到 这里。
描述:不受信任的外部合约调用可能回调,导致意外的结果,例如多次提款或乱序事件。
补救措施:使用 Check-Effects-Interactions 模式或 重入 保护。
此漏洞的核心在于操作顺序:当智能合约与外部合约交互时,它会创建一个潜在的窗口,允许该外部合约在初始交互完成之前回调到原始合约。
如果原始合约将关键状态更改延迟到外部调用之后,恶意的重入可能会利用此延迟,从而在合约处于不一致的过渡阶段时操纵合约的状态。
考虑下面简单的 Bank
合约。它的 withdraw
函数旨在向用户发送以太币,然后更新他们的余额。
pragma solidity ^0.8.0;
contract Bank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Check: Ensure sufficient balance exists
// 检查:确保存在足够的余额
require(balances[msg.sender] >= amount, "Insufficient balance");
// Interaction: External transfer of funds
// 交互:外部资金转移
// This is where the reentrancy window opens. The attacker receives funds prematurely.
// 这是重入窗口打开的地方。攻击者过早地收到资金。
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Effect: Update balance
// 效果:更新余额
// This state change occurs *after* the external call, creating the vulnerability.
// 此状态更改发生在外部调用*之后*,从而产生漏洞。
unchecked {
balances[msg.sender] -= amount;
}
}
}
// Malicious Contract to exploit the Bank contract
// 利用 Bank 合约的恶意合约
contract Attacker {
Bank public bank;
constructor(Bank _bank) {
bank = _bank;
}
function attack() public payable {
// Step 1: Deposit funds to establish a legitimate balance for the attack
// 步骤 1:存入资金以建立用于攻击的合法余额
bank.deposit{value: 1 ether}();
// Step 2: Initiate the withdrawal. This call triggers the reentrancy.
// 步骤 2:启动提款。此调用触发重入。
bank.withdraw(1 ether);
// Step 3: Any remaining stolen funds held by this contract are sent back to the original caller.
// 步骤 3:此合约持有的任何剩余被盗资金都会被发送回原始调用者。
msg.sender.call{value: address(this).balance}("");
}
// This fallback function is automatically executed when this contract receives Ether.
// 当此合约收到以太币时,会自动执行此 fallback 函数。
// It's the core of the reentrancy logic.
// 它是重入逻辑的核心。
fallback() external payable {
// Continuously call withdraw as long as the Bank contract has funds
// 只要 Bank 合约有资金,就持续调用 withdraw
// and the attacker wants to withdraw (e.g., 1 ether per re-entry).
// 并且攻击者想要提款(例如,每次重入 1 以太币)。
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
// The condition `address(bank).balance >= 1 ether` prevents unending recursion
// 条件 `address(bank).balance >= 1 ether` 阻止了永无止境的递归
// by stopping when the bank is sufficiently drained.
// 通过在银行被充分耗尽时停止来防止递归。
}
}
这种特定的重入攻击按以下步骤展开:
攻击者通过外部拥有的帐户 ( EOA) 启动该过程,首先为 Attacker
合约提供资金,然后调用 Attacker.attack()
。
在 Attacker.attack()
内部,攻击者首先将 1 个以太币 deposit
存入 Bank
合约,以建立合法的余额。
接下来,Attacker
合约调用 bank.withdraw(1 ether)
。
在 Bank.withdraw()
函数中:
require(balances[msg.sender] >= amount)
检查通过,因为攻击者的余额足够。
行 (bool success, ) = msg.sender.call{value: amount}("");
执行,向 Attacker
合约发送 1 个以太币。
至关重要的是,此时,Bank.withdraw()
中的 balances[msg.sender] -= amount;
行尚未执行。 关于攻击者余额的 Bank
合约的状态仍然是 1 ether
。
收到以太币后,会自动触发 Attacker
合约的 fallback()
函数。
在 Attacker.fallback()
内部:
它检查 if (address(bank).balance >= 1 ether)
。由于 Bank
合约仍然持有资金(例如,最初的 10 个 ETH 加上 1 个 ETH 的存款),因此此条件为真。
fallback()
函数递归地_再次_调用 bank.withdraw(1 ether)
。
第 4-7 步重复。每次递归调用 bank.withdraw()
都会成功,因为 balances[msg.sender]
尚未从_先前_调用中减少。这允许攻击者重复提取资金。
此过程一直持续到 Bank
合约的以太币储备被大量耗尽,特别是当 address(bank).balance
低于 1 ether
的提款金额时,这会停止递归。
仅在 Bank
合约基本被清空后,对 Bank.withdraw()
的原始调用和所有递归调用最终完成其执行,并且 balances[msg.sender] -= amount;
行会减少攻击者每次提款的余额。但是,到那时,攻击者已经提取了比他们合法存入的金额多得多的以太币。
为了解决此重入漏洞,遵守 Check-Effects-Interactions 模式至关重要。此模式要求函数内的操作具有严格的顺序:
通过在进行任何外部调用_之前_更新所有内部状态变量,合约可确保即使发生重入调用,其内部状态也是一致且正确的。然后,任何重入调用都将基于更新后的正确状态进行操作,从而防止非法提款。
将此模式应用于 Bank
合约仅涉及重新排序 withdraw
函数中的行:
pragma solidity ^0.8.0;
contract FixedBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Check: Validate balance
// 检查:验证余额
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effect: Update balance *before* sending - Vulnerability fixed here!
// 效果:在发送*之前*更新余额 - 此处修复了漏洞!
balances[msg.sender] -= amount;
// Interaction: External transfer of funds *after* state update
// 交互:状态更新*之后*的外部资金转移
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
现在,如果发生重入调用,balances[msg.sender] 将已被减少。随后任何试图提取超过调整后余额的尝试都会立即使 require(balances[msg.sender] >= amount) 检查失败,从而防止重入攻击。
针对重入攻击的另一种防御措施是实施 重入保护。此机制利用 state 变量(例如布尔值或枚举),并结合 修饰符 来确保函数一次只能执行一次。调用受保护的函数时,state 变量设置为 "locked" state,并且随后对同一函数的任何重入调用都将 reverted,从而有效地阻止攻击。
OpenZeppelin 的 ReentrancyGuard 合约被广泛采用且经过全面测试,可以继承以实现此保护。此外,OpenZeppelin 还提供了 ReentrancyGuardTransient,它使用瞬态 storage 来实现相同的保护,并降低 gas 成本,使其成为兼容环境的有效替代方案。
通过优先考虑 Check-Effects-Interactions 模式并采用重入保护,开发人员可以有效地降低与重入漏洞相关的风险。
ENTERED
,以防止返回陈旧数据。这个漏洞更加微妙,并且经常被忽视。虽然 view
函数并非旨在修改合约的状态,但如果在外部调用期间调用它们,而该调用暂时使合约处于不一致的状态,它们仍然可能成为攻击的媒介。这种情况被称为 只读重入。
核心问题是,view
函数可能会读取在正在进行的外部调用期间部分更新或处于临时、不一致配置中的状态变量。如果其他关键合约逻辑或外部协议依赖于这些 view
函数返回的准确性,则它们可能会根据 "陈旧" 或被操纵的数据做出有缺陷的决策。这可能导致经济损失或协议泄露。
考虑一个简化的借贷协议,该协议与 Vault
合约集成,使用 Vault
的份额价格来确定贷款的抵押品价值。
pragma solidity ^0.8.19;
// Vault that issues shares for ETH deposits
// 为 ETH 存款发行份额的 Vault
contract Vault is ReentrancyGuard {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowances;
uint256 public totalShares;
uint256 public totalBalance; // Track ETH balance internally
// 在内部跟踪 ETH 余额
function deposit() external payable nonReentrant {
uint256 sharesToMint = msg.value; // 1:1 for simplicity
// 为了简单起见,1:1
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value; // Update internal balance tracker
// 更新内部余额跟踪器
}
function withdraw(uint256 shareAmount) external nonReentrant {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount; // Update totalShares BEFORE external call - VULNERABILITY
// 在外部调用之前更新 totalShares - 漏洞
// External call with ETH transfer - totalBalance not yet updated creates inflated price
// 使用 ETH 转移的外部调用 - 尚未更新的 totalBalance 会产生虚高的价格
(bool success,) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
totalBalance -= ethAmount; // Update totalBalance AFTER external call
// 在外部调用之后更新 totalBalance
}
// Returns ETH value per share - can be manipulated during reentrancy
// 返回每股 ETH 价值 - 可以在重入期间被操纵
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (totalBalance * 1e18) / totalShares; // Use internal balance tracker
// 使用内部余额跟踪器
}
// Approve another address to transfer shares on your behalf
// 批准另一个地址代表你转移股份
function approve(address spender, uint256 amount) external {
allowances[msg.sender][spender] = amount;
}
// Transfer shares from one address to another (requires approval)
// 将股份从一个地址转移到另一个地址(需要批准)
function transferFrom(address from, address to, uint256 amount) external {
require(shares[from] >= amount, "Insufficient shares");
if (from != msg.sender) {
require(allowances[from][msg.sender] >= amount, "Insufficient allowance");
allowances[from][msg.sender] -= amount;
}
shares[from] -= amount;
shares[to] += amount;
}
}
// Lending protocol that uses vault share price as collateral oracle
// 使用 vault 份额价格作为抵押品预言机的借贷协议
contract LendingProtocol {
Vault public vault;
mapping(address => uint256) public collateralShares;
mapping(address => uint256) public debt;
constructor(Vault _vault) {
vault = _vault;
}
function fund() external payable {} // For funding the lending pool
// 用于资助借贷池
function depositCollateral(uint256 shareAmount) external {
require(vault.shares(msg.sender) >= shareAmount, "Insufficient vault shares");
// Transfer shares from user to this contract as collateral
// 将股份从用户转移到此合约作为抵押品
vault.transferFrom(msg.sender, address(this), shareAmount);
collateralShares[msg.sender] += shareAmount;
}
function borrow() external {
// This function calls vault.getSharePrice() to determine collateral value.
// 此函数调用 vault.getSharePrice() 来确定抵押品价值。
// During a reentrancy, vault.getSharePrice() might return an inflated value,
// 在重入期间,vault.getSharePrice() 可能会返回虚高的值,
// allowing the borrower to take out more than their collateral is truly worth.
// 允许借款人提取超过其抵押品真正价值的金额。
uint256 sharePrice = vault.getSharePrice();
uint256 collateralValue = (collateralShares[msg.sender] * sharePrice) / 1e18;
uint256 maxBorrow = collateralValue * 99 / 100; // 99% LTV for maximum impact
// 99% 的 LTV 以获得最大影响
require(maxBorrow > debt[msg.sender], "Insufficient collateral");
uint256 borrowAmount = maxBorrow - debt[msg.sender];
require(address(this).balance >= borrowAmount, "Insufficient lending pool funds");
debt[msg.sender] += borrowAmount;
(bool success,) = msg.sender.call{value: borrowAmount}("");
require(success, "Borrow transfer failed");
}
}
// Attacker exploits read-only reentrancy for profit
// 攻击者利用只读重入来获利
contract Attacker {
Vault vault;
LendingProtocol lending;
uint256 private investment;
bool private attacking = false;
constructor(Vault _vault, LendingProtocol _lending) {
vault = _vault;
lending = _lending;
}
function exploit() external payable {
investment = msg.value;
// 1. Attacker deposits ETH to Vault to obtain shares.
// 1. 攻击者将 ETH 存入 Vault 以获取股份。
vault.deposit{value: investment}();
uint256 shareAmount = investment / 2; // Use half for collateral, half for withdrawal later
// 使用一半作为抵押品,一半作为稍后提款
// 2. Approve the lending protocol to transfer a portion of shares for collateral.
// 2. 批准借贷协议转移一部分股份作为抵押品。
vault.approve(address(lending), shareAmount);
// 3. Deposit a portion of shares (e.g., half) as collateral into the lending protocol.
// 3. 将一部分股份(例如,一半)作为抵押品存入借贷协议。
lending.depositCollateral(shareAmount);
// 4. Initiate a withdrawal of the remaining shares from the Vault.
// 4. 启动从 Vault 提款剩余股份。
// This call will trigger the Attacker's receive() function *before*
// 此调用将在 *之前* 触发 Attacker 的 receive() 函数
// the vault's totalBalance is fully updated, creating the reentrancy window.
// vault 的 totalBalance 被完全更新,从而创建重入窗口。
attacking = true;
vault.withdraw(investment - shareAmount); // withdraw the other half of shares
// 提取另一半股份
attacking = false;
}
// This function is automatically triggered when Vault.withdraw() sends ETH to the Attacker.
// 当 Vault.withdraw() 向 Attacker 发送 ETH 时,会自动触发此函数。
receive() external payable {
if (attacking) {
// **During reentrancy**:
// **在重入期间**:
// The Vault's `totalShares` has been reduced by `withdraw()`.
// Vault 的 `totalShares` 已被 `withdraw()` 减少。
// However, the Vault's `totalBalance` has NOT yet been reduced.
// 但是,Vault 的 `totalBalance` 尚未减少。
// This temporary state inconsistency causes `getSharePrice()` to return an inflated value.
// 这种临时状态不一致会导致 `getSharePrice()` 返回虚高的值。
// The attacker immediately requests to borrow from the lending protocol.
// 攻击者立即请求从借贷协议借款。
// The lending protocol uses the inflated share price to calculate collateral value.
// 借贷协议使用虚高的份额价格来计算抵押品价值。
try lending.borrow() {} catch {
// If the borrow fails (e.g., due to insufficient lending pool funds), catch the error.
// 如果借款失败(例如,由于借贷池资金不足),请捕获错误。
}
}
}
}
这种复杂的只读重入攻击通过以下步骤展开:
易受攻击的 Vault.withdraw()
函数:
Vault
合约中的 withdraw
函数首先根据当前的 totalBalance
和 totalShares
计算 ethAmount
(要发送的以太币)。
然后,它继续更新用户的 shares
,并且至关重要的是,在对 msg.sender
进行外部 call
以转移以太币之前更新 totalShares
。
关键缺陷:totalBalance
仅在外部调用完成后更新。
这创建了一个特定的重入窗口:totalShares
已经减少,但相对于份额而言,totalBalance
仍然持有其较大、提款前的值。这种暂时的状态不一致导致 getSharePrice()
函数报告虚高的份额价格。例如,如果一个 vault 以 10 ETH 和 10 股开始(价格为 1 ETH/股),并且一个用户提取了两股,则 totalShares
变为八股,但在 ETH 转移完全处理之前,totalBalance
暂时保持为 10 ETH。此时,getSharePrice()
将计算 (10 * 1e18) / 8
,从而导致 1.25 ETH/股的虚高价格。
值得注意的是,公共状态更改函数 deposit()
和 withdraw()
受 nonReentrant
修饰符保护,可防止直接重入这些函数。但是,getSharePrice()
函数是一个 view 函数,没有此保护,允许在重入窗口期间调用它。
LendingProtocol.borrow()
的依赖性:
LendingProtocol
旨在允许用户根据其抵押的 Vault 份额进行借款。
当调用 LendingProtocol.borrow()
时,它会查询 vault.getSharePrice()
以确定抵押品的当前价值。
由于 LendingProtocol
依赖于外部 view
函数来进行关键估值,因此如果该 view
函数返回陈旧或被操纵的值,它就会变得容易受到攻击。
通过 Attacker.exploit()
和 Attacker.receive()
执行攻击:
第 1 阶段:设置( Attacker.exploit()
):
攻击者将以太币存入 Vault
以获取 Vault
份额。
他们批准 LendingProtocol
转移一部分这些份额。
然后,他们将这部分份额作为抵押品存入 LendingProtocol
。
最后,攻击者使用其_剩余_份额调用 vault.withdraw()
。此策略性调用会启动重入。
第 2 阶段:重入(由 vault.withdraw
的外部调用触发的 Attacker.receive()
):
当 vault.withdraw()
将以太币发送到 Attacker
的合约 地址 时,会自动触发 Attacker
的 receive()
函数。
在这个精确的时刻,Vault
合约处于其不一致的状态,因为 totalShares
已经减少,但 totalBalance
尚未减少。
在 Attacker.receive()
内部,攻击者立即调用 lending.borrow()
。
当 lending.borrow()
执行时,它会从 vault.getSharePrice()
获取 sharePrice
。由于 Vault 的暂时不一致状态,vault.getSharePrice()
返回暂时虚高的份额价格。
lending.borrow()
使用此人为虚高的价格计算 collateralValue
,允许攻击者借入_更多_以太币,而不是其实际抵押品应该允许的金额。
第 3 阶段:清理:
在 Attacker.receive()
完成后,原始的 vault.withdraw()
调用恢复并最终更新 totalBalance
。Vault 合约恢复到一致的状态。
已经成功超额借款的攻击者从借贷协议中提取利润(如果借贷协议有足够的资金)。
这种复杂的攻击表明了为什么 view
函数必须返回准确的值。当其他协议依赖于它们时,即使是微小的不一致也可能导致重大漏洞。
view
函数本身,而是源于 Vault.withdraw()
函数。我们再次注意到,withdraw()
函数受到 nonReentrant
修饰符的保护,这可以防止直接重入。开发人员可能认为这使得忽略 Check-Effects-Interactions 模式是安全的。但是,正如我们所见,这允许 getSharePrice()
函数在重入窗口期间返回陈旧的值,从而导致只读重入攻击。最直接的修复方法是在 withdraw 中正确实现 Check-Effects-Interactions 模式。通过将 totalBalance -= ethAmount; 行移动到外部调用之前,消除了暂时的不一致,确保即使以递归方式调用,getSharePrice() 也始终返回一致的值。
请注意,我们不能直接在 view
函数上使用 nonReentrant 修饰符,因为它会阻止它们在只读上下文中被调用。相反,我们可以使用自定义修饰符,在允许访问关键 view
函数之前检查重入保护状态。这确保了如果一个函数当前正在执行并且已经锁定了重入保护,则任何调用 view
函数的尝试都将 revert,从而防止它返回陈旧数据。
// Fixed Vault contract
// 修复后的 Vault 合约
contract FixedVault is ReentrancyGuard {
...
function withdraw(uint256 shareAmount) external nonReentrant {
// Check
// 检查
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
// Effect 1: Calculate amount *before* state changes
// 效果 1:在状态更改*之前*计算金额
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
// Effect 2: Update ALL relevant state *before* external call
// 效果 2:在外部调用*之前*更新 ALL 相关状态
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalBalance -= ethAmount; // This line is now moved BEFORE the external call
// 此行现在移到外部调用之前
// Interaction (now safe)
// 交互(现在安全)
(bool success,) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
}
// Checking _reentrancyGuardEntered ensures it cannot be called if *another*
// 检查 _reentrancyGuardEntered 确保如果*另一个*(如 withdraw)
// function (like withdraw) has engaged the reentrancy guard.
// 函数已启用重入保护,则无法调用此函数。
// This provides an extra layer of protection by preventing access to potentially
// 这通过防止访问潜在的
// stale data during an unsafe state window, even before the Check-Effects-Interactions
// 在不安全的状态窗口期间返回陈旧的数据,即使在 Check-Effects-Interactions
// pattern fully fixes the root cause of the inconsistency.
// 模式完全修复了不一致的根本原因之前。
function getSharePrice() public view returns (uint256) {
if (_reentrancyGuardEntered()) {
revert ReentrancyGuardReentrantCall();
}
if (totalShares == 0) return 1e18;
return (totalBalance * 1e18) / totalShares;
}
}
通过在 withdraw
函数中正确实现 Check-Effects-Interactions 模式(移动 totalBalance
更新),可以解决暂时的状态不一致。
此外,通过向函数 getSharePrice()
添加额外的检查 _reentrancyGuardEntered()
,可以直接阻止重入调用。如果 withdraw
(或任何其他具有 nonReentrant
的函数)当前正在执行,因此锁定了重入修饰符,则在此受限期间内任何调用 getSharePrice()
的尝试都将 revert。
此保护措施可防止在合约的状态可能暂时不一致时被调用 getSharePrice()
,从而确保在安全执行期间返回准确的值。
我们已经探讨了与智能合约中重入相关的关键 check 项目。通过了解攻击者如何利用延迟的状态更改和暂时不一致的数据,开发人员可以构建更强大的防御措施。分散式金融 ( DeFi) 协议的日益复杂需要从一开始就集成的强大安全措施。
主要要点:
经典重入:在外部调用_之后_更新状态变量会创建一个重入窗口,从而导致非法资金耗尽等漏洞。
Check-Effects-Interactions 模式:这是主要的防御策略,可确保所有状态更改在外部调用之前发生。
重入保护:使用状态变量(如布尔值或枚举)以及修饰符,以在执行期间锁定函数。这会 revert 任何重入调用并有效地阻止重入攻击。
只读重入:如果 view 函数从由于另一个函数启动的正在进行的外部调用而处于暂时不一致状态的合约返回数据,那么即使 view 函数也可能变得脆弱。依赖此类 "陈旧" 值的其他协议可能会做出不正确的决策。
保护 view 函数:使用 Check-Effects-Interactions 修复根本原因是必不可少的。此外,重入保护检查可以应用于关键的 view 函数,以防止在不安全的状态窗口期间进行访问。
采用这些做法可以大大降低重入攻击的威胁,并有助于构建更安全和可靠的分散式环境。
在我们的下一篇文章中,我们将通过攻击者的角度揭示智能合约安全的其他层。保持敏锐,精确编码,像对手一样思考。
- 原文链接: cyfrin.io/blog/solodit-c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!