solidity-重入攻击全景-学习文档

· ☕ 8 分钟阅读
solidity-重入攻击全景-学习文档

Solidity 重入攻击全景学习文档

基于 RareSkills 文章整理,面向有 Solidity 基础的中级开发者 参考来源:https://rareskills.io/post/where-to-find-solidity-reentrancy-attacks


一、什么是重入攻击(Reentrancy)?

重入攻击是指:合约在执行过程中将控制权交给外部合约,外部合约趁状态尚未更新,**重新进入(re-enter)**原合约的函数,利用不一致的状态获利。

一句话: 你还没改完账本,别人就趁机再来取一次钱。


二、重入攻击发生的根本条件

重入攻击需要同时满足以下条件:

条件 1: 合约向外部地址/合约发送 ETH 或调用外部合约
        (控制权转移给外部代码)

条件 2: 状态更新发生在外部调用之后
        (先调用,后记账)

条件 3: 攻击者可以在回调中重新调用原合约
        (利用未更新的状态再次执行逻辑)

三、在哪里找到重入攻击入口?(Where to Find)

3.1 发送 ETH 的三种方式

// 方式 1: transfer(2300 gas 限制,基本安全但已不推荐)
payable(to).transfer(amount);

// 方式 2: send(2300 gas 限制,基本安全但已不推荐)
bool sent = payable(to).send(amount);

// 方式 3: call(无 gas 限制,最危险!)⚠️
(bool sent, ) = to.call{value: amount}("");

.call{value: ...}("") 是最常见的重入攻击入口,因为它转发所有可用 Gas 给接收方,接收方可以在 receive()fallback() 中执行任意复杂逻辑。

3.2 Token 标准中的回调 Hook

这是 RareSkills 文章的核心重点:很多开发者忽视了 token 回调也是重入入口!

Token 标准回调函数触发方法重入风险
ERC-721onERC721Received()safeTransferFrom()⚠️ 高
ERC-1155onERC1155Received()safeTransferFrom()⚠️ 高
ERC-1155onERC1155BatchReceived()safeBatchTransferFrom()⚠️ 高
ERC-777tokensReceived()send() / transfer()⚠️ 极高
ERC-1363onTransferReceived()transferAndCall()⚠️ 高
ERC-20无回调transfer() / transferFrom()✅ 低(无回调)

3.3 ERC-20 为什么相对安全?

// 标准 ERC-20 transfer 不调用任何接收方代码
function transfer(address to, uint256 amount) public returns (bool) {
    _balances[msg.sender] -= amount;
    _balances[to] += amount;
    emit Transfer(msg.sender, to, amount);
    return true;
    // ← 没有外部调用!接收方不知道自己收到了 token
}

但注意: ERC-777 的 transfer() 看起来和 ERC-20 一样,实际会触发 tokensReceived() hook!

3.4 ERC-721/1155 的 safeTransferFrom 流程

function safeTransferFrom(address from, address to, uint256 tokenId) public {
    // Step 1: 权限检查
    require(isApprovedOrOwner(msg.sender, tokenId));

    // Step 2: 转移 token
    _transfer(from, to, tokenId);

    // Step 3: ⚠️ 如果 to 是合约,调用其 onERC721Received
    if (to.code.length > 0) {
        require(
            IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "") ==
            IERC721Receiver.onERC721Received.selector
        );
    }
    // ← 如果有其他状态还未更新,此处可被攻击者利用
}

3.5 完整攻击入口清单

ETH 转账:
├── address.call{value: ...}("")     ← 最常见
├── address.transfer()               ← 2300 gas 限制,基本安全
└── address.send()                   ← 同上

Token 回调:
├── ERC-721 safeTransferFrom → onERC721Received()
├── ERC-721 safeMint → onERC721Received()
├── ERC-1155 safeTransferFrom → onERC1155Received()
├── ERC-1155 safeBatchTransferFrom → onERC1155BatchReceived()
├── ERC-777 send/transfer → tokensReceived()
├── ERC-1363 transferAndCall → onTransferReceived()
└── ERC-1363 approveAndCall → onApprovalReceived()

低级调用:
├── address.call(data)               ← 通用调用
└── address.delegatecall(data)       ← 代理调用

四、重入攻击的四种类型

4.1 单函数重入(Single-Function Reentrancy)

最经典的形式 —— The DAO Hack(2016年)

// ❌ 有漏洞的合约
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0);

        // ⚠️ 先发钱(控制权转移)
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent);

        // 后记账(攻击者在上面的 call 中重入 withdraw,此时余额还没清零)
        balances[msg.sender] = 0;
    }
}

攻击者合约:

contract Attacker {
    VulnerableVault vault;

    receive() external payable {
        // 在收到 ETH 时重新进入 withdraw
        if (address(vault).balance >= 1 ether) {
            vault.withdraw();  // 重入!balances 还没被设为 0
        }
    }

    function attack() external payable {
        vault.deposit{value: 1 ether}();
        vault.withdraw();  // 触发第一次
    }
}

4.2 跨函数重入(Cross-Function Reentrancy)

攻击者在回调中不是重入同一个函数,而是调用同一合约的另一个函数。

contract VulnerableToken {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount);

        // ⚠️ 先发 ETH
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent);

        // 后更新余额
        balances[msg.sender] -= amount;
    }

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

攻击:withdraw 的 callback 中调用 transfer,因为 balances[attacker] 还没被扣减,可以把余额”转走”一份再提取一份 → 双倍获利。

4.3 跨合约重入(Cross-Contract Reentrancy)

涉及多个合约之间的状态不一致。

// 合约 A: 负责管理 shares
contract VaultA {
    mapping(address => uint256) public shares;
    TokenB public token;

    function withdraw(uint256 shareAmount) external {
        uint256 tokenAmount = shareAmount * token.totalAssets() / totalShares();
        shares[msg.sender] -= shareAmount;

        // ⚠️ 调用外部合约
        token.transfer(msg.sender, tokenAmount);
    }
}

// 合约 B: 是一个 ERC-777 token
// 当 transfer 时,tokensReceived() 被触发
// 攻击者在 tokensReceived() 中调用 VaultA 的其他函数
// 此时 VaultA 的 shares 可能已更新,但 token.totalAssets() 还未反映新状态

4.4 只读重入(Read-Only Reentrancy)

最隐蔽的形式 —— 不修改被攻击合约的状态,但利用其 view 函数返回的陈旧值。

contract LiquidityPool {
    uint256 public totalDeposits;

    function withdraw(uint256 amount) external {
        totalDeposits -= amount;

        // ⚠️ 此时 totalDeposits 已更新,但 ETH 还没发出
        // 如果另一个协议在这期间读取 getPrice()...
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent);
    }

    // view 函数,其他协议用来获取价格
    function getPrice() external view returns (uint256) {
        return address(this).balance * 1e18 / totalDeposits;
        // ⚠️ balance 还包含即将发出的 ETH,但 totalDeposits 已扣减
        // → 价格被虚高!
    }
}

攻击流程:

  1. 攻击者调用 withdraw
  2. totalDeposits 被扣减
  3. ETH 发送,控制权进入攻击者的 receive()
  4. 攻击者在 receive() 中调用另一个协议(如借贷协议)
  5. 该协议调用 pool.getPrice() 获取价格 → 得到虚高价格
  6. 攻击者利用虚高价格获取超额借款

五、防御措施

5.1 Checks-Effects-Interactions 模式(CEI)

// ✅ 安全版本
function withdraw() external {
    uint256 amount = balances[msg.sender];

    // Checks:检查条件
    require(amount > 0, "No balance");

    // Effects:更新状态(先记账!)
    balances[msg.sender] = 0;

    // Interactions:最后才进行外部调用
    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent, "Transfer failed");
}

5.2 ReentrancyGuard(互斥锁)

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0);

        balances[msg.sender] = 0;
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent);
    }
}

原理:

// OpenZeppelin ReentrancyGuard 简化版
uint256 private _status;
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;

modifier nonReentrant() {
    require(_status != ENTERED, "ReentrancyGuard: reentrant call");
    _status = ENTERED;
    _;
    _status = NOT_ENTERED;
}

5.3 Pull 模式(Pull over Push)

// ✅ 不主动发 ETH,让用户自己来取
contract PullPayment {
    mapping(address => uint256) public pendingWithdrawals;

    function allowWithdrawal(address user, uint256 amount) internal {
        pendingWithdrawals[user] += amount;
    }

    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0);
        pendingWithdrawals[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

5.4 针对只读重入的防御

// 方法 1: 给 view 函数也加锁
modifier nonReentrantView() {
    require(_status != ENTERED, "Reentrancy: view during mutation");
    _;
}

function getPrice() external view nonReentrantView returns (uint256) {
    return address(this).balance * 1e18 / totalDeposits;
}

// 方法 2: 在外部调用前确保所有状态一致
function withdraw(uint256 amount) external nonReentrant {
    // ✅ 先更新所有相关状态
    totalDeposits -= amount;
    // balance 的变化和 totalDeposits 的变化保持一致
    // 此时即使有人读 getPrice(),返回值也是一致的

    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent);
}

六、各类型重入的防御总结

重入类型CEI 能防ReentrancyGuard 能防特殊措施
单函数重入-
跨函数重入⚠️ 需要所有函数都遵循所有共享状态的函数都加 nonReentrant
跨合约重入⚠️ 难以保证⚠️ 只保护单个合约全局锁或协议级设计
只读重入❌ 不够⚠️ 需额外措施view 函数加锁 / 状态一致性优先

七、真实世界攻击案例

7.1 The DAO Hack(2016)

  • 类型: 单函数重入
  • 损失: 约 $60M(导致以太坊硬分叉)
  • 原因: withdraw 先发 ETH 后更新余额
  • 教训: 催生了 Checks-Effects-Interactions 模式

7.2 ERC-777 相关攻击(多起)

  • 类型: 跨函数/跨合约重入
  • 原因: ERC-777 的 tokensReceived hook 在每次 transfer 时触发
  • 受害者: Uniswap V1(2020)、imBTC 等
  • 教训: ERC-777 与未做防护的协议组合极度危险

7.3 只读重入攻击(2024-2026)

  • 类型: Read-only reentrancy
  • 原因: view 函数在外部调用窗口期返回不一致状态
  • 受害者: 多个 DeFi 借贷协议
  • 教训: view 函数也需要重入保护

八、代码审计检查清单

□ 是否有 .call{value: ...}("") ?
□ 是否使用了 ERC-721/1155 的 safeTransferFrom ?
□ 是否使用了 ERC-777 token ?
□ 是否使用了 ERC-1363 的 transferAndCall ?
□ 外部调用前状态是否已完全更新?(CEI)
□ 关键函数是否加了 nonReentrant ?
□ 跨函数是否共享可被利用的状态?
□ view 函数在外部调用窗口期是否返回一致值?
□ 是否有跨合约的状态依赖?

九、关键概念回顾

  1. 控制权转移 = 重入风险 — 任何外部调用都可能被利用
  2. ERC-20 相对安全 — 无回调机制,不转移控制权
  3. safeTransfer 类方法有回调 — ERC-721/1155/777/1363 都是攻击面
  4. 四种重入类型 — 单函数、跨函数、跨合约、只读
  5. CEI 模式是基础 — 先改状态,后调外部
  6. ReentrancyGuard 是保险 — 即使忘了 CEI 也能防住
  7. 只读重入最隐蔽 — view 函数也可能被利用
  8. 不要假设 transfer/send 安全 — 虽然 2300 gas 限制基本安全,但建议统一用 CEI + Guard

文档整理日期:2026-05-31 参考来源:RareSkills - Where to Find Solidity Reentrancy Attacks

💬 评论