本文深入探讨了以太坊智能合约中臭名昭著的可重入攻击漏洞,回顾了 2016 年 DAO 被黑事件等历史教训,详细解释了可重入攻击的原理、类型和攻击流程。文章还提供了使用 CEI 模式和重入锁的防御方法,以及利用 Slither、Foundry 和 Forta 等工具进行测试和监控的最佳实践。
重入攻击是以太坊智能合约中最臭名昭著的漏洞之一,2016 年的 DAO 黑客事件(损失 6000 万美元)给开发者敲响了警钟。通过利用合约在更新其状态之前被重复调用的能力,攻击者可以耗尽资金或扰乱逻辑。本文是 智能合约安全:Solodit 检查清单系列 的一部分,探讨了 SOL-AM-ReentrancyAttack 漏洞,涵盖了两个关键问题:外部调用后的状态更改和返回过时值的 view 函数。
截至 2025 年 8 月,以太坊的权益证明系统,具有约 4500 万 gas 的区块限制和约 12 秒的区块时间,使得安全合约设计至关重要。我们将分解重入的工作原理,展示有漏洞和安全的代码,提供清晰的图表,并分享使用最新工具和标准的最佳实践,所有这些都是为开发者和审计员量身定制的,即使是那些刚接触 Solidity 的人也是如此。
重入攻击利用合约与外部合约或地址交互的方式,导致:
当恶意合约在状态更新之前(例如,发送 ETH)在外部调用期间回调到易受攻击的合约时,就会发生重入,从而利用未更改的状态。
攻击类型:
这显示了攻击者如何利用易受攻击的合约:
外部调用(例如,调用以发送 ETH)之前的状态更新会创建一个重入窗口,让攻击者回调并利用未更改的状态(例如,非零余额)。
此 Bank 合约允许在更新余额之前进行多次提款:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract VulnerableBank {
mapping(address => uint256) public balances;
// Deposit ETH
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Vulnerable withdraw function
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Interaction: Send ETH before updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Effect: Too late!
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
contract Attacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() external payable {
require(msg.value >= 1 ether, "Need 1 ETH");
bank.deposit{value: msg.value}();
bank.withdraw();
}
// Fallback re-enters withdraw
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
}
在发送 ETH 之前更新余额:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract FixedBankCEI {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Effect: Update state first
balances[msg.sender] = 0;
// Interaction: Safe external call
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
使用 OpenZeppelin 的 ReentrancyGuard:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FixedBankGuard is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
这显示了 CEI 或保护如何阻止攻击:
只读(view)函数可以在重入窗口期间返回过时的数据,从而误导其他合约(例如,使用价格数据的借贷协议)。
此 Vault 合约在重入期间具有过时的 getSharePrice:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract VulnerableVault {
mapping(address => uint256) public shares;
uint256 public totalShares;
uint256 public totalBalance;
function deposit() external payable {
uint256 sharesToMint = msg.value;
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value;
}
function withdraw(uint256 shareAmount) external {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
// Vulnerable: Call before updating totalBalance
(bool success, ) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
totalBalance -= ethAmount;
}
// Stale during reentrancy
function getSharePrice() public view returns (uint256) {
return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
}
}
contract LendingProtocol {
VulnerableVault public vault;
mapping(address => uint256) public collateralShares;
mapping(address => uint256) public debt;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function depositCollateral(uint256 shareAmount) external {
require(vault.shares(msg.sender) >= shareAmount, "Low shares");
vault.transferFrom(msg.sender, address(this), shareAmount);
collateralShares[msg.sender] += shareAmount;
}
function borrow() external {
uint256 sharePrice = vault.getSharePrice(); // Stale during reentrancy
uint256 collateralValue = (collateralShares[msg.sender] * sharePrice) / 1e18;
uint256 maxBorrow = collateralValue * 99 / 100;
require(maxBorrow > debt[msg.sender], "Low collateral");
uint256 borrowAmount = maxBorrow - debt[msg.sender];
debt[msg.sender] += borrowAmount;
payable(msg.sender).transfer(borrowAmount);
}
}
contract Attacker {
VulnerableVault public vault;
LendingProtocol public lending;
bool private attacking;
constructor(address _vault, address _lending) {
vault = VulnerableVault(_vault);
lending = LendingProtocol(_lending);
}
function exploit() external payable {
vault.deposit{value: msg.value}();
uint256 shareAmount = msg.value / 2;
vault.approve(address(lending), shareAmount);
lending.depositCollateral(shareAmount);
attacking = true;
vault.withdraw(msg.value - shareAmount);
}
receive() external payable {
if (attacking) {
lending.borrow(); // Uses stale price
}
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FixedVault is ReentrancyGuard {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowances;
uint256 public totalShares;
uint256 public totalBalance;
function deposit() external payable nonReentrant {
uint256 sharesToMint = msg.value;
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value;
}
function withdraw(uint256 shareAmount) external nonReentrant {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalBalance -= ethAmount; // Update first
(bool success, ) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
}
function getSharePrice() public view returns (uint256) {
if (_reentrancyGuardEntered()) revert("Reentrant call detected");
return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
}
function approve(address spender, uint256 amount) external {
allowances[msg.sender][spender] = amount;
}
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, "Low allowance");
allowances[from][msg.sender] -= amount;
}
shares[from] -= amount;
shares[to] += amount;
}
}
it("prevents reentrancy", async () => {
await attacker.attack({value: ethers.parseEther("1")});
await expect(attacker.exploit()).to.be.revertedWith("Reentrant call");
});
重入可以放大:
结合防御措施:
从 The DAO 到现代 DeFi,重入攻击表明为什么安全设计是不容谈判的。使用 CEI、重入保护和 view 函数检查可以同时阻止经典重入和只读重入。借助 Slither、Foundry 和 Forta 等工具,以及彻底的测试,你可以锁定你的合约。接下来,我们将解决像 UUPS 代理的存储冲突这样的可升级性风险。首先考虑安全性进行编码,以确保 DeFi 的安全和可信!
- 原文链接: medium.com/@ankitacode11...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!