The Hidden Dangers of Using Selfdestruct in Upgradable Smart Contracts

While the selfdestruct()
function in Solidity, the primary programming language for Ethereum smart contracts offers a convenient way to remove a contract from the blockchain and send its remaining ether to any specified address, its use is generally discouraged in upgradable smart contract systems.
Upgradable smart contracts allow developers to modify a contract’s functionality over time, enabling them to fix bugs, add new features, or adapt to changing requirements. The selfdestruct
function, which permanently removes a contract from the blockchain, is incompatible with this approach, as it would prevent future updates to the contract.
Upgradable smart contracts typically rely on proxy patterns. In these patterns, a proxy contract forwards all function calls to an underlying implementation contract. This separation of concerns allows the implementation contract to be updated without disrupting the existing contract interface.
One might assume that upgrading a contract is to use selfdestruct
on the old implementation and deploy a new one. While this seems logical at first glance, it introduces critical security risks and operational issues that can break the upgrade mechanism entirely.
The Intuitive Yet Flawed Idea
Imagine a scenario where a DeFi protocol has launched a lending contract. Over time, developers discover a bug in the implementation contract, and they need to upgrade it. Someone suggests:
“Why not just use selfdestruct
on the old implementation contract and deploy a new one? This would remove the old contract’s code from the blockchain and let us point the proxy to the new version."
On the surface, this idea makes sense. Destroy the outdated contract, eliminate any potential exploits it might contain, and replace it with a fresh one. However, this approach has several fundamental flaws that can introduce new vulnerabilities, disrupt users, and even render the contract permanently unusable.
Real-World Implementation Example
Let’s examine both vulnerable and secure implementations to understand the practical implications:
Vulnerable Pattern
First, let’s look at how developers might intuitively (but dangerously) implement upgradeable contracts:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
// Bad Example - Vulnerable Implementation
contract VulnerableImplementation {
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// DANGEROUS: Allows contract destruction
function upgradeContract() external {
require(msg.sender == owner, "Not owner");
selfdestruct(payable(owner)); // This is the dangerous part!
}
}
// Bad Example - Vulnerable Proxy
contract VulnerableProxy {
address public implementation;
address public admin;
constructor(address _implementation) {
implementation = _implementation;
admin = msg.sender;
}
// Delegates all calls to the implementation contract
fallback() external payable {
address _impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Problems with this Implementation:
- The
selfdestruct
function inupgradeContract()
will permanently destroy the contract - No proper separation between proxy and implementation storage
- Lack of proper initialization patterns
- High risk of storage collisions
Why Selfdestruct is Not the Right Solution
1. Proxy Contracts Depend on the Implementation Code
Upgradable smart contracts follow the proxy pattern, where the proxy stores user data and delegates function calls to the implementation contract. If the implementation contract is destroyed:
- The proxy contract is still live but now points to an address that contains no bytecode.
- Any function call routed through the proxy results in a failed transaction.
- The contract becomes bricked, rendering user funds and interactions inaccessible.
2. Delegatecall Still Points to an Empty Address
Even after deploying a new implementation contract, the proxy still points to the old address (which now contains nothing). Since the EVM does not allow redeploying contracts to the same address, the only way to fix this is to manually update the proxy’s implementation address — which may not always be possible if the upgrade mechanism is poorly designed or requires multi-signature approvals.
3. Loss of Historical Execution Context
Destroying an implementation contract erases all on-chain execution history associated with it. Auditors, security analysts, and users lose valuable debugging information.
4. Potential Attack Surface for Malicious Actors
A sophisticated attacker could use selfdestruct
as an exploit vector. If an upgradeable contract allows arbitrary execution of selfdestruct
, an attacker could:
- Forcefully delete the implementation contract.
- Make the proxy unusable, permanently breaking the dApp.
- Deploy a malicious contract at the same address (if they control contract creation conditions).
5. Safer Alternatives Exist
Instead of using selfdestruct
, the best practice is to rely on established upgrade patterns like:
- Prevent
selfdestruct
in Implementation Contracts: Use Solidity>=0.8.18
, whereselfdestruct
is deprecated. - Transparent Proxy Pattern: Controlled upgrades where only authorized addresses can change the implementation.
- UUPS (Universal Upgradeable Proxy Standard): Contracts can be upgraded without needing external contracts, and self-destruct is explicitly disabled.
- Beacon Proxy Pattern: Multiple proxies share a single upgradeable implementation.
Implementing Safe Upgrade Patterns
Let’s look at how to properly implement upgradeable contracts using the UUPS pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
// Safe Example - Using UUPS Pattern
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract SafeImplementation is UUPSUpgradeable, OwnableUpgradeable {
mapping(address => uint256) public balances;
// Initialize function replaces constructor
function initialize() public initializer {
__Ownable_init(); // Initialize the owner
__UUPSUpgradeable_init();
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// Required by UUPS
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
// Safe Example - New Implementation Version
contract SafeImplementationV2 is SafeImplementation {
// New feature: interest rate for deposits
uint256 public interestRate;
// No need for initialize() as we inherit it
function setInterestRate(uint256 _rate) external onlyOwner {
interestRate = _rate;
}
// Enhanced withdraw with interest calculation
function withdraw(uint256 amount) external override {
require(balances[msg.sender] >= amount, "Insufficient balance");
uint256 interest = (balances[msg.sender] * interestRate) / 100;
uint256 totalAmount = amount + interest;
balances[msg.sender] -= amount;
payable(msg.sender).transfer(totalAmount);
}
}
// Safe Example - ERC1967 Proxy
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract SafeProxy is ERC1967Proxy {
constructor(
address _implementation,
bytes memory _data
) ERC1967Proxy(_implementation, _data) {}
}
Conclusion
While selfdestruct
might seem like a clean way to eliminate old code and facilitate upgrades, it introduces more problems than it solves. Modern upgradable contract patterns provide safer, more controlled ways to manage upgrades without risking the integrity of the system. The next time you think about upgrading a contract, remember that: deleting is easy, but recovering from it is not.