OpenZeppelin Contracts v5.7.0 ERC-7739 安全修复:拒绝 `contentsDescr` 畸形解析,杜绝签名验证退化为恒定 `structHash`
2026/9/10 17:26:32 网站建设 项目流程

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.

核心语义可以拆解为三层:

  1. 拒绝条件:签名携带的contentsDescr无法解析出非空contentsName时,该签名必须被拒绝;
  2. 被阻止的攻击:畸形描述符会让验证退化为一个恒定structHash(即bytes32(0)),从而不再绑定消息内容(contentsHash)与账户的 EIP-712 域(APP_DOMAIN_SEPARATOR);
  3. 安全后果:若不加防护,攻击者可以把同一份签名用于任意消息,实现跨消息的签名重放。

值得注意的是,这是patch级别的安全加固,说明它在函数签名、公开 API 与正常用法上均无破坏性变更,仅对原本"应该失败但未失败"的畸形输入补齐了校验。

背景:ERC-7739 嵌套签名如何工作

在深入漏洞前,需要先理解ERC7739在这个仓库中的定位。OpenZeppelin 在 contracts/utils/cryptography/signers/draft-ERC7739.sol 中提供了ERC7739抽象合约,它继承自 AbstractSigner、EIP712IERC1271,用于验证"把消息哈希包装进嵌套 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 dataTypedDataSign):模拟eth_signTypedData,由 _isValidNestedTypedDataSignature 处理;
  • Nested personal signPersonalSign):模拟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_SEPARATOR32 字节请求验证的应用合约的 EIP-712 域分隔符
contentsHash32 字节底层消息或数据结构的哈希
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 负责从描述符中解析出contentsNamecontentsType。按照 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)哨兵,跳过了对contentsHashdomainBytes的哈希绑定。源码注释明确指出调用方义务:"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 域信息。攻击者于是可以构造这样一条被验证的摘要,用它换取任意消息的通过:

  1. 诱导用户用底层私钥对hashTypedData(appDomain, bytes32(0))这个"无害"摘要签名(该摘要无法通过常规signMessage/signTypedData入口产生,但攻击者可以用低层signingKey.sign原语让用户签署任意摘要);
  2. 在签名后拼接攻击者自选的contentsHash(如目标消息的哈希)与畸形contentsDescr(如")"——非空但解析出的名称仍为空);
  3. 提交给验证合约,由于验证退化为恒定摘要,签名照常通过——同一份签名可以被无限次重放于任意内容。

修复方案:验证入口拒绝空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" 完整复现了攻击流程:

  1. 构造恒定摘要签名:使用低层signingKey.sign(hashTypedData(appDomain, ethers.ZeroHash))签署"坍缩后的摘要"——即appSeparator.toTypedDataHash(0)对应的摘要。测试注释特别说明:常规的signMessage/signTypedData入口总会把载荷包装成结构良好的PersonalSign/TypedDataSign,永远不会产生这里被利用的structHash == 0摘要;
  2. 拼接攻击载荷:选取攻击者控制的contentsHashethers.id('Message the app expects')),并传入畸形描述符')'——非空、但按decodeContentsDescr解析后contentsName为空;
  3. 断言拒绝:调用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 变更。

对于升级者,建议按以下方式核对与验证:

  1. 确认修复代码存在:检查 draft-ERC7739.sol 的_isValidNestedTypedDataSignature是否包含bytes(contentsName).length != 0守卫条件(当前仓库 v5.7.0 已包含);
  2. 运行相关测试套件:在仓库根目录执行npx hardhat test test/utils/cryptography/ERC7739.test.jsnpx hardhat test test/utils/cryptography/ERC7739Utils.test.js,确认畸形描述符用例通过(仓库使用 Hardhat,见 hardhat.config.js);
  3. 自查集成方:若你的账户/验证器在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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询