Understanding why Selfdestruct should not be used in Upgradeable Smart Contracts

💡
There has been a change in how selfdestruct works in EIP-4758. read here

Understanding Selfdestruct in Standard Contracts

In a standard Ethereum smart contract, selfdestruct is a built-in function that allows a contract to be destroyed. When called, it performs two main actions:

  1. Removes all bytecode from the contract's address

  2. Sends any remaining Ether to a specified address

Let's look at a simple example of a normal contract with selfdestruct functionality:

// Standard contract with selfdestruct
contract StandardContract {
    address payable public owner;

    // Set the owner to the address that deploys the contract
    constructor() {
        owner = payable(msg.sender);
    }

    // Ensure only the owner can call certain functions
    modifier onlyOwner() {
        require(msg.sender == owner, "Not authorized");
        _;
    }

    // Function to destroy the contract
    function destroyContract() external onlyOwner {
        selfdestruct(owner);
    }

    // Example function to receive funds
    receive() external payable {}
}

In this standard contract, the behavior is straightforward and predictable:

  1. The owner deploys the contract

  2. When the owner calls destroyContract()

  3. The contract is destroyed and any remaining Ether is sent to the owner

  4. Future transactions to this address will fail

This is the expected and safe behavior in a standard contract. Now, let's see how this changes dramatically in an upgradeable contract.

Understanding Upgradeable Contracts

Upgradeable contracts work differently from standard contracts. They use a proxy pattern where:

  • A proxy contract stores the state and receives user transactions

  • A logic contract (implementation) contains the actual code

  • The proxy delegates all calls to the logic contract

Here's how an upgradeable contract using the UUPS pattern might look:

// UUPS Upgradeable contract with selfdestruct
contract UpgradeableContract is UUPSUpgradeable {
    address payable public owner;
    bool private initialized;

    // Note: We use initialize instead of constructor
    function initialize() external {
        require(!initialized, "Already initialized");
        initialized = true;
        owner = payable(msg.sender);
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not authorized");
        _;
    }

    // WARNING: This function creates a serious vulnerability
    function destroyContract() external onlyOwner {
        selfdestruct(owner);
    }

    // Required for UUPS
    function _authorizeUpgrade(address) internal override onlyOwner {}

    receive() external payable {}
}

Why This Becomes Dangerous

Let's walk through what happens when selfdestruct is used in this upgradeable context:

  1. Normal Operation:
User → Proxy Contract → Logic Contract (Implementation)
  1. The Attack Scenario:
// Attack steps
1. Attacker finds logic contract address
2. Attacker calls initialize() directly on logic contract
3. Attacker becomes owner of logic contract (not proxy!)
4. Attacker calls destroyContract()
5. Logic contract is destroyed
  1. The Devastating Result:
User → Proxy Contract → ❌ (Empty Address)

After the logic contract is destroyed:

  • The proxy continues to point to the now-empty address

  • All future transactions through the proxy will succeed but do nothing

  • The contract can never be upgraded again because the upgrade logic was in the destroyed contract

Here's a concrete example of what happens after destruction:

// Before destruction
proxy.transfer(1 ether);        // Works normally
proxy.someFunction();           // Works normally

// After destruction
proxy.transfer(1 ether);        // "Succeeds" but Ether is lost forever
proxy.someFunction();           // "Succeeds" but does nothing
proxy.upgradeTo(newAddress);    // Fails permanently

Why This Is Worse Than It Seems

The situation is particularly dangerous because:

  1. Silent Failure: Transactions don't revert; they appear to succeed while doing nothing:
// This transaction appears to succeed
proxy.deposit(1000);  // Returns true, but money is lost forever
  1. Permanent Effect: Once the logic contract is destroyed, there's no way to recover:
// No recovery possible
proxy.upgrade(newImplementation);  // Fails because upgrade logic is gone
  1. State Lock: All state variables in the proxy become permanently inaccessible:
// All of these are now inaccessible forever
proxy.getUserBalance();    // Returns 0
proxy.getOwner();         // Returns 0x0
proxy.getTotalSupply();   // Returns 0

“For example, if the contract is a crowdsale and it is selfdestructed once the funding goal is reached, all following attemps to participate in the crowdsale will trap the ether in this black hole. The address would have been publicized in the internet, and it’s unlikely that all of those locations would be updated with a huge “DO NOT USE” sign. It’s better to encode this logic in the contract itself and keep the contract alive so that transactions are rejected from then on.” copied

“During the bug bounty program of Harvest Finance, uninitialized implementation contracts for Uniswap V3 vault proxies were discovered. This critical bug could potentially cause the implementation contract’s self-destruction, rendering the proxy contracts useless. For more details, click here

Safe Alternatives to Selfdestruct

Instead of using selfdestruct, consider these safer alternatives:

contract SafeUpgradeableContract is UUPSUpgradeable {
    bool public paused;
    address payable public owner;

    // Pause the contract instead of destroying it
    function pause() external onlyOwner {
        paused = true;
    }

    // All functions should check pause status
    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    // Example function with pause protection
    function someFunction() external whenNotPaused {
        // Function logic here
    }

    // Optional: Allow withdrawal of funds without destruction
    function withdrawFunds() external onlyOwner {
        uint256 balance = address(this).balance;
        (bool success, ) = owner.call{value: balance}("");
        require(success, "Transfer failed");
    }
}

Conclusion

The use of selfdestruct in upgradeable contracts creates a critical vulnerability that can permanently disable your entire contract system. Instead of using selfdestruct, implement pause mechanisms, proper access controls, and well-designed withdrawal patterns. Remember that in blockchain, permanent often means permanent – there's no "undo" button once a logic contract is destroyed.

References

https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable#potentially-unsafe-operations https://forum.openzeppelin.com/t/selfdestruct-operation-on-upgradeable-contracts/33264/2 https://x.com/sherlockdefi/status/1890086617265893822

https://x.com/JeanCavallera/status/1891274888633356686
https://hackmd.io/@vbuterin/selfdestruct
https://www.reddit.com/r/ethdev/comments/14mnc6k/after_selfdestruct_will_have_been_removed_how/

https://www.talentica.com/blogs/implementing-upgradeable-smart-contracts-using-proxy-patterns/
https://medium.com/immunefi/harvest-finance-uninitialized-proxies-bug-fix-postmortem-ea5c0f7af96b

1
Subscribe to my newsletter

Read articles from Adewale Iyanuoluwa Isaac directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Adewale Iyanuoluwa Isaac
Adewale Iyanuoluwa Isaac