Selfdestruct Remediation
How to eliminate selfdestruct vulnerabilities by replacing balance-dependent invariants with internal accounting, protecting library contracts from direct initialisation, and avoiding the SELFDESTRUCT opcode entirely.
Selfdestruct Remediation
Overview
The SELFDESTRUCT opcode has three distinct vulnerability surfaces. The first is direct: a selfdestruct call reachable without adequate access control lets anyone destroy the contract and steal its ETH balance. The second is the Parity-style library destruction pattern: a shared library contract callable through DELEGATECALL can be initialised directly by an attacker who becomes its owner and then calls kill(), breaking every proxy that depends on that library permanently. The Parity Wallet hack (November 2017) froze 513,774 ETH (~$150M) using exactly this pattern. The third is force-feeding: any contract that uses address(this).balance as a protocol invariant can be permanently broken by an attacker who force-sends ETH via selfdestruct — bypassing receive() and fallback() entirely.
As of EIP-6049, SELFDESTRUCT is deprecated. Future hard forks may alter or remove its behaviour. New contracts should not use it.
Related Detector: Access Control Detector
Recommended Fix
Before (Vulnerable)
// VULNERABLE 1: Unprotected selfdestruct
contract VulnerableKillable {
address public owner;
// Missing onlyOwner — any caller can destroy the contract
function kill(address payable recipient) external {
selfdestruct(recipient);
}
}
// VULNERABLE 2: Library with initialisable owner — Parity pattern
contract WalletLibrary {
address public owner;
bool public initialized;
// Guard only protects the proxy's storage, not the library's own storage
function initWallet(address _owner) external {
require(!initialized, "Already initialized");
owner = _owner;
initialized = true;
}
function kill(address payable recipient) external {
require(msg.sender == owner, "Not owner");
selfdestruct(recipient); // Destroys the shared library
}
}
// VULNERABLE 3: Balance invariant breakable by force-feeding
contract VulnerableVault {
uint256 public totalDeposits;
function deposit() external payable {
totalDeposits += msg.value;
}
function withdraw(uint256 amount) external {
// An attacker can force-send ETH via selfdestruct,
// making address(this).balance > totalDeposits permanently.
// This require will then always fail, locking all withdrawals.
require(address(this).balance == totalDeposits, "Invariant broken");
// ...
}
}
After (Fixed)
// FIX 1: Remove selfdestruct — use emergency withdrawal + pause instead
contract SafeWithdrawable {
address public immutable owner;
bool public locked;
modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; }
modifier whenUnlocked() { require(!locked, "Locked"); _; }
// No selfdestruct — contract code persists, only operations are halted
function emergencyLock() external onlyOwner {
locked = true;
emit EmergencyLocked(block.timestamp);
}
function emergencyWithdraw() external onlyOwner {
require(locked, "Must lock first");
uint256 balance = address(this).balance;
payable(owner).transfer(balance);
emit EmergencyWithdraw(owner, balance);
}
}
// FIX 2: Library without initialise — stateless, no owner, no kill
library SafeWalletLibrary {
// Libraries that are stateless and have no ownership cannot be hijacked.
// All state lives in the calling proxy's storage.
function executeTransaction(
address to,
uint256 value,
bytes calldata data
) external returns (bool) {
(bool success,) = to.call{value: value}(data);
return success;
}
// No initWallet(), no owner, no kill() — nothing to attack.
}
// FIX 3: Internal accounting instead of raw balance
contract SafeVault {
// Track what came through deposit() — immune to force-feed attacks
uint256 public accounted;
function deposit() external payable {
accounted += msg.value;
}
function withdraw(uint256 amount) external {
require(amount <= accounted, "Insufficient accounted balance");
accounted -= amount;
payable(msg.sender).transfer(amount);
}
// Any ETH force-sent via selfdestruct is ignored in the accounting.
// It accumulates as "dust" in the raw balance but never affects protocol logic.
}
Alternative Mitigations
Immutable initialisation guard using a storage slot sentinel — for upgradeable proxies where a library or implementation must track its own initialisation, use a storage slot that is guaranteed non-zero after initialisation rather than a boolean:
contract SafeInitialisable {
// keccak256("eip1967.proxy.initialized") - 1
bytes32 private constant INITIALIZED_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
modifier initializer() {
bytes32 slot = INITIALIZED_SLOT;
uint256 v;
assembly { v := sload(slot) }
require(v == 0, "Already initialized");
assembly { sstore(slot, 1) }
_;
}
// No owner, no kill — only the initializer modifier
function initialize(address admin) external initializer {
_admin = admin;
}
}
OpenZeppelin Initializable for upgradeable contracts — provides a hardened initialisation guard that handles the proxy/library storage separation correctly:
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract SafeUpgradeableContract is Initializable {
address public admin;
// 'initializer' modifier from OpenZeppelin prevents re-initialisation
// in both the proxy context and the implementation context.
function initialize(address _admin) external initializer {
admin = _admin;
}
// No selfdestruct, no kill function.
}
Accepting force-fed ETH gracefully — if a contract’s logic can tolerate a slightly higher raw balance than expected, designing the system around this reality is simpler than fighting it:
contract ResilientVault {
uint256 public totalDeposited;
function deposit() external payable {
totalDeposited += msg.value;
}
function withdraw(uint256 amount) external {
require(amount <= totalDeposited, "Exceeds deposited amount");
totalDeposited -= amount;
// Use tracked accounting — never compare against raw address(this).balance
payable(msg.sender).transfer(amount);
}
// Any force-fed ETH is "owned" by no one — it can be swept by governance
// but has no impact on user accounting.
function sweepDust() external onlyGovernance {
uint256 dust = address(this).balance - totalDeposited;
payable(treasury).transfer(dust);
}
}
Common Mistakes
Depending on address(this).balance for any protocol invariant — SELFDESTRUCT can deliver ETH to any address with no fallback call. Every protocol that computes collateral, solvency, or peg stability using raw balance rather than internal accounting is vulnerable to balance manipulation.
Shared library contracts with ownership state — a WalletLibrary that stores owner in its own storage can always be directly initialised by an attacker. Libraries should be stateless: they contain only pure or view logic, with all mutable state stored exclusively in the calling proxy.
Assuming selfdestruct will zero out storage — SELFDESTRUCT removes the code and sends ETH, but storage slots are not cleared. Any subsequent contract deployed at the same address (possible via CREATE2) inherits the old storage, which may produce unexpected behaviour.
Not auditing DELEGATECALL target changeability — if a proxy allows its implementation address to be changed without adequate governance controls, an attacker who gains admin access can point it at a malicious contract containing selfdestruct.