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.

1 Like

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?

You're asking about the risk of predicting the random number generated by the getNumberWin() function. This is crucial for the fairness and security of the game.

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

Risk Analysis:

This function uses keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp)) to generate a "random" number. This method is not secure on the blockchain and is vulnerable to prediction. The main reasons are:

  1. Predictability of block.prevrandao: block.prevrandao is the random number from the previous block, provided by the miner. While theoretically miners would need significant hash power to manipulate this value, in practice, especially on smaller or less decentralized networks, miners could influence block.prevrandao by controlling a portion of the hash power. This would allow them to influence the generated random number.
  2. Limited Manipulability of block.timestamp: block.timestamp is the timestamp of the current block, also set by the miner. Although the Ethereum protocol imposes limits on how much the timestamp can be adjusted, miners still have some control over it. This provides a window for predicting or manipulating the random number.
  3. msg.sender is Known: msg.sender is the address of the user calling the function. This is public information known to everyone.

Combining these factors, the random number generated by keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp)) is not truly random and is susceptible to prediction or manipulation.

Level of Risk:

  • On Ethereum Mainnet: Due to the highly distributed hash power of the Ethereum mainnet, it's very difficult for a single miner or pool to control block.prevrandao. Therefore, directly manipulating the random number is more challenging. However, a non-zero risk still exists, especially in unforeseen circumstances like a sudden consolidation of hash power.
  • On Testnets or Private Networks: On testnets or private networks, where hash power is more concentrated, controlling block.prevrandao is much easier. The risk of random number prediction or manipulation is very high.
  • For Malicious Actors: If an attacker controls a significant portion of the network's hash power or can discover patterns in block.prevrandao, they could exploit this information to predict the output of getNumberWin() and cheat in the game.
3 Likes