← Back to Blog

Solidity Mapping Deletion Vulnerabilities and Ghost State Bugs

2026-04-18 solidity mappings ghost-state storage smart-contract-security delete vulnerabilities

Solidity's delete keyword resets a variable to its zero-value default — but for mappings, the zero value is effectively nothing. A mapping's keys are not tracked by the EVM; the language has no knowledge of which slots have been written. When you call delete on a mapping or a struct containing a mapping, only the top-level storage slot is cleared. Every nested key-value pair in any inner mapping remains intact in contract storage, permanently readable and writable by anyone who knows the key. Security researchers call this lingering data ghost state.

Ghost state is not merely a code smell. Real exploits have used it to claim rewards twice, re-enter deleted accounts, and read data across proxy upgrades from a previous implementation. The six patterns below cover the most dangerous manifestations, each with a vulnerable example, an explanation of the root cause, a secure alternative, and detection guidance.


1. delete on a Struct Containing a Nested Mapping

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VaultManager {
    struct Vault {
        uint256 totalDeposited;
        bool active;
        mapping(address => uint256) balances; // nested mapping
    }

    mapping(uint256 => Vault) public vaults;

    function closeVault(uint256 vaultId) external {
        delete vaults[vaultId]; // totalDeposited and active are zeroed
        // balances mapping is NOT cleared
    }

    function getBalance(uint256 vaultId, address user) external view returns (uint256) {
        return vaults[vaultId].balances[user]; // still returns old data
    }
}

Explanation

delete vaults[vaultId] resets totalDeposited to 0 and active to false, but it cannot enumerate and zero the nested balances mapping because the EVM stores each mapping entry at a different storage slot derived from keccak256(key, slot). After closeVault, any call to getBalance still returns the original deposits. If the contract later creates a new vault at the same vaultId, it inherits all ghost balances from the previous owner.

Fixed Code

contract VaultManager {
    struct Vault {
        uint256 totalDeposited;
        bool active;
        address[] depositors; // track all keys explicitly
        mapping(address => uint256) balances;
    }

    mapping(uint256 => Vault) public vaults;

    function closeVault(uint256 vaultId) external {
        Vault storage v = vaults[vaultId];
        // zero every tracked key before deleting the struct
        for (uint256 i = 0; i < v.depositors.length; i++) {
            delete v.balances[v.depositors[i]];
        }
        delete v.depositors;
        delete v.totalDeposited;
        v.active = false;
    }
}

Detection Tips

Search for delete applied to storage variables whose type is a struct. Use a static analyzer or manual review to check whether that struct contains any mapping field. Any positive match is a potential ghost-state source. Tools like Slither can flag struct deletions; grep for delete combined with struct type names in your codebase as a quick triage pass.


2. Mapping Key Enumeration Is Impossible — Orphaned Entries After Deletion

Vulnerable Code

contract MemberRegistry {
    mapping(address => bool) public members;
    uint256 public memberCount;

    function addMember(address user) external {
        require(!members[user], "already member");
        members[user] = true;
        memberCount++;
    }

    function removeMember(address user) external {
        require(members[user], "not member");
        delete members[user];
        memberCount--;
    }

    function isMember(address user) external view returns (bool) {
        return members[user];
    }
}

Explanation

memberCount stays consistent with the boolean flag, but if any secondary mapping tied to membership (rewards accrued, vote weight, etc.) is stored under the same key, removing the user from members leaves all secondary data orphaned. There is no built-in way to iterate all keys of a mapping and purge related entries. Contracts that assume a delete on one mapping produces a clean slate for the entire identity of that address are wrong.

Fixed Code

contract MemberRegistry {
    mapping(address => bool) public members;
    mapping(address => uint256) public rewardDebt;
    address[] private _memberList; // enumerable set pattern
    mapping(address => uint256) private _memberIndex;

    function addMember(address user) external {
        require(!members[user], "already member");
        members[user] = true;
        _memberIndex[user] = _memberList.length;
        _memberList.push(user);
    }

    function removeMember(address user) external {
        require(members[user], "not member");
        // zero all related state first
        delete rewardDebt[user];
        delete members[user];
        // swap-and-pop to maintain compact list
        uint256 idx = _memberIndex[user];
        address last = _memberList[_memberList.length - 1];
        _memberList[idx] = last;
        _memberIndex[last] = idx;
        _memberList.pop();
        delete _memberIndex[user];
    }
}

Detection Tips

Whenever a mapping is deleted, audit every other mapping in the contract that is keyed by the same type. If the same address (or uint256 id) appears as a key in multiple mappings and only one is cleared on removal, ghost state exists. OpenZeppelin's EnumerableSet library solves the enumeration problem and should be the default for any allowlist or registry.


3. Double-Delete Exploit — Re-Using State That Should Be Gone

Vulnerable Code

contract RewardClaimer {
    mapping(address => uint256) public pendingRewards;
    mapping(address => bool) public hasClaimed;

    function claimReward() external {
        require(!hasClaimed[msg.sender], "already claimed");
        uint256 amount = pendingRewards[msg.sender];
        require(amount > 0, "nothing to claim");
        hasClaimed[msg.sender] = true;
        delete pendingRewards[msg.sender];
        payable(msg.sender).transfer(amount);
    }

    function resetClaim(address user) external onlyAdmin {
        delete hasClaimed[user]; // intended: allow re-claim after admin reset
        // pendingRewards[user] was deleted earlier and NOT restored
        // but storage slot is now zero, matching a "fresh" user
    }
}

Explanation

delete hasClaimed[user] sets the boolean back to false. If pendingRewards was also cleared during the first claim, the mapping slot for that user is already zero — indistinguishable from a new user who has never claimed. An attacker who can trigger resetClaim (or who finds an edge case that achieves the same effect) can call claimReward again with pendingRewards[user] == 0, passing the amount > 0 check only if rewards were re-added by a separate deposit path. More dangerously, in contracts where pendingRewards is never zeroed on claim, a double-delete / double-reset chain enables double claiming.

Fixed Code

contract RewardClaimer {
    mapping(address => uint256) public pendingRewards;
    mapping(address => uint256) public claimEpoch; // epoch-based guard

    uint256 public currentEpoch;

    function claimReward() external {
        require(claimEpoch[msg.sender] < currentEpoch, "already claimed this epoch");
        uint256 amount = pendingRewards[msg.sender];
        require(amount > 0, "nothing to claim");
        claimEpoch[msg.sender] = currentEpoch;
        pendingRewards[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }

    function newEpoch() external onlyAdmin {
        currentEpoch++;
    }
}

Detection Tips

Any pattern where one mapping is deleted independently of a companion boolean or counter mapping is suspect. Look for delete mapping1[key] calls that do not also reset every other mapping keyed on the same value. Fuzz with repeated delete-then-reset sequences to confirm whether state can be recycled unintentionally.


4. Array-of-Structs with Mapping Fields — Ghost Data Under Swapped Indices

Vulnerable Code

contract StakingPool {
    struct Position {
        address owner;
        uint256 stakedAmount;
        mapping(uint256 => bool) epochClaimed;
    }

    Position[] public positions;

    function removePosition(uint256 index) external {
        require(positions[index].owner == msg.sender, "not owner");
        // swap-and-pop pattern
        uint256 last = positions.length - 1;
        positions[index] = positions[last]; // copies owner + stakedAmount
        // epochClaimed mapping is NOT copied or cleared
        positions.pop();
    }
}

Explanation

Solidity cannot copy a struct containing a mapping with a simple assignment. positions[index] = positions[last] copies the value fields (owner, stakedAmount) but leaves the epochClaimed mapping of the original index slot untouched in storage. The new occupant at index inherits the ghost epochClaimed entries from the previous position, potentially allowing them to skip epoch claims that were already consumed by the prior owner. Conversely, the last element's mapping data remains at last even after pop, accessible to anyone who crafts a direct storage read.

Fixed Code

contract StakingPool {
    struct PositionData {
        address owner;
        uint256 stakedAmount;
    }

    PositionData[] public positions;
    // separate, position-id-keyed mapping instead of nesting
    mapping(uint256 => mapping(uint256 => bool)) public epochClaimed;
    uint256 private _nextPositionId;

    function createPosition(uint256 amount) external returns (uint256 id) {
        id = _nextPositionId++;
        positions.push(PositionData(msg.sender, amount));
    }

    function removePosition(uint256 index) external {
        require(positions[index].owner == msg.sender, "not owner");
        uint256 last = positions.length - 1;
        positions[index] = positions[last];
        positions.pop();
        // epochClaimed entries for the removed position-id remain but
        // are keyed by id (not index), so no ghost data crosses to new owner
    }
}

Detection Tips

Never nest mappings inside structs that are stored in arrays if those structs will be deleted or swapped. Use a monotonically increasing ID as the outer mapping key instead of an array index, and keep mappings separate from array-stored structs. Static analysis tools that flag struct-in-array patterns with nested mappings can catch this at compile time.


5. Access Control Via Mapping Not Properly Revoked — Ghost Permissions

Vulnerable Code

contract PermissionedVault {
    mapping(address => bool) public allowlisted;
    mapping(address => uint256) public vaultBalance;
    mapping(address => mapping(address => uint256)) public tokenApprovals;

    function addUser(address user) external onlyAdmin {
        allowlisted[user] = true;
    }

    function removeUser(address user) external onlyAdmin {
        allowlisted[user] = false;
        // vaultBalance and tokenApprovals are NOT cleared
    }

    function deposit(uint256 amount) external {
        require(allowlisted[msg.sender], "not allowed");
        vaultBalance[msg.sender] += amount;
    }

    function addAgain(address user) external onlyAdmin {
        allowlisted[user] = true;
        // user immediately regains access to ghost vaultBalance
    }
}

Explanation

Setting allowlisted[user] = false blocks the user from interacting with the vault, but their vaultBalance and any tokenApprovals remain in storage. If an admin later re-adds the user, they instantly regain access to ghost balances they should have forfeited. Worse, a new user issued the same address (possible in test environments or via CREATE2 redeployment patterns) inherits prior state. Protocols that use this pattern for role revocation — revoking a MINTER role but not zeroing associated minting quotas — are especially vulnerable.

Fixed Code

contract PermissionedVault {
    mapping(address => bool) public allowlisted;
    mapping(address => uint256) public vaultBalance;
    mapping(address => address[]) private _approvedTokens;
    mapping(address => mapping(address => uint256)) public tokenApprovals;

    function removeUser(address user) external onlyAdmin {
        // sweep all related state before revoking access
        vaultBalance[user] = 0;
        address[] storage tokens = _approvedTokens[user];
        for (uint256 i = 0; i < tokens.length; i++) {
            delete tokenApprovals[user][tokens[i]];
        }
        delete _approvedTokens[user];
        allowlisted[user] = false;
    }
}

Detection Tips

For every access control mapping (allowlisted, hasRole, isOperator), enumerate all other mappings keyed by the same address. Confirm that the revocation function zeros each of them before flipping the access flag. Access control audits should treat role revocation as a multi-mapping operation, not a single boolean flip.


6. Proxy Upgrade Slot Collision — Reading Ghost Mapping Data Across Upgrades

Vulnerable Code

// Implementation V1
contract VaultV1 {
    // slot 0: owner
    address public owner;
    // slot 1: mapping(address => uint256) deposits
    mapping(address => uint256) public deposits;
}

// Implementation V2 — new storage layout
contract VaultV2 {
    // slot 0: owner (same)
    address public owner;
    // slot 1: mapping(address => bool) blacklist  <-- reuses V1's slot 1
    mapping(address => bool) public blacklist;
    // slot 2: mapping(address => uint256) deposits (moved)
    mapping(address => uint256) public deposits;
}

Explanation

When the proxy is upgraded from V1 to V2, the storage from V1 is not wiped. The EVM simply starts interpreting slot 1 using V2's ABI. The old deposits mapping in V1 stored values at keccak256(address, 1). The new blacklist mapping in V2 reads from keccak256(address, 1) — exactly the same slot. Any user who had a non-zero deposit balance in V1 will appear as blacklist[user] == true in V2 if their balance was odd, or some large integer interpreted as a boolean. Real-world upgrades have accidentally granted admin privileges or bypassed zero-balance checks because of this pattern.

Fixed Code

// Implementation V2 — layout-safe
contract VaultV2 {
    // slot 0: owner (unchanged)
    address public owner;
    // slot 1: deposits — preserved from V1, never moved
    mapping(address => uint256) public deposits;
    // slot 2: new mapping appended at end
    mapping(address => bool) public blacklist;

    // Use storage gaps to reserve slots in libraries/base contracts
    uint256[47] private __gap;
}

Detection Tips

Always append new storage variables after existing ones in upgraded implementations. Use a storage layout diff tool (such as OpenZeppelin's upgrades plugin for Hardhat or Foundry's storage layout inspection) to compare V1 and V2 slot assignments before deploying an upgrade. Never reorder, remove, or change the type of existing state variables. Storage layout tests should be part of every upgrade PR.


Protecting Your Protocol

Mapping ghost state is invisible at the Solidity level — the compiler never warns you, and standard unit tests rarely catch it because test suites do not inspect raw storage slots after deletion. A comprehensive audit requires manual review of every delete statement, every role-revocation path, and every proxy upgrade diff.

ContractScan's automated scanner detects nested-mapping deletion patterns, slot-collision risks in proxy upgrades, and orphaned key tracking issues across your entire codebase. Run a free scan at contractscan.io before your next deployment to catch ghost state bugs before they become million-dollar exploits.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →