Summary
The contract lacks functionality to reset the session state after a session has ended, creating a permanent lock where inSession
remains true forever. This prevents starting new sessions or enrolling new students after the first session ends, severely limiting the contract's long-term usability.
Vulnerability Details
The contract implements a session system with a start mechanism but no corresponding end or reset functionality:
function startSession(uint256 _cutOffScore) public onlyPrincipal notYetInSession {
sessionEnd = block.timestamp + 4 weeks;
inSession = true;
cutOffScore = _cutOffScore;
emit SchoolInSession(block.timestamp, sessionEnd);
}
modifier notYetInSession() {
if (inSession == true) {
revert HH__AlreadyInSession();
}
_;
}
While the contract sets a sessionEnd
timestamp, it never changes the inSession
flag back to false when this time is reached. Critical functions like startSession()
and enroll()
use the notYetInSession
modifier, which will permanently revert after the first session is started.
Impact
After the first session ends (4 weeks after startSession()
):
The inSession
flag remains true indefinitely
New educational sessions cannot be started due to the notYetInSession
modifier
New students cannot enroll for the same reason
The school effectively becomes locked in a permanent "in session" state despite the session having ended
The only way to "reset" this state is through a contract upgrade, which is an overly complex solution
This creates a fundamental limitation on the contract's lifecycle, requiring constant upgrades to maintain basic functionality.
Tools Used
Manual Review
Recommendations
Implement a function to reset the session state after sessionEnd
is reached:
function endSession() public onlyPrincipal {
require(block.timestamp >= sessionEnd, "Session has not ended yet");
require(inSession == true, "No active session");
inSession = false;
emit SessionEnded(sessionEnd, block.timestamp);
}
Alternatively, modify functions that use the notYetInSession
modifier to also check if the current time is past sessionEnd
:
modifier notYetInSession() {
if (inSession == true && block.timestamp < sessionEnd) {
revert HH__AlreadyInSession();
}
_;
}
This would automatically consider a session ended after the sessionEnd
timestamp has passed.
POC
function testPermanentStateLock() public {
_teachersAdded();
console.log("==================== INITIAL STATE ====================");
console.log("Session active:", levelOneProxy.getSessionStatus());
vm.startPrank(clara);
usdc.approve(address(levelOneProxy), schoolFees);
levelOneProxy.enroll();
vm.stopPrank();
console.log("Student enrolled successfully");
console.log("Total students:", levelOneProxy.getTotalStudents());
console.log("\n==================== STARTING SESSION ====================");
vm.prank(principal);
levelOneProxy.startSession(70);
console.log("Session started with cutoff score:", levelOneProxy.cutOffScore());
console.log("Session active:", levelOneProxy.getSessionStatus());
console.log("Session end timestamp:", levelOneProxy.sessionEnd());
console.log("Current timestamp:", block.timestamp);
uint256 timeJump = 4 weeks + 1 days;
console.log("\n==================== FAST FORWARD PAST SESSION END ====================");
console.log("Fast forwarding time by:", timeJump / 1 days, "days");
vm.warp(block.timestamp + timeJump);
console.log("New current timestamp:", block.timestamp);
console.log("Session end timestamp:", levelOneProxy.sessionEnd());
console.log("Time past session end:", block.timestamp - levelOneProxy.sessionEnd(), "seconds");
console.log("Session still active?", levelOneProxy.getSessionStatus());
console.log("\n==================== ATTEMPTING TO START NEW SESSION ====================");
vm.prank(principal);
try levelOneProxy.startSession(80) {
console.log("New session started - UNEXPECTED!");
} catch Error(string memory reason) {
console.log("Error starting new session:", reason);
} catch {
console.log("Failed to start new session due to AlreadyInSession error");
}
console.log("\n==================== ATTEMPTING TO ENROLL NEW STUDENT ====================");
vm.startPrank(dan);
usdc.approve(address(levelOneProxy), schoolFees);
try levelOneProxy.enroll() {
console.log("New student enrolled - UNEXPECTED!");
} catch Error(string memory reason) {
console.log("Error enrolling student:", reason);
} catch {
console.log("Failed to enroll new student due to AlreadyInSession error");
}
vm.stopPrank();
console.log("\n==================== FINAL STATE VERIFICATION ====================");
console.log("Current time is after session end:", block.timestamp > levelOneProxy.sessionEnd());
console.log("Session is still considered active:", levelOneProxy.getSessionStatus());
console.log("\nCONCLUSION: The contract is permanently locked in 'inSession' state");
console.log("No new sessions can be started and no new students can enroll");
assertGt(block.timestamp, levelOneProxy.sessionEnd());
assertTrue(levelOneProxy.getSessionStatus());
}
Output
forge test --mt testPermanentStateLock -vv
[⠊] Compiling...
[⠃] Compiling 1 files with Solc 0.8.26
[⠊] Solc 0.8.26 finished in 774.42ms
Compiler run successful!
Ran 1 test for test/LeveOnelAndGraduateTest.t.sol:LevelOneAndGraduateTest
[PASS] testPermanentStateLock() (gas: 546621)
Logs:
==================== INITIAL STATE ====================
Session active: false
Student enrolled successfully
Total students: 1
==================== STARTING SESSION ====================
Session started with cutoff score: 70
Session active: true
Session end timestamp: 2419201
Current timestamp: 1
==================== FAST FORWARD PAST SESSION END ====================
Fast forwarding time by: 29 days
New current timestamp: 2505601
Session end timestamp: 2419201
Time past session end: 86400 seconds
Session still active? true
==================== ATTEMPTING TO START NEW SESSION ====================
Failed to start new session due to AlreadyInSession error
==================== ATTEMPTING TO ENROLL NEW STUDENT ====================
Failed to enroll new student due to AlreadyInSession error
==================== FINAL STATE VERIFICATION ====================
Current time is after session end: true
Session is still considered active: true
CONCLUSION: The contract is permanently locked in 'inSession' state
No new sessions can be started and no new students can enroll
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.21ms (3.95ms CPU time)
Ran 1 test suite in 103.65ms (17.21ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)