Hawk High

First Flight #39
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Unchecked USDC Transfers in `graduateAndUpgrade` Enable Denial of Service by Malicious Teachers

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:

  1. The teacher’s address is blacklisted by the USDC contract (common in real-world USDC implementations).

  2. The address is a contract that self-destructed or reverts on receiving tokens.

  3. 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.

// SPDX-License-Identifier: MIT
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 {
// Deploy malicious teacher contract
address MaliciousTeacher = makeAddr("MaliciousTeacher");
vm.prank(MaliciousTeacher);
usdc.blacklist(MaliciousTeacher);
vm.stopPrank();
// Add malicious teacher
vm.prank(principal);
levelOneProxy.addTeacher(MaliciousTeacher);
vm.stopPrank();
_studentsEnrolled();
// Attempt upgrade
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.

// SPDX-License-Identifier: MIT
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;
// ... existing variables ...
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();
}
// Check all students have 4 reviews (fixing [H-3])
uint256 studentCount = listOfStudents.length;
for (uint256 i = 0; i < studentCount; i++) {
if (reviewCount[listOfStudents[i]] != 4) {
revert HH__IncompleteReviews();
}
}
// Calculate wages (fixing [H-2])
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;
// Ensure sufficient balance
require(usdc.balanceOf(address(this)) >= totalPayout, "Insufficient USDC balance");
// Record teacher wages in pendingWithdrawals
for (uint256 n = 0; n < totalTeachers; n++) {
pendingWithdrawals[listOfTeachers[n]] += payPerTeacher;
emit WagesPending(listOfTeachers[n], payPerTeacher);
}
// Record principal wages
pendingWithdrawals[principal] += principalPay;
emit WagesPending(principal, principalPay);
// Filter eligible students (fixing [H-3])
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);
// Initialize LevelTwo
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);
}
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.