Summary
The LevelOne::graduateAndUpgrade function uses usdc.safeTransfer to pay teachers without handling potential transfer failures. If a teacher’s address is malicious (e.g., blacklisted by the USDC contract, a self-destructed contract, or a contract with a reverting hook), the transfer reverts, causing the entire upgrade to fail. This allows a single malicious teacher to block the system upgrade, preventing student graduation and wage distribution, compromising protocol reliability.
Vulnerability Details
In graduateAndUpgrade, teacher wages are distributed via:
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);
}
The SafeERC20.safeTransfer
function reverts if the USDC
transfer fails, which can occur if:
The teacher’s address is blacklisted
by the USDC contract (common in real-world USDC implementations).
The address is a contract that self-destructed or reverts on receiving tokens.
The address triggers a hook that fails (e.g., a malicious contract).
Since the transfer
is in a loop and not wrapped in a try-catch block, a single failure causes the entire function to revert, halting the upgrade to LevelTwo
. A malicious teacher could register such an address via addTeacher
(if not properly vetted by the principal) or if an existing teacher’s address becomes blacklisted. This violates the implicit invariant that the system should upgrade reliably at the end of a session (related to Invariant 7: "System upgrade cannot take place unless school's sessionEnd has reached").
Proof of Concept
The following test demonstrates the DoS vulnerability using a MockUSDC
contract that reverts transfers to blacklisted addresses, simulating a malicious teacher.
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
mapping(address => bool) public blacklisted;
constructor() ERC20("Mock USDC", "mUSDC") {}
function mint(address to, uint256 value) external {
_mint(to, value);
}
function blacklist(address account) external {
blacklisted[account] = true;
}
function transfer(address recipient, uint256 amount) public override returns (bool) {
require(!blacklisted[recipient], "Recipient blacklisted");
_transfer(_msgSender(), recipient, amount);
return true;
}
}
function test_paymentDoSByMaliciousTeacher() public {
address MaliciousTeacher = makeAddr("MaliciousTeacher");
vm.prank(MaliciousTeacher);
usdc.blacklist(MaliciousTeacher);
vm.stopPrank();
vm.prank(principal);
levelOneProxy.addTeacher(MaliciousTeacher);
vm.stopPrank();
_studentsEnrolled();
levelTwoImplementation = new LevelTwo();
vm.prank(principal);
vm.expectRevert("Recipient blacklisted");
levelOneProxy.graduateAndUpgrade(address(levelTwoImplementation), "");
}
Output::
forge test --mt test_paymentDoSByMaliciousTeacher
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/LeveOnelAndGraduateTest.t.sol:LevelOneAndGraduateTest
[PASS] test_paymentDoSByMaliciousTeacher() (gas: 1229063)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.97ms (484.58µs CPU time)
Ran 1 test suite in 92.80ms (15.97ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Upgrade Blocked: A single malicious teacher prevents the system from upgrading to LevelTwo, halting student graduation and violating the implicit invariant of reliable upgrades (tied to Invariant 7).
Financial Disruption: Legitimate teachers and the principal cannot receive wages, as the function reverts before completing transfers, affecting trust in the system.
Protocol Reliability: The vulnerability to DoS undermines confidence in the system’s ability to execute critical operations, especially since upgrades are a core part of the protocol’s lifecycle.
Tools Used
Foundry
Recommendations
To prevent the DoS, remove usdc.safeTransfer calls from graduateAndUpgrade and record teacher and principal payments in a pendingWithdrawals mapping. Require teachers and the principal to manually claim their funds by connecting their wallet and calling a withdrawPending function. This ensures upgrades complete regardless of address issues.
pragma solidity 0.8.26;
contract LevelOne is Initializable, UUPSUpgradeable {
using SafeERC20 for IERC20;
error HH__SessionNotEnded();
error HH__NotInSession();
error HH__IncompleteReviews();
error HH__NoPendingWithdrawal();
event WagesPending(address indexed recipient, uint256 amount);
event EligibleStudentsUpgraded(address[] students, uint256 count);
mapping(address => uint256) public pendingWithdrawals;
function graduateAndUpgrade(address _levelTwo, bytes memory) public onlyPrincipal {
if (_levelTwo == address(0)) {
revert HH__ZeroAddress();
}
if (!inSession) {
revert HH__NotInSession();
}
if (block.timestamp < sessionEnd) {
revert HH__SessionNotEnded();
}
uint256 studentCount = listOfStudents.length;
for (uint256 i = 0; i < studentCount; i++) {
if (reviewCount[listOfStudents[i]] != 4) {
revert HH__IncompleteReviews();
}
}
uint256 totalTeachers = listOfTeachers.length;
uint256 teacherShare = (bursary * TEACHER_WAGE) / PRECISION;
uint256 payPerTeacher = totalTeachers > 0 ? teacherShare / totalTeachers : 0;
uint256 principalPay = (bursary * PRINCIPAL_WAGE) / PRECISION;
uint256 totalPayout = (payPerTeacher * totalTeachers) + principalPay;
uint256 remainingBursary = bursary - totalPayout;
require(usdc.balanceOf(address(this)) >= totalPayout, "Insufficient USDC balance");
for (uint256 n = 0; n < totalTeachers; n++) {
pendingWithdrawals[listOfTeachers[n]] += payPerTeacher;
emit WagesPending(listOfTeachers[n], payPerTeacher);
}
pendingWithdrawals[principal] += principalPay;
emit WagesPending(principal, principalPay);
address[] memory eligibleStudents = new address[](studentCount);
uint256 eligibleCount = 0;
for (uint256 i = 0; i < studentCount; i++) {
address student = listOfStudents[i];
if (studentScore[student] >= cutOffScore) {
eligibleStudents[eligibleCount] = student;
eligibleCount++;
}
}
emit EligibleStudentsUpgraded(eligibleStudents, eligibleCount);
LevelTwo(_levelTwo).graduateWithStudents(
principal,
address(usdc),
remainingBursary,
eligibleStudents,
eligibleCount
);
_authorizeUpgrade(_levelTwo);
inSession = false;
bursary = remainingBursary;
emit Graduated(_levelTwo);
}
function withdrawPending() public {
uint256 amount = pendingWithdrawals[msg.sender];
if (amount == 0) {
revert HH__NoPendingWithdrawal();
}
pendingWithdrawals[msg.sender] = 0;
usdc.safeTransfer(msg.sender, amount);
}
}