1. The EVM Execution Model

The Ethereum Virtual Machine is a deterministic, quasi-Turing-complete state machine. Every EVM instance has access to five data regions: the stack, memory, storage, calldata, and code. Understanding which region a value lives in — and what it costs to access it — is the foundation of EVM security.

The Stack

The EVM operates on a 256-bit-wide stack with a maximum depth of 1,024 items. All arithmetic, logic, and control-flow opcodes read from and write to the stack. Stack depth overflows cause a revert — this is the "stack too deep" error you see in the compiler, and it is also an attack surface: a malicious contract can craft calls that push the stack to the limit before re-entering.

Security note: At call depth 1,024 the EVM silently fails external calls (pre-EIP-150). Post EIP-150, the 63/64 gas rule makes this harder to exploit but not impossible. Never assume an external call succeeded without checking its return value.

Memory

EVM memory is a byte-addressable, linearly-expandable array. It exists only for the duration of a single transaction. Slots are zero-initialized. Memory expands in 32-byte chunks, and gas cost scales quadratically with the maximum word offset reached — this is relevant for any loop that writes to memory.

Solidity reserves the first 128 bytes of memory for its own use:

0x00–0x1F
Scratch space — temporary use by ABI encoder
0x20–0x3F
Scratch space — temporary use by ABI encoder
0x40–0x5F
Free memory pointer — points to next free slot
0x60–0x7F
Zero slot — invariant: must always be zero
0x80+
Dynamic allocations — structs, arrays, ABI-encoded data
Free memory pointer pitfall: Assembly blocks that allocate memory without updating the free memory pointer at 0x40 can corrupt the heap. Any subsequent abi.encode or dynamic array allocation will overwrite your data.

2. Opcodes and Gas

Every EVM instruction has a fixed or context-dependent gas cost. Understanding opcode costs is essential for building gas-efficient contracts — and for identifying where cost manipulation can cause DoS vulnerabilities.

Critical gas costs (security-relevant)

OpcodeNameGasSecurity Relevance
0x54SLOAD2,100 (cold) / 100 (warm)Storage reads in loops → DoS via gas exhaustion
0x55SSTORE20,000 (zero→nonzero) / 5,000Unbounded writes → gas griefing
0xF1CALLVariable + 2,300 stipendReentrancy vector; stipend limits re-entry
0xF4DELEGATECALLVariableProxy pattern — storage collisions
0xFFSELFDESTRUCT5,000 (+ 25,000 if creates)Force-sends ETH; contract destruction
0x3DRETURNDATASIZE2Used in safeTransfer pattern
0x40BLOCKHASH20Only last 256 blocks; randomness pitfall
0x44DIFFICULTY/PREVRANDAO2Post-Merge: validator-influenceable
DELEGATECALL storage collisions: When a proxy delegates to an implementation, both contracts share the same storage layout. If the implementation declares different state variables at the same slot offsets as the proxy, storage corruption occurs. This is the EIP-1967 storage slot standardization motivation.

3. Storage Layout

Contract storage is a mapping from uint256 key to uint256 value. It is persistent across calls and transactions. Solidity assigns storage slots sequentially, starting at slot 0 for the first declared state variable.

Slot packing

Variables smaller than 32 bytes are packed together into a single slot, right-aligned. The compiler packs consecutive variables if they fit. Order matters for gas efficiency and for security.

Slot 0
· · · · · padding · · · · ·
address (20B)
bool
uint8 · uint8 · uint8
Slot 1
· · · · · · · · padding · · · · · · · ·
uint256 (32B) — full slot

Mappings and dynamic arrays

Mappings use keccak256(abi.encode(key, slot)) to compute the actual storage slot. Dynamic arrays store their length at the declared slot, and elements starting at keccak256(slot). This is why you can never directly iterate over a mapping — the keys are hashed and there is no reverse index.

StorageLayout.sol · slot calculationSolidity
// mapping(address => uint256) public balances; // declared at slot 2 // actual storage slot for balances[user]: bytes32 slot = keccak256(abi.encode(user, uint256(2))); // dynamic array at slot 5: length stored at slot 5 // elements stored starting at keccak256(bytes32(5)) bytes32 base = keccak256(abi.encode(uint256(5))); bytes32 elem2 = bytes32(uint256(base) + 2); // array[2]

Attack surfaces from storage layout

  • Storage collision in proxies: Proxy at slot 0 stores admin address. Implementation also declares a state variable at slot 0. DELEGATECALL overwrites the admin. Use EIP-1967 unstructured storage slots.
  • Dirty upper bits: When a uint128 value is packed in the upper half of a slot and code reads the full 256-bit slot via assembly, the upper bits may contain stale data from a different variable.
  • Storage aliasing in upgradeable contracts: An upgrade that reorders state variables without a storage gap corrupts existing data. Always use storage gaps (uint256[50] __gap;) in upgradeable base contracts.

4. Calldata and ABI Encoding

Calldata is the immutable input payload sent with a transaction. It begins with the 4-byte function selector (first 4 bytes of keccak256("functionName(types)")), followed by ABI-encoded arguments.

The ABI encoding format

ABI encoding distinguishes between head and tail sections. Static types (uint, address, bool, fixed-size arrays) are encoded directly in the head. Dynamic types (bytes, string, dynamic arrays) place a 32-byte offset in the head pointing to the tail where the actual data lives.

ABI encoding — transfer(address to, uint256 value)Hex
// transfer(0xdeadbeef...c3e9, 1000000000000000000) // Selector: keccak256("transfer(address,uint256)")[0:4] a9059cbb // Head — address (padded to 32 bytes, left-padded with zeros) 000000000000000000000000deadbeef1f4c9a2e8b5fc3e900000000000000 // Head — uint256 (1 ETH in wei) 0000000000000000000000000000000000000000000000000de0b6b3a7640000

ABI encoding pitfalls

abi.encodePacked collision: abi.encodePacked(string_a, string_b) produces the same bytes as abi.encodePacked(string_b, string_a) if the strings are transpositions of each other. Never use encodePacked with two dynamic types when the result is used as a signature message or hash key.
Collision.sol · abi.encodePacked vulnerabilitySolidity
// VULNERABLE: "AA" + "BBB" == "A" + "ABBB" == "AAB" + "BB" bytes32 msgHash = keccak256(abi.encodePacked(a, b)); // ← collision risk // SAFE: abi.encode adds length prefix and padding bytes32 msgHash = keccak256(abi.encode(a, b)); // ← correct // ALSO SAFE: explicit delimiters, or hash each component bytes32 msgHash = keccak256(abi.encodePacked( keccak256(bytes(a)), keccak256(bytes(b)) ));

This vulnerability (SWC-133) is detected by AuditHunt's EncodePackedCollisionDetector on every scan. It affects signature verification, Merkle proof construction, and any hash-based authorization check.

5. Memory Safety in Assembly

Inline assembly (assembly { ... }) bypasses the Solidity memory safety model. Common mistakes:

  • Not updating the free memory pointer — subsequent allocations overwrite your data.
  • Reading uninitialized memory — EVM memory is zero-initialized per call, but dirty reads are possible if you calculate an offset that points before your allocation.
  • Return data buffer race — after CALL, returndatacopy reads from a shared buffer. If you make another external call before reading, the buffer is overwritten.
MemorySafety.sol · correct assembly allocationSolidity
assembly { let freePtr := mload(0x40) // read free memory pointer let dataStart := freePtr mstore(freePtr, someValue) // write at current free slot mstore(add(freePtr, 0x20), moreData) // next 32 bytes mstore(0x40, add(freePtr, 0x40)) // ← update free pointer }

6. Security Implications — Checklist

A summary of the attack surfaces introduced by EVM internals:

  • DELEGATECALL storage collision — Use EIP-1967 slots; never declare variables at slot 0 in an implementation contract.
  • abi.encodePacked with dynamic types — Always use abi.encode for hash inputs when two or more parameters are dynamic.
  • Stack depth manipulation — Check return values of all external calls; do not assume success.
  • Dirty storage upper bits — When reading packed values in assembly, mask the bits you need; don't assume padding is clean.
  • Memory corruption in assembly — Always save and restore the free memory pointer; never write below it.
  • SLOAD in hot loops — Cache storage reads in memory variables inside loops; a single SLOAD costs 2,100 gas cold.
  • Mapping enumeration — Mappings are not iterable. If you need iteration, maintain a parallel array of keys.
Test your contracts: AuditHunt scans for storage collisions, encodePacked collisions, weak randomness, and assembly memory safety automatically. Paste your contract and run a free T0 scan.
Related Lessons
v3.38.0 · college-evm