本文深入探讨了以太坊生态系统中Safe多签钱包的关键方面。
安全的多重签名钱包是 Ethereum 生态系统中的核心项目之一。它一直在发展,而且已经不是 2017 年的样子了:它正在从相对简单的签名验证发展成一个灵活且模块化的系统。
由于我们审计了 Safe 的 1.4.0 版本,我们意识到 Safe 在 Ethereum 生态系统中的重要性,并决定持续研究它。我们为 Safe 用户和围绕它构建项目的开发人员收集了几个提示和潜在风险。以下是我们研究的结果,其中包含一些有价值的资源,供那些决定深入研究的人使用。
在智能合约中使用 msg.sender
非常常见。我们将其用于访问控制,作为映射键,或者将其与其他参数一起编码,以将数据连接到特定地址。但是,某些设计模式可能以不太直接的方式工作。其中之一是 Safe 的 FallbackHandler
合约。
为了扩展 Safe 的功能,我们必须部署一个新的单独的合约,称为 Fallback Handler。为什么?因为 Safe 是一个经过实战检验的单例合约,更改它可能不是最好的主意。相反,我们部署指向 Safe 合约和我们的处理程序的代理,我们可以在其中以我们想要的任何方式扩展我们的 Safe 的功能。
它是如何工作的?首先,我们将 FallbackHandler
合约地址添加到 Safe 中。然后,我们使用 FallbackHandler
中的函数调用 Safe 地址。因为该函数未在 Safe 合约中实现,所以调用会落入 fallback
,在该位置,调用被转发到 FallbackHandler
。
在此调用跟踪期间,FallbackHandler
中的 msg.sender
将携带 Safe 的地址,而不是原始发送者的地址。记住这一点非常重要,因为任何人都可以代表 Safe 调用 FallbackHandler
。例如,仅允许 Safe 调用合约的简单访问控制条件将毫无用处。如果我们想使用原始发送者的地址,我们使用函数 msg.sender()
代替,该函数在 HandlerContext 合约 中实现。
function _msgSender() internal pure returns (address sender) {
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
}
正如我们所看到的,该函数提取调用数据的特定部分,该部分携带原始发送者的地址。将地址存储在调用数据中的过程可以在 FallbackManager 合约 中 fallback 函数的逻辑中看到。
// msg.sender 地址向左移动 12 个字节以删除填充
// 然后将没有填充的地址存储在 calldata 之后
let senderPtr := allocate(20)
mstore(senderPtr, shl(96, caller()))
// 为附加在末尾的地址添加 20 个字节
let success := call(gas(), handler, 0, calldataPtr, add(calldatasize(), 20), 0, 0)
Guard 是一个智能合约,它实现了特定的数据验证逻辑。它通常包含两个主要函数(即将到来的 Safe 版本可能会引入 module 事务的 guard):
checkTxBeforeExecution
checkTxAfterExecution
这些函数充当Hook。Guard 可以实现任何将在事务执行之前和之后执行的数据验证。以下代码段简化了 Safe 的 execTransaction
函数:
function execTransaction(transaction data) {
// ...
// 交易数据预处理
address guard = getGuard();
if (guard != address(0)) {
Guard(guard).checkTransaction(transaction data);
}
// ...
// 事务执行
if (guard != address(0)) {
Guard(guard).checkAfterExecution(txHash, success);
}
}
必须调用函数 setGuard()
才能设置守卫。此函数在 Safe 继承的合约 GuardManager
中实现。该函数受 authorized
修饰符保护,该修饰符仅允许进行 Safe 自调用,即 Safe 执行调用 Safe 自身的事务。
当在设置守卫之前未考虑或实现任何恢复机制时,可能会出现问题。如果设置了守卫,并且其代码包含一个错误,导致事务回滚,则 Safe 将被锁定。所有设置函数都通过 Safe 自调用来调用,如果它由于损坏的守卫代码而始终回滚,则无法执行该调用。
针对这种情况的缓解措施可以是 Module。Module 是一个单独的合约,具有通过其函数 executeTxFromModule()
执行 Safe 事务的权限。要设置 Module,必须调用合约 ModuleManager
中的函数 enableModule()
。如果在损坏的守卫之前设置了 Module,则 Module 事务可以使用新的工作守卫地址调用 setGuard()
。但是,假设之前未设置 Module,并且守卫已损坏。在这种情况下,无法添加新 Module,因为函数 enableModule()
受修饰符 authorized 的保护。
在新版本 1.5.0 中,守卫调用也在 Module 事务中执行。损坏守卫的缓解变得更加棘手并且几乎不可能。
Module 是一个单独的合约,可以代表 Safe 执行事务。Module 事务的强大之处在于,不需要所有者的进一步签名。Module 可以通过函数 ExecTransactionFromModule()
对任意地址执行 CALL
,也可以执行 DELEGATECALL
。
如果调用地址上的逻辑包含更改状态的函数,它将更改 Safe 合约的状态。在极端情况下,可以调用具有 selfdestruct
的合约。在不太极端但同样危险的情况下,可以将合约写入 Safe 的存储槽中。例如,它可以覆盖重要的槽,例如所有者地址、Module 地址或阈值数字。这导致了一个简单的建议:在添加 Module 之前始终正确地审计 Module,并确保 Module 的所有者是可信任的。
一种情况是,Safe 所有者可能没有注意到 Module 已连接到 Safe。在 Safe Setup 函数中,调用了函数 setupModules()
,该函数执行 DELEGATECALL
。
function setupModules(address to, bytes memory data) internal {
require(modules[SENTINEL_MODULES] == address(0), "GS100");
modules[SENTINEL_MODULES] = SENTINEL_MODULES;
if (to != address(0)) {
require(isContract(to), "GS002");
// Setup has to complete successfully or transaction fails.
require(execute(to, 0, data, Enum.Operation.DelegateCall, type(uint256).max), "GS000");
}
}
当 Safe 合约的不受信任的第三方部署者决定执行任何恶意操作时,他可以通过此 DELEGATECALL
轻松地执行该操作。 部署者在此初始设置阶段具有执行任何 Safe 操作的无限能力,因为所有者不签署设置调用。 例如,部署者可以更改 Safe 的存储槽、批准代币或设置 Module,该 Module 本身在未来对 Safe 具有无限的权力。 你可以在这篇 OpenZeppelin 文章 中了解有关该问题的更多信息。
为了避免这种情况,Safe 所有者应仔细检查 Safe 的设置、链接的 Module、存储槽中的值,并且最好检查设置过程的调用跟踪。
此模式仍用于许多 NFT 项目中,以防止机器人铸造 NFT。 但是,此模式与账户抽象不兼容。 智能帐户将具有创建事务的能力。 这意味着 tx.origin
将是一个合约,而不是 EOA。 2024 年可能是账户抽象之年,因此我们应避免使用此模式,以免减慢采用过程。
任何拥有签名的人都可以执行事务。 特定事务的 Safe 所有者的签名在链下制作,并作为输入参数传递给函数。 一旦有足够的签名通过阈值,Safe 事务将被执行。 谁是调用该函数的人? 这并不重要。
所有数据(包括签名)都可以从 mempool 中事务的 calldata
中读取。 因此,如果任何人读取 calldata
并决定执行该事务,则没有任何限制。 为了提取价值 (MEV),我们管理单独事务的顺序,以实现盈利目标。 由于没有访问控制(拥有签名和交易数据即为访问控制),我们可以从 mempool 中选择 Safe 事务,并将其包含在我们的原子事务中(执行在智能合约中执行)。 这种提取价值的方式甚至比经典的提取价值方式更强大,因为提取者对状态具有更大的控制权。
如何减轻这种潜在的危险? 使用 OnlyOwnerGuard
,它仅允许所有者调用执行函数。
使用你自己的 1-of-1 多重签名 Safe 钱包。 即使在冷钱包上,它也具有许多安全优势。 你可以:
最重要的是,你的地址将相同。
Safe 合约(Safe 继承的 OwnerManager
)包含函数 addOwnerWithThreshold()
。 该函数添加一个新的所有者地址并同时增加阈值。 除非你犯了一个小错误,否则它没有任何问题。
假设你有一个个人 1-of-1 Safe。
一段时间后,你决定添加第二个所有者(例如,辅助冷钱包地址),并通过将阈值升级到 2-of-2 来提高安全性。 前面提到的函数更有效,因为它可以同时执行这两个步骤。 但是,如果你错误地输入了错误的地址并增加了阈值会发生什么? Safe 将被锁定。 永远。
一个简单的缓解和更防错的方法是通过调用具有相同阈值的函数来添加所有者。 然后,使用新添加的所有者地址增加阈值。 在此流程中,你可以确保不会出现任何错误。
我们可以在 Safe 代码库(SimulateTxAccessor
合约)中找到 simulate()
函数。 该函数模拟 Safe 事务执行。 从模拟的事务中,我们可以提取调用跟踪、合约状态更改、发出的事件、余额更改、使用的 gas 等。 所有这些信息可以在实际事务执行之前给我们更多的信心。 该功能通过 Tenderly 集成到 Safe UX 中,我们只需单击一下即可模拟事务。
随着 Redefine 推出 DeFirewall,此功能已得到进一步发展。 该加密“防火墙”在签署事务之前对其进行扫描,并检查潜在的风险。 作为一个简单的场景,想象一个黑客控制了你最喜欢的 DeFi 项目的网站。 当一个钱包弹出带有交易数据进行签名时,一切看起来都很正常。
不幸的是,数据几乎无法读取,并且我们习惯于在不检查的情况下进行签名。 现在是 DeFirewall 发挥作用的时候了。 它模拟事务,并通过使用弹出窗口,它会高亮显示恶意事件或状态更改。 例如:
区块链世界中的网络钓鱼造成了数亿美元 $ 的损失,即使对于网络安全专业人员来说,发现一个制作精良的网络钓鱼活动也可能具有挑战性。
- 原文链接: ackee.xyz/blog/staying-s...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!