Hawk High

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

Permanently Locked Native ETH and Unintended USDC in Hawk High Contracts

Summary

Both LevelOne and LevelTwo contracts lack mechanisms to handle and recover native ETH and accidentally transferred USDC. Any ETH sent to these contracts becomes permanently locked, and any USDC sent outside the expected enrollment flow is unaccounted for and potentially unrecoverable.

Vulnerability Details

1.Missing ETH Handling Functions: Neither contract implements receive() or fallback() functions necessary to properly handle incoming ETH:

// LevelOne.sol - Line 54
contract LevelOne is Initializable, UUPSUpgradeable {
// No receive() or fallback() functions
// ...
}
// LevelTwo.sol - Line 7
contract LevelTwo is Initializable {
// No receive() or fallback() functions
// ...
}

This means if ETH is sent to either contract:

  • If sent via a function call, it will revert (which is safe but not user-friendly)

  • If sent via selfdestruct or as a mining/validator reward, ETH will be forcibly added to the contract balance with no way to retrieve it

  1. Unaccounted Direct USDC Transfers: The contracts only account for USDC received through the enrollment process:

    // LevelOne.sol - Line 249-252
    function enroll() external notYetInSession {
    // ...
    usdc.safeTransferFrom(msg.sender, address(this), schoolFees);
    // ...
    bursary += schoolFees;
    // ...
    }

If USDC is transferred directly to the contract via:

  • Direct transfer() or transferFrom() calls

  • Forced transfers (some tokens allow this)

  • Airdrops targeting the contract address

These tokens will be in the contract but not accounted for in bursary, becoming effectively lost or requiring a contract upgrade to recover.

  1. No Recovery Mechanisms: The contracts lack any function that would allow authorized users (such as the principal) to recover mistakenly sent assets

Impact

The severity is medium because:

  • It leads to permanent fund loss for users

  • It affects all assets sent outside designated flows

  • The lost assets cannot be recovered without a contract upgrade

  • It could affect the school's reputation

The likelihood is high because:

  • Users commonly misunderstand contract interaction patterns

  • Direct ETH transfers are a frequent mistake in blockchain interactions

  • Frontends might use incorrect patterns for token transfers

  • The proxy pattern may confuse integrators about which address to interact with

Tools Used

Manual review of contract code

Recommendations

  1. Implement Proper ETH Handling:

// Add to LevelOne.sol
receive() external payable {
// Optionally emit an event for tracking purposes
emit EthReceived(msg.sender, msg.value);
}
// Add a withdrawal function for the principal
function withdrawEth() external onlyPrincipal {
uint256 balance = address(this).balance;
(bool success, ) = principal.call{value: balance}("");
require(success, "ETH transfer failed");
emit EthWithdrawn(principal, balance);
}
// Define relevant events
event EthReceived(address indexed sender, uint256 amount);
event EthWithdrawn(address indexed recipient, uint256 amount);
  1. Add USDC Recovery Function:


// Add to LevelOne.sol
function reconcileUSDC() external onlyPrincipal {
uint256 contractBalance = usdc.balanceOf(address(this));
uint256 expectedBalance = bursary;
if (contractBalance > expectedBalance) {
uint256 excessAmount = contractBalance - expectedBalance;
usdc.safeTransfer(principal, excessAmount);
emit TokensReconciled(address(usdc), excessAmount);
}
}
event TokensReconciled(address indexed token, uint256 amount);
  1. Add Similar Functions to LevelTwo:


// Add to LevelTwo.sol
receive() external payable {
emit EthReceived(msg.sender, msg.value);
}
modifier onlyPrincipal() {
require(msg.sender == principal, "Not principal");
_;
}
function withdrawEth() external onlyPrincipal {
uint256 balance = address(this).balance;
(bool success, ) = principal.call{value: balance}("");
require(success, "ETH transfer failed");
emit EthWithdrawn(principal, balance);
}
function withdrawUSDC() external onlyPrincipal {
uint256 balance = usdc.balanceOf(address(this));
usdc.safeTransfer(principal, balance);
emit TokensWithdrawn(address(usdc), balance);
}
// Define events
event EthReceived(address indexed sender, uint256 amount);
event EthWithdrawn(address indexed recipient, uint256 amount);
event TokensWithdrawn(address indexed token, uint256 amount);
Updates

Lead Judging Commences

yeahchibyke Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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