borj404/sc-reentrancy-attack

GitHub: borj404/sc-reentrancy-attack

跨函数重入漏洞的教育演示项目,揭示共享状态变量如何绕过单函数重入保护,并在真实测试网上完成攻击验证。

Stars: 0 | Forks: 0

智能合约的安全性是一个至关重要的关注点,众多漏洞类型构成了显著风险。其中,重入攻击被认为是最具危害性且最关键的漏洞之一。重入漏洞存在于多个复杂度和精密性层级。 本分析揭示、解释并利用了一个智能合约中的重入漏洞,该漏洞可被称为**共享状态跨函数重入漏洞**(shared-state cross-function reentrancy vulnerability),或简称为**跨函数重入漏洞**。其根本原因在于多个函数共享同一个状态变量,而某些函数对该变量的更新方式不安全,从而在状态保护中造成了可被利用的不一致性。 初始[测试](https://github.com/borj404/sc-reentrancy-attack/blob/master/logs/foundry_test_output.txt)是使用 Foundry 进行的;然而,其受控的确定性环境在很大程度上掩盖了该漏洞的实际影响,使其近乎理论化。鉴于这些局限性,我决定在公共测试网上执行攻击,以观察 Foundry 受控环境无法重现的真实世界效果。选择 Base Sepolia 测试网是因为其低 Gas 成本以及得益于 Optimistic Rollup 架构带来的快速交易确认时间。 本项目仅使用 secp256k1 和 ethash 等基本加密库,从头开始实现了所有组件,故意避免使用框架以保持更高的控制权和灵活性(同时也为了挑战自己是否能用 C++ 实现所有必要的 EVM 基础设施)。这也是我第一次使用 C++ modules,所以请对那部分宽容一些。这是我几个月前为了好玩而构建的,现在决定发布出来,希望对任何对 SC(智能合约)安全性感兴趣的人有用... ## 易受攻击的合约逻辑 _在整个解释和代码库中,我将此合约称为“易受攻击合约”或“目标合约”,两者可互换使用。_ [`UnsafeResolutionHub`](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/UnsafeResolutionHub.sol) 合约被设计为电商平台的争议解决系统。管理员可以创建争议并决定受益人和赔付金额,本质上是为不满意的买家提供退款机制。 该合约为用户提供了两种管理资金的主要机制: - [`getRefund()` 函数](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/UnsafeResolutionHub.sol#L156-L170):允许调用者(买家)从合约的 `balances` 映射中提取其全部 ETH 余额。该余额是通过有利的争议裁决获得的。 - [`donate()` 函数](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/UnsafeResolutionHub.sol#L172-L186):允许调用者(买家)将指定的 ETH 金额计入其他地址。此函数仅修改智能合约内部的 `balances` 映射,需要受益人稍后调用 `getRefund()` 来提取实际的 ETH。它可以服务于各种目的,包括将裁决资金分配到不同地址、补偿卖家的运输费用或捐赠给慈善事业。 _注意:为了操纵争议裁决以偏向买家并在合约内部映射中获得资金,卖家和买家可以是同一个人。例如,一名攻击者充当卖家,可以上架产品,使用不同的攻击者合约地址多次购买,然后向管理员报告缺陷,促使争议解决偏向买家。然而,这些社会工程策略超出了本重入漏洞分析的范围。_ ## 合约中的漏洞 该漏洞源于操作共享状态的函数之间重入保护的不一致,从而导致了跨函数重入攻击。核心缺陷在于 `getRefund` 函数违反了 Checks-Effects-Interactions 模式,它在更新关键状态变量之前执行了外部调用。 `getRefund` 函数执行以下顺序: - 从 `balances` 映射中读取用户余额。 - 验证合约是否存在足够的余额。 - 执行外部调用以将 ETH 发送给 `msg.sender`。 - 通过设置 `balances[msg.sender] = 0` 来更新状态。 ``` function getRefund() external nonReentrant { uint256 balance = balances[msg.sender]; if (balance == 0) revert NoBalanceToWithdraw(); if (address(this).balance < balance) revert InsufficientContractBalance(); // External call before state update (bool success, ) = msg.sender.call{value: balance}(""); require(success, "Transfer failed"); // State update after external call balances[msg.sender] = 0; emit BalanceUpdated(msg.sender, 0); emit Withdrawal(msg.sender, balance); } ``` 尽管 `getRefund` 上有 `nonReentrant` 修饰符(通过 [ReentrancyGuard](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/ReentrancyGuard.sol)),但这种保护措施是不够的,因为未受保护的 `donate` 函数也操作同一个 `balances` 映射。 ``` // No reentrancy protection function donate(address _to, uint256 _amount) external { if (_to == address(0)) revert InvalidRecipient(); if (_to == msg.sender) revert CannotTransferToYourself(); if (_amount == 0) revert AmountMustBeGreaterThanZero(); if (balances[msg.sender] < _amount) revert InsufficientUserBalance(); unchecked { balances[msg.sender] -= _amount; balances[_to] += _amount; } emit BalanceUpdated(msg.sender, balances[msg.sender]); emit BalanceUpdated(_to, balances[_to]); } ``` 跨函数重入之所以可能,是因为该架构在状态保护中造成了根本性的不一致。`balances` 映射作为两个函数访问的共享关键资源,但只有一个函数实施了重入保护。当 `getRefund` 执行其外部调用时,合约的状态保持不一致(ETH 余额已转移,但内部余额尚未清零)。在此窗口期间,未受保护的 `donate` 函数提供了一条替代路径,可以操作 `getRefund` 试图修改的同一共享状态。 这种架构缺陷允许攻击者在外部 ETH 转账和随后的状态更新之间的时间间隔内,将其记录的余额重新分配给另一个地址。因此,攻击者可以在提取 ETH 的同时重定向其内部余额,有效地实施功能性双花攻击。 ## 重入攻击流程 该攻击采用双合约协调机制,通过交替利用循环系统性地耗尽易受攻击合约的资金。攻击设置需要部署两个[攻击者合约](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/FancyReentrancy.sol)实例,每个都配置为对等节点。当 `getRefund` 中的外部调用触发恶意合约的 `receive` 函数时,攻击者可以在 `getRefund` 完成其状态更新之前调用 `donate` 来操纵共享的 `balances` 映射。 _本分析假设两个攻击者合约在目标合约的 balances 映射中各有 0.01 ETH(通过争议解决过程获得)。每个合约中拥有 0.01 ETH 使攻击的耗尽速度加倍。_ _从这里开始,这两个攻击者合约对等节点将分别被称为 **Attacker1** 和 **Attacker2**。_ ### 第一个交易周期(Attacker1 → Attacker2) **初始状态:** - Attacker1 和 Attacker2:在 `balances` 映射中各有 0.01 ETH。 **启动:** - 攻击者调用 [`attacker1.attack()`](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/FancyReentrancy.sol#L45-L49),触发 `resolutionHub.getRefund()`。 **创建漏洞利用窗口:** - `getRefund` 读取 Attacker1 的余额(0.01 ETH)。 - 验证合约有足够资金。 - 执行外部调用:`msg.sender.call{value: balance}("")`。 **利用阶段:** - 外部调用触发 [`attacker1.receive()`](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/FancyReentrancy.sol#L31-L43)。 - Attacker1 的余额在映射中仍为 0.01 ETH(尚未更新)。 - `receive()` 函数调用 `resolutionHub.donate(address(attackPeer), drainAmount)`。 - `donate` 在映射中将 0.01 ETH 从 Attacker1 转移给 Attacker2。 - 余额状态:Attacker1 = 0 ETH,Attacker2 = 0.02 ETH。 **结果:** - Attacker1 收到 0.01 实际 ETH。 - Attacker2 在映射中持有 0.02 ETH。 ### 后续循环 在首轮之后,攻击维持一致的提取模式。在第一笔交易中 Attacker1 提取了 0.01 ETH 之后,Attacker2 现在在映射中持有 0.02 ETH。当调用 `attacker2.attack()` 时,Attacker2 将全部 0.02 ETH 作为实际 ETH 提取,同时通过 `donate` 函数将这 0.02 ETH 余额转移给 Attacker1。 随后的每一次 `attack()` 调用在两个攻击者合约之间交替,从合约余额中提取 0.02 ETH,同时将两个攻击者合约的内部总余额维持在 0.02 ETH。 ### 攻击终止 攻击以这种每笔交易提取 0.02 ETH 的方式持续进行,直到合约资金完全耗尽或低于 [0.0001 ETH](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/FancyReentrancy.sol#L14)。 ## 设置与执行 ### 前置条件 *注意:这些说明适用于 Ubuntu Noble 24.04,其他发行版可能有所不同。* **安装依赖项:** 运行提供的[安装脚本](https://github.com/borj404/sc-reentrancy-attack/edit/master/install_prerequisites.sh)。 **手动安装(替代方案):** 如果您更喜欢手动安装依赖项: ``` # 添加 repositories wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc | gpg --dearmor - | sudo tee /etc/apt/trusted.gpg.d/kitware.gpg >/dev/null sudo apt-add-repository "deb https://apt.kitware.com/ubuntu/ noble main" -y wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor - | sudo tee /etc/apt/trusted.gpg.d/llvm-snapshot.gpg >/dev/null echo "deb https://apt.llvm.org/noble/ llvm-toolchain-noble-18 main" | sudo tee /etc/apt/sources.list.d/llvm-18.list # 安装 packages sudo apt update && sudo apt install -y curl git cmake ninja-build clang-18 clang-tools-18 libcurl4-openssl-dev nlohmann-json3-dev libspdlog-dev libssl-dev ``` ### 仓库结构 智能合约位于 `contracts` 目录中,而它们对应的字节码放置在 `build` 目录中。`include` 目录包含库,[`evm_utils` 模块](https://github.com/borj404/sc-reentrancy-attack/blob/master/include/evm_utils.cppm)对脚本的共享函数进行分组。所有脚本都放在 `src` 目录中,应按指定顺序执行: - [`deploy_vulnerable.cpp`](https://github.com/borj404/sc-reentrancy-attack/blob/master/src/deploy_vulnerable.cpp): 部署[易受攻击的合约](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/UnsafeResolutionHub.sol)并将合约地址添加到 .env 文件中。 - [`deploy_attackers.cpp`](https://github.com/borj404/sc-reentrancy-attack/blob/master/src/deploy_attackers.cpp): 部署两个[攻击者合约](https://github.com/borj404/sc-reentrancy-attack/blob/master/contracts/FancyReentrancy.sol)实例,将其地址添加到 .env 文件,并将每个配置为另一个的对等节点以用于攻击流程。 - [`setup_vulnerable.cpp`](https://github.com/borj404/sc-reentrancy-attack/blob/master/src/setup_vulnerable.cpp): 为易受攻击的合约提供资金(在本例中为 6.39 ETH),为两个攻击者合约创建争议,并以有利于攻击者的方式解决它们,在余额映射中各分配 0.01 ETH。 - [`execute_attack.cpp`](https://github.com/borj404/sc-reentrancy-attack/blob/master/src/execute_attack.cpp): 计算耗尽整个易受攻击合约余额所需的调用,预计算所有交易,启动攻击,将被盗资金提取到攻击者钱包,并提供带有结果的最终摘要。 [您可以在此处查看整个执行流程日志。](https://github.com/borj404/sc-reentrancy-attack/blob/master/logs/testnet_attack_results.txt) 攻击时间测量了从发起第一笔交易发送操作(包括所有网络传播延迟和处理时间)到最后一笔交易在链上确认(或者在这种情况下,被添加的安全额外交易回滚)为止的完整端到端持续时间,捕捉了从攻击者角度看的完整攻击执行时间。 _[`withdraw.cpp`](https://github.com/borj404/sc-reentrancy-attack/blob/master/src/withdraw.cpp) 脚本仅供紧急使用。它允许在过程中出现问题时从所有合约中提取所有资金。它在开发阶段被使用过,但我将其留作预防措施。* 整个过程的编译和执行由 [`run.sh`](https://github.com/borj404/sc-reentrancy-attack/blob/master/run.sh) 自动化。要复制攻击,请确保 [`.env`](https://github.com/borj404/sc-reentrancy-attack/edit/master/.env) 文件中的所有字段都已正确填写。 一些常量值被调整为在 L2(如 Base Sepolia)上执行(低 Gas 费和短确认时间),因此如果您打算使用不同的网络,您可能必须修改 [`evm_utils` 模块](https://github.com/borj404/sc-reentrancy-attack/blob/master/include/evm_utils.cppm#L41-L73)中的这些常量。 ## 结论 完整的重入攻击(320 笔交易)在不到 10 秒的时间内执行完毕,耗尽了整个易受攻击合约的余额(6.39 ETH)。预计算的交易序列在极短的时间范围内完成,甚至挑战了现代监控系统识别攻击模式和触发自动防御响应的能力。这种预计算方法展示了 L2 效率的双刃剑性质:同样近乎即时的交易确认在增强用户体验的同时,也加速了攻击执行,潜在地将智能合约漏洞转化为检测和干预时间大幅缩短的攻击载体。 归根结底,智能合约的安全性最终取决于链上部署的逻辑。区块链严格执行我们编写的内容,而不是我们的意图。在一个不可变的生态系统中,这种代码与意图之间的差距可能会带来毁灭性的昂贵代价。 ## 历史一角 2016 年 6 月 17 日,一名攻击者执行了区块链历史上后果最严重的智能合约利用,通过重入攻击从 [The DAO](https://etherscan.io/address/0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413) 中耗尽了 360 万 ETH。这一事件最终导致了以太坊硬分叉。 目标是 The DAO 的 `splitDAO` 函数,该函数在更新其内部状态之前进行了外部调用。攻击者部署了一个带有回退函数的恶意合约,每当它收到 ETH 时就会递归调用 `splitDAO`。由于攻击者的余额直到外部调用完成后才被清零,每次递归调用都发现他们仍有资格提款,从而允许快速耗尽资金。 该攻击利用了以太坊的交易处理机制:外部调用将控制权传递给接收合约,接收合约可以在将控制权返回给原始调用者之前执行任意代码。攻击者耗尽了超过 360 万 ETH,几乎占 The DAO 总资金的三分之一。 我不会进一步展开技术细节,但如果您感兴趣,这里是 [The DAO 合约](https://github.com/TheDAO/DAO-1.0/blob/master/DAO.sol)。 所有证据都表明,攻击者第二天自愿停止了攻击,剩下的 900 万 ETH 未被动过。几天后,在社区对原始攻击者是否会回来或模仿者攻击者是否会出现的恐慌中,一群名为罗宾汉团体的白帽黑客利用同样的漏洞将其余资金耗尽到他们自己的子 DAO 中,以“拯救”这些资金。最终的解决方案是通过以太坊备受争议的硬分叉,该分叉逆转了整个攻击。 令情况特别令人不安的是,安全研究人员在 The DAO 的筹资期间就已经发现了这个漏洞,但他们的警告没有得到充分解决。 这次攻击引发了关于区块链技术本身的根本危机。以太坊社区分裂为两种哲学:一派认为区块链不可篡改性是神圣的,即使接受盗窃,因为逆转它将破坏无信任系统并开创一个危险的先例。反对派则认为,允许盗窃将摧毁对智能合约的信心,并使去中心化应用程序的发展倒退数年。但本文并不旨在深入探讨那个哲学困境。 经过激烈的社区辩论,以太坊进行了硬分叉,通过将被盗资金重定向到恢复合约来逆转攻击。然而,少数派拒绝这种干预,继续挖掘原始链,即现在的以太坊经典。 这次攻击直接导致了 Checks-Effects-Interactions 模式的行业广泛采用,该模式要求智能合约在进行外部调用之前更新内部状态。它还催化了整个安全生态系统:自动化漏洞扫描器、专业审计公司和具有内置保护的开发框架。 最重要的是,这次攻击将智能合约开发从实验性实践转变为 disciplined engineering( disciplined engineering)。它确立了区块链的不可篡改性使得错误可能成为灾难性的,要求在开发中具有前所未有的严谨性。 尽管采用了安全最佳实践,重入漏洞仍以令人担忧的频率被利用;已经进行了超过 80 次有记录的重入攻击,绝大多数是在基于 EVM 的区块链上。随着新协议的出现,潜在漏洞也在增加,扩大了生态系统的攻击面。 ## 免责声明 本仓库仅用于教育和研究目的,以增强对智能合约安全漏洞的理解。所呈现的利用技术和方法论不得用于恶意目的,包括未经授权访问智能合约、盗窃数字资产或任何违反适用法律法规的活动。 我对因使用或误用此信息而产生的任何损害不承担责任。您承担对自己行为及由此产生的任何法律后果的全部责任,并且您全权负责确保您的活动符合所有适用的地方、国家和国际法律法规。 本仓库的内容在 [MIT 许可证](LICENSE)下。
标签:Base Sepolia测试网, C++, CISA项目, Ethash, EVM, secp256k1, Web3安全, Web报告查看器, 以太坊开发, 加密库实现, 区块链攻防, 安全演示, 数据擦除, 智能合约安全, 漏洞分析, 状态变量不一致, 跨函数重入, 路径探测, 重入攻击, 零知识底层