The selfdestruct(address) opcode destroys a contract and sends all of its ETH to the specified address. This Ether arrives without triggering a receive() or fallback() function — so contracts that track their ETH balance via address(this).balance can have their accounting broken by a forced ETH injection.
Attackers deploy a contract with some ETH, then self-destruct it targeting your contract as the recipient. Your contract suddenly holds more ETH than expected — and if your logic assumes address(this).balance reflects only intentional deposits, all invariants break.
// VULNERABLE — logic based on exact balance
contract Vault {
uint public expectedBalance;
function deposit() public payable {
expectedBalance += msg.value;
}
// This can be broken by a selfdestruct attack
function isBalanced() public view returns (bool) {
return address(this).balance == expectedBalance;
}
function withdraw() public {
require(isBalanced(), "Balance mismatch");
// Attack: selfdestruct sends extra ETH, isBalanced() always returns false
// withdraw() is permanently bricked
}
}
// SAFE — track balances internally, don't rely on address(this).balance
mapping(address => uint) internal _deposits;
uint internal _totalDeposits;
function deposit() public payable {
_deposits[msg.sender] += msg.value;
_totalDeposits += msg.value;
}
// Use _totalDeposits for logic, not address(this).balance
selfdestruct call can be destroyed by any caller.selfdestruct, freezing $280 million.address(this).balance in contract logic — track deposits and withdrawals in internal state variables.selfdestruct calls — consider removing them entirely (they're being deprecated in EIP-6049).selfdestruct, guard it behind onlyOwner with multi-sig or timelocks.