Hawk High

First Flight #39
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

[H-4] Teacher Payment Calculation Error Leads to Fund Drainage and System DoS

Severity

Critical

Impact

The graduateAndUpgrade function in LevelOne has a critical calculation error that gives each teacher 35% of the bursary instead of sharing the 35% among all teachers. This has two devastating consequences:

  1. Fund Drainage: With just 2 teachers, 70% of funds go to teachers (instead of the intended 35% total), dramatically reducing the bursary preserved in upgrades.

  2. Complete System DoS: With 3+ teachers, the function attempts to distribute over 105% of available funds, causing the transaction to revert due to insufficient balance and making it impossible to upgrade the contract.

This error directly violates the core invariant: "teachers share 35% of bursary" and breaks the entire upgrade mechanism when teacher count exceeds 2.

Description

In the LevelOne.sol contract, there is a critical calculation error in the graduateAndUpgrade function. According to the contract documentation, teachers are supposed to collectively share 35% of the bursary, with the principal receiving 5% and the remaining 60% reflecting in the bursary after upgrade.

However, the current implementation incorrectly gives each teacher the full 35% of the bursary instead of dividing the 35% among all teachers:

uint256 payPerTeacher = (bursary * TEACHER_WAGE) / PRECISION;

This calculation sets payPerTeacher to 35% of the bursary for each teacher, regardless of how many teachers there are.

As a result:

  • With 1 teacher: They receive 35% (as intended)

  • With 2 teachers: Each receives 35%, totaling 70% (exceeds the intended 35%)

  • With 3+ teachers: Each receives 35%, totaling 105%+ (exceeds 100% of available funds)

When there are 3 or more teachers, the contract will attempt to pay out more than 100% of the available funds, causing the transaction to revert due to insufficient balance. This creates a denial of service condition that prevents the school from graduating and upgrading to the next level.

The impact escalates with each additional teacher added to the system. The economic damage is substantial:

  1. With 2 teachers: 40% of funds are drained compared to the intended design (70% paid vs. 35% intended)

  2. With 3+ teachers: System completely freezes with no ability to graduate or distribute funds

Proof of Concept

PoC test code
// SPDX-License-Identifier: MIT
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 {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
contract TeacherPaymentExploitTest is Test {
LevelOne levelOne;
LevelTwo levelTwo;
ERC20Mock usdc;
address principal = makeAddr("principal");
address student = makeAddr("student");
address teacher1 = makeAddr("teacher1");
address teacher2 = makeAddr("teacher2");
address teacher3 = makeAddr("teacher3");
uint256 schoolFees = 100 ether;
uint256 initialBalance = 1000 ether;
function setUp() public {
// Deploy mocked USDC with 18 decimals
usdc = new ERC20Mock();
// Create levelOne contract
levelOne = new LevelOne();
levelOne.initialize(principal, schoolFees, address(usdc));
// Create levelTwo for upgrading
levelTwo = new LevelTwo();
// Mint USDC to student
usdc.mint(student, initialBalance);
// Setup approval for student to pay fees
vm.prank(student);
usdc.approve(address(levelOne), schoolFees);
// Add teachers
vm.startPrank(principal);
levelOne.addTeacher(teacher1);
levelOne.addTeacher(teacher2);
levelOne.addTeacher(teacher3);
vm.stopPrank();
console.log("Initial setup:");
console.log("Number of teachers:", levelOne.getTotalTeachers());
}
function test_TwoTeachers_Take70Percent() public {
// 1. Setup with 2 teachers
vm.startPrank(principal);
// Remove teacher3 to leave only 2 teachers
levelOne.removeTeacher(teacher3);
vm.stopPrank();
// 1. Student enrolls in the school
vm.prank(student);
levelOne.enroll();
console.log("\nTest with 2 teachers:");
console.log("Student enrolled with fees: %s ETH", schoolFees / 1 ether);
console.log("Bursary amount: %s ETH", levelOne.bursary() / 1 ether);
// 2. Principal starts the session
vm.prank(principal);
levelOne.startSession(60); // 60 as cutoff score
// Store starting timestamp for incrementing
uint256 currentTime = block.timestamp;
// 3. Give weekly reviews (one per week for 4 weeks)
for (uint i = 0; i < 4; i++) {
// Add a week
currentTime += 1 weeks;
vm.warp(currentTime);
vm.prank(teacher1);
levelOne.giveReview(student, true);
}
// Warp to end of session
vm.warp(block.timestamp + 1 days);
// 4. Calculate what each teacher should receive
uint256 totalBursary = levelOne.bursary();
uint256 teacherWagePercent = levelOne.TEACHER_WAGE();
uint256 precision = levelOne.PRECISION();
uint256 totalTeachers = levelOne.getTotalTeachers();
// What should happen - 35% divided among teachers
uint256 teacherPool = (totalBursary * teacherWagePercent) / precision;
uint256 correctPayPerTeacher = teacherPool / totalTeachers;
// What actually happens - each teacher gets 35%
uint256 incorrectPayPerTeacher = (totalBursary * teacherWagePercent) / precision;
console.log("Payment calculation with 2 teachers:");
console.log("Total bursary: %s ETH", totalBursary / 1 ether);
console.log("Number of teachers: %s", totalTeachers);
console.log("Teacher wage percent: %s%%", teacherWagePercent);
console.log("Correct: Each teacher should get %s ETH", correctPayPerTeacher / 1 ether);
console.log("Incorrect: Each teacher actually gets %s ETH", incorrectPayPerTeacher / 1 ether);
// 5. Show the total payout with 2 teachers
uint256 principalPay = (totalBursary * levelOne.PRINCIPAL_WAGE()) / precision;
uint256 totalTeacherPay = incorrectPayPerTeacher * totalTeachers;
uint256 totalPayout = totalTeacherPay + principalPay;
console.log("Principal payment: %s ETH", principalPay / 1 ether);
console.log("Total teacher payment: %s ETH (%s%% of bursary)", totalTeacherPay / 1 ether, (totalTeacherPay * 100) / totalBursary);
console.log("Total payout: %s ETH (%s%% of bursary)", totalPayout / 1 ether, (totalPayout * 100) / totalBursary);
// With 2 teachers, the payout should be 75% (35%*2 + 5%)
assertTrue(
totalPayout <= totalBursary,
"With 2 teachers, payout should not exceed total bursary"
);
// 6. Show that graduation succeeds with 2 teachers
vm.prank(principal);
levelOne.graduateAndUpgrade(address(levelTwo), "");
}
function test_ThreeTeachers_ExceedFunds() public {
// 1. Student enrolls in the school
vm.prank(student);
levelOne.enroll();
console.log("\nTest with 3 teachers:");
console.log("Student enrolled with fees: %s ETH", schoolFees / 1 ether);
console.log("Bursary amount: %s ETH", levelOne.bursary() / 1 ether);
// 2. Principal starts the session
vm.prank(principal);
levelOne.startSession(60); // 60 as cutoff score
// Store starting timestamp for incrementing
uint256 currentTime = block.timestamp;
// 3. Give weekly reviews (one per week for 4 weeks)
for (uint i = 0; i < 4; i++) {
// Add a week
currentTime += 1 weeks;
vm.warp(currentTime);
vm.prank(teacher1);
levelOne.giveReview(student, true);
}
// Warp to end of session
vm.warp(block.timestamp + 1 days);
// 4. Calculate what each teacher should receive
uint256 totalBursary = levelOne.bursary();
uint256 teacherWagePercent = levelOne.TEACHER_WAGE();
uint256 precision = levelOne.PRECISION();
uint256 totalTeachers = levelOne.getTotalTeachers();
// What should happen - 35% divided among teachers
uint256 teacherPool = (totalBursary * teacherWagePercent) / precision;
uint256 correctPayPerTeacher = teacherPool / totalTeachers;
// What actually happens - each teacher gets 35%
uint256 incorrectPayPerTeacher = (totalBursary * teacherWagePercent) / precision;
console.log("Payment calculation with 3 teachers:");
console.log("Total bursary: %s ETH", totalBursary / 1 ether);
console.log("Number of teachers: %s", totalTeachers);
console.log("Teacher wage percent: %s%%", teacherWagePercent);
console.log("Correct: Each teacher should get %s ETH", correctPayPerTeacher / 1 ether);
console.log("Incorrect: Each teacher actually gets %s ETH", incorrectPayPerTeacher / 1 ether);
// 5. Show that the total payout would exceed the bursary
uint256 principalPay = (totalBursary * levelOne.PRINCIPAL_WAGE()) / precision;
uint256 totalTeacherPay = incorrectPayPerTeacher * totalTeachers;
uint256 totalPayout = totalTeacherPay + principalPay;
console.log("Principal payment: %s ETH", principalPay / 1 ether);
console.log("Total teacher payment: %s ETH (%s%% of bursary)", totalTeacherPay / 1 ether, (totalTeacherPay * 100) / totalBursary);
console.log("Total payout: %s ETH (%s%% of bursary)", totalPayout / 1 ether, (totalPayout * 100) / totalBursary);
console.log("Exceeds bursary by: %s ETH", (totalPayout > totalBursary) ? (totalPayout - totalBursary) / 1 ether : 0);
// Assert that the incorrect calculation would result in more than 100% of the bursary being paid out
assertTrue(
totalPayout > totalBursary,
"With 3 teachers, payout should exceed total bursary"
);
// 6. Demonstrate the actual revert on upgrade
vm.prank(principal);
vm.expectRevert(); // We expect the transaction to revert
levelOne.graduateAndUpgrade(address(levelTwo), ""); // This will revert due to insufficient funds
}
}

Recommended Mitigation

function graduateAndUpgrade(address _levelTwo, bytes memory) public onlyPrincipal {
if (_levelTwo == address(0)) {
revert HH__ZeroAddress();
}
uint256 totalTeachers = listOfTeachers.length;
+
+ // Ensure there is at least one teacher
+ if (totalTeachers == 0) {
+ revert("HH__NoTeachersAvailable");
+ }
- uint256 payPerTeacher = (bursary * TEACHER_WAGE) / PRECISION;
+ uint256 teacherPool = (bursary * TEACHER_WAGE) / PRECISION;
+ uint256 payPerTeacher = teacherPool / totalTeachers;
uint256 principalPay = (bursary * PRINCIPAL_WAGE) / PRECISION;
_authorizeUpgrade(_levelTwo);
for (uint256 n = 0; n < totalTeachers; n++) {
usdc.safeTransfer(listOfTeachers[n], payPerTeacher);
}
usdc.safeTransfer(principal, principalPay);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

incorrect teacher pay calculation

`payPerTeacher` in `graduateAndUpgrade()` is incorrectly calculated.

Support

FAQs

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