初始化器 (Initializers) 是可升级合约实现构造函数行为的方式。
在部署合约时,通常会调用构造函数来初始化存储变量。例如,构造函数可能会设置代币的名称或最大供应量。在可升级合约中,这些信息需要存储在代理 (proxy) 的存储变量中,而不是实现 (implementation) 的存储变量中。在代理中添加如下构造函数:
constructor(
string memory name_,
string memory symbol_,
uint256 maxSupply_
) {
name = name_;
symbol = symbol_;
maxSupply = maxSupply_;
}
并不是一个好方案,因为在代理和实现之间对齐存储变量位置很容易出错。在实现中创建构造函数也行不通,因为这会将存储变量设置在实现本身中。
初始化器如何工作
解决上述所有问题的方案是:在实现中创建一个 initializer() 函数,它以与构造函数相同的方式设置存储变量,并让代理通过 delegatecall 调用实现的 initialize() 函数。将 initializer() 放在实现中可以确保存储变量的对齐自动正确。为了模拟构造函数,至关重要的一点是,该函数只能被代理通过 delegatecall 调用一次。
OpenZeppelin 的 Initializable.sol 合约旨在为这种初始化模式提供一种稳健的实现。目前,Initializable.sol 被用于 OpenZeppelin 的可升级合约中,例如 ERC20Upgradeable.sol。
本文旨在详细解释 Initializable.sol 是如何工作的。但在此之前,让我们先展示一下这种模式的天真 (naïve) 实现方式,以及为什么这种天真的实现仅适用于最简单的场景。
初始化器工作流程的高层级演示如下方动画所示:
一种天真的实现方式
我们很容易想到编写类似下面这样的合约,即设计一个修饰符 (modifier) 来限制函数的执行仅此一次,之后不再执行。
contract NaiveInitialization {
// initialized indicates if the contract has been initialized
bool initialized = false;
// restricts the function to be executed only once
modifier initializer() {
require(initialized == false, "Already initialized");
initialized = true;
_;
}
// can be executed only once
function initialize() public initializer {
// Initialize necessary storage variables
}
}
上述代码对于这个特定的合约是有效的,它确保了 initialize() 函数只能被执行一次。然而,当与继承结合使用时,这种模式就会失效。
Initialize 实现失败的演示
上述模式的问题在于,它不支持合约使用继承且父合约也必须被初始化的情况。让我们通过以下代码来分析这个问题的示例。
contract Initializable {
// initialized indicates if the contract has been initialized
bool initialized = false;
// restricts the function to be executed only once
modifier initializer() {
require(initialized == false, "Already initialized");
initialized = true;
_;
}
}
contract ParentNaive is Initializable {
// initialize the Parent contract
function initializeParent() internal initializer {
// Initialize some state variables
}
}
contract ChildNaive is ParentNaive {
// initialize the Child contract
function initializeChild() public initializer {
super.initializeParent();
// Initialize other state variables
}
}
上述合约的预期执行顺序如下:
- 调用
initializeChild()函数。带有initializer修饰符的它会将initialized变量更新为true。该变量由继承链中的所有合约共享使用。 - 接着,在
initializeChild()内部调用initializeParent()函数。initializeParent()也带有initializer修饰符,因此它要求initialized变量为false。 - 但是当
initializeChild运行时,initialized变量已经被设置为true,因此当调用initializeParent()时,交易将会回滚 (revert)。
OpenZeppelin 的 Initializable.sol 合约解决了这个问题,它允许继承链中的所有合约进行初始化,同时仍然防止在初始化交易完成后再次调用初始化器。
理解 Initializable.sol
Initializable.sol 的核心包含三个修饰符:initializer、reinitializer 和 onlyInitializing,以及两个状态变量:_initializing 和 _initialized。
每个修饰符仅用于特定场景并具有独特的用途。它们的用法概述如下:
initializer修饰符应用于可升级合约的初始部署期间,并且专门用于最底层的子合约中。reinitializer修饰符应用于初始化新版本的实现合约,同样仅限于在最底层的子合约中使用。onlyInitializing修饰符与父合约初始化器一起使用,在初始化期间运行,并防止这些初始化器在后续交易中被调用。这解决了上一节中提到的问题(即底层子合约的初始化器禁用了父合约初始化器,导致后者无法运行)。通过这种方案,可以初始化所有父合约以及最底层的子合约。
下面是说明这些场景的视觉图表。接下来的小节中将提供关于这些修饰符用法的更详细解释。

Initializable.sol 实现了 ERC-7201 模式,其中状态变量在结构体 (struct) 中声明。如果您不了解这种模式,只需将 _initializing 和 _initialized 视为状态变量即可。
struct InitializableStorage {
/**
* @dev Indicates that the contract has been initialized.
*/
uint64 _initialized;
/**
* @dev Indicates that the contract is in the process of being initialized.
*/
bool _initializing;
}
_initializing 变量是一个布尔值,指示合约是否处于初始化过程中,而 _initialized 变量存储合约的当前版本。它的初始值为 0,在第一次初始化后变为 1。如果开发人员选择部署新的实现并希望将存储变量“重新初始化”为新值,它可能会变得更高。
下方的视频演示了这些部分是如何协同工作的:
initializer 修饰符
initializer 修饰符如下所示。代码的某些部分将在稍后进行更详细的解释。

由于需要解决与旧版本的向后兼容问题,上述代码并不那么直观。但是,其主要思想有两点:
- 将
_initialized变量设置为1,以防止该函数被再次执行(绿色框)。 - 在
_initializing为 true 时,临时允许用onlyInitializing修饰的父合约初始化器运行。从上述代码可以看出,当合约尚未初始化时,_initializing为 false;在初始化交易运行期间为 true;而在初始化交易结束时为 false。
因为 initializer 要求 _initializing 为 false,所以它不能用于继承链中的父合约,因为当这些父合约执行时 _initializing 状态是 true。相反,父合约的初始化函数必须使用另一个不同的修饰符,即 onlyInitializing,该修饰符仅允许函数在 _initializing 为 true 时执行。
onlyInitializing 修饰符
onlyInitializing 修饰符是专为父合约设计的,因为它仅在 _initializing 为 true 时执行,如下所示。
modifier onlyInitializing() {
_checkInitializing();
_;
}
function _checkInitializing() internal view virtual {
if (!_isInitializing()) {
revert NotInitializing(); // reverts if _initializing is false
}
}
下面是此流程的图示说明。

总结来说,父合约初始化器受到 onlyInitializing 修饰符的保护,除非最底层子合约的 initializer 正在执行,否则它们无法被调用。
reinitializer 修饰符
reinitializer 修饰符的作用与 initializer 相似,但如果新版本需要在初始化时更新存储变量,则必须使用它来初始化新版本的实现合约。
此修饰符有一个 uint64 参数,表示合约版本号,该版本号必须大于当前版本。如果希望防止未来再次重新初始化,可以将版本号设置为 或 type(uint64).max。将该变量设为 uint64 允许与 _initializing 布尔变量打包在同一个存储槽中,并且 的版本数量为未来的升级留足了空间。以下是 reinitializer 修饰符的代码。
modifier reinitializer(uint64 version) {
// solhint-disable-next-line var-name-mixedcase
InitializableStorage storage $ = _getInitializableStorage();
if ($._initializing || $._initialized >= version) {
revert InvalidInitialization();
}
$._initialized = version;
$._initializing = true;
_;
$._initializing = false;
emit Initialized(version);
}
让我们通过一个例子来说明它的用法。假设在对 ERC20Upgradeable 合约的首次升级中,我们希望修改代币的名称和符号。该函数必须按照如下方式编写,其中值 2 表示这是该合约的第二个版本:
function initialize() reinitializer(2) public {
__ERC20_init("MyToken2", "MTK2");
}
总结一下如何进行升级:
- 你不能在新版本上使用
initializer。 - 如果你想在初始化函数中改变任何状态变量,必须使用
reinitializer。 - 或者,如果在升级期间不需要更改状态,你可以不提供任何初始化器。
未初始化合约的漏洞
在 initializer 修饰符中,可能有一行代码引起了您的注意,但在我最初的解释中并没有提及。它如下所示:
bool construction = initialized == 1 && address(this).code.length == 0;
表达式 address(this).code.length == 0 仅在合约部署期间为 true。因此,只有当 initializer 修饰符被用在构造函数中时,construction 变量才可能为 true。
实际上,initializer 修饰符可以用于实现合约的构造函数中以“初始化”它自己。这似乎有违直觉,因为实现合约本身的存储应该无关紧要。然而正如我们将看到的,这种初始化是作为一种安全措施。
涉及未初始化合约的 UUPS 漏洞
关键点在于,合约的初始化函数是公开的,既可以通过代理调用,也可以由 EOA (外部拥有账户) 或另一个合约直接调用,如下图所示。在 可升级的 Ownable 合约 中,owner 通常在 initialize 函数中设置。
- 如果通过代理发起 delegatecall 来调用
initialize,owner 将存储在代理的存储中。 - 如果直接在实现上调用
initialize,owner 将存储在实现的存储中。

成为实现合约的 owner 应该并不重要,因为直接在实现合约上执行函数只会修改其自身的存储,而那并非“真实的”存储。出于这个原因,许多团队并未考虑直接在实现合约上执行 initialize 函数的情况。
严重的问题在于,任何定义为 onlyOwner 的函数都可以由实现合约上的这个“其他” owner 来执行。
正是这种场景,暴露了 OpenZeppelin 在 v4.1.0 到 v4.3.1 版本的 UUPS 合约中的一个漏洞。负责将合约从旧实现迁移到新实现的函数,同样会向该新地址执行一次 delegatecall。

该函数受到 onlyOwner 修饰符的保护,本意是只允许由“合法所有者”执行。然而,它同样可以被实现合约的 owner 执行。
修改实现合约的存储并非核心问题。然而,owner 现在可以向包含 selfdestruct 操作码的合约发起 delegatecall。此操作会抹除实现合约的代码,阻止代理迁移到新的实现。从本质上讲,这种漏洞可能会将价值数百万美元的资产无限期地锁定在代理中。
任何使用了存在漏洞的 UUPS 库版本且其实现合约未被“初始化”的代理都处于危险之中。任何人都可以去执行初始化函数,成为 owner,随后对带有 selfdestruct 操作码的合约执行 delegatecall。
应对攻击者夺取未初始化实现合约所有权的原始缓解措施
为了缓解这个问题,OpenZeppelin 工程团队最初的建议是:始终在构造函数中使用 initializer 修饰符来“初始化”实现合约,如下所示。
constructor() initializer {}
这仅仅是一项安全措施,旨在防止攻击者通过初始化实现合约中的存储变量来成为 owner。此修饰符旨在放置于继承链中所有实现合约的构造函数中。
对于最底层的子合约,变量 initialSetup 将始终为 true。然而,在实现合约的父合约中,部署期间的 initialized 为 1 且 address(this).code.length == 0。正是针对这种场景,construction 变量才存在——为了能让实现合约的父合约进行初始化。
换句话说,这一行代码是为考虑像下面这种合约结构的案例而设计的,其中父合约同样需要被初始化。应该使用 initializer 修饰符;onlyInitializing 修饰符并不能用来初始化构造函数。
contract ImplementationParent is Initializable {
// here initialSetup will be false
// but construction will be true
constructor() initializer {}
}
contract ImplementationChild is Initializable, ImplementationParent {
// here initialSetup will be true
constructor() ImplementationParent() initializer {}
}
一旦实现合约被“初始化”,任何人都无法再执行初始化函数并成为合约的 owner。
这已经不再是推荐的缓解措施,防止攻击者成为实现合约 owner 的推荐解决方案将在下一节中介绍,但出于向后兼容的考虑,initializer 修饰符保留了 construction 变量。它可能会在未来的合约版本中被移除。
_disableInitializers() 函数
OpenZeppelin 提出的最新也是推荐的“初始化”实现合约的方法是使用 _disableInitializers() 函数。
代码如下:
function _disableInitializers() internal virtual {
// solhint-disable-next-line var-name-mixedcase
InitializableStorage storage $ = _getInitializableStorage();
if ($._initializing) {
revert InvalidInitialization();
}
if ($._initialized != type(uint64).max) {
$._initialized = type(uint64).max; // set initialized to its max value, preventing reinitializations
emit Initialized(type(uint64).max);
}
}
因此,目前防止因“未初始化”合约引起漏洞的推荐方法是,在所有实现合约中包含以下构造函数:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
实现合约本身永远不会被升级,只有代理会被升级。因此,将版本设置为 type(uint64).max(在实现合约中的 _initialized 变量)可以确保实现合约永远不会被初始化。在实现合约中锁定初始化器不会阻止代理通过 delegatecall 去调用它们,因为会阻止 delegatecall 调用的 _initialized 存储变量是保存在代理存储中的。
_init 和 _init_unchained 函数
初始化可升级合约的函数可以使用任何名称,但 OpenZeppelin 合约遵循一个标准。实际上,它们都有两个初始化函数,使用两种命名方式:<Contract Name>_init 和 <Contract Name>_init_unchained。
<Contract Name>_init_unchained 函数包含了初始化实现合约时必须执行的所有代码。例如,在 ERC20 代币的例子中,此函数会设置代币的名称和符号。
function __ERC20_init_unchained(string memory name_, string memory symbol_)
internal
onlyInitializing
{
ERC20Storage storage $ = _getERC20Storage();
$._name = name_;
$._symbol = symbol_;
}
<Contract Name>_init 函数执行 <Contract Name>_init_unchained,并且会为所有需要初始化的父合约执行 <Parent Contract>_init_unchained。
让我们考虑 GovernorUpgradeable.sol v5 合约的情况,该合约需要初始化自身以及它的一个父合约,即 EIP712Upgradeable 合约。

通常情况下,执行一个合约的 _init 函数就足够了,它同时也会初始化各级父合约。但必须小心不要把同一个合约初始化两次,这在两个合约共享同一个父合约的继承链中可能会发生。
这就是为什么存在两个函数 _init 和 _init_unchained 的原因。如果需要仅初始化当前合约而不初始化其父合约,则必须使用 _init_unchained 函数。
初始化一个 ERC20 可升级合约
如何初始化可升级合约的示例请见下图,展示了由 OpenZeppelin Wizard 生成的一个可升级 ERC20 代币合约。

请注意,它在父级合约上调用了 __<Contract Name>_init。无论采用哪种初始化合约的方案,都必须确保继承链中的所有合约都被正确初始化,且没有合约被初始化两次,或者初始化器是幂等的 (idempotent)。
警告与建议
在结束本文之前,针对 Initializable.sol 合约的正确使用给出一些建议。
- OpenZeppelin 在其 openzeppelin-upgrades 库中有另一个名为 Initializable.sol 的合约。该合约是出于向后兼容性原因而存在的,不应用于新项目。推荐的导入方式是使用
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";。 - 由于初始化函数是一个常规函数,存在被其他交易抢跑 (frontrun) 的风险。如果发生这种情况,代理合约必须重新部署。为了防止这种情况发生,ERC1967Proxy 合约构造函数在部署时会对实现合约发起一次调用。初始化调用必须在此刻进行,并将其编码在
_data变量中。 - 如上一节所述,当合约是继承链的一部分时,必须人工留意以免发生父合约初始化器被调用两次的情况。目前的模式无法自动识别这种潜在问题,因此必须手动进行验证。解决此问题的一种方法是确保所有初始化函数都是幂等的,这意味着无论执行多少次,其效果都是相同的。
最初发布于 7月8日