UUPS 模式是一种代理模式,其中升级函数驻留在实现合约中,但通过代理发起 delegatecall 来改变存储在代理合约中的实现地址。其高层机制如下方动画所示:

与 Transparent Upgradeable Proxy 类似,UUPS 模式通过完全消除代理中的公共函数,解决了函数选择器冲突的问题。
本文假设读者已经阅读过我们关于 Transparent Upgradeable Proxy 模式的文章。
ERC-1967 代理存储槽标准
正如我们在关于 Transparent Upgradeable Proxy 的文章中所述,一个功能完整的 Ethereum 代理至少需要以下两个特性:
- 一个用于保存实现合约地址的存储槽。
- 一个供管理员更改实现地址的机制。
ERC-1967 标准 规定了保存实现地址的存储槽应该放置在何处,但并未规定如何更改实现地址,也就是说,它将升级机制的选择权留给了开发者。
UUPS 是一种代理模式,其中用于更改实现合约地址的机制位于实现合约自身内部,而不是代理合约中。
下方的简化代码说明了这一差异:

在升级过程中,_upgradeLogic() 函数被 delegatecall 到 UUPSProxy 中。与 Transparent Upgradeable Proxy 不同,这里不需要 AdminProxy —— 如果需要,一个普通的 EOA 也可以作为管理员。
Transparent Upgradeable Proxy 使用 AdminProxy 来保持管理员地址不变。由于 Transparent Upgradeable Proxy 在每笔交易中都必须将 msg.sender 与管理员进行比较,因此将 msg.sender 与不可变变量进行比较是理想的做法。然而,UUPS 代理仅在用户显式调用代理上的 _upgradeLogic()(该操作会 delegatecall 实现中的 _upgradeLogic())时,才需要检查 msg.sender 是否为管理员。
这种模式的优势之一是实现逻辑本身可以升级,也就是说,可升级性机制可以在不同的实现之间进行修改。例如,可以将简单的升级逻辑过渡到带有投票或时间锁机制的更复杂的逻辑。
该标准的一个重要权衡是,如果升级到了一个缺乏有效升级机制的新实现合约,升级链就会终止,因为将无法再迁移到下一个实现。换句话说,由于升级机制本身可能是可升级的,因此存在破坏升级机制的风险。
为了应对这种权衡,有人提出了一些方案,即在迁移到新实现合约之前,首先检查其是否具有有效的升级机制。UUPS 就是此类提案之一。
在本文中,我们将解释 UUPS 的总体工作原理,详细检查 OpenZeppelin 的实现,并讨论在使用此模式时必须考虑到的一些漏洞。
UUPS vs Transparent Proxy
OpenZeppelin 目前提供了 Transparent 和 UUPS 两种代理标准的实现,但 推荐使用后者。原因是,除了修改升级机制的灵活性之外,UUPS 的实现更加轻量,因此在部署和使用过程中消耗的 gas 更少。
这是因为不需要像 Transparent Proxies 那样部署管理合约,也不需要检查交易是否源自合约所有者。然而,该模式下的每个新实现合约确实都需要一个升级函数,这略微增加了新实现合约的部署成本。
如果使用 UUPS 的实现合约达到了 24kb 的大小限制,那么 Transparent Upgradeable Pattern 可能更合适,因为它不需要包含升级逻辑。
UUPS 是如何工作的
UUPS 最初在 ERC-1822 中被定义。
正如我们在上一节所见,有必要防止代理合约接受一个未实现 UUPS 标准的新实现合约。换句话说,任何试图迁移到不符合 UUPS 标准的实现合约的操作都应该 revert。
proxiableUUID() 函数
这就是为什么标准要求每个实现合约都必须包含一个签名为 proxiableUUID() 的函数。此函数的目的是作为兼容性检查,以确保新实现合约遵循通用可升级代理标准。
该函数应返回存储实现地址的存储槽。虽然返回值是任意的,标准的提出者本可以定义该函数返回诸如 “Hey, I’m UUPS compliant” 这样的字符串,但返回存储槽在 gas 方面更高效。
其思路是在实际迁移到新实现之前,先调用新实现中的 proxiableUUID() 函数。如果新实现合约已正确实现了 proxiableUUID(),则认为其符合 UUPS 标准,迁移应继续进行。否则,必须回滚交易。
成功迁移和失败迁移尝试的过程如下图所示。

存储槽
原始提案建议通过公式 keccak256("PROXIABLE") 来定义存储槽地址,然而,由于 OpenZeppelin 的实现使用了 ERC-1967 标准,他们实现中的槽地址由 keccak256("eip1967.proxy.implementation") - 1 定义。我们稍后会在代码中看到这一点。
下方动画展示了向新实现迁移的过程:
OpenZeppelin UUPS Upgradeable 解析
在 OpenZeppelin 库中实现 UUPS 标准的合约名为 UUPSUpgradeable.sol。此合约应由实现合约继承,而不是由代理合约继承。 代理通常继承自 ERC1967Proxy,这是一个符合 ERC-1967 标准的最小代理合约。
UUPSUpgradeable.sol 的作用有两方面:
- 它提供了
proxiableUUID()函数,为了符合 UUPS 标准,每个实现都必须包含该函数; - 它还提供了
updateToAndCall()函数,用于迁移到新实现。正如我们所见,具有此目的的函数必须存在于每个实现合约中。
proxiableUUID() 函数
proxiableUUID() 函数必须在迁移之前在新实现合约中调用,其定义如下,并返回 ERC-1967 标准的存储槽。
function proxiableUUID() external view virtual notDelegated returns (bytes32) {
return ERC1967Utils.IMPLEMENTATION_SLOT; // conformal to the ERC-1967 standard
}
upgradeToAndCall 函数
负责升级到下一个实现的函数可以使用任何名称。由于它定义在实现合约内,因此不存在像在 Transparent Proxy 中那样发生函数签名冲突的风险。
在 UUPSUpgradeable.sol 中,该函数名为 upgradeToAndCall,其定义如下所示:
function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
// checks whether the upgrade can proceed
_authorizeUpgrade(newImplementation);
// upgrade to the new implementation
_upgradeToAndCallUUPS(newImplementation, data);
}
function _authorizeUpgrade(address newImplementation) internal virtual;
function _upgradeToAndCallUUPS(address newImplementation, bytes memory data) private {
// checks whether the new implementation implements ERC-1822
try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
if (slot != ERC1967Utils.IMPLEMENTATION_SLOT) {
revert UUPSUnsupportedProxiableUUID(slot);
}
ERC1967Utils.upgradeToAndCall(newImplementation, data);
} catch {
// The implementation is not UUPS
revert ERC1967Utils.ERC1967InvalidImplementation(newImplementation);
}
}
由于它是一个公共函数,且应该仅通过活动的代理进行调用,因此它带有一个 onlyProxy 修饰符以确保这一点。
_authorizeUpgrade 内部函数
开发者有责任在代码中实现 _authorizeUpgrade 函数。此函数决定了谁可以执行升级。一个简单的实现可能只检查所有权来执行升级,如下所示:
function _authorizeUpgrade(address newImplementation)
internal onlyOwner override {}
如前所述,每个新实现都可以有自己带有独特逻辑的 _authorizeUpgrade 函数。例如,如果所有者希望在一个新实现中切换到多签方案,可以将必要的代码包含在此函数中。
由于 UUPSUpgradeable 是一个抽象合约,除非你显式实现 _authorizeUpgrade,否则代码将无法编译。
使用 Remix 学习 UUPS
在本节中,我们将使用 Remix 来更清晰地可视化 UUPS 的基本工作原理。虽然 Remix 提供了使用代理的高级部署功能,但为了保持底层过程透明,我们在这里不会使用这些功能。
此外,为了使代码更加简洁,我们将省略初始化函数和修饰符。这会使我们的代码变得不安全,但我们的目标是专注于理解 UUPS 运作机制的核心概念。
代理合约
我们的代理合约将利用 ERC1967Proxy.sol 库,该库实现了一个符合 ERC-1967 标准的最小代理方案。初始实现合约的地址将在构造函数中传入。然而,代理本身缺乏更新到新实现的机制;该机制必须在实现合约内部进行实现。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
以下是可供复制和粘贴的代码:
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data)
payable {}
}
实现合约
实现合约必须继承自 UUPSUpgradeable 合约,该合约遵循 UUPS 模式并包含迁移到下一个实现的机制。重写 _authorizeUpgrade 函数是必不可少的,因为授权机制没有预先定义,必须由开发者实现。
在下方代码中,我们以一种极度不安全的方式实现了这一点,因为任何人都有权执行升级。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
// UUPS Implementation
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // 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 {}
}
为了给合约定义一个所有者,必须创建一个初始化函数,因为实现合约不应该使用构造函数。你可以通过我们关于 Initializable.sol 的文章了解更多有关此主题的内容。同样,以下是代码:
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // 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 {}
}
要在 Remix 中测试上述合约,你必须按照以下步骤操作:
- 部署名为
ImplementationOne的实现合约。 - 部署名为
MyProxy的代理合约。构造函数需要两个参数:实现合约的地址(即ImplementationOne的地址)和一个用于初始化实现合约的bytes类型参数。该参数可以为0x,因为它不会被使用。 - 为了测试合约,使用实现合约的 ABI 打开一个代理合约实例。为此,在部署选项卡中,选择实现合约
ImplementationOne,并在 At Address 字段中输入代理合约地址,如下图所示。

现在,你将能够通过代理执行实现合约中的 myNumber() 函数。
迁移到下一个实现
为了迁移到下一个实现,必须首先创建一个遵循 UUPS 模式的新合约,类似于下方的示例。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) payable {}
}
// UUPS Implementation
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract ImplementationOne is UUPSUpgradeable {
function myNumber() public pure returns (uint256) {
return 1; // 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 {}
}
// NEW UUPS Implementation
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 {}
}
// ---- updated implementation ----
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 {}
}
创建合约后,接下来的步骤如下:
- 部署
ImplementationTwo。 - 通过代理在之前的实现合约上调用
upgradeToAndCall函数,将ImplementationTwo的地址作为第一个参数传入(第二个参数可以为0x)。proxiableUUID()函数在父合约中定义,在迁移之前将验证其正确的返回值。
尝试迁移到不符合 UUPS 标准的实现将会失败,因为通过 proxiableUUID() 函数进行的安全检查机制会阻止该操作。
UUPS 中的漏洞
1. 未初始化合约的漏洞
初始化实现合约是很常见的操作。例如,在 ERC20 可升级合约中,通常在部署期间设置代币名称和符号。该过程通常使用构造函数完成。然而,在实现合约中使用构造函数毫无帮助,因为那会改变实现合约的存储,而“真正的”存储实际上驻留在代理合约中。
要初始化实现合约,我们必须依赖配置为仅执行一次的常规函数。这可以使用 OpenZeppelin 在 Initializable.sol 库中提供的修饰符来完成。
以下代码是初始化函数的一个示例。
function initialize(address initialOwner) initializer public {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
获取实现合约的所有权
一个主要的漏洞在于这是一个公共函数。它本应通过代理调用,但也可以直接在实现合约上调用。由于它设置了合约的所有者,任何最先直接在实现合约上调用此函数的人都将成为该合约的“所有者”。
澄清一下,在此时,合约将会有两个所有者:
- 通过代理设置的所有者。
- 通过直接调用实现合约设置的“所有者”。

任何标记为 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 标准时必须遵循的一些准则:
- 如果你重写
upgradeToAndCall,请务必极其小心,以免破坏升级功能。 - 确保
_authorizeUpgrade包含onlyOwner修饰符或其他将访问权限仅限于授权账户的机制。 - 在升级中,当在新版实现合约里更改授权方案时要多加小心。例如,切换到管理员之前已放弃其特权但并未被注意到的授权类型。
- 在实现合约的构造函数中使用
_disableInitializers()函数以防止初始化。 - 不使用
delegatecall或selfdestruct。
我们要感谢来自 OpenZeppelin 的 ernestognw.eth 审阅本文的早期草稿。
最初发布于 2024 年 8 月 26 日