Crypto Training
Solidity Storage Layout: Slots, Packing, and the Bugs It Enables
Storage is where Solidity hides complexity. If you can reason about slots, you can reason about upgrade safety, state corruption, and exploit primitives.
Most Solidity bugs are obvious when you stay in the source layer: missing access control, unchecked external calls, etc.
The nastier ones show up when you cross a boundary:
- an upgrade changes layout
- inline assembly writes the wrong slot
- a dynamic structure grows until it becomes un-callable
- a proxy stores admin state where the implementation expects user balances
If you can map “variable” -> “slot and offset”, you can explain whole classes of incidents.
The mental model that survives the optimizer#
The EVM gives you a simple primitive:
- key:
uint256 slot - value:
bytes32
SLOAD(slot) reads one bytes32.
SSTORE(slot, value) writes one bytes32.
Everything else is convention.
Cheat sheet#
| Solidity type | Where the data lives | What attackers exploit |
|---|---|---|
uint256, address | direct slot | upgrades that reorder variables |
small values (bool, uint8, uint16) | packed into a slot | assembly that overwrites neighbors |
fixed array T[n] | sequential slots | incorrect assumptions about packing |
dynamic array T[] | slot stores length; elements at keccak256(slot)+i | unbounded growth DoS; wrong slot math |
mapping(K=>V) | entry at keccak256(abi.encode(key, slot)) | iteration via index arrays (DoS); collisions |
struct | sequential slots (with packing), nested dynamics hash off base | base slot drift across upgrades |
If you can explain those six rows without looking them up, you're already ahead of most “Solidity devs”.
Packing: free gas, expensive mistakes#
Solidity packs multiple small variables into one slot when it can.
contract Packed {
// slot 0: a (1 byte) + b (1 byte) + padding
uint8 a;
uint8 b;
// slot 1
uint256 c;
}
Packing becomes dangerous when you touch storage manually.
A classic footgun:
// WRONG if slot contains packed neighbors.
assembly {
sstore(0, newValue)
}
That overwrites all 32 bytes, not just a.
If you must do this, you need masks.
Masking (the correct idea)#
If a is stored in the lowest byte:
- read the slot
- clear the lowest byte
- OR in the new value
assembly {
let slot := sload(0)
// clear lowest byte
slot := and(slot, not(0xff))
// set new a
slot := or(slot, and(newA, 0xff))
sstore(0, slot)
}
This is the kind of code that passes unit tests and then causes an incident six months later when someone adds another packed field.
Dynamic arrays: length is not the data#
For a dynamic array T[] arr stored at slot p:
- storage slot
pstores the length - element
arr[i]is stored atkeccak256(p) + i
Two security consequences:
- Any loop over
arris a potential liveness bug ifarrcan grow without bounds. - If you do slot math incorrectly in assembly, you write to some other random place.
If you see “we store all user addresses in an array so we can iterate later”, you should immediately ask: what is the worst-case length and what is the bounded-work escape hatch?
Mappings: hashed addressing, no native iteration#
For mapping(K => V) m at slot p:
m[key]lives atkeccak256(abi.encode(key, p))
Mappings are great because they don’t require contiguous slots, but they come with two realities:
- You can’t iterate without a separate index.
- That index becomes a DoS surface.
I treat “mapping + parallel array” as a red flag until proven safe.
Structs: composition amplifies risk#
Struct layout is “just variables in order”, but nested dynamics (mapping/arrays inside structs) are where people lose track.
If you put a mapping inside a struct, its base slot depends on the struct’s location.
So an innocent refactor like “add a field to the top of the struct” can shift the base and effectively orphan state.
This is why upgradeable projects often keep structs stable and append-only.
Upgradeability: where layout becomes existential#
Upgrades are the most common reason storage layout becomes security-critical.
Two rules keep you alive:
- append-only layouts
- never change the meaning of an existing slot
A realistic failure mode:
v1 stores
bool paused; address owner;packed. v2 insertsuint256 feeBps;above them. packing shifts.pausedreads as true andownerbecomes garbage.
Nothing reverts. The system just behaves wrong.
Proxy collisions (why EIP-1967 exists)#
In proxy setups, the proxy stores state and delegates execution to the implementation.
If the proxy uses slot 0 for admin state and the implementation uses slot 0 for owner, you collide.
EIP-1967 avoids this by placing proxy admin/implementation pointers at special “unguessable” slots derived from hashes.
The takeaway for auditors: if you see a custom proxy, verify its storage slots are well-separated from implementation state.
How I audit storage layout in practice#
I do this by hand before I trust tooling:
- Write out state variables in order.
- Mark packing groups (anything < 32 bytes is suspicious).
- For each dynamic type, write down the base slot
p. - For mappings/arrays inside structs, note the struct base.
Then for upgrades:
- diff v1 vs v2
- verify every existing slot is preserved
If you want to go deeper, compute slots and inspect them.
Slot computation snippets#
Compute a mapping slot:
function slotOf(address user, uint256 p) internal pure returns (bytes32) {
return keccak256(abi.encode(user, p));
}
Compute a dynamic array element slot:
function elemSlot(uint256 p, uint256 i) internal pure returns (bytes32) {
return bytes32(uint256(keccak256(abi.encode(p))) + i);
}
Those two functions are enough to debug half of “mysterious storage corruption” incidents.
Mini-lab (do this once; it pays forever)#
Open Remix and deploy a toy contract that has:
- packed variables
- a dynamic array
- a mapping
Then:
- set values
- read raw storage slots
- modify source order and redeploy
You’ll build the intuition that makes upgrade reviews and incident response dramatically easier.
Further reading#
- Storage deep dive: https://programtheblockchain.com/posts/2018/03/09/understanding-ethereum-smart-contract-storage/
- Arrays and hidden costs: https://medium.com/@hayeah/diving-into-the-ethereum-vm-the-hidden-costs-of-arrays-28e119f04a9b
- EVM opcode reference: https://www.evm.codes/