High severity

Unprotected upgradeable logic contract

Behind an upgradeable proxy sits a second, separate contract: the logic (or “implementation”) contract that holds the code. The proxy delegatecalls into it, so in normal use only the proxy ever holds state and runs initialize. But the logic contract is a live, independently addressable contract too, and nothing stops a stranger from calling initialize on it directly. If that initialize makes the caller the owner, and the logic contract also has a path that can selfdestruct it, then anyone can seize the logic contract and destroy it — which pulls the code out from under every proxy pointing at it, bricking them all and freezing whatever they hold. This is the shape behind the 2017 Parity multisig freeze, where an unprotected library was initialized by a stranger and then self-destructed, locking roughly 513,000 ETH across every wallet that depended on it.

Vulnerable pattern

A minimal logic contract with the bug
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Initializable} from
    "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

// Deployed once as the logic behind a proxy. The proxy delegatecalls into it,
// but this contract is also live at its own address with nothing initialised.
contract WalletLogic is Initializable {
    address payable public owner;

    // Meant to run once, via the proxy. Nothing stops a stranger from calling
    // it on the logic contract directly and becoming its owner.
    function initialize() external initializer {
        owner = payable(msg.sender);
    }

    // An "admin only" kill switch -- but on the logic contract the admin is
    // whoever called initialize() first, i.e. anyone.
    function kill() external {
        require(msg.sender == owner, "not owner");
        selfdestruct(owner);
    }
}

The fix

Lock the logic contract at construction so its initializers can never run on it. OpenZeppelin’s idiom is a constructor that calls _disableInitializers(); the constructor runs on the logic contract when it is deployed (a constructor never runs through a proxy’s delegatecall), so initialize is permanently closed there while the proxy can still initialise its own storage. Better still, drop the selfdestruct path entirely unless you truly need it: with no destroy primitive there is nothing to brick even if an initializer were left open. Add the constructor and remove the kill switch, or gate and timelock any genuine upgrade mechanism behind the proxy admin.

The same contract, corrected
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Initializable} from
    "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract WalletLogic is Initializable {
    address payable public owner;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();   // initialize() can never run on the logic contract
    }

    function initialize() external initializer {
        owner = payable(msg.sender);
    }

    // No selfdestruct: withdraw to the owner instead, so there is no brick primitive.
    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "not owner");
        (bool ok, ) = owner.call{value: amount}("");
        require(ok, "transfer failed");
    }

    receive() external payable {}
}

A note on EIP-6780

Since the Cancun hard fork (EIP-6780, live on Ethereum mainnet from March 2024), selfdestruct only erases a contract’s code when it runs in the same transaction that created the contract. Self-destructing an already-deployed logic contract in a later transaction no longer removes its code, so on chains that have adopted EIP-6780 the classic “brick every proxy” outcome is blunted — the attacker can still seize the logic contract and take its balance, but the proxies keep working. On chains that have not adopted it, and in any create-and-destroy flow, the full freeze still applies. The detector rates the pattern High on all of them, because leaving an upgradeable logic contract open is a latent flaw whose blast radius depends on the chain and the surrounding code.

Scan your contract for this

pactlint flags unprotected-upgrade and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon or Solana with x402, no signup; the first few scans each day are free.

How to scan →

An unprotected upgradeable logic contract is detected by Slither’s stock unprotected-upgrade detector, rated high impact at high confidence, which pactlint surfaces at high severity when a logic contract is upgradeable, leaves its initializers unprotected, and has a function that can destroy it. pactlint does run worker-side triage on this class: it drops a finding to low only in the narrow benign case where the implementation has no selfdestruct anywhere and every reported destroy path’s only delegatecall targets the contract itself (a multicall), which cannot actually destroy it — a genuine unprotected contract with a reachable self-destruct stays High. Run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.