Appearance
L3-8: Multisig Extension(多签扩展)
1. 问题
单个私钥控制大量资金是极其危险的——私钥被盗、丢失或持有者作恶都会导致不可逆的资金损失。多签钱包通过要求 N 个签名者中的 M 个批准才能执行交易来分散风险。但多签钱包本身也面临设计挑战:签名如何收集和验证?如何防止同一签名被重放到不同交易中?多签钱包本身如何升级(例如增减签名者)?
Gnosis Safe(现 Safe)是 Gnosis Safe 是生产级的解决方案,锁定了数百亿美元资产。这个挑战构建的是一个精简版——直接使用 eth_sign 兼容的 ECDSA 签名验证,不依赖 EIP-712 的完整结构。理解多签的实现是构建团队金库、DAO 财务管理和机构级托管工具的基础。
2. 原因
多签是 Web3 组织资金管理的基础设施。几乎所有 DAO 金库都使用多签(通常通过 Safe),核心团队的资金管理同样依赖多签。理解其内部机制——从签名者集合管理,到交易哈希构建,到签名排序防重放——不仅是合约开发技能,也是理解协作安全管理的重要知识。
与单签相比,多签在以下场景提供了关键的安全增强:(1) 防止单一作恶——需要合谋;(2) 防止私钥丢失——其余签名者仍可操作;(3) 层级审批——不同阈值对应不同操作敏感度。这个挑战也展示了 ECDSA 签名恢复 (ecrecover) 的高级应用——按签名者地址排序以防止重复签名的技巧。
3. 方案
MultiSigWallet.sol 实现了 N-of-M 多签钱包,核心功能包括:
构造函数:接收初始所有者列表和阈值(threshold)。验证:阈值 > 0 且 <= 所有者数量,每个地址非零且不重复。
交易执行(execTransaction):这是多签的核心函数。流程如下:
- 构建交易哈希:
keccak256(abi.encode(to, value, keccak256(data), nonce))— 使用 eth_sign 兼容格式(无 EIP-712 前缀) - 验证签名:
_verifySignatures()解析拼接的签名字节(每个签名 65 字节 = r(32) + s(32) + v(1)),逐个恢复签名者地址 - 签名排序验证:每个恢复的签名者地址必须严格大于上一个(
uint160(signer) > uint160(lastSigner)),防止重复签名 - 执行:通过
call发送交易,成功后递增 nonce
Owner 管理:addOwner() 和 removeOwner() 只能通过多签本身执行(onlyMultiSig 修饰符要求 msg.sender == address(this))。这意味着增减签名者本身就是一项需要多签批准的操作——自管理和自治理。
签名验证(_verifySignatures):从 65 字节的签名中提取 r、s、v。v 值规范化:如果 v < 27,加 27(兼容以太坊的 v 值编码)。使用 ecrecover(hash, v, r, s) 恢复签名者。
参考源文件:src/level3/MultiSigWallet.sol
4. 遭遇的陷阱
- 签名排序的边界情况:如果两个签名者地址的
uint160值非常接近,比较逻辑是否依然有效? - ecrecover 的无效签名风险:
ecrecover在签名无效时返回address(0),而不是 revert——如果合约不检查signer != address(0),可能接受一个伪造的零地址签名 - nonce 管理的防重放:nonce 随每次成功执行递增。如果签名者同时签署了当前 nonce 和未来 nonce 的交易,执行顺序是否确定?
- 签名格式与外部工具的不兼容:eth_sign(
vm.sign)直接对 bytes32 哈希签名,而 MetaMask 的personal_sign添加\x19Ethereum Signed Message:\n32前缀——两者恢复出的签名者不同 - 没有时间锁或过期机制:签名者签署的交易理论上永远有效(直到 nonce 被消耗),如果签名被泄露但交易尚未提交,存在风险
5. 陷阱的原因
ecrecover 的怪异行为是 Solidity 中最经典的陷阱之一。EVM 预编译 ecrecover 在以下情况返回 address(0):(1) 签名格式正确但签名者不是预期的地址;(2) v 值不是 27 或 28;(3) r 或 s 超出 secp256k1 曲线的有效范围。如果合约代码不检查 recovered != address(0),攻击者可能传入一个精心构造的签名,使 ecrecover 返回零地址——而零地址可能恰好是合约中的一个合法签名者(虽然不太可能,因为 zero address 通常被排除在外)。
在 MultiSigWallet.sol 中,_recoverSigner() 内部执行了 require(v == 27 || v == 28, "Invalid v") 检查,但未检查恢复地址是否为零。_verifySignatures() 中的 isOwner[signer] 检查会捕获零地址(因为构造函数不允许零地址作为 owner),所以这个特定漏洞被间接缓解,但依赖于 isOwner 映射的完整性。
签名格式不兼容的问题来源于以太坊签名生态的碎片化。eth_sign 直接对 bytes32 签名(Foundry 的 vm.sign 使用这种格式),而 personal_sign 添加前缀,EIP-712 又添加了 domain separator。合约中使用的哈希构建方式必须与前端签名方式完全匹配。在 MultiSigWallet.sol 中,getTransactionHash() 返回 keccak256(abi.encode(to, value, keccak256(data), _nonce)),前端需要用相同的编码方式计算哈希并用 Foundry 的 vm.sign 签名。
6. 如何解决陷阱
ecrecover 安全封装:
solidity
function _recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
require(sig.length == 65, "Invalid sig len");
// ... 提取 r, s, v ...
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "Invalid signature");
return signer;
}
签名者排序防重放:当前排序设计(strictly increasing by address)已经可以有效防止重复签名和签名者顺序被修改。重要的是在 JavaScript 端也要对签名按 signer 地址排序:
javascript
signatures.sort((a, b) =>
BigInt(a.signer) < BigInt(b.signer) ? -1 : 1
);
nonce 锁定:在签署交易时明确指定当前 nonce,前端通过 multisig.nonce() 查询最新 nonce。交易一旦执行成功,nonce 递增,所有针对旧 nonce 的签名作废。
添加到期时间:生产级实现应在交易哈希中包含 validUntil 时间戳,使签名在指定时间后失效。
7. 技术要点
| 技术点 | 说明 |
|---|---|
| N-of-M 阈值 | threshold 个 owner 签名即可执行 |
| 签名排序 | 按 uint160(signer) 升序排列,防重复 |
| 签名格式 | 每个签名 65 字节:r(32) + s(32) + v(1) |
| ecrecover | EVM 预编译,恢复 ECDSA 签名者地址 |
| nonce 防重放 | 每次成功执行递增,使旧签名失效 |
| onlyMultiSig | 管理操作自身需要通过多签批准 |
| eth_sign 兼容 | 直接签名 bytes32 哈希,匹配 Foundry vm.sign |
| swap-and-pop | 从 owners 数组中移除成员的经典模式 |