← Back to Blog

Solidity selfdestruct: Forced Ether Injection, Contract Destruction, and Post-Cancun Changes

2026-04-18 selfdestruct forced ether contract destruction eip-6780 cancun solidity security

selfdestruct is one of the few Solidity operations that breaks the invariants of contracts that never opted in to receiving ETH. A non-payable contract with no receive() or fallback() function still has no defense against ETH sent via selfdestruct. The opcode bypasses all function dispatch, all access control, and all balance-guarding logic. The ETH arrives whether the target contract wants it or not.

This makes selfdestruct qualitatively different from other Ether-transfer mechanisms. Every other path — transfer, send, call{value: ...} — requires the recipient to cooperate, or at least to not revert. selfdestruct requires nothing from the recipient. It is a push with no opt-out.

The consequences fall into two categories. First, contracts that use address(this).balance as an invariant can have that invariant silently violated by an attacker spending as little as 1 wei. Second, the opcode itself can be used to destroy logic contracts, drain pools at unexpected moments, and deceive users who trust a deployed address. Both categories are actively exploited.

Post-Cancun (EIP-6780), the semantics changed in an important but often-misunderstood way. The opcode still exists and still sends ETH forcibly. What changed is when it destroys bytecode. Understanding both the legacy behavior and the EIP-6780 rules is essential for auditing modern contracts.

This post covers six concrete vulnerability classes, with vulnerable code, attack mechanics, and safe fixes for each.


1. Balance-Based Invariant Broken by Forced ETH

A common guard pattern checks that a contract holds no ETH before executing sensitive logic. Developers assume the balance is zero at initialization and is only modified by their own payable functions. That assumption is wrong.

// VULNERABLE: balance check can be bypassed
contract Vault {
    bool public initialized;

    function initialize() external {
        require(address(this).balance == 0, "Already funded");
        initialized = true;
    }

    receive() external payable {}
}

Attack: The attacker deploys a minimal contract funded with 1 wei, then selfdestructs it with the Vault address as the beneficiary. The Vault now holds 1 wei before initialize() is ever called. The require(address(this).balance == 0) check permanently fails. The vault can never be initialized.

The same pattern breaks in less obvious forms: contracts that use address(this).balance == 0 to determine whether a round is "fresh", or to assert that all funds have been withdrawn.

// FIXED: use internal accounting
contract Vault {
    bool public initialized;
    uint256 private _deposited;

    function initialize() external {
        require(_deposited == 0, "Already funded");
        initialized = true;
    }

    receive() external payable {
        _deposited += msg.value;
    }
}

Never use address(this).balance as an invariant. Track deposits with a dedicated storage variable and use that for all logical comparisons. This pattern holds regardless of whether your contract has a receive() function.


2. Game Contract Prize Pool Manipulation

Prize pool contracts often check address(this).balance to determine whether a target amount has been reached, then release the prize to the winner.

// VULNERABLE: balance-based win condition
contract PrizePool {
    uint256 public constant TARGET = 1 ether;
    address public winner;

    function deposit() external payable {
        require(winner == address(0), "Game over");
        if (address(this).balance == TARGET) {
            winner = msg.sender;
        }
    }

    function claim() external {
        require(msg.sender == winner);
        payable(winner).transfer(address(this).balance);
    }
}

Attack: The target is 1 ether. If 0.9 ether has been legitimately deposited, an attacker selfdestructs a contract holding 0.1 ether targeting this address. The balance jumps to 1 ether without calling deposit(). The winner is never set, because the ETH arrived outside a function call. Alternatively, if 0.99 ether is deposited, the attacker overshoots the exact target, making the == TARGET check permanently unreachable — the game is broken forever.

// FIXED: track cumulative deposits separately
contract PrizePool {
    uint256 public constant TARGET = 1 ether;
    uint256 public totalDeposited;
    address public winner;

    function deposit() external payable {
        require(winner == address(0), "Game over");
        totalDeposited += msg.value;
        if (totalDeposited >= TARGET) {
            winner = msg.sender;
        }
    }

    function claim() external {
        require(msg.sender == winner);
        payable(winner).transfer(address(this).balance);
    }
}

Use >= rather than ==, and track deposits in a storage variable rather than reading the live balance.


3. Proxy Implementation Selfdestructed

In proxy patterns, user-facing calls are delegated to a logic contract. If the logic contract contains a selfdestruct call reachable by its owner (or any caller), destroying it renders the proxy permanently broken.

// VULNERABLE: logic contract with selfdestruct
contract LogicV1 {
    address public owner;

    function initialize(address _owner) external {
        owner = _owner;
    }

    // Intended for "emergency" cleanup
    function shutdown() external {
        require(msg.sender == owner);
        selfdestruct(payable(owner));
    }
}

Attack: The proxy delegates all calls to LogicV1. The owner calls shutdown() through the proxy. Because the call is a delegatecall, address(this) inside LogicV1 is the logic contract's address — selfdestruct destroys LogicV1. The proxy now delegates to an empty address. Every subsequent user call succeeds silently (calls to empty addresses return success), but no logic executes. Funds held in proxy storage are trapped with no withdrawal path.

// FIXED: no selfdestruct in logic contracts
contract LogicV2 {
    address public owner;
    bool public paused;

    function initialize(address _owner) external {
        owner = _owner;
    }

    function pause() external {
        require(msg.sender == owner);
        paused = true;
    }

    // Upgrades handled by proxy admin, not by logic contract
}

Logic contracts must never contain selfdestruct. Upgrade paths belong in the proxy's admin layer, controlled by a timelock or multisig with an auditable upgrade path.


4. EIP-6780 (Cancun) Selfdestruct Semantic Change

The Cancun hard fork (March 2024) shipped EIP-6780, which fundamentally changed when selfdestruct actually destroys contract bytecode.

Pre-Cancun behavior: selfdestruct always destroys the contract's bytecode and storage, then sends its ETH balance to the beneficiary.

Post-Cancun (EIP-6780) behavior:
- If selfdestruct is called in the same transaction that deployed the contract, it destroys the bytecode and storage as before.
- If selfdestruct is called in any other transaction, it only sends ETH to the beneficiary. The bytecode and storage remain intact.

// Behavior changed post-Cancun
contract Disposable {
    constructor(address payable target) payable {
        // This still destroys bytecode post-Cancun
        // because it's in the same tx as deployment
        selfdestruct(target);
    }
}

contract LongLived {
    address public owner;

    function cleanup() external {
        require(msg.sender == owner);
        // Post-Cancun: ETH sent, but bytecode NOT destroyed
        // Pre-Cancun: contract fully removed
        selfdestruct(payable(owner));
    }
}

Implications for protocols: Any contract that relied on selfdestruct to remove a deployed contract from the chain (to free storage, to prevent future calls, or to "clean up" after a migration) can no longer do so unless the destruction happens within the deployment transaction. Deployed contracts calling selfdestruct in a later transaction will persist on-chain indefinitely.

Implications for attack contracts: Flash-loan attack contracts that deploy, exploit, and selfdestruct in one transaction still work. But a multi-step attack requiring a previously-deployed contract to selfdestruct after the fact will no longer clean up the bytecode — the contract will remain visible and auditable on-chain.

Audit any protocol that uses selfdestruct for cleanup semantics and verify whether it still functions correctly under EIP-6780 rules.


5. CREATE2 Redeploy After Selfdestruct (Metamorphic Contracts)

CREATE2 deploys a contract to a deterministic address based on deployer, salt, and initcode hash. Pre-Cancun, an attacker could exploit this to swap bytecode at a trusted address.

// Metamorphic contract pattern (pre-Cancun exploit)
contract Factory {
    function deploy(bytes32 salt, bytes memory code) external returns (address) {
        address deployed;
        assembly {
            deployed := create2(0, add(code, 0x20), mload(code), salt)
        }
        return deployed;
    }
}

// Step 1: Deploy SafeContract at address X via CREATE2
// Step 2: Owner calls selfdestruct on SafeContract (pre-Cancun: bytecode removed)
// Step 3: Deploy MaliciousContract with same salt at same address X
// Users who audited X now interact with a different contract

Attack: A protocol deploys a contract at address X using CREATE2. Users verify the bytecode and trust it. The deployer then selfdestructs the contract (pre-Cancun: bytecode is removed) and redeployed a different contract at the identical address using the same salt with different initcode. Users still see address X in their approvals but the code has changed.

Post-Cancun: EIP-6780 breaks this attack when the selfdestruct is in a separate transaction from deployment. The bytecode is not removed, so CREATE2 would revert when trying to deploy to a non-empty address.

Detection via extcodehash: Protocols can defend by snapshotting extcodehash at the time of a trust decision and verifying it on each subsequent interaction.

// Metamorphic contract detection
contract TrustRegistry {
    mapping(address => bytes32) public trustedCodehash;

    function register(address target) external {
        bytes32 h;
        assembly { h := extcodehash(target) }
        require(h != bytes32(0) && h != keccak256(""), "Not a contract");
        trustedCodehash[target] = h;
    }

    function verify(address target) external view returns (bool) {
        bytes32 h;
        assembly { h := extcodehash(target) }
        return h == trustedCodehash[target];
    }
}

6. Precompile ETH Loss via Selfdestruct

Ethereum precompile addresses (0x1 through 0x9) are not regular contracts. They execute special logic built into the EVM client. Sending ETH to them via normal transfer calls will revert if the address has no receive() function. However, selfdestruct bypasses this entirely.

// VULNERABLE: ETH permanently lost when sent to precompile via selfdestruct
contract Sweeper {
    // Attacker tricks protocol into selfdestructing to a precompile address
    function emergencyDrain(address payable target) external {
        require(msg.sender == owner);
        selfdestruct(target); // If target == 0x1, ETH is lost forever
    }
}

Attack: A selfdestruct call targeting any address from 0x0000...0001 to 0x0000...0009 will not revert. The ETH is credited to the precompile address. Precompiles do not have storage or the ability to forward ETH — the funds are permanently inaccessible. An attacker who can influence the target parameter (via a governance proposal, parameter injection, or access control bypass) can permanently destroy the contract's entire ETH balance.

// FIXED: validate beneficiary is not a precompile
contract Sweeper {
    address public owner;

    function emergencyDrain(address payable target) external {
        require(msg.sender == owner, "Not owner");
        require(uint160(target) > 9, "Precompile address");
        require(target != address(0), "Zero address");
        selfdestruct(target);
    }
}

Any function that accepts an arbitrary address as a selfdestruct beneficiary must validate that the address is not in the precompile range (1–9) and is not the zero address.


What ContractScan Detects

ContractScan performs static and semantic analysis across all six selfdestruct vulnerability classes described in this post.

Vulnerability Detection Method Severity
Balance-based invariant (address(this).balance == 0) Dataflow: balance used as equality guard in control flow High
Game prize pool with balance == TARGET Pattern: address(this).balance == in conditional branches High
Proxy logic contract with selfdestruct Structural: selfdestruct present in contracts identified as logic/implementation targets Critical
EIP-6780 cleanup assumption post-Cancun Semantic: selfdestruct called outside constructor context flagged for post-Cancun review Medium
CREATE2 + selfdestruct metamorphic pattern Combined: CREATE2 factory + selfdestruct reachability in same deployer High
Selfdestruct to arbitrary beneficiary address Taint: beneficiary parameter not validated against precompile range Medium

Scan your contracts at contractscan.io.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →