Storage slots of variables in LevelTwo does not match those of LevelOne. After school system upgrades, LevelTwo implementation contract will attempt to access variables in the wrong storage slot of proxy contract, resulting in storage collision and breaks the school records.
Before the school system upgrades, LevelOne implementation contract has modified state of storage slots in proxy contract in a particular order. In more detail (ref), address, bool and uint256 are packed (if possible) and stored in 32 byte slots based on the order of declaration (excluding constant and immutable variables). arrays stores the array.length to the storage slot at declaration, and array data is stored sequentially starting at slot keccak256(abi.encode(ARRAY_SLOT_DECLARATION)). mapping of key:value pair leaves an empty slot at mapping declaration, with the value stored at keccak256(abi.encode(KEY, SLOT_INDEX_DECLARATION)).
After the school system upgrades, LevelTwo implementation contract does not have the same order of variables as LevelOne contract, leading to LevelTwo contract accessing the wrong storage slot locations of the proxy contract.
Below shows the storage slot layout in the proxy contract, the variables/functions in LevelOne and LevelTwo accessing the corresponding storage slots as well as the storage collision impact. Using two teachers and two students as example for brevity.
| Slot | LevelOne | LevelTwo | Collision |
|---|---|---|---|
| 0 | principal | principal | |
| 1 | inSession | inSession | |
| 2 | schoolFees | sessionEnd | <@ sessionEnd returns schoolFees (5000e18) |
| 3 | sessionEnd | bursary | <@ bursary returns sessionEnd (block.timestamp + 4 weeks if session started, 0 otherwise) |
| 4 | bursary | cutOffScore | <@ cutOffScore returns bursary |
| 5 | cutOffScore | isTeacher declaration | |
| 6 | isTeacher declaration | isStudent declaration | |
| 7 | isStudent declaration | studentScore declaration | |
| 8 | studentScore declaration | listOfStudents.length | <@ listOfStudents.length returns 0 (listOfStudents returns empty array) |
| 9 | reviewCount declaration | listOfTeachers.length | <@ listOfTeachers.length returns 0 (listOfTeachers returns empty array) |
| 10 | lastReviewTime declaration | usdc | <@ usdc returns 0 address |
| 11 | listOfStudents.length | ||
| 12 | listOfTeachers.length | ||
| 13 | usdc | ||
| ka(teacher0Addr,5) | isTeacher[teacher0] | <@ isTeacher[teacherAddr] returns false (teacher is de-registered) | |
| ka(teacher1Addr,5) | isTeacher[teacher1] | <@ isTeacher[teacherAddr] returns false (teacher is de-registered) | |
| ka(teacher0Addr,6) | isTeacher[teacher0] | isStudent[teacher0] | <@ isStudent[teacherAddr] returns true (teacher registered as student) |
| ka(teacher1Addr,6) | isTeacher[teacher1] | isStudent[teacher1] | <@ isStudent[teacherAddr] returns true (teacher registered as student) |
| ka(student0Addr,6) | isStudent[student0] | <@ isStudent[studentAddr] returns false (student is de-registered) | |
| ka(student0Addr,7) | isStudent[student0] | studentScore[student0] | <@ studentScore[studentAddr] returns 1 (student's score reduced) |
| ka(student0Addr,8) | studentScore[student0] | ||
| ka(student0Addr,9) | reviewCount[student0] | ||
| ka(student0Addr,10) | lastReviewTime[student0] | ||
| ka(student1Addr,6) | isStudent[student1] | <@ isStudent[studentAddr] returns false (student is de-registered) | |
| ka(student1Addr,7) | isStudent[student1] | studentScore[student1] | <@ studentScore[studentAddr] returns 1 (student's score reduced) |
| ka(student1Addr,8) | studentScore[student1] | ||
| ka(student1Addr,9) | reviewCount[student1] | ||
| ka(student1Addr,10) | lastReviewTime[student1] | ||
| ka(11) | listOfStudents[0] i.e. student0Addr | ||
| ka(11) + 1 | listOfStudents[1] i.e. student1Addr | ||
| ka(12) | listOfTeachers[0] i.e. teacher0Addr | ||
| ka(12) + 1 | listOfTeachers[1] i.e. teacher1Addr |
**ka(params) denotes keccak256(abi.encode(params))
**Note: minor changes needed to the LevelTwo contract before running PoC to fix upgradeability issue. (_authorizeUpgrade left unprotected only for demonstration purposes)
Place the following into LevelOne|AndGraduateTest.t.sol and run
forge test --mt testStorageCollision
Below is the list of impacts after school system upgrade caused by the storage collision
LevelTwo::sessionEnd returns LevelOne::schoolFees (5000e18)
LevelTwo::bursary returns LevelOne::sessionEnd (block.timestamp + 4 weeks if session started, 0 otherwise)
LevelTwo::cutOffScore returns LevelOne::bursary
LevelTwo::listOfStudents.length returns 0 (breaks LevelTwo::getTotalStudents)
LevelTwo::listOfStudents returns empty array (breaks LevelTwo::getListOfStudents)
LevelTwo::listOfTeachers.length returns 0 (breaks LevelTwo::getTotalTeachers)
LevelTwo::listOfTeachers returns empty array (breaks LevelTwo::getListOfTeachers)
LevelTwo::usdc returns 0 address (breaks LevelTwo::getSchoolFeesToken)
LevelTwo::isTeacher[teacherAddr] returns false (teacher is de-registered)
LevelTwo::isStudent[teacherAddr] returns true (teacher registered as student)
LevelTwo::isStudent[studentAddr] returns false (student is de-registered)
LevelTwo::studentScore[studentAddr] returns 1 (student's score reduced)
Impact: High, school records are broken
Likelihood: High, principal will upgrade school system at the end of school session (after 4 weeks)
Severity: High
Manual review
Add dummy variables to fill up storage slots and prevent storage collisions
LevelTwo.sol
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.