Storage and Data Pitfalls
This page covers three classes of storage-related bugs that fall outside the proxy/upgrade discussion in §4.12: the misconception that private data is hidden, the patterns that cause writes to land in attacker-controlled slots, and the way Solidity's array semantics make element deletion easy to get wrong.
Unencrypted Private Data On-Chain
Solidity's private keyword controls language-level access. It does not encrypt the data, hide it from the chain, or prevent any caller from reading it via low-level RPC.
contract Vault {
bytes32 private password; // anyone can read this
function unlock(bytes32 p) external { require(p == password); ... }
}
Any client can call eth_getStorageAt(vaultAddress, slotIndex, blockTag) and recover the value. The slot index is deterministic from the source layout (see §4.12.2).
cast storage 0xVault 0 # reads slot 0 of the deployed contract
This is not a bug in the EVM; it is a routine misunderstanding. The patterns it produces:
- Commit-reveal schemes that commit a hash but store the preimage in a
privateslot ahead of reveal. - "Off-chain" admin credentials burned into a contract during deployment as
privateconstants. - Hidden mappings of allowlisted addresses, balances, or off-chain identifiers that the team assumed were unobservable.
Remediation. Treat every on-chain value as public. If a value must remain secret, it must not live on-chain in any form derivable from chain data — keep it off-chain entirely, or commit only a hash and reveal in a single transaction. For commit-reveal, use the established two-phase pattern with a delay and a salt held off-chain until reveal.
Write to Arbitrary Storage Location
Several patterns let an attacker write to a storage slot of their choosing. The compiler has closed the most notorious ones, but the underlying class remains relevant in inline-assembly code and in legacy contracts.
Unbounded Array Length (legacy)
Pre-0.6, a delete on an array followed by direct length manipulation, or a write to arr.length, allowed extending the array's logical length without allocating storage. The next write to arr[i] for a large i computed keccak256(slot) + i and landed in arbitrary storage slots — including the slot holding owner. The compiler now forbids direct length writes; legacy contracts may still expose this.
Uninitialized Storage Pointer
Pre-0.5, declaring a local struct or array variable inside a function without assigning a memory or storage location made it default to a storage pointer pointing at slot 0. Writes to that local clobbered the contract's first state variables. The compiler now requires an explicit memory or storage qualifier on local reference types, eliminating the silent bug.
Inline Assembly sstore
The category remains alive any time a contract uses inline assembly to compute a slot from caller-controlled input.
// VULNERABLE
function setAt(uint256 slot, uint256 value) external onlyOwner {
assembly { sstore(slot, value) }
}
If the function is reachable by an attacker (missing modifier, bypassable check, or the slot calculation is derived from user input even with proper access control), the attacker controls which slot gets the write — overwriting owner, fee receivers, or critical accounting.
Bespoke Storage-Slot Patterns
ERC-1967, ERC-7201 (Namespaced Storage Layout for upgradeable contracts), and Diamond Storage (ERC-2535) each compute storage slots from a hash of a string. Bugs occur when:
- The slot constant is recomputed inconsistently between contracts that should share it.
- An upgraded implementation uses a different namespace than the predecessor.
- The slot derivation accepts user input (a parameter, a token address, a market id) and the input space overlaps an existing slot.
Detection. Every sstore, every keccak256(...) used to derive a slot, every assembly block that touches storage, and every storage-layout constant should be reviewed for: (1) is the slot value attacker-influenceable, (2) does the derivation collide with another known slot, (3) does the access-control gate cover all reachable callers?
Remediation. Constrain sstore to compile-time-known slots or to slots derived from hashes whose inputs are not user-controlled. Use the standardized namespacing patterns (ERC-7201) rather than ad-hoc derivations. Tooling: Slither's arbitrary-send and controlled-storage-write detectors; Foundry's forge inspect storage-layout.
Improper Array Deletion
delete arr[i] does not remove the element. It writes the zero value of the element type into that slot. The array length is unchanged; the slot still exists, and iteration produces gaps.
address[] public holders;
function remove(uint256 i) external onlyOwner {
delete holders[i]; // holders[i] is now address(0), but holders.length is unchanged
}
// Later:
for (uint256 j = 0; j < holders.length; j++) {
pay(holders[j]); // calls pay(address(0)) for deleted indices
}
Effects observed in production:
- Iteration counts include zeroed slots, wasting gas and producing zero-value transfers that revert (or, worse, succeed and burn funds).
- Off-chain consumers (subgraph indexers, frontends) display the zero entry as a literal
address(0)holder. - Functions that assume
holders.lengthequals the number of real holders compute incorrect aggregates (totals, averages, weights). - An attacker who can predict which index will be deleted can later claim it by inserting at the now-empty slot if the contract supports index-based insertion.
Correct Patterns
Swap-and-pop is the standard idiom for unordered arrays. O(1) and removes the element without leaving a gap.
function remove(uint256 i) external {
uint256 last = holders.length - 1;
if (i != last) holders[i] = holders[last];
holders.pop();
}
For ordered arrays where order matters, shifting is O(n) and may exceed block gas limits at scale; consider alternative data structures (a linked list, a mapping with an external length tracker, an indexed mapping with explicit removal flags).
Bonus: delete on Mappings
delete map is a no-op. The compiler accepts it; nothing happens because Solidity cannot enumerate the mapping's keys. Removing individual entries requires the keys to be tracked separately — typically in a parallel array, with the swap-and-pop pattern above.
Auditor Checklist
-
No reliance on the
privatekeyword to hide on-chain data; any secret value is either off-chain or only committed as a hash. -
Every inline-assembly
sstoreoperates on a compile-time-known slot or a slot whose derivation has no user-controlled input. - Every hashed-namespace storage pattern (ERC-1967, ERC-7201, Diamond) is consistent across upgrades and used identically across contracts that share state.
-
No
delete arr[i]on arrays that are subsequently iterated; arrays use swap-and-pop or an alternative data structure. -
No
delete mapcalls that assume the mapping clears; key tracking is in place where individual entries must be removable. -
Static analyzers (Slither, Aderyn) report no findings in the
controlled-storageorarbitrary-sendfamilies. -
forge inspect storage-layoutoutput reviewed for unexpected slot assignments, especially after an upgrade.