Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Missing Address Type Check Before Low-level Call May Lead To Malicious Code Execution

Missing Address Type Check Before Low-level Call May Lead To Malicious Code Execution

Description

  • The contract uses payable(address).call{value: amount}("") to transfer ETH to user-controlled addresses in withdrawWinnings() and withdrawPlatformFees().

  • If the target address is not an Externally Owned Account (EOA) but a contract, the low-level call will invoke the contract’s fallback() or receive() function, which may contain malicious logic such as re-entrancy.

function withdrawWinnings() external nonReentrant {
uint256 amount = pendingWinnings[msg.sender];
require(amount > 0, "Game: No winnings to withdraw.");
@> (bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Game: Failed to withdraw winnings.");
pendingWinnings[msg.sender] = 0;
emit WinningsWithdrawn(msg.sender, amount);
}
function withdrawPlatformFees() external onlyOwner nonReentrant {
uint256 amount = platformFeesBalance;
require(amount > 0, "Game: No platform fees to withdraw.");
platformFeesBalance = 0;
@> (bool success, ) = payable(owner()).call{value: amount}("");
require(success, "Game: Failed to withdraw platform fees.");
emit PlatformFeesWithdrawn(owner(), amount);
}

Risk

Likelihood:High

  • These functions allow sending ETH to arbitrary addresses without checking whether the recipient is a contract.

  • When the recipient is a contract, the fallback or receive function is triggered, allowing it to perform arbitrary logic, including re-entrant calls.

Impact:High

  • Can cause the re-entrant issue(etc. take away all the ether of the Game contract).

  • It could also cause cross-function re-entrancy, bypassing access control or logic assumptions elsewhere in the contract.

  • Note: While nonReentrant provides protection against direct re-entrancy in the current context, relying solely on modifiers is not always sufficient.

    For example, if future changes introduce internal calls, delegatecalls, or access through another entry point, the low-level call may still become a reentrancy vector.

    In addition, the fallback or receive function of the recipient may contain side effects that interfere with contract logic or gas usage, even without reentrancy.

Proof of Concept

A malicious contract can exploit the low-level call as follows:

//fallback function in malicious contract
function fallback() external {
address GameContractAddress = 0x....aaa;
// Re-enter the Game contract
address(GameContractAddress).withdrawWinnings();
}

In this example, when the Game contract sends ETH to the malicious contract, it triggers the fallback function, which re-enters the vulnerable withdrawWinnings() logic before the previous withdrawal finalizes.

Recommended Mitigation

  • Option 1: Use transfer() or send() instead of call

    • These have fixed gas stipends and are safer for simple transfers (but less flexible).

    • Note: transfer() reverts on failure, while send() returns false.

payable(msg.sender).transfer(amount);
  • Option 2: Validate recipient is an EOA before sending ETH

    • Use extcodesize to detect whether the address is a contract:

require(msg.sender.code.length == 0, "Game: recipient is a contract");
Updates

Appeal created

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Doss by reverting contract / contract that doesn't implement receive.

The only party affected is the "attacker". Info at best.

Support

FAQs

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