本文是对Uniswap Emissary项目的代码审计报告,该项目旨在提供一种链上注册机制,以便安全灵活地授权签名。审计发现并解决了多个中低风险问题,包括重复调度密钥移除可能导致执行阻塞,以及当注册密钥超过256个时密钥移除失败等。审计范围包括BaseKeyVerifier.sol、GenericKeyManager.sol等文件。没有发现高危漏洞。
概要 类型: DeFi 时间线: 从 2025-08-11 → 到 2025-09-05
语言: Solidity
发现
问题总数:15(已解决 14 个)
严重:0(已解决 0 个)· 高:0(已解决 0 个)· 中:2(已解决 2 个)· 低:4(已解决 4 个)
注释 & 补充信息
提出了 9 条注释(已解决 8 条)
OpenZeppelin 审计了 Uniswap/emissary 仓库,提交哈希为 715a4d7。
以下文件在审计范围内:
src
├── BaseKeyVerifier.sol
├── GenericKeyManager.sol
├── KeyLib.sol
├── KeyManagerEmissary.sol
├── interfaces
│ ├── IERC1271.sol
│ └── ISignatureVerifier.sol
└── types
└── VerificationContext.sol
Emissary 项目是一个链上注册表,旨在促进安全和灵活的签名授权委托。它支持 secp256k1 和 P-256 加密密钥格式,适应硬件安全模块 (HSM) 和 WebAuthn 的实现。此功能充当签名验证的后备机制,增强了去中心化应用程序 (dApp) 和智能合约的稳健性。
Emissary 的架构支持签名权限的委托,从而实现生态系统和其他去中心化平台内更具适应性和安全性的交互。通过与各种加密标准和基于硬件的解决方案集成,它确保即使在主验证方法不可用时,签名验证仍然可靠。
Emissary 是 The Compact 的一部分,The Compact 是一个无所有者的 ERC-6909 合约,支持自愿创建和调解可重用的资源锁。它允许将 token 可信地承诺用于支出,以换取在任意异步环境中执行操作,并在满足指定条件后进行声明。它在系统中的作用是为赞助者在授权声明时提供后备验证机制。这对于以下情况特别有用:
有关更多详细信息,请参阅原始文档和 The Compact 文档。
scheduleKeyRemoval 和 scheduleMultisigRemoval 函数定义了一个延迟期,之后才能执行相关的 remove* 函数。这些调度函数可以通过经由 _checkKeyManagementAuthorization 授权的账户访问,该函数可能会在子合约中被覆盖。它们还会验证指定的密钥或多重签名在调度删除之前是否已注册。预期的设计确保删除仅由授权实体在指定延迟期后调度和执行。
目前,这些函数不验证密钥或多重签名是否已处于待删除状态。此遗漏允许多个授权账户重复调度相同的密钥或多重签名以进行删除。每个新的调度调用都会重置删除延迟,这可能会无限期地推迟实际删除。因此,尝试完成删除的其他授权实体可能会一直被阻止这样做。
考虑添加一个验证步骤,以确保已调度删除的密钥或多重签名不会再次被调度。
更新: 已在 pull request #2 中解决。
GenericKeyManager 合约将每个帐户的密钥存储在 keyHashes[account] 中,并在每个多重签名中使用 256 位位图来标记作为签名者的密钥索引。当删除不是数组中最后一个元素的密钥时,合约会应用交换和弹出算法:它将最后一个密钥移动到已删除的槽中,然后更新引用移动索引的所有多重签名。_removeKey 处的范围检查确保原始索引和移动密钥的旧索引都在 0–255 范围内,以适合位图。
但是,当数组长度超过 256 时,oldIndex = accountKeyHashes.length - 1 ≥ 256。任何删除非最后一个密钥的尝试都必须更新移动密钥的旧位置,因此范围检查会回退。因此,只能删除最后一个密钥,而较早的密钥实际上变得无法删除。由于密钥注册不受限制,因此任何用户都可以将帐户的密钥数量推到 256 个以上,之后大多数删除都会回退。registerKey 中没有警告或限制,因此这可能会意外发生。
考虑在 registerKey 中强制执行每个帐户 256 个密钥的硬性上限,并在达到限制后回退并显示明确的错误。此外,考虑记录容量。
更新: 已在 pull request #5 中解决。
为了清楚地标识合约将使用哪个 Solidity 版本进行编译,pragma 指令应该在所有文件导入中保持固定和一致。
在整个代码库中,使用了不同的 pragma 指令:
BaseKeyVerifier.sol 具有 pragma solidity ^0.8.30; pragma 指令,并导入 BaseKeyVerifier.sol,它具有不同的 pragma 指令。GenericKeyManager.sol 具有 pragma solidity ^0.8.30; pragma 指令,并导入 GenericKeyManager.sol,它具有不同的 pragma 指令。KeyLib.sol 具有 pragma solidity ^0.8.27; pragma 指令,并导入 KeyLib.sol,它具有不同的 pragma 指令。KeyManagerEmissary.sol 具有 pragma solidity ^0.8.27; pragma 指令,并导入 KeyManagerEmissary.sol,它具有不同的 pragma 指令。考虑在所有文件中使用相同的、固定的 pragma 指令。
更新: 已在 pull request #3 中解决。
_registerMultisig 中的输入验证不正确在 _registerMultisig 函数中,签名者数量 (signerCount) 是通过获取 signerIndices 的长度并将其转换为 uint8 来确定的。随后的检查确保 signerCount 大于 0 且小于或等于 255。此验证顺序不正确,因为转换为 uint8 可能会在应用范围检查之前截断原始长度,从而可能绕过正确的验证。
考虑在转换为 uint8 之前,直接根据预期范围验证 signerIndices 的长度。
更新: 已在 pull request #4 中解决。
在 _registerKey 函数中,使用 _checkKeyManagementAuthorization 函数验证授权。此逻辑针对 registerKey 函数(为给定帐户注册密钥)和 register 函数(为调用者注册密钥)触发。但是,问题在于,当注册多重签名时,仅当注册是针对帐户执行时才检查授权,但如果它是由调用者执行时则不检查。
考虑统一行为,要么仅当为帐户发出调用时才验证授权,要么在所有路径上都验证授权。
更新: 已在 pull request #6 中解决。
Key.index 是一个 uint16,它是从 1 开始的数组索引,其中 0 表示“未注册”。在注册期间,实现将新的密钥哈希附加到 keyHashes[account],然后设置 key.index = uint16(keyHashes[account].length)。在删除期间,代码依赖于此存储的索引来计算从 0 开始的数组位置 (uint256(key.index) - 1) 并在 keyHashes[account] 上执行交换和弹出操作。多重签名者位图也根据这些数组位置进行更新,并包括一个单独的检查,以确保位图中使用的索引 < 256。
一旦 keyHashes[account].length >= 65,536,强制转换为 uint16 可能会溢出。长度为 65,536 时,强制转换产生 0,长度为 65,537 时,产生 1,依此类推。索引 0 会导致 _removeKey 在计算 key.index - 1 时回退。一个包装的非零索引可能会导致 _removeKey 对错误的数组槽进行操作:该函数可能会交换和弹出不同的元素,更新错误密钥的多重签名位图,并使数组和映射不同步(例如,删除 keys[account][keyHash],但实际上没有从 keyHashes[account] 中删除 keyHash)。
考虑在强制转换之前添加显式检查,以确保数组长度不超过 type(uint16).max。
更新: 已在 pull request #5 中解决。团队声明:
由 M-02 的修复程序隐式解决。
isValidKey 中 P-256 点的验证不足isValidKey 函数支持多种密钥类型(从 secp256k1 公钥派生的以太坊地址、P-256 公钥和 WebAuthn P-256 公钥)。对于 P-256,它不验证公钥坐标是否在素数域范围 [0,p−1][0, p-1][0,p−1] 内,或者该点是否满足曲线方程。这意味着可能会接受格式错误或离曲线的点。由于 P-256 的伴随因子为 1,因此除了平凡群之外,没有(小)子群。因此,一旦确认该点在曲线上,就不需要额外的检查来验证它是否属于正确的子群。
考虑对 P-256 密钥强制执行正确的验证:检查坐标范围,拒绝无穷远点,并确认曲线方程成立。
更新: 已在 pull request #7 中解决,提交哈希为 73d4c33 和 0873812。
在 GenericKeyManager.sol 中,发现了多个具有无法解释含义的字面值实例。
字面值(如 256 和 255)在合约中的多个位置出现(1,2,3,4)。如果没有明确的定义,就不清楚这些值代表什么或为什么使用它们。
考虑定义和使用 constant 变量而不是使用字面值,以提高代码库的可读性。
更新: 已在 pull request #7 中的提交 c8628ab 中解决。
require 语句中的自定义错误自从 Solidity 版本 0.8.26 以来,自定义错误支持已添加到 require 语句中。最初,此功能仅通过 IR 管道提供。但是,Solidity 0.8.27 将其支持扩展到遗留管道。
GenericKeyManager.sol 包含多个可以使用 require 语句替换的 if-revert 语句实例:
if (usageCount > 0) {\\ revert KeyStillInUse(keyHash, usageCount);\\ } statementif (newIndex >= 256 || oldIndex >= 256) {\\ revert MultisigSignerIndexOutOfRange(newIndex);\\ } statementif ((cfg.signerBitmap & (1 << newIndex)) != 0) {\\ revert MultisigSignerIndexCollision(msHash, newIndex);\\ } statementif (index >= 256) revert MultisigSignerIndexOutOfRange(index) statement为了简洁和节省 gas 成本,请考虑使用 require 语句替换 if-revert 语句。
更新: 已在 pull request #7 中的提交 7dbcf6f 中解决。
在整个代码库中,发现了多个合约的函数顺序不一致的实例:
BaseKeyVerifier.sol 中的 BaseKeyVerifier 合约GenericKeyManager.sol 中的 GenericKeyManager 合约KeyManagerEmissary.sol 中的 KeyManagerEmissary 合约为了提高项目的整体可读性,请考虑按照 Solidity 风格指南(函数顺序)的建议,在整个代码库中标准化顺序。
更新: 已确认,未解决。团队声明:
已确认,不会修复
在智能合约中提供特定的安全联系人(例如电子邮件地址或 ENS 名称)可以大大简化个人在发现代码漏洞时进行沟通的过程。这种做法非常有益,因为它允许代码所有者指定漏洞披露的沟通渠道,从而消除了因缺乏如何操作的知识而导致沟通不畅或未能报告的风险。此外,如果合约包含第三方库并且在这些库中出现错误,维护人员可以更轻松地联系到负责此问题的人员并提供缓解说明。
在整个代码库中,发现了多个缺少安全联系人的合约实例:
BaseKeyVerifier 合约GenericKeyManager 合约KeyLib 库KeyManagerEmissary 合约IERC1271 接口ISignatureVerifier 接口考虑在每个合约定义上方添加包含安全联系人的 NatSpec 注释。建议使用 @custom:security-contact 约定,因为它已被 OpenZeppelin Wizard 和 ethereum-lists 采用。
更新: 已在 pull request #7 中的提交 ea9364b 中解决。
MultisigSignerIndexOutOfRange 中报告的索引不正确在 _removeKey 中,newIndex 和 oldIndex 都会根据 0–255 范围进行检查。但是,即使 oldIndex 是超出范围的值,回退始终报告 newIndex。这会导致错误显示有效索引,从而使根本原因不清楚。
考虑使用实际的失败索引回退(例如,对 newIndex 和 oldIndex 进行单独检查)。
更新: 已在 pull request #7 中的提交 9e080b0 中解决。
在 GenericKeyManager 合约中,密钥删除需要一个时间锁才能删除。_removeKey 函数 首先检查密钥的 removalTimestamp 是否已设置且已过期。对于未注册的密钥,此值为零,这将触发 KeyRemovalUnavailable(0) 的回退,而不是表明该密钥不存在。
考虑先验证密钥是否已注册,然后再执行时间锁检查,并在适当的时候使用 KeyNotRegistered 回退。
更新: 已在 pull request #7 中的提交 7551939 中解决。
canRemoveKey 助手可能会返回 true,而 removeKey 却回退canRemoveKey 助手旨在指示是否可以删除密钥。它目前仅检查删除时间锁是否已过期。但是,实际的删除过程还需要密钥未被任何多重签名引用。由于忽略了此附加条件,因此即使 removeKey 会因 KeyStillInUse 而回退,canRemoveKey 也可能返回 true。这种差异可能会误导依赖 canRemoveKey 来确定是否可以删除的集成人员。
考虑更新 canRemoveKey 以反映密钥删除期间强制执行的所有条件,包括多重签名引用,以便其结果与 removeKey 的行为一致。
更新: 已在 pull request #7 中的提交 d44a980 中解决。
Secp256k1 密钥类型时出现意外panicKeyLib 库中用于 KeyType.Secp256k1 的 isValidKey 函数验证 key.publicKey 的长度是否等于 32,然后尝试 将其值解码为 address。问题在于,如果高位字节(超过 20 字节地址的长度)不等于 0,则它会在解码 key.publicKey 时panic。
考虑添加进一步的验证以确保 Secp256k1 密钥类型的高位字节归零,以便 abi.decode 能够成功。
更新: 已在 pull request #7 中的提交 684b807 中解决。
经过审计的范围为跨不同协议的密钥管理提供了一个可组合的基础,包括 The Compact 协议中的 emissary 实现。
在审计期间,发现并解决了几个中低严重性问题,但没有报告高严重性问题。
感谢 Uniswap Labs 团队在整个审查过程中的合作,并及时、明确地回应了审计团队提出的所有疑问。
准备好保护你的代码了吗?
- 原文链接: openzeppelin.com/news/un...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!