Summary
The graduateAndUpgrade
function in the LevelOne contract allows the principal to end a school session at any time without enforcing a minimum duration. This violates the intended functionality where a school session should last at least 4 weeks, potentially allowing malicious principals to collect fees and end sessions prematurely. Which is a causing Invariant mentioned in the documentation
Vulnerability Details
When starting a session via the startSession
function, the contract sets sessionEnd
to 4 weeks in the future:
function startSession(uint256 _cutOffScore) external onlyPrincipal {
if (inSession) {
revert HH__SessionOngoing();
}
inSession = true;
sessionEnd = block.timestamp + 4 weeks;
cutOffScore = _cutOffScore;
emit SessionStarted(sessionEnd);
}
However, the graduateAndUpgrade
function has no checks to ensure that the 4-week period has actually elapsed:
function graduateAndUpgrade(
address _levelTwo,
bytes memory
) public onlyPrincipal {
if (_levelTwo == address(0)) {
revert HH__ZeroAddress();
}
uint256 totalTeachers = listOfTeachers.length;
uint256 payPerTeacher = (bursary * TEACHER_WAGE) / PRECISION;
uint256 principalPay = (bursary * PRINCIPAL_WAGE) / PRECISION;
_authorizeUpgrade(_levelTwo);
for (uint256 n = 0; n < totalTeachers; n++) {
usdc.safeTransfer(listOfTeachers[n], payPerTeacher);
}
usdc.safeTransfer(principal, principalPay);
}
This allows the principal to end the session immediately after starting it, without waiting for the 4-week duration to complete.
Impact
Students pay school fees for a complete session but might receive a shortened educational period
The principal can collect fees, end the session prematurely, and potentially start a new session to collect fees again
Teachers have less time to conduct reviews and evaluate students
Violates the contract's own declared session duration of 4 weeks
Tools Used
Manual code review
Custom test case
Proof of Concept
Add the following test to demonstrate the issue:
function test_premature_graduation() public schoolInSession {
levelTwoImplementation = new LevelTwo();
levelTwoImplementationAddress = address(levelTwoImplementation);
bytes memory data = abi.encodeCall(LevelTwo.graduate, ());
uint256 sessionStartTime = block.timestamp;
uint256 expectedSessionEnd = levelOneProxy.sessionEnd();
uint256 expectedSessionDuration = expectedSessionEnd - sessionStartTime;
console2.log("Session start time:", sessionStartTime);
console2.log("Expected session end time:", expectedSessionEnd);
console2.log("Expected session duration (seconds):", expectedSessionDuration);
console2.log("Expected session duration (days):", expectedSessionDuration / 1 days);
assertEq(expectedSessionDuration, 4 weeks, "Session should be set for 4 weeks");
vm.prank(principal);
levelOneProxy.graduateAndUpgrade(levelTwoImplementationAddress, data);
uint256 actualSessionDuration = block.timestamp - sessionStartTime;
console2.log("Time elapsed before graduation:", actualSessionDuration);
console2.log("Time remaining in session that was skipped:", expectedSessionEnd - block.timestamp);
console2.log("Percentage of session completed:", (actualSessionDuration * 100) / expectedSessionDuration, "%");
assertTrue(block.timestamp < expectedSessionEnd, "Graduation should not be possible before session ends");
assertTrue(actualSessionDuration < expectedSessionDuration, "Session ended prematurely");
assertTrue(actualSessionDuration < 1 days, "Session lasted less than a day instead of 4 weeks");
}
Recommendations
Modify the graduateAndUpgrade
function to require that the session end date has been reached:
function graduateAndUpgrade(
address _levelTwo,
bytes memory
) public onlyPrincipal {
if (_levelTwo == address(0)) {
revert HH__ZeroAddress();
}
require(block.timestamp >= sessionEnd, "Session duration not yet completed");
}