How did this contract got hacked?

Hi,

This contract got hacked, and I don't see how it happened, here is the contract, and one of the hacker transactions:

// SPDX-License-Identifier: Propietary
pragma solidity ^0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract CastleVampiresGame is Ownable(0x2867d29563BB9c011AA82E2718146Ba1a7070955), ReentrancyGuard {

    IERC20 public tokenAddress; 
    
    mapping(address => bool) public wonLottery;  
    mapping(address => uint8) public tierReward;  

    mapping(address => bool) public isInGame;
    mapping(address => uint256) public ticketExpiration;   
    
    uint256 public tier3Amount = 0.0175 ether;
    uint256 public tier2Amount = 0.015 ether;
    uint256 public tier1Amount = 0.01 ether;
    uint256 public ticketCost = 0.01 ether;
    uint256 public tokensNeededToPlay = 1 ether;

    //The numbers that will decide what tier rewards will be given
    uint8 public oddsToWin = 7;
    uint8 public tier1Limit = 60;
    uint8 public tier2Limit = 94;

    event Claimed(address player ,uint amount);
    event DiceResult(address player ,bool result);
    event TierWon(address player ,uint8 tier);
    event Dice(uint256 diceNum);
    event Tier(uint8 tierNum);
    event GameEntered(address player); 

    constructor(address _tokenAddress)  {
        tokenAddress = IERC20(_tokenAddress);       
    }

    // Function to receive Ether. msg.data must be empty
    receive() external payable {}

    function EnterGame() external payable nonReentrant{
        require(msg.value == ticketCost, "Incorrect amount");
        require(getPlayerTokenBalance() >= tokensNeededToPlay, "More tokens needed in wallet to play");        
        isInGame[msg.sender] = true;
        ticketExpiration[msg.sender] = block.timestamp + 6 minutes;
        emit GameEntered(msg.sender);       
    } 

    //claim amount to send in wei
    function claim() public nonReentrant {        
        require (address(this).balance >= 0 , "Not enough funds");
        require (wonLottery[msg.sender], "Can only claim if won the dice lottey");                      
               
        wonLottery[msg.sender] = false;

        address payable _to = payable(msg.sender);

        if (tierReward[msg.sender] == 1) {            
            bool sent = _to.send(tier1Amount);
            require(sent, "Failed to send Ether");
            emit Claimed(msg.sender,tier1Amount);            
        } else if (tierReward[msg.sender] == 2) {             
            bool sent = _to.send(tier2Amount);
            require(sent, "Failed to send Ether");
            emit Claimed(msg.sender,tier2Amount);          
        } else if (tierReward[msg.sender] == 3) {            
            bool sent = _to.send(tier3Amount);
            require(sent, "Failed to send Ether");
            emit Claimed(msg.sender,tier3Amount);          
        }    

        tierReward[msg.sender] = 0;        
    }    

    function getNumberWin() private returns (uint8){       
       bytes32 numberR = keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp));
       uint256 convertedNumber = uint256(numberR);
       uint8 lastDigit = uint8(convertedNumber % 10);      
       emit Dice(convertedNumber);
       return (lastDigit);
    }

    function getNumberTier() private view returns (uint8) {         
        uint numberR = block.prevrandao + block.timestamp / 2;
        // Get the last two digits directly
        uint8 lastTwoDigits = uint8(numberR % 100);
        return lastTwoDigits;
    }

    function throwDice() public {
    require (!wonLottery[msg.sender], "You already won, go and claim your prize");    
    require(isInGame[msg.sender] == true, "Not in game");        
    require(block.timestamp < ticketExpiration[msg.sender]  , "Ticket expired"); 
       if (getNumberWin() < oddsToWin){
            wonLottery[msg.sender] = true;
            diceTier();
            emit DiceResult(msg.sender, true);          
        } else {  
            emit DiceResult(msg.sender, false);
        } 
    isInGame[msg.sender] = false;
    }

    function diceTier() private returns (string memory){    

        uint8 result = getNumberTier();
        emit Tier(result);
        if (result <= tier1Limit) {
            emit TierWon(msg.sender, 1);
            tierReward[msg.sender] = 1;
            return "Tier 1";
        } else if (result > tier1Limit && result <= tier2Limit) {
            emit TierWon(msg.sender, 2);
            tierReward[msg.sender] = 2;
            return "Tier 2";
        } else if (result > tier2Limit) {
            emit TierWon(msg.sender, 3);
            tierReward[msg.sender] = 3;
            return "Tier 3";
        } else {
            emit TierWon(msg.sender, 1);
            return "Tier1";
        }
    }

    // new amount to claim in wei units
    function updateTier3Amount(uint256 newAmount) external onlyOwner {
       tier3Amount = newAmount;
    }

    function updateTier2Amount(uint256 newAmount) external onlyOwner {
       tier2Amount = newAmount;
    }

    function updateTier1Amount(uint256 newAmount) external onlyOwner {
       tier1Amount = newAmount;
    }

    //Change the difficulty of the tiers
    function updateTier1Limit(uint8 newLimit) external onlyOwner {
       tier1Limit = newLimit;
    }

    function updateTier2Limit(uint8 newLimit) external onlyOwner {
       tier2Limit = newLimit;
    }

    function updateOddsToWin(uint8 newOdds) external onlyOwner {
       oddsToWin = newOdds;
    }

    //Change the amount of tokens needed to be on the wallet to play, in wei units
    function updateAmountOfTokensToPlay(uint256 newLimit) external onlyOwner {
       tokensNeededToPlay = newLimit;
    }  

    function getPlayerTokenBalance() public view returns (uint256){
       return tokenAddress.balanceOf(address(msg.sender));
    }

    function updateERC20TokenAddress (address _tokenAddress) external onlyOwner {
        tokenAddress = IERC20(_tokenAddress);
    }    

    //Withdraw Eth
    function withdraw() external onlyOwner {        
        payable(msg.sender).transfer(address(this).balance);        
    }

}

In specific, all the ETH balance got drained.

Have you totally ruled out access to or theft of the project's private keys that could drain the game?

The main problem with your contract, and possible cause of the hack, is the use of onChain randomness.

In the early days of the Ethereum network the first thing that was invented was gambling. From that moment on, the techniques of guessing by miners of those numbers that cannot give randomness due to the deterministic nature of the network were developed.

This means that any malicious miner who detects your contract knows how to guess that number.

You have to use an oracle.

Other than that:

  1. Controlled reentrancy, but the use of send() is insecure.
  2. Lack of ERC20 token balance control during the whole game.
  3. Lack of proper verification of contract funds before making payments.

My opinion is that the potential for manipulation by a miner is remarkable. I believe that was the cause.

Thanks for the breakdown.

How would a contract that needs a random number would do it then?, without having to pay Chainlink $20 per request?