Appearance
链上宝藏大揭秘:Safe Wallet 注册器的“后门”挑战
在去中心化金融(DeFi)的世界里,安全是永恒的主题。尤其是在管理数字资产的 Safe 钱包生态中,开发者们正不断努力构建更健固、更可靠的钱包。然而,正如任何复杂的系统一样,意想不到的“后门”可能就潜藏在最不引人注目的地方。
游戏背景:DVT 代币的诱惑
本次的 CTF 挑战,代号“Backdoor”,将我们引入一个精心设计的 Safe 钱包注册系统。这个系统旨在激励团队成员创建和注册 Safe 钱包,每成功注册一个,便能获得 10 DVT(Damn Valuable Token)代币的奖励。注册器与官方的 Safe Proxy Factory 紧密集成,并宣称拥有严格的安全校验机制。
目前,系统已记录了四位受益人:Alice、Bob、Charlie 和 David。注册器内有 40 DVT 代币等待分配。我们的任务是:揭露注册器中的漏洞,一举“拯救”所有被困的资金,并将它们安全地转移到指定的恢复账户中,并且这一切必须在单笔交易内完成。
深入代码:WalletRegistry.sol 的秘密
我们首先审视 WalletRegistry.sol 合约。它的核心功能是允许预设的受益人通过 Safe Proxy Factory 创建 Safe 钱包,并在创建成功后,由注册器向该钱包支付 DVT 代币。
合约的关键逻辑体现在 proxyCreated 函数中,这是 Safe Proxy Factory 在创建代理钱包后调用的回调函数。在这个函数里,注册器执行了一系列严格的验证:
- 资金充足性检查: 确保有足够的 DVT 代币用于支付。
- 工厂和单例验证: 验证调用者是否为合法的 Safe Proxy Factory,以及创建的钱包是否使用了正确的单例合约。
- 初始化校验: 确认创建调用的正是
Safe::setup方法。 - 钱包配置校验: 检查钱包的阈值(
EXPECTED_THRESHOLD为 1)和所有者数量(EXPECTED_OWNERS_COUNT为 1)是否符合预期。 - 受益人身份验证: 确保新钱包的唯一所有者是系统中注册的受益人之一。
- 回退管理器检查: 确认钱包的回退管理器未被设置(
_getFallbackManager返回address(0))。
通过所有这些检查后,注册器会将钱包所有者从受益人名单中移除,并记录下该钱包,最后将 DVT 代币安全地发送给新创建的钱包。
漏洞何在?“单笔交易”的陷阱
乍一看,这些校验似乎密不透风。然而,挑战的关键在于“单笔交易”的要求,以及对 Safe 钱包本身功能的利用。
真正的漏洞并非出在 WalletRegistry 的直接逻辑错误,而是如何利用 Safe 钱包的多模块(Multi-module)功能来绕过注册器的预期。
WalletRegistry 合约期望的是一个最简化的 Safe 钱包,其所有者就是受益人,并且没有启用任何模块。但 Safe 钱包本身是一个强大的多重签名和模块化钱包。这意味着,在初始化 Safe 钱包时,我们可以:
- 设置一个具有攻击者控制模块的初始配置: 尽管
WalletRegistry检查了Safe::setup的调用,但我们可以通过Safe::setup方法,在创建钱包的同时,指定启用一个模块,并且在模块的初始化数据中,就预设了将所有权转移或资金提取的逻辑。 - 利用
DelegateCall绕过主合约的逻辑: Safe 钱包允许通过execTransactionFromModule来执行由模块发起的交易。如果模块中的逻辑使用了DelegateCall,它就可以直接在 Safe 钱包的存储(Storage)中执行代码,从而绕过 Safe 钱包本身的许多内部逻辑,甚至包括WalletRegistry强加的限制。
解决方案:巧妙的模块设计
攻击者的 MyContract 提供了绝妙的解决方案。核心思路是:
- 创建带有预设模块的 Safe 钱包: 在
MyContract的构造函数中,它遍历受益人列表,为每个受益人创建一个 Safe 钱包。 - 启用
TokenToRecovery模块: 在创建钱包时,通过Safe::setup的initializer参数,不仅指定了钱包的初始所有者(即受益人),还启用了两个关键模块:EnableModule和TokenToRecovery模块。 EnableModule的作用:EnableModule的enableModule函数被设计用来接收一个模块地址,并通过ModuleManager.enableModule将该模块添加到 Safe 钱包中。TokenToRecovery的“毒计”:TokenToRecovery模块是攻击的关键。它的构造函数接收 DVT 代币的地址和恢复账户地址。更重要的是,它实现了一个execTransactionFromModule函数,该函数在被调用时,会执行toRecovery函数。toRecovery函数的终极目标:toRecovery函数将 Safe 钱包(当前持有 DVT 代币)的余额 直接转移给预设的恢复账户。
攻击流程演示:
- 攻击者部署
MyContract,并传入singletonCopy、walletFactory、token、users、walletRegistry以及recovery地址。 MyContract遍历users数组。对于每个受益人(例如 Alice):- 它会构建一个
Safe::setup的initializer。这个initializer除了设置 Alice 作为唯一所有者和 1 的阈值外,还会在moduleData中包含启用TokenToRecovery模块的指令,并附带TokenToRecovery模块的地址和 DVT 代币的地址。 - 通过
walletFactory.createProxyWithCallback创建 Safe 钱包,并将walletRegistry作为回调。 - 此时,
WalletRegistry在proxyCreated中进行验证时,会发现钱包的singleton和factory都正确,并且初始调用是Safe::setup。然而,WalletRegistry并没有检查Safe::setup中是否设置了任何模块,这成为了一个关键的遗漏。 - 一旦钱包创建成功,
walletRegistry会按照预期向钱包支付 10 DVT 代币。 - 接着,
MyContract自动调用tokenToRecoveryModule.execTransactionFromModule(payable(address(proxy)))。 - 这个调用通过 Safe 钱包的
execTransactionFromModule,最终触发了TokenToRecovery模块中的toRecovery函数。 toRecovery函数直接将 Safe 钱包中的 10 DVT 代币转移到攻击者指定的recovery地址。
- 它会构建一个
结论
通过巧妙地利用 Safe 钱包的多模块功能,攻击者成功地在 WalletRegistry 的安全校验中找到了“后门”。WalletRegistry 专注于验证钱包的创建方式,却忽视了钱包在创建时启用模块的可能性,以及模块内部可能存在的恶意逻辑。最终,所有 40 DVT 代币都在一笔交易中被转移到了恢复账户,完成了这场精彩的链上“寻宝”挑战。
这个挑战也深刻地提醒我们:在构建智能合约系统时,不仅要考虑自身合约的安全性,还要充分考虑与其他合约(尤其是复杂的 DeFi 组件如 Safe 钱包)交互时可能出现的潜在风险和组合效应。