【技术分析】EIP-7702 场景下 EOA 授权签名的安全探讨

EIP-7702

在 2025 年即将到来的以太坊 Pectra 升级中,将会引入 EIP-7702 这个提案。其主要的内容就是使得 EOA 账户拥有了自己的 Storage ,并且可以通过 delegate 的方式指定一个合约地址作为 EOA 的 implement 合约。总的来说就是 EOA 拥有了 "Proxy" 的功能。除此之外,EOA 还能够进行更换 implement 合约。但目前协议级别不支持清空 Storage 操作,需要通过特定的合约来清空 Storage 。

https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7702.md

目前在 7702 场景下主流的开发思路是将 EOA 账户扩展成拥有直接调用、授权签名调用、外部调用等功能的超级 EOA(把 ERC-4337 抽象账户的功能直接在 EOA 上面实现)。其中,授权签名调用是本篇文章想要着重讨论的场景,接下来将会结合 EIP-712 签名方案进行讨论。

EIP-712

EIP-712 作为一个被广泛使用的标准,除了它能够提供格式化便于解析的签名内容外,还因为它在签名内容中加入了合约地址及链 id,能够防止签名被跨合约或跨链重放攻击

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/EIP712.sol

当生成签名内容 digest 时,EIP-712 会将 TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this) 这些内容打包进去。其中,TYPE_HASH 是一个常量,而 _hashedName_hashedVersion 由合约进行配置。

在 7702 场景下使用 712 签名

下面这个合约实现了一个简单的验签功能,通过 validateSignature() 可以验证 signature 的签名者是否为 address(this)

jsx 复制代码
contract SimpleImp is EIP712 {
    constructor() EIP712("NAME", "VERSION") {}

    function validateSignature(bytes32 digest, bytes memory signature) public view {
        address signer = ECDSA.recover(digest, signature);
        require(signer == address(this), "Invalid signature");
    }

    function getDigest(uint256 message) public view returns (bytes32) {
        return _hashTypedDataV4(keccak256(abi.encode(message)));
    }
}

这在此前的场景下无法正常运行,因为合约地址没有私钥,所以无法对消息进行签名。但是在 7702以后,EOA 可以将 SimpleImp 合约作为实现合约,在验签时如果消息的签名者是 EOA ,则能够通过 require(signer == address(this)) 的验证了。

至此,SimpleImp 合约实现了基本的签名验证功能,但存在签名重放风险:同一个签名可以在协议中被多次使用。为了解决这个问题,可以通过引入 nonce 机制来解决这个问题。通过将 nonce 值纳入签名内容中,并在每次验证签名成功后将 nonce 值加 1,就能确保每个签名只能在合约中使用一次。

在 stateless 7702 场景下使用 712 签名

7702 场景下的 Storage 冲突问题

在上一节中提到可以通过添加 nonce 的方法来避免签名重放,就如下面的代码一样,添加一个全局变量 nonce,每次使用后 nonce 的值加 1 避免签名被重复使用。

💡 但是在 7702 场景下,这个方案实施起来就有点"尴尬"。

jsx 复制代码
contract SimpleImp is EIP712 {
		uint256 nonce;
		
    constructor() EIP712("NAME", "VERSION") {}

    function validateSignature(bytes memory signature) public view {
        bytes32 digest = getDigest(nonce);
        address signer = ECDSA.recover(digest, signature);
        require(signer == address(this), "Invalid signature");
        nonce++;
    }

    function getDigest(uint256 nonce) public view returns (bytes32) {
        return _hashTypedDataV4(keccak256(abi.encode(nonce)));
    }
}

在介绍 7702 的章节提到,EOA 能够更换 implement 合约,这使得用户可以根据自己的喜好、收益率、功能实现等需求去切换不同的协议。

虽然 7702 给每个 EOA 都增加了 Storage 进行数据的存储,但是由于用户在切换 implement 合约时 Storage 的内容是不会重置的,这也就使得前一个合约使用过的 Storage 会被后一个合约所继承。如果毫无顾及地肆意修改 Storage,可能会导致关键的 slot 发生冲突。

比如,在 OldImp 中 slot0 的值为 nonce = 1,而更新到了 NewImp 时,变成了 owner = 1

以上的场景显然不是开发者和用户希望看到的,也就导致了开发者们"有 Storage 不敢用"的尴尬局面。

Stateless 7702

不过没关系,虽然不能直接使用 EOA 的 Storage,聪明的开发者们想到了办法:可以通过 Create2 的方法外挂一个 NonceStorage 合约对 nonce 进行存储。

在 ImplementV1 合约中通过 Create2.deploy() 部署了一个 NonceStorage 合约用作 nonce值的存储。而需要使用 nonce 时,再根据 Create2.computeAddress() 计算得到的地址进行调用,这样就避免了把全局变量存放在 EOA 的 Storage 中。

为了避免采用相同方案的不同协议会 deploy 出相同的 NonceStorage 合约地址,ImplementV1 通过 SALT = keccak256("IMP1") 变量添加了标识特征。即便是后续使用的协议 ImplementV2 采用了和 ImplementV1 相同的 SALT ,也无法重复部署 NonceStorage 合约。

jsx 复制代码
contract ImplementV1 is EIP712 {
    bytes32 constant SALT = keccak256("IMP1");
    constructor() EIP712("NAME", "VERSION") {}

    function initialStorage() public returns (address storageAddress) {
        bytes memory bytecode = type(NonceStorage).creationCode;
        bytes memory deployCode = abi.encodePacked(bytecode, abi.encode(address(this)));
        storageAddress = Create2.deploy(0, SALT, deployCode);
    }

    function execute(bytes memory signature) public {
        address storageAddress = Create2.computeAddress(SALT, keccak256(abi.encodePacked(type(NonceStorage).creationCode, abi.encode(address(this)))));
        uint256 nonce = NonceStorage(storageAddress).useNonce();
        bytes32 digest = getDigest(nonce);
        validateSignature(digest, signature);
    }
    ...
 }

NonceStorage 合约的功能就比较简单了:nonce 的储存与修改。

jsx 复制代码
contract NonceStorage is Ownable {
    uint256 public nonce;
    constructor(address initialOwner) Ownable(initialOwner) {}
    function useNonce() public onlyOwner returns (uint256 currentNonce) {
        currentNonce = nonce;
        nonce++;
    }
}

当用户从 ImplementV1 切换到 ImplementV2 时,将会新部署一个属于 ImplementV2 的 NonceStorage 合约来存储 nonce 值。

jsx 复制代码
contract ImplementV2 is EIP712 {
    bytes32 constant SALT = keccak256("IMP2");
    constructor() EIP712("NAME", "VERSION") {}
    ... // Same as ImplementV1
}

两个 NonceStorage 合约互相独立,分别存储各自对应协议所使用的 nonce。

通过以上的方案,解决了以下问题:

  1. 防止签名在同一个 implement 合约上发生重放。
  2. 解决了 EOA Storage 的冲突问题,使得每个协议拥有了各自独立的内存空间。

Stateless 7702 上存在的签名重放问题

设想这样一个场景,当用户更换 implement 合约时,旧合约与新合约采用的是同一套签名方案,且 _hashedName_hashedVersion 的值也是采用相同的赋值(比如: EIP712("NAME", "VERSION") ),会出现什么情况?

💡在 oldSimpleImp 上使用过的签名,可以在 newSimpleImp 上进行重放。

我们通过分析 712 的签名内容可以得知,在 7702 的场景下,无论是 oldSimpleImp 上使用的签名,还是 newSimpleImp 上使用的签名,他们的 address(this) 都是一样的,都是 EOA 的地址。所以能够进行重放操作。

jsx 复制代码
function _buildDomainSeparator() private view returns (bytes32) {
    return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)));
}

看到这里读者可能会有疑问,为什么要假设两个合约间的 _hashedName_hashedVersion 值是相等的呢?这两个值不相等的话,那不就避免了这个重放的问题了?

是的,如果这两个值不相等,就能避免签名重放的问题。但是问题的重点是开发者与用户无法在协议级别对签名的使用范围进行限制,只能寄希望于使用流程上"采用不同的值"来规避签名重放的风险

换句话说,开发者在设置 _hashedName_hashedVersion 时无法保证两件事情:

  1. 当开发者要使用某一套参数时,他需要保证这套参数没有从来没有被使用过。
  2. 当开发者使用了某一套参数后,他无法保证后来的开发者能够发现这套参数已经被使用,且自觉地避免使用相同的值。

基于以上情况,我们做出了参数值相等这个看似"苛刻"的假设,并且基于这个假设的场景来讨论可能发生的安全问题。

为什么这个问题在 7702 以外的场景(如 4337)没有出现,因为即使他们的 _hashedName_hashedVersion 值相等,address(this) 参数也不相等,所以签名无法跨协议进行重放。

目前 7702 尚未在主网部署,对于可能存在的安全场景还需要在使用过程中发现与挖掘。对于 stateless 7702 授权签名重放的问题笔者暂时没有一个较为完备的解决方法。下面是一些不成熟的想法:

  1. 根据 7702 的特性提出更为完备的签名方案,比如在 implement 合约中添加地址,在签名方案中把该地址添加进去。

    jsx 复制代码
    contract ImplementWithAddress {
        address immutable public IMP_ADDRESS;
        constructor() {
            IMP_ADDRESS = address(this);
        }
    }
  2. 制定 EOA Storage 的使用标准,各个协议间遵循这套标准来使用 Storage,从而避免 Storage 冲突问题。

本文所提到的代码以及测试案例可以在此处访问:https://github.com/ACaiSec/EIP7702SignatureReplay

后记

在写这篇文章的过程中其实是信心不足的,因为 7702 提案还没有真正在主链部署(已经上线了测试链和 Foundry)。所以对于这个提案下的安全场景讨论会略显"纸上谈兵",担心写出来的内容会缺乏说服力,让读者感觉在"装模作样地吹牛"。但是最终经过多番的修改与调整,还是把整个流程写下来了。希望这篇文章能够给你带来一些收获。如果你读完整篇文章后感觉毫无营养,那真的不是我在敷衍了事,而是哥们的能力就到这里了 T T。如果文章中有什么理解错误的地方欢迎随时指出,有什么想要聊的也可以随时讨论。

相关推荐
Blockchina5 小时前
什么是sol节点?Solana RPC节点?
web3·区块链
我是前端小学生12 小时前
一文了解在中心化交易所中手续费的预估模型
区块链
Q8137574601 天前
市场波动中的风险管理与策略优化
区块链
落——枫1 天前
区块链知识点2
服务器·网络·区块链
Allover#566991 天前
区块链竞赛--智能合约备赛建议
区块链·智能合约
萧大侠jdeps2 天前
智能合约:Solidity(基于以太坊或兼容链,如 Polygon、BSC)(仅供学习区块链知识,不可进行违法开发应用)
区块链·智能合约
sprklestars2 天前
Secure and Privacy-Preserving Decentralized Federated Learning同态加密联邦学习文献阅读
去中心化·区块链·同态加密
我是前端小学生2 天前
手把手带你写一个solana程序:计数器合约
智能合约
加密新世界2 天前
Arbitrum之智能合约
区块链·智能合约