UUPS:通用可升级代理标准(ERC-1822)

  • RareSkills
  • 更新于 2024-09-04 22:16
  • 阅读 317

UUPS:通用可升级代理标准(ERC-1822)

UUPS 模式是一种代理模式,其中升级功能位于实现合约中,但通过代理合约中的 delegatecall 更改存储在代理合约中的实现地址。上层机制如下动画所示:

UUPS 代理存储的升级功能

与透明可升级代理类似,UUPS 模式通过完全消除代理中的公共函数来解决函数选择器冲突的问题。

ERC-1967 代理存储槽标准

正如我们在关于透明可升级代理的文章中所述,功能性以太坊代理至少需要以下两个特性:

  • 一个存储槽:保存实现合约的地址。

  • 一个机制:允许管理员更改实现地址。

ERC-1967 标准规定了保存实现地址的存储槽的位置,但并未规定如何更改实现的地址,也就是说,它将升级机制的选择留给开发者。

UUPS 是一种代理模式,其中更改实现合约地址的机制位于实现合约本身,而不是在代理合约中。

这一差异在以下简化代码中得到了说明:

透明代理和 UUPS 代理简化代码差异

在升级过程中,_upgradeLogic()函数被 delegatecall 到 UUPSProxy。与透明可升级代理不同,不需要 AdminProxy —— 如果需要,普通 EOA 可以作为管理员。

透明可升级代理使用 AdminProxy 来保持管理员地址不变。由于透明可升级代理必须在每个交易中将 msg.sender 与管理员进行比较,因此希望将 msg.sender 与不可变变量进行比较。然而,UUPS 代理只需要在显式调用 _upgradeLogic()时检查 msg.sender 是否为管理员(这会 delegatecall 到 _upgradeLogic()到实现中)。

这种模式的一个优点是实现逻辑本身可以被升级,也就是说,升级机制可以从一个实现修改到另一个实现。例如,可以从简单的升级逻辑过渡到更复杂的投票或时间锁机制。

这一标准的重要权衡是,如果对缺乏有效升级机制的新实现合约进行升级,则升级链结束,因为无法迁移到下一个实现。换句话说,由于升级机制本身可能是可升级的,存在破坏升级机制的风险。

为了应对这一权衡,一些提案提出在迁移到新实现合约之前,首先检查其是否具有有效的升级机制。UUPS 就是其中一个提案。

在本文中,我们将一般性地解释 UUPS 的工作原理,详细检查 OpenZeppelin 的实现,并讨论在使用此模式时必须考虑的一些漏洞。

UUPS 与透明代理

OpenZeppelin 目前为透明和 UUPS 代理标准提供实现,但我们推荐使用后者 。原因在于,除了修改升级机制的灵活性外,UUPS 实现更轻,因此在部署和使用过程中消耗更少的 gas。

这是因为不需要部署管理合约或检查交易是否来自合约所有者,这在透明代理中是必需的。然而,这种模式中的每个新实现合约确实需要一个升级函数,这稍微提高了新实现合约的部署成本。

如果实现合约在使用 UUPS 时遇到 24kb 的大小限制,则透明可升级模式可能更合适,因为它不需要包含升级逻辑。

UUPS 的工作原理

UUPS 最初在 ERC-1822 中定义。

正如我们在前一节中看到的,必须防止代理合约接受不实现 UUPS 标准的新实现合约。换句话说,任何尝试迁移到不符合 UUPS 的实现合约的行为都应回滚。

proxiableUUID()函数

这就是标准要求每个实现合约都包含一个签名为 proxiableUUID()的函数的原因。该函数的目的是作为兼容性检查,以确保新实现合约遵循通用可升级代理标准。

该函数应返回存储实现地址的存储槽尽管返回值是任意的,标准的支持者本可以定义该函数返回一个字符串,如“嘿,我是 UUPS 合规的”,但返回存储槽更节省 gas。

其思路是在实际迁移到新实现之前调用 proxiableUUID()函数。如果新实现合约正确实现了 proxiableUUID(),则被视为 UUPS 合规,迁移应继续。否则,交易必须回滚。

成功迁移和失败迁移尝试的过程如下图所示。

proxiableUUID 函数的工作原理

存储槽

原始提案建议存储槽地址由公式 keccak256("PROXIABLE")定义,然而,由于 OpenZeppelin 实现使用 ERC-1967 标准,因此其实现中的槽地址由 keccak256("eip1967.proxy.implementation") - 1 定义。我们稍后将在代码中看到这一点。

下面可以看到迁移到新实现的过程动画:https://img.learnblockchain.cn/2024/mp4/upgrade_uups.mp4

OpenZeppelin UUPS 可升级的逐步讲解

在 OpenZeppelin 库中实现 UUPS 标准的合约名为 UUPSUpgradeable.sol该合约应由实现合约继承,而不是由代理合约继承。 代理通常继承自 ERC1967Proxy,这是一个符合 ERC-1967 标准的最小代理合约。

UUPSUpgradeable.sol 的目的有两个:

  1. 提供每个实现必须包含的 proxiableUUID()函数,以确保 UUPS 合规,

  2. 还提供 updateToAndCall()函数,用于迁移到新实现。正如我们所见,具有此目的的函数必须在每个实现合约中存在。

proxiableUUID() 函数

proxiableUUID() 函数必须在新的实现合约 之前 调用迁移,定义如下,并返回 ERC-1967 标准的存储槽。

function proxiableUUID() external view virtual notDelegated returns (bytes32) {
        return ERC1967Utils.IMPLEMENTATION_SLOT;  // 符合 ERC-1967 标准
}

upgradeToAndCall 函数

负责升级到下一个实现的函数可以有任何名称。由于它是在实现合约内定义的,因此没有函数签名冲突的风险,这在透明代理中也会发生。

在 UUPSUpgradeable.sol 中,这个函数被命名为 upgradeToAndCall,其定义如下:

function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
        // 检查升级是否可以进行
        _authorizeUpgrade(newImplementation); 
        // 升级到新的实现
        _upgradeToAndCallUUPS(newImplementation, data); 
}

function _authorizeUpgrade(address newImplementation) internal virtual;

function _upgradeToAndCallUUPS(address newImplementation, bytes memory data) private {
      // 检查新的实现是否实现了 ERC-1822
        try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
            if (slot != ERC1967Utils.IMPLEMENTATION_SLOT) { 
                revert UUPSUnsupportedProxiableUUID(slot);
            }
            ERC1967Utils.upgradeToAndCall(newImplementation, data);
        } catch {
            // 实现不是 UUPS
            revert ERC1967Utils.ERC1967InvalidImplementation(newImplementation);
        }
    }

由于这是一个公共函数,只应通过活动代理调用,因此它具有 onlyProxy 修饰符以确保这一点。

开发者有责任在代码中实现 _authorizeUpgrade 函数。该函数确定谁可以执行升级。一个简单的实现可能只是检查所有权以执行升级,如下所示:

    function _authorizeUpgrade(address newImplementation)
        internal onlyOwner override {}

如前所述,每个新实现可以具有其自己的 _authorizeUpgrade 函数,具有独特的逻辑。例如,如果所有者希望在新实现中切换到多签名方案,则可以在此函数中包含必要的代码。

因为 UUPSUpgradeable 是一个抽象合约,除非你显式实现 _authorizeUpgrade,否则代码将无法编译。

使用 Remix 学习 UUPS

在本节中,我们将使用 Remix 更清晰地可视化 UUPS 的基本工作原理。尽管 Remix 提供了使用代理的高级部署功能,但我们在这里不会使用这些功能,以保持底层过程的透明性。

此外,为了使代码更简洁,我们将省略初始化函数和修饰符。这将使我们的代码不安全,但目标是专注于理解 UUPS 操作的核心概念。

代理合约

我们的代理合约将利用 ERC1967Proxy.sol 库,该库实现了符合 ERC-1967 标准的最小代理方案。初始实现合约的地址在构造函数中传递。然而,代理本身缺乏更新到新实现的机制;该机制必须在实现合约中实现。

在 UUPSProxy 合约中利用 OpenZeppelin ERC1967

以下是复制和粘贴的代码:

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract UUPSProxy is ERC1967Proxy {

    constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) 
    payable {}

}

实现合约

实现合约必须继承自 UUPSUpgradeable 合约,该合约遵循 UUPS 模式并包含移动到下一个实现的机制。重写 _authorizeUpgrade 函数是至关重要的,因为 授权机制没有预定义,必须实现。

在下面的代码中,我们以一种高度不安全的方式实现这一点,因为任何人都被授权执行升级。

不安全的 UUPS 可升级合约实现(仅用于测试目的)

要为合约定义所有者,必须创建一个初始化函数,因为实现合约不应使用构造函数。你可以在我们的 关于 Initializable.sol 合约的文章中了解更多关于此主题的信息。再次提供代码:

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract ImplementationOne is UUPSUpgradeable {

    function myNumber() public pure returns (uint256) {
        return 1; // 测试实现的函数
    }

        // 在实践中,此函数应包括 onlyOwner 修饰符 
        // 或其他形式的所有权保护机制
    function _authorizeUpgrade(address _newImplementation) internal override {}

}

要在 Remix 中测试上述合约,你必须遵循以下步骤:

  1. 部署名为 ImplementationOne 的实现合约。

  2. 部署名为 MyProxy 的代理合约。构造函数需要两个参数:实现合约的地址(ImplementationOne 的地址)和一个类型为 bytes 的参数来初始化实现合约。该参数可以是 0x,因为它将不被使用。

  3. 要测试合约,请使用实现合约 ABI 打开代理合约实例。为此,在部署选项卡中,选择实现合约 ImplementationOne,并在 At Address 字段中输入代理合约地址,如下图所示。

Remix 中的在地址处部署按钮

现在,你将能够通过代理执行实现合约中的 myNumber() 函数。

移动到下一个实现

要移动到下一个实现,必须首先创建一个遵循 UUPS 模式的新合约,类似于下面的示例。

新的 UUPS 可升级合约示例

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationTwo is UUPSUpgradeable {

    function myNumber() public pure returns (uint256) {
        return 2; // A function to test the implementation
    }

        // In practice, this function should include an onlyOwner modifier 
        // or some other form of ownership protection mechanism
    function _authorizeUpgrade(address _newImplementation) internal override {}

}

创建合约后,接下来的步骤如下:

  1. 部署 ImplementationTwo。

  2. 通过代理在之前的实现合约上调用 upgradeToAndCall 函数,传递 ImplementationTwo 的地址作为第一个参数(第二个参数可以是 0x)。proxiableUUID() 函数在父合约中定义,其正确的返回值将在迁移之前进行验证。

尝试迁移到不符合 UUPS 的实现将失败,因为通过 proxiableUUID() 函数的安全检查机制将阻止它。

UUPS 中的漏洞

1. 未初始化合约的漏洞

初始化实现合约是很常见的。例如,在 ERC20 可升级合约的情况下,通常在部署时设置代币名称和符号。这个过程通常通过构造函数完成。然而,在实现合约中使用构造函数并没有帮助,因为这会改变实现的存储,而“真实”的存储位于代理合约中。

要初始化实现合约,我们必须依赖于配置为仅执行一次的常规函数。这可以通过 OpenZeppelin 提供的 Initializable.sol 库中的修饰符来完成。

初始化函数的示例代码如下。

    function initialize(address initialOwner) initializer public {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
    }

问题

一个主要的漏洞在于这是一个公共函数。它应该通过代理调用,但也可以直接在实现合约上调用。由于它设置了合约的所有者,谁先直接在实现合约上调用这个函数,谁就会成为该合约的“所有者”。

为了澄清,此时合约将有两个所有者:

  1. 通过代理设置的所有者。

  2. 通过直接调用实现合约设置的“所有者”。

UUPS 可升级代理的两个所有者问题

任何标记为 onlyOwner 的函数都将允许这两个所有者中的任何一个调用。这种意外行为可能会对合约构成风险,正如我们将很快看到的那样。

解决方案

解决此漏洞的方法是始终通过在实现合约中直接设置所需的状态变量来“初始化”实现合约。例如,你应该设置实现合约的所有者或防止任何人直接在实现合约上调用初始化函数。

OpenZeppelin 提供了必须在构造函数中执行的 _disableInitializers() 函数,以实现这一点,如下代码所示:

constructor() {
        _disableInitializers();
}

2. 通过 delegatecall 的漏洞

在实现合约中,你应该避免对任意合约使用 delegatecall。最大的风险在于无意中对 selfdestruct 操作码进行 delegatecall。自 Cancun 分叉以来,selfdestruct 不再删除合约代码。然而,建议在实现合约中继续避免使用 delegatecall,可能是因为在 selfdestruct 仍然有效的链中使用。

OpenZeppelin 的 UUPS 实现中,Contracts v4.1.0 到 v4.3.1 的一个严重漏洞是由于上述两个漏洞的结合造成的:

除了更改实现地址外,移动到下一个实现的代码还包括对新合约的初始化的 delegatecall。这个函数 upgradeToAndCall 只能由所有者执行,并且旨在仅通过代理调用。然而,如前所述,如果合约没有正确“初始化”,任何人都可以假设所有者的角色,并使用 upgradeToAndCall 函数对包含 selfdestruct 操作码的合约进行 delegatecall。

由于上述漏洞,OpenZeppelin 认为在实现合约中使用 delegatecall 是不安全的。

使用 UUPS 的检查清单

以下是使用 OpenZeppelin 库中的 UUPS 标准时必须遵循的一些指南:

  1. 如果你重写 upgradeToAndCall,请非常小心,以免破坏升级功能。

  2. 确保 _authorizeUpgrade 包含 onlyOwner 修饰符或其他限制访问的机制,仅允许授权账户访问。

  3. 在升级中,谨慎更改新版本实现合约中的授权模式。例如,切换到一种授权类型,其中管理员之前已放弃其特权,而这一点未被注意到。

  4. 在实现合约的构造函数中使用_disableInitializers() 函数以防止初始化。

  5. 不使用 delegatecallselfdestruct

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/