The LevelTwo contract contains a public function named graduate() which, despite being empty, is marked with the reinitializer(2) modifier from OpenZeppelin's Initializable contract. This allows any external actor to call graduate(), thereby advancing the contract's initialization version to 2. If LevelTwo (or a future version it's upgraded from) were to also contain another function with reinitializer(2) intended for legitimate version 2 initialization, an attacker could preemptively call graduate() and then potentially call the legitimate initialization function to maliciously reconfigure or take control of critical contract parameters.
OpenZeppelin's Initializable contract provides a mechanism to manage contract initialization, ensuring that initializer functions are called only once, or once per "version" using the reinitializer(version) modifier. The reinitializer(version) modifier allows a function to be called if the contract's current _initialized version is less than version, and upon successful execution, it sets _initialized to version.
The LevelTwo contract has the following function:
public Visibility: Anyone can call this graduate() function.
reinitializer(2) Modifier: This modifier means:
If the contract's _initialized version is 0 or 1, calling graduate() will succeed.
Upon successful execution of graduate() (which it always will be since it's empty), the contract's internal _initialized state variable will be set to 2.
Empty Function Body: The function itself performs no useful action.
The Vulnerability Scenario:
The danger arises if LevelTwo (or a contract it might be upgraded from, if LevelTwo itself is an intermediate step) also defines, or intends to define in a future patch, a legitimate initialization function for version 2, also marked with reinitializer(2). Let's call this hypothetical function initializeLevelTwoFeatures() public reinitializer(2) { /* ... sets critical state ... */ }.
An attacker could perform the following steps:
Call the public graduate() function. This transaction will succeed, and the contract's _initialized state will become 2.
If the legitimate initializeLevelTwoFeatures() function is also public (or callable by the attacker), the attacker could then call it. Since _initialized is now 2, the reinitializer(2) guard on initializeLevelTwoFeatures() would not prevent its execution if it was designed to run when _initialized < 2 and then set it to 2. More accurately, if initializeLevelTwoFeatures was reinitializer(2), it could be called if _initialized < 2. If graduate() sets _initialized to 2, then initializeLevelTwoFeatures() could not be called.
Let's refine the attack: The risk is more subtle if the reinitializer logic is intended to allow multiple different functions to contribute to initializing a specific version, or if a function is poorly designed. The core issue with graduate() is that it advances the _initialized state without proper access control and without performing any actual initialization.
A More Accurate Vulnerability Scenario (Front-Running or Hijacking Initialization):
If LevelTwo had a proper initializer function intended to be called once by the principal to set up version 2 parameters, like:
The graduate() public reinitializer(2) function is a misuse of the Initializable pattern. The reinitializer modifier is typically used on functions that actually perform initialization for that version. An empty, public function that just bumps the version number is highly problematic.
If there were another function like configureSensitiveSettings() public reinitializer(2), an attacker calling graduate() first would set _initialized = 2. Then, if configureSensitiveSettings() was called, it would not run because reinitializer(2) only allows execution if _initialized < 2. This could be a DoS.
However, the primary risk of a public reinitializer is often initialization hijacking if the actual initialization function lacks its own robust access control and relies solely on the reinitializer guard.
In this specific case of graduate() public reinitializer(2) {}:
It allows anyone to advance the _initialized state to 2.
This means any other function in LevelTwo (or a future upgrade LevelThree if LevelTwo was an intermediate step that somehow kept UUPS alive) that also uses reinitializer(2) would now be callable only if its own internal logic permits execution when _initialized is already 2, or it could be prevented from running if it expected to be the one to set _initialized to 2.
The most direct risk is that it allows an unprivileged user to manipulate a critical internal state variable (_initialized) of Initializable.sol.
The impact of this vulnerability can range from denial of service to potential state corruption or takeover, depending on how other parts of LevelTwo (or future versions) are designed to use reinitializer(2):
Denial of Service for Legitimate Initialization:
If a legitimate initialization function (e.g., setupLevelTwoParameters()) also uses reinitializer(2) and is intended to be called once by the principal when _initialized is less than 2, an attacker calling graduate() first would set _initialized to 2. This would prevent the legitimate setupLevelTwoParameters() from ever running, as reinitializer(2) requires _initialized < 2. The contract would be stuck in a partially uninitialized or incorrectly initialized state.
Hijacking of Initialization Parameters (If Other Checks are Weak):
While less direct with an empty function, if another reinitializer(2) function existed that set critical parameters (e.g., a new admin, fee rates) and lacked its own separate, robust access control (relying only on the reinitializer guard), then manipulating the _initialized state could be a step in a more complex exploit chain. However, reinitializer itself protects against re-entry for the same version. The risk is more about controlling when or if a versioned initialization occurs.
Violation of Initialization Logic Integrity:
The Initializable pattern is designed for controlled, one-time (per version) setup. Allowing any user to arbitrarily advance the initialization version number without performing any actual initialization or having the authority to do so fundamentally breaks this pattern.
Unpredictable Contract Behavior:
The _initialized variable is critical for the correct functioning of upgradeable contracts. Uncontrolled modification can lead to unexpected behavior in other parts of the contract that might check this variable or in future upgrade attempts.
Confusion and Increased Audit Complexity:
Such a function is a red flag during audits because it's an unconventional and unsafe use of the reinitializer pattern, requiring auditors to meticulously check all other reinitializer(2) uses.
Severity: High. It represents a significant flaw in the use of the Initializable pattern, creating a clear vector for either denying legitimate initialization steps or, in conjunction with other potential weaknesses, enabling malicious state manipulation.
To address this vulnerability and ensure the safe and correct use of the Initializable pattern:
Remove or Refactor the graduate() Function:
If graduate() is not intended to perform any version 2 initialization, remove the reinitializer(2) modifier. If it's truly meant to be an empty public function for some other (unclear) purpose, it should not interact with the initialization state.
If graduate() is meant to be part of a version 2 initialization:
It should perform actual initialization logic.
It must have strict access control (e.g., onlyPrincipal or an appropriate role). It should not be public if it modifies critical state or initialization versions.
The version number in reinitializer(X) should be carefully considered in the context of the overall upgrade and initialization strategy.
Proper Use of reinitializer(version):
reinitializer(version) modifiers should only be used on functions that:
Perform legitimate initialization tasks for that specific contract version.
Are protected by appropriate access control (e.g., callable only by an admin, principal, or only during the upgrade process itself if called internally).
Are not public if they allow arbitrary state changes or control over the initialization flow.
Principle of Least Privilege for Initialization Functions:
Any function that performs initialization or re-initialization tasks should have the narrowest possible visibility (e.g., internal if called by another contract function during an upgrade, or public but with strong onlyOwner/onlyPrincipal type modifiers).
Clear Initialization Strategy:
The project should have a well-defined strategy for how each version of the contract is initialized or re-initialized upon upgrade, including:
Which functions are responsible.
What parameters they set.
Who is authorized to call them.
The specific reinitializer version numbers to be used.
Specific Action for LevelTwo.graduate():
Given its current empty state, the graduate() function in LevelTwo should likely have the reinitializer(2) modifier removed entirely. If it serves no other purpose, the function itself could be removed. If it's a placeholder for future V2 initialization, it must be properly secured (e.g., internal or onlyPrincipal) before any reinitializer modifier is used on it.
By following these recommendations, the risk of unintended or malicious manipulation of the contract's initialization state can be eliminated, ensuring that the Initializable pattern contributes to security rather than detracting from it.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.