Hawk High

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

Missing Access Control in initialize Function

Summary

The initialize function in the LevelOne contract lacks proper access control, allowing any external attacker to call it before the legitimate deployer. This vulnerability enables an attacker to set themselves as the principal, giving them complete control over the contract, including teacher management, student expulsion, and funds distribution.

Vulnerability Details

The initialize function in the LevelOne contract is implemented with the initializer modifier from OpenZeppelin's Initializable contract, but does not include any access control mechanism to restrict who can call it:

// @audit-high attacker can run this function to initialize before deployer.
// need access control
function initialize(address _principal, uint256 _schoolFees, address _usdcAddress) public initializer {
if (_principal == address(0)) {
revert HH__ZeroAddress();
}
if (_schoolFees == 0) {
revert HH__ZeroValue();
}
if (_usdcAddress == address(0)) {
revert HH__ZeroAddress();
}
principal = _principal;
schoolFees = _schoolFees;
usdc = IERC20(_usdcAddress);
__UUPSUpgradeable_init();
}

The vulnerability arises from the function being public without any access control or validation that the caller is the legitimate deployer. In upgradeable contract patterns, the initialization function replaces the constructor, but unlike constructors which automatically execute during deployment and are inaccessible afterward, initializers require explicit protection.

This issue creates a critical front-running opportunity: an attacker monitoring the mempool can detect the deployment transaction and front-run it with their own initialization call, setting themselves as principal before the legitimate deployer can initialize the contract.

As the principal, the attacker would have complete control over the contract, including:

  1. Adding/removing teachers

  2. Expelling students

  3. Setting cutoff scores

  4. Starting sessions

  5. Triggering upgrades and distributing funds

Impact

The impact of this vulnerability is severe:

  1. Complete contract takeover by an unauthorized party

  2. Potential theft of all collected school fees (via the graduateAndUpgrade function)

  3. Ability to control who can teach or enroll

  4. Complete subversion of the platform's intended academic governance

While examining the project repository, we found that the actual deployment script (DeployLevelOne.s.sol) does implement proper atomic deployment, where contract creation and initialization happen in the same transaction, which mitigates this vulnerability in practice. However, the contract itself still lacks built-in protection, which means any alternative deployment method that doesn't use this script would be vulnerable. This represents a moderate security concern rather than a critical failure since proper deployment procedures appear to be in place.

Tools Used

Manual code review

Recommendation

Implement proper access control for the initialization function. Here are two effective approaches:

Option 1: Deploy using a factory contract (Recommended)

A factory contract pattern is the most secure approach as it ensures that initialization happens in the same transaction as deployment, making front-running impossible:

contract LevelOneFactory {
function deployLevelOne(address _principal, uint256 _schoolFees, address _usdcAddress) external returns (address) {
// Deploy proxy and implementation
LevelOne implementation = new LevelOne();
ERC1967Proxy proxy = new ERC1967Proxy(
address(implementation),
abi.encodeWithSignature("initialize(address,uint256,address)", _principal, _schoolFees, _usdcAddress)
);
return address(proxy);
}
}

With this approach, the entire deployment and initialization happens atomically in a single transaction, completely eliminating the front-running risk.

Option 2: Add access control to the initializer

If a factory pattern cannot be used, add explicit access control to the initializer:

contract LevelOne is Initializable, UUPSUpgradeable {
// Add a deployer state variable
address private deployer;
// Set deployer in the constructor
constructor() {
deployer = msg.sender;
}
// Add access control to initialize
function initialize(address _principal, uint256 _schoolFees, address _usdcAddress) public initializer {
// Only the contract deployer can initialize
require(msg.sender == deployer, "Only deployer can initialize");
if (_principal == address(0)) {
revert HH__ZeroAddress();
}
if (_schoolFees == 0) {
revert HH__ZeroValue();
}
if (_usdcAddress == address(0)) {
revert HH__ZeroAddress();
}
principal = _principal;
schoolFees = _schoolFees;
usdc = IERC20(_usdcAddress);
__UUPSUpgradeable_init();
}
}

This ensures that only the address that deployed the contract can initialize it, preventing unauthorized initialization.

Additional Consideration: Use OpenZeppelin's Transparent Proxy Pattern

Consider using OpenZeppelin's TransparentUpgradeableProxy instead of the current UUPS pattern. The transparent proxy pattern includes built-in access control for admin functions and can provide additional protection against similar vulnerabilities.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 6 months ago
Submission Judgement Published
Validated
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.