(HIGH) Upgrading to LevelTwo
corrupts critical state variables (usdc
token address, totalTeachers
), breaking functionality
File: src/LevelTwo.sol
(State Variables)
Technical Root Cause
The vulnerability is due to a storage layout incompatibility between LevelOne
and LevelTwo
when using the UUPS (Universal Upgradeable Proxy Standard) pattern. UUPS proxies allow upgrading the implementation contract while preserving the storage and address of the proxy. For this to work safely, the new implementation contract (LevelTwo
) must maintain a compatible storage layout with the old one (LevelOne
) for any state variables it intends to continue using or for new variables it appends.
Specifically:
Initializable
contract's storage: Both LevelOne
and LevelTwo
inherit Initializable
, which typically uses storage slot 0 for _initialized
and _initializing
. This part is compatible.
LevelOne
storage layout (after Initializable
's slot 0):
Slot 1: address public principal;
Slot 2: uint256 public schoolFees;
Slot 3: IERC20 public usdc;
(effectively an address)
Slot 4: mapping(address => bool) public isTeacher;
(conceptual slot for mapping)
Slot 5: address[] public teachers;
(length and data pointer)
Slot 6: uint256 public totalTeachers;
And so on for other variables.
LevelTwo
storage layout (after Initializable
's slot 0):
Slot 1: address public principal;
(Compatible with LevelOne.principal
)
Slot 2: IERC20 public usdc;
(INCOMPATIBLE: LevelOne
has uint256 schoolFees
at this slot)
Slot 3: uint256 public totalTeachers;
(INCOMPATIBLE: LevelOne
has IERC20 usdc
at this slot)
Slot 4: uint256 public totalStudents;
(INCOMPATIBLE: LevelOne
has mapping(address => bool) isTeacher
associated with this slot, followed by teachers
array)
Slot 5: address[] public teachers;
(INCOMPATIBLE: LevelOne
has uint256 totalTeachers
at slot 6, teachers
array was at slot 5. LevelTwo.teachers
will try to read its length from LevelOne.totalTeachers
slot if packing allows, or a subsequent slot, leading to further corruption.)
This mismatch means that when LevelTwo
's code executes, it reads and writes to storage slots assuming its own layout, thereby misinterpreting or corrupting the data previously stored by LevelOne
. For instance, LevelTwo.usdc
will read the uint256
value of LevelOne.schoolFees
and interpret its lower 20 bytes as an address.
Likelihood of Exploitation: High. This is not an "exploitation" by a malicious external actor but an operational failure during a planned upgrade. If the incompatible LevelTwo
contract is deployed as an upgrade, the corruption is guaranteed to occur. The likelihood of deploying such an incompatible upgrade depends on the team's diligence and testing practices.
AI assistance for identification an impact analaysis
Manual review
To fix this vulnerability, ensure that the storage layout of LevelTwo.sol
is compatible with LevelOne.sol
. This means:
Preserve Order and Type: Existing state variables from LevelOne
that are still needed in LevelTwo
must be declared in the exact same order and with the exact same types.
Append New Variables: New state variables in LevelTwo
must be appended after all the variables from LevelOne
.
Careful with Deletion/Modification: Deleting or changing the type of an existing state variable from LevelOne
in LevelTwo
will cause a storage layout shift and is generally unsafe unless the variable is no longer used and its slot can be repurposed (an advanced and risky technique). It's safer to mark unused variables as deprecated and leave them in place to preserve layout, or use techniques like "gap" variables (uint256[50] private __gap;
) in earlier versions to reserve space for future variables.
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.