Hawk High

First Flight #39
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: medium
Invalid

Possibility of front-running the proxy initialization before the legitimate owner, causing a Denial of Service (DoS)

Description:

The LevelOne contract follows OpenZeppelin’s ERC1967Proxy upgradeability pattern. When the proxy is deployed without passing the encoded initialize() call as data to the proxy constructor, a critical time window is created where any actor can initialize the contract before the legitimate owner does.

This call to initialize() is executed via delegatecall, writing directly to the proxy’s storage. OpenZeppelin’s initializer modifier ensures that this function can only be called once. As a result, any subsequent initialization attempt — including one from the legitimate principal — will revert with InvalidInitialization().

This enables an attacker to:

Initialize the system with controlled values (e.g., setting themselves as the principal),
Block the legitimate initialization,
Permanently disable the contract by locking the proxy in an unusable state.
Although deployment scripts are not formally within the scope of this audit, this risk is structural, as the contract does not enforce its own secure initialization internally, relying instead on external deployment logic.

Therefore, this is a design limitation, not merely an implementation oversight.

function deployLevelOne() public returns (address) {
usdc = new MockUSDC();
vm.startBroadcast();
levelOneImplementation = new LevelOne();
@> proxy = new ERC1967Proxy(address(levelOneImplementation), "");
@> LevelOne(address(proxy)).initialize(principal, schoolFees, address(usdc));
vm.stopBroadcast();
return address(proxy);
}

Impact:

  • Proxy initialization by an external actor before the legitimate owner.

  • Permanent loss of control over the system.

  • Complete Denial of Service (DoS) of the upgradeable contract.

  • Possibility for an attacker to set critical parameters or roles maliciously.

Proof of Concept:

  1. Deployment of LevelOne and ERC1967Proxy.

  2. Between the proxy deployment and the contract initialization, there is a time window that an attacker exploits to initialize LevelOne.

  3. The legitimate owner attempts to initialize the contract, but the function is protected by the initializer() modifier and cannot be re-initialized, causing it to revert.

function test_front_Running_Deploy() public {
// 1.
LevelOne levelOne = new LevelOne();
ERC1967Proxy proxy = new ERC1967Proxy(address(levelOne), "");
// 2.
vm.prank(attacker);
LevelOne(address(proxy)).initialize(attacker, schoolFees, address(usdc));
// 3.
vm.expectRevert(Initializable.InvalidInitialization.selector);
LevelOne(address(proxy)).initialize(principal, schoolFees, address(usdc));
address principalAttacker = LevelOne(address(proxy)).getPrincipal();
console2.log("Attacker is Principal: ", principalAttacker);
}

Revert when the owner attempts to initialize the contract:
Result:

├─ [1675] LevelOne::initialize(principal: [0x6b9470599cb23a06988C6332ABE964d6608A50ca], 5000000000000000000000 [5e21], MockUSDC: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f])
@> │ └─ ← [Revert] InvalidInitialization()

Shows that the principal is the attacker:

├─ [0] console::log("Attacker is Principal: ", attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e]) [staticcall]

Tools Used:

Manual Review, Foundry

Recommended Mitigation:

The initialize() call should be passed as an encoded parameter at the time of proxy deployment, ensuring atomic initialization:

function deployLevelOne() public returns (address) {
usdc = new MockUSDC();
vm.startBroadcast();
levelOneImplementation = new LevelOne();
+ bytes memory data = abi.encodeCall(LevelOne.initialize, (
+ principal,
+ schoolFees,
+ address(usdc)
+ ));
- proxy = new ERC1967Proxy(address(levelOneImplementation), "");
+ proxy = new ERC1967Proxy(address(levelOne), data);
- LevelOne(address(proxy)).initialize(principal, schoolFees, address(usdc));
vm.stopBroadcast();
return address(proxy);
}

To further protect against unauthorized or accidental direct calls to the implementation contract, consider adding the onlyProxy modifier to the initialize() function. This ensures that initialization can only occur through the proxy, preventing any direct interaction with the logic contract:

- function initialize(address _principal, uint256 _schoolFees, address _usdcAddress) public initializer {
+ function initialize(address _principal, uint256 _schoolFees, address _usdcAddress) public initializer onlyProxy {
if (_principal == address(0)) {
revert HH__ZeroAddress();
}
if (_schoolFees == 0) {
revert HH__ZeroValue();
}
....
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
4 months ago
yeahchibyke Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

jfornells Submitter
4 months ago
yeahchibyke Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

contract can be re-initialized

The system can be re-initialized by an attacker and its integrity tampered with due to lack of `disableInitializer()`

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.