Skip to main content
Sigvex

Locked Ether Remediation

Add an access-controlled withdrawal path to contracts that accept ETH, or remove payability the contract does not need.

Locked Ether Remediation

Overview

A contract that can receive ETH but never send it traps funds permanently — the EVM has no recovery mechanism. The remediation is one of two symmetrical moves: give the contract an exit path (an access-controlled withdrawal), or take away the intake (remove payable and receive() so direct sends revert). Which one is right depends on whether the contract has any business holding ETH.

Related Detector: Locked Ether

Before (Vulnerable)

contract FeeSink {
    // Accepts ETH from anywhere...
    receive() external payable {}
    // ...and has no function that can send it out.
}

After (Fixed)

contract FeeSink {
    address public immutable owner = msg.sender;

    receive() external payable {}

    function withdraw(address payable to, uint256 amount) external {
        require(msg.sender == owner, "not owner");
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "send failed");
    }
}

The withdrawal must be guarded — an unguarded exit swaps a locked-funds bug for a theft bug. Use call{value: ...} with a success check rather than transfer, which forwards a fixed 2300 gas and fails against recipients with nontrivial receive logic.

Alternative Mitigations

  • Remove payability instead: if the contract should never hold ETH, delete receive(), drop payable from the constructor and all functions, and let the default fallback revert direct sends. Note ETH can still be force-pushed via selfdestruct or block rewards — that residue is unrecoverable but harmless as long as no logic depends on address(this).balance.
  • Sweep-to-treasury pattern: instead of per-owner withdrawal, a permissionless sweep() that always sends the full balance to a fixed, immutable treasury address gives an exit path with no access-control surface.
  • Upgradeable contracts: if the locked contract is behind a proxy, add the withdrawal function in the next implementation upgrade; the trapped balance becomes reachable as soon as the upgrade lands.

Common Mistakes

  • Adding an unprotected withdrawal. function withdraw() external { msg.sender.call{value: address(this).balance}(""); } fixes the lock by letting anyone drain the contract. Every exit path needs access control.
  • Relying on selfdestruct as the rescue plan. Since EIP-6780 (Cancun), selfdestruct only transfers the balance when called in the same transaction as contract creation — it is no longer a viable recovery mechanism for deployed contracts.
  • Using transfer() for the exit. The 2300-gas stipend breaks withdrawals to multisigs and smart-contract wallets; use call with a checked return.
  • Marking the constructor payable “to save gas” with no withdrawal behind it. Any ETH sent at deployment is locked from block one.

References