Summary
The LevelOne contract, a UUPS proxy, upgrades to LevelTwo via the graduateAndUpgrade function. However, LevelOne uses 12 storage slots, while LevelTwo uses 9 misaligned slots, leading to a storage collision. Post-upgrade, LevelTwo variables read incorrect values from LevelOne storage (e.g., sessionEnd reads schoolFees), corrupting critical protocol state.
Vulnerability Details
In UUPS upgradeable contracts, the storage layout must remain compatible across implementations to prevent collisions. LevelOne defines 12 storage slots:
Slot 1: `schoolFees` (uint256)
Slot 2: `sessionEnd` (uint256)
Slot 3: `bursary` (uint256)
Slot 4: `cutOffScore` (uint256)
Slot 5: `isTeacher` (mapping(address => bool))
Slot 6: `isStudent` (mapping(address => bool))
Slot 7: `studentScore` (mapping(address => uint256))
Slot 8: `reviewCount` (mapping(address => uint256))
Slot 9: `lastReviewTime` (mapping(address => uint256))
Slot 10: `listOfStudents` (address[] length)
Slot 11: `listOfTeachers` (address[] length)
Slot 12: `usdc` (IERC20)
LevelTwo Storage Layout (9 slots):
Slot 1: `sessionEnd` (uint256)
Slot 2: `bursary` (uint256)
Slot 3: `cutOffScore` (uint256)
Slot 4: `isTeacher` (mapping(address => bool))
Slot 5: `isStudent` (mapping(address => bool))
Slot 6: `studentScore` (mapping(address => uint256))
Slot 7: `reviewCount` (mapping(address => uint256))
Slot 8: `lastReviewTime` (mapping(address => uint256))
Slot 9: `listOfStudents` (address[] length)
Post-upgrade, LevelTwo variables read incorrect LevelOne slots:
`sessionEnd` (slot 1) = `schoolFees`
`bursary` (slot 2) = `sessionEnd`
`cutOffScore` (slot 3) = `bursary`
`isTeacher` (slot 4) = `cutOffScore`
`isStudent` (slot 5) = `isTeacher`
`studentScore` (slot 6) = `isStudent`
`reviewCount` (slot 7) = `studentScore`
`lastReviewTime` (slot 8) = `reviewCount`
`listOfStudents` (slot 9) = `lastReviewTime`
This misalignment corrupts critical protocol state, violating
Proof of Concept:
Add a new file in folder test as StorageCollisionTest.t.sol and paste all these code there
pragma solidity ^0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {LevelOne} from "../src/LevelOne.sol";
import {LevelTwo} from "../src/LevelTwo.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {MockUSDC} from "../test/mocks/MockUSDC.sol";
contract StorageCollisionTest is Test {
LevelOne levelOne;
LevelTwo levelTwo;
MockUSDC usdc;
address proxy;
address PRINCIPAL = makeAddr("PRINCIPAL");
address TEACHER = makeAddr("TEACHER");
address STUDENT = makeAddr("STUDENT");
function setUp() public {
usdc = new MockUSDC();
levelOne = new LevelOne();
levelTwo = new LevelTwo();
usdc.mint(STUDENT, 100_000e18);
proxy = address(new ERC1967Proxy(
address(levelOne),
abi.encodeWithSelector(
LevelOne.initialize.selector,
PRINCIPAL,
5000e18,
address(usdc)
)));
}
function testStorageCollisionOnUpgrade() public {
vm.startPrank(PRINCIPAL);
LevelOne(proxy).addTeacher(TEACHER);
vm.stopPrank();
vm.startPrank(STUDENT);
usdc.approve(proxy, 5000e18);
LevelOne(proxy).enroll();
vm.stopPrank();
vm.startPrank(PRINCIPAL);
LevelOne(proxy).startSession(70);
vm.stopPrank();
for (uint i = 0; i < 4; i++) {
vm.startPrank(TEACHER);
vm.warp(block.timestamp + 1 weeks);
LevelOne(proxy).giveReview(STUDENT, true);
vm.stopPrank();
}
bytes32 slot1Before = vm.load(proxy, bytes32(uint256(1)));
bytes32 slot2Before = vm.load(proxy, bytes32(uint256(2)));
bytes32 slot3Before = vm.load(proxy, bytes32(uint256(3)));
bytes32 slot4Before = vm.load(proxy, bytes32(uint256(4)));
bytes32 slot5Before = vm.load(proxy, bytes32(uint256(5)));
bytes32 slot6Before = vm.load(proxy, bytes32(uint256(6)));
bytes32 slot7Before = vm.load(proxy, bytes32(uint256(7)));
bytes32 slot8Before = vm.load(proxy, bytes32(uint256(8)));
bytes32 slot9Before = vm.load(proxy, bytes32(uint256(9)));
bytes32 slot10Before = vm.load(proxy, bytes32(uint256(10)));
bytes32 slot11Before = vm.load(proxy, bytes32(uint256(11)));
bytes32 slot12Before = vm.load(proxy, bytes32(uint256(12)));
console.log("Pre-upgrade slot1 (schoolFees):", uint256(slot1Before));
console.log("Pre-upgrade slot2 (sessionEnd):", uint256(slot2Before));
console.log("Pre-upgrade slot3 (bursary):", uint256(slot3Before));
console.log("Pre-upgrade slot4 (cutOffScore):", uint256(slot4Before));
console.log("Pre-upgrade slot5 (isTeacher):", uint256(slot5Before));
console.log("Pre-upgrade slot6 (isStudent):", uint256(slot6Before));
console.log("Pre-upgrade slot7 (studentScore):", uint256(slot7Before));
console.log("Pre-upgrade slot8 (reviewCount):", uint256(slot8Before));
console.log("Pre-upgrade slot9 (lastReviewTime):", uint256(slot9Before));
console.log("Pre-upgrade slot10 (listOfStudents):", uint256(slot10Before));
console.log("Pre-upgrade slot11 (listOfTeachers):", uint256(slot11Before));
console.log("Pre-upgrade slot12 (usdc):", uint256(slot12Before));
vm.startPrank(PRINCIPAL);
LevelOne(proxy).graduateAndUpgrade(address(levelTwo), "");
vm.stopPrank();
bytes32 slot1After = vm.load(proxy, bytes32(uint256(1)));
bytes32 slot2After = vm.load(proxy, bytes32(uint256(2)));
bytes32 slot3After = vm.load(proxy, bytes32(uint256(3)));
bytes32 slot4After = vm.load(proxy, bytes32(uint256(4)));
bytes32 slot5After = vm.load(proxy, bytes32(uint256(5)));
bytes32 slot6After = vm.load(proxy, bytes32(uint256(6)));
bytes32 slot7After = vm.load(proxy, bytes32(uint256(7)));
bytes32 slot8After = vm.load(proxy, bytes32(uint256(8)));
bytes32 slot9After = vm.load(proxy, bytes32(uint256(9)));
console.log("Post-upgrade slot1 (sessionEnd):", uint256(slot1After));
console.log("Post-upgrade slot2 (bursary):", uint256(slot2After));
console.log("Post-upgrade slot3 (cutOffScore):", uint256(slot3After));
console.log("Post-upgrade slot4 (isTeacher):", uint256(slot4After));
console.log("Post-upgrade slot5 (isStudent):", uint256(slot5After));
console.log("Post-upgrade slot6 (studentScore):", uint256(slot6After));
console.log("Post-upgrade slot7 (reviewCount):", uint256(slot7After));
console.log("Post-upgrade slot8 (lastReviewTime):", uint256(slot8After));
console.log("Post-upgrade slot9 (listOfStudents):", uint256(slot9After));
}
}
forge test --mt testStorageCollisionOnUpgrade -vvv
[⠊] Compiling...
[⠊] Compiling 1 files with Solc 0.8.26
[⠒] Solc 0.8.26 finished in 850.04ms
Compiler run successful!
Ran 1 test for test/LevelTwoTest.t.sol:StorageCollisionTest
[PASS] testStorageCollisionOnUpgrade() (gas: 466988)
Logs:
Pre-upgrade slot1 (schoolFees): 5000000000000000000000
Pre-upgrade slot2 (sessionEnd): 2419201
Pre-upgrade slot3 (bursary): 5000000000000000000000
Pre-upgrade slot4 (cutOffScore): 70
Pre-upgrade slot5 (isTeacher): 0
Pre-upgrade slot6 (isStudent): 0
Pre-upgrade slot7 (studentScore): 0
Pre-upgrade slot8 (reviewCount): 0
Pre-upgrade slot9 (lastReviewTime): 0
Pre-upgrade slot10 (listOfStudents): 1
Pre-upgrade slot11 (listOfTeachers): 1
Pre-upgrade slot12 (usdc): 491460923342184218035706888008750043977755113263
Post-upgrade slot1 (sessionEnd): 5000000000000000000000
Post-upgrade slot2 (bursary): 2419201
Post-upgrade slot3 (cutOffScore): 5000000000000000000000
Post-upgrade slot4 (isTeacher): 70
Post-upgrade slot5 (isStudent): 0
Post-upgrade slot6 (studentScore): 0
Post-upgrade slot7 (reviewCount): 0
Post-upgrade slot8 (lastReviewTime): 0
Post-upgrade slot9 (listOfStudents): 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.33ms (3.57ms CPU time)
Ran 1 test suite in 88.20ms (14.33ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Incorrect Protocol State: - sessionEnd as 5000e18 (~158 years) allows operations beyond the intended 4-week session. bursary as 2419201 wei underfunds the program, breaking fund distribution logic.
cutOffScore as 5000e18 makes student evaluations impossible.
Access Control Failure: Misaligned isTeacher and isStudent mappings allow unauthorized actions or block legitimate ones.
Data Corruption: listOfStudents reading lastReviewTime corrupts student records, breaking enrollment tracking.
Invariant Violations: Breaks Invariant 4 and Invariant 7, undermining protocol reliability.
Tools Used
Foundry
Recommendations
To prevent the storage collision, align LevelTwo storage layout with LevelOne by maintaining the same slot assignments: Update LevelTwo.sol:
Ensure LevelTwo replicates LevelOne 12-slot layout:
contract LevelTwo is Initializable, UUPSUpgradeable {
address public principal; // Slot 0
+ uint256 public schoolFees; // Slot 1
uint256 public sessionEnd; // Slot 2
uint256 public bursary; // Slot 3
uint256 public cutOffScore; // Slot 4
mapping(address => bool) public isTeacher; // Slot 5
mapping(address => bool) public isStudent; // Slot 6
mapping(address => uint256) public studentScore; // Slot 7
+ mapping(address => uint256) public reviewCount; // Slot 8
+ mapping(address => uint256) public lastReviewTime; // Slot 9
address[] public listOfStudents; // Slot 10
address[] public listOfTeachers; // Slot 11
IERC20 public usdc; // Slot 12
modifier onlyPrincipal() {
require(msg.sender == principal, "Only principal");
_;
}
function graduate() external onlyPrincipal {
delete listOfStudents; // Reset if needed
isLevelTwo = true;
}
function getBursary() external view returns (uint256) {
return bursary;
}
function getTotalStudents() external view returns (uint256) {
return listOfStudents.length;
}
}