OpenZeppelin Contracts v5.7.0 ERC-7739 安全修复:拒绝contentsDescr畸形解析,杜绝签名验证退化为恒定structHash
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
本指南以 OpenZeppelin Contracts 仓库中的 changeset 变更记录(.changeset/erc7739-malformed-contents-descr.md)为切入点,深入讲解本次ERC7739安全修复的背景、攻击原理、修复方式与配套测试。读者将理解:为什么一个"看似可解析"的畸形contentsDescr会把签名验证退化成对恒定structHash的校验,从而让同一签名可在任意消息上重放;以及本次变更如何从验证入口(draft-ERC7739.sol)和底层库(draft-ERC7739Utils.sol)两侧封堵该漏洞。
变更摘要:一次针对签名描述符的防御性收紧
本次变更对应 changeset 文件.changeset/erc7739-malformed-contents-descr.md,属于openzeppelin-solidity包的patch级别修复,与 CHANGELOG.md 中 v5.7.0(2026-07-29)条目下的ERC7739记录一致:
ERC7739: Reject signatures whosecontentsDescrfails to parse into a non-emptycontentsName, preventing a malformed descriptor from degrading verification to a constantstructHashthat no longer binds the message contents or the account's EIP-712 domain.
核心语义可以拆解为三层:
- 拒绝条件:签名携带的
contentsDescr无法解析出非空的contentsName时,该签名必须被拒绝; - 被阻止的攻击:畸形描述符会让验证退化为一个恒定
structHash(即bytes32(0)),从而不再绑定消息内容(contentsHash)与账户的 EIP-712 域(APP_DOMAIN_SEPARATOR); - 安全后果:若不加防护,攻击者可以把同一份签名用于任意消息,实现跨消息的签名重放。
值得注意的是,这是patch级别的安全加固,说明它在函数签名、公开 API 与正常用法上均无破坏性变更,仅对原本"应该失败但未失败"的畸形输入补齐了校验。
背景:ERC-7739 嵌套签名如何工作
在深入漏洞前,需要先理解ERC7739在这个仓库中的定位。OpenZeppelin 在 contracts/utils/cryptography/signers/draft-ERC7739.sol 中提供了ERC7739抽象合约,它继承自 AbstractSigner、EIP712与IERC1271,用于验证"把消息哈希包装进嵌套 EIP-712 类型"的签名。
引入这一层包装的根本目的是防重放:把签名与账户自己的 EIP-712 域绑定,避免同一个离链拥有者(offchain owner)在多个 EIP-712 域下签署的消息被互相重放。正如 draft-ERC7739.sol 注释所说明的:"Linking the signature to the EIP-712 domain separator is a security measure to prevent signature replay across different EIP-712 domains"。
isValidSignature(hash, signature)支持两种嵌套形式(分别对应两种链下签名习惯):
- Nested typed data(
TypedDataSign):模拟eth_signTypedData,由 _isValidNestedTypedDataSignature 处理; - Nested personal sign(
PersonalSign):模拟personal_sign,由_isValidNestedPersonalSignSignature处理。
其中 TypedDataSign 路径正是本次修复的目标。
嵌套签名的编码格式
nested typed data 签名的编码格式由ERC7739Utils.encodeTypedDataSig定义,为以下拼接结构:
signature ‖ APP_DOMAIN_SEPARATOR ‖ contentsHash ‖ contentsDescr ‖ uint16(contentsDescr.length)各字段含义如下:
| 字段 | 长度 | 含义 |
|---|---|---|
signature | 可变(如 65 字节的 ECDSA) | 对嵌套 struct hash 的原始签名 |
APP_DOMAIN_SEPARATOR | 32 字节 | 请求验证的应用合约的 EIP-712 域分隔符 |
contentsHash | 32 字节 | 底层消息或数据结构的哈希 |
contentsDescr | 可变 | 对"contents"部分 EIP-712 类型的描述 |
uint16(contentsDescr.length) | 2 字节 | 描述符的字节长度(便于定界解析) |
在测试辅助文件 test/helpers/erc7739.js 中,ERC7739Signer.signTypedData展示了链下如何构造该编码:把原始签名后依次拼接TypedDataEncoder.hashDomain(domain)(应用域)、TypedDataEncoder.hashStruct(contentsTypeName, types, value)(contentsHash)、UTF-8 编码的描述符以及其 2 字节长度。
漏洞机理:contentsName为空如何导致退化
漏洞根植于底层库 draft-ERC7739Utils.sol 中两个函数的组合行为。
decodeContentsDescr:畸形描述符返回空名称
decodeContentsDescr 负责从描述符中解析出contentsName与contentsType。按照 ERC-7739 规范,contentsName在以下情况下非法:为空,或包含,、)、\x00等禁止字节(见_isForbiddenChar,源码)。该函数支持两种模式:
- 隐式模式:描述符形如
SomeType(address foo,uint256 bar),从开头读取contentsName; - 显式模式:描述符形如
A(C c)B(A a)C(uint256 v)B,类型定义在前、名称附加在后。
关键行为是:无论输入为空还是畸形,解析失败时都返回空字符串(Calldata.emptyString())。这意味着decodeContentsDescr本身无法区分"空描述符"和"畸形描述符",两者产出同样的空contentsName。
typedDataSignStructHash:空名称返回零哨兵
typedDataSignStructHash 在计算嵌套 struct hash 前先检查contentsName:
return bytes(contentsName).length == 0 ? bytes32(0) : keccak256( abi.encodePacked(typedDataSignTypehash(contentsName, contentsType), contentsHash, domainBytes) );当contentsName为空时,它直接返回bytes32(0)哨兵,跳过了对contentsHash与domainBytes的哈希绑定。源码注释明确指出调用方义务:"Since {decodeContentsDescr} yields an emptycontentsNamefor both empty and malformed descriptors, callers must either sanitize the input so an emptycontentsNameis never passed, or reject thebytes32(0)return before signing/verifying."
退化后的验证等式
在修复前,draft-ERC7739.sol 的_isValidNestedTypedDataSignature只检查了两点:hash == appSeparator.toTypedDataHash(contentsHash),以及底层原始签名验证。当contentsName为空导致typedDataSignStructHash返回bytes32(0)时,实际被验证的摘要坍缩为:
appSeparator.toTypedDataHash(bytes32(0))这是一个只由appSeparator决定的恒定值——既不含contentsHash,也不含账户自身的 EIP-712 域信息。攻击者于是可以构造这样一条被验证的摘要,用它换取任意消息的通过:
- 诱导用户用底层私钥对
hashTypedData(appDomain, bytes32(0))这个"无害"摘要签名(该摘要无法通过常规signMessage/signTypedData入口产生,但攻击者可以用低层signingKey.sign原语让用户签署任意摘要); - 在签名后拼接攻击者自选的
contentsHash(如目标消息的哈希)与畸形contentsDescr(如")"——非空但解析出的名称仍为空); - 提交给验证合约,由于验证退化为恒定摘要,签名照常通过——同一份签名可以被无限次重放于任意内容。
修复方案:验证入口拒绝空contentsName
修复落在验证入口_isValidNestedTypedDataSignature(draft-ERC7739.sol)上。修复后的校验链路为:
return hash == appSeparator.toTypedDataHash(contentsHash) && bytes(contentsName).length != 0 && _rawSignatureValidation( appSeparator.toTypedDataHash( ERC7739Utils.typedDataSignStructHash(contentsName, contentsType, contentsHash, _buildDomainBytes()) ), signature );注意新增的中间条件bytes(contentsName).length != 0——这是本次 patch 的核心改动:
contentsName非空:正常路径,继续计算绑定contentsHash与账户域的嵌套 struct hash,并交给_rawSignatureValidation做底层密码学校验;contentsName为空:无论描述符是空还是畸形,验证立即短路为false,不会触碰typedDataSignStructHash,更不会走到底层签名校验。
从更宽的视角看,修复策略是"双层防线":底层库 draft-ERC7739Utils.sol 的typedDataSignStructHash仍保留bytes32(0)哨兵语义(其文档注释也强调调用方必须拒绝该返回值或预先净化输入),而验证入口在调用它之前就完成了空名称拦截,因此不会发生"验证退化为恒定 structHash"的情况。由于改动只发生在验证函数的判定逻辑内,公开 API、事件与正常输入的处理完全不变,因此 changeset 标记为patch。
测试佐证:模拟完整攻击链路
仓库测试对这次修复给出了精确到"攻击步骤"的验证。在 test/utils/cryptography/ERC1271.behavior.js 中,测试用例 "returns false for a malformed contents descriptor" 完整复现了攻击流程:
- 构造恒定摘要签名:使用低层
signingKey.sign(hashTypedData(appDomain, ethers.ZeroHash))签署"坍缩后的摘要"——即appSeparator.toTypedDataHash(0)对应的摘要。测试注释特别说明:常规的signMessage/signTypedData入口总会把载荷包装成结构良好的PersonalSign/TypedDataSign,永远不会产生这里被利用的structHash == 0摘要; - 拼接攻击载荷:选取攻击者控制的
contentsHash(ethers.id('Message the app expects')),并传入畸形描述符')'——非空、但按decodeContentsDescr解析后contentsName为空; - 断言拒绝:调用
isValidSignature(hashTypedData(appDomain, contentsHash), encodedSignature),必须不等于ERC-1271 魔数0x1626ba7e,即签名被拒绝。
该用例被shouldBehaveLikeERC1271({ erc7739: true })注入到 ECDSA、P256、RSA 三套签名算法的测试中(见 ERC7739.test.js:分别部署$ERC7739ECDSAMock、$ERC7739P256Mock、$ERC7739RSAMock,后者定义于 ERC7739Mock.sol),确保修复对三种底层密码学算法都生效。
与此同时,ERC7739Utils.test.js 的decodeContentsDescr测试套件系统性地覆盖了描述符解析的合法与非法输入,包括:
- 合法的隐式描述符
SomeType(address foo,uint256 bar)→ 解析出SomeType; - 合法的显式描述符
A(C c)B(A a)C(uint256 v)B→ 解析出名称B与类型A(C c)B(A a)C(uint256 v); - 空描述符、缺少
(的描述符、以(开头的描述符、以及含,/)/\x00禁止字节的各种变体 → 全部返回空contentsName/contentsType。
这些用例恰好印证了漏洞面:"非空但畸形"的描述符(如SomeType、(SomeType(...)、SomeType,(...)等)都会产出空名称,因此在修复后的ERC7739验证入口处被一律拒绝。
修复验证与发布状态
本次修复归属于OpenZeppelin Contracts v5.7.0(CHANGELOG.md 首条版本记录,2026-07-29 发布)Cryptography 分类下的变更,changeset 头部声明openzeppelin-solidity: patch,表示它随@openzeppelin/contractsnpm 包的patch版本发布,属于安全加固型改动而非 API 变更。
对于升级者,建议按以下方式核对与验证:
- 确认修复代码存在:检查 draft-ERC7739.sol 的
_isValidNestedTypedDataSignature是否包含bytes(contentsName).length != 0守卫条件(当前仓库 v5.7.0 已包含); - 运行相关测试套件:在仓库根目录执行
npx hardhat test test/utils/cryptography/ERC7739.test.js与npx hardhat test test/utils/cryptography/ERC7739Utils.test.js,确认畸形描述符用例通过(仓库使用 Hardhat,见 hardhat.config.js); - 自查集成方:若你的账户/验证器在
ERC7739之上自行处理描述符,请确认在调用typedDataSignStructHash前,对空contentsName(无论是空描述符还是畸形描述符解析所致)做了拒绝或净化,避免再次踩中哨兵退化路径。
小结
这次 patch 修复揭示了一个容易被忽视的安全边界:在"容忍畸形输入"与"密码学绑定"之间存在张力——decodeContentsDescr用空字符串统一表示解析失败,而typedDataSignStructHash又用bytes32(0)表示空名称;一旦验证入口把这两种"空"当作正常输入继续处理,绑定就被悄然解除。修复通过在 draft-ERC7739.sol 验证入口强制要求非空contentsName,让畸形描述符的签名在任何底层签名算法(ECDSA、P256、RSA)下都必然失败,从根源上消除了恒定structHash重放攻击面。对使用 OpenZeppelinERC7739的智能合约账户与验证器而言,升级到 v5.7.0 即可获得该防护,无需修改任何调用代码。
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考