Core Fundamentals
The EVM and Solidity mental model from a security review angle — the parts that show up in interviews because the bugs hide in them.
EVM mental model
The EVM is a stack machine with a 256-bit word size. For security work, the parts that earn their keep:
- Execution context — every external call creates a new frame with its own
msg.sender,msg.value,address(this). Exceptdelegatecall, which keeps the caller's context. - Storage — one 2^256 keyspace per contract, accessed by
SLOAD/SSTORE. Cold reads are 2100 gas, warm reads 100, cold writes 22100. State persists across transactions. - Memory — per-call scratch, byte-addressable, expansion costs gas quadratically beyond 724 bytes.
- Calldata — read-only input region. Cheaper than memory.
- Stack — 1024 words max. Overflow is rare in modern Solidity but possible in deeply nested or assembly-heavy code.
- Transient storage (EIP-1153, post-Cancun) — cleared at end of transaction. Useful for reentrancy locks and ephemeral state.
Most of the high-severity bug taxonomy lives in misunderstanding the context — delegatecall context, storage layout, returndata handling. If you can't draw the call frame on a whiteboard, you'll miss bugs.
Call types and their security implications
The four call opcodes — CALL, STATICCALL, DELEGATECALL, CALLCODE (deprecated) — have very different security profiles.
CALL
Standard external call. New execution context. msg.sender becomes address(this). State changes happen in the callee. Returns success bool and returndata.
// Classic: not checking return value
token.transfer(to, amount); // OK for revert-on-fail tokens, BAD for bool-returning tokens
// Safer pattern (SafeTransfer style)
(bool success, bytes memory data) = address(token).call(
abi.encodeWithSelector(IERC20.transfer.selector, to, amount)
);
require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAIL");STATICCALL
Same as CALL but reverts on any state-modifying opcode (SSTORE, LOG*, CREATE*, SELFDESTRUCT, etc.) — including indirectly through a sub-call. Used for view functions. Security implication: oracle reads via staticcall still consume gas; a malicious oracle can OOG you.
DELEGATECALL
This is the one that makes proxies work and security engineers nervous. The callee's code runs in the caller's storage / context. msg.sender and msg.value are preserved from the original caller. address(this) is the caller, not the callee.
- The callee's storage layout must match the caller's, slot-for-slot. Mismatch = silent corruption.
- If the callee can be replaced or contains a
selfdestruct, your caller's logic disappears. - If the callee logs, the log appears as if emitted by the caller.
- If
msg.senderis privileged in the callee, it's the original EOA / contract — not the proxy.
Storage layout and proxy collisions
Storage is a flat 256-bit keyspace. Solidity packs state variables starting at slot 0. Mappings hash key+slot. Dynamic arrays put length at slot S and elements at keccak256(S) + i.
contract Layout {
uint256 a; // slot 0
uint256 b; // slot 1
mapping(address => uint256) m; // slot 2 (m[k] at keccak256(k, 2))
uint256[] arr; // length at slot 3, arr[i] at keccak256(3) + i
address owner; // slot 4 (20 bytes, padded)
bool paused; // slot 4 (packed with owner — 1 byte after 20-byte address)
}Why a security engineer cares:
- Proxy upgrade collisions. If V2 reorders or inserts a state variable above an existing one, every later slot shifts. Reads return garbage, writes corrupt unrelated state.
- UUPS / Transparent proxies use EIP-1967 slots (
keccak256("eip1967.proxy.implementation") - 1) precisely to avoid collisions with the implementation's slot 0. - Diamond storage (EIP-2535) uses isolated storage structs at hashed pseudo-random slots.
- Initializer slot in OpenZeppelin's
Initializableis also a fixed offset. Forgetting_disableInitializers()on the implementation contract is a real-world bug (Wormhole, et al).
forge inspect MyContract storage-layout --prettyMemory, calldata, returndata
Memory
Per-call scratch space. Expansion costs 3*n + n^2/512 for n 32-byte words. A malicious external call can return huge returndata to grief gas consumers.
Calldata
Read-only. The function selector is the first 4 bytes; arguments follow ABI encoding. Decoding mistakes are a real bug class. abi.decode on attacker-controlled bytes can revert or behave unexpectedly if you don't bound types.
Returndata
The most common bug here is "return bomb" — a malicious callee returns huge bytes; the caller's returndatacopy blows up gas.
// Vulnerable: returndatacopy reads all of it
(bool success, bytes memory data) = target.call(payload);
// Safer: cap the returndata you accept
bool success;
bytes memory data;
assembly {
success := call(gas(), target, 0, add(payload, 0x20), mload(payload), 0, 0)
let size := returndatasize()
if gt(size, MAX_RETURN) { size := MAX_RETURN }
data := mload(0x40)
mstore(data, size)
returndatacopy(add(data, 0x20), 0, size)
mstore(0x40, add(data, add(0x20, size)))
}Solidity gotchas that show up in interviews
- Visibility defaults. Functions default to
publicif you forget. State variables default tointernal. A missinginternalon what should be a helper turns it intopublic; a missing modifier turns it into a free function call for the world. - Shadowing. A local variable with the same name as a state variable silently shadows. Modern compilers warn; older code doesn't.
- Integer wraparound. Solidity 0.8+ checks by default.
unchecked {}blocks disable checks. Pre-0.8 code is unchecked everywhere — SafeMath was required. - ABI decoding strictness.
abi.decodereverts on malformed data but doesn't tell you why. Custom encoding (assembly) can silently misbehave. - Function selectors. First 4 bytes of
keccak256("foo(uint256)"). Selector clashes are theoretical but real in diamond proxies and fallback routing. - Constructor vs initializer. Implementation behind a proxy can't use a constructor for state. Initializer functions need replay protection. Forgetting to lock the implementation initializer is a classic.
- Receive vs fallback.
receive()handles plain ETH;fallback()handles unknown selectors. A fallback that does anything other than revert can be an attack surface. - tx.origin. The original EOA. Never use for authorization — phishing-trivial.
- block.timestamp. Manipulable by miners/proposers within seconds. Don't use for fine-grained ordering.
- Function-type variables and external calls. Storing a
functionselector and calling it without validation is delegatecall-in-disguise.
Compiler version pinning and Solc bugs
Solc itself has had real bugs. A few must-knows:
- Storage write to
bytes(0.8.13) — optimizer bug, fixed in 0.8.15. - ABI re-encoding bug (0.5.x) — fixed in 0.5.14.
- Yul optimizer bugs — historically the optimizer has shipped with bugs that affect bytecode determinism.
via_irincreases this surface.
Practice:
- Pin a specific patch (
pragma solidity 0.8.24;, not^0.8.0). - Track the Solidity bugs.json for the version you're on.
- Be cautious with
--via-ir; the optimizer pass set is different and historically buggier. - Audit reports should record the exact compiler version and settings used for the audited bytecode.
Reviewing inline assembly
Yul / inline assembly is where the compiler stops protecting you. A few rules of thumb when reviewing:
- Memory pointer discipline. The free memory pointer is at
0x40. Anyone writing memory must update it or scope their writes. A missing update creates "memory aliasing" bugs. - Scratch space at
0x00 - 0x40is short-term. Don't rely on its persistence across a function call. - Selector loading — many gas-optimized contracts load the function selector with assembly. Make sure the comparison is exact and signed/unsigned correct.
- Calldata bounds.
calldataload(offset)beyondcalldatasize()returns 0 silently. Decoding past the end is a real vuln class. - Memory-safe assembly.
assembly ("memory-safe") { ... }tells the Yul optimizer it can rely on standard memory discipline. Mark assembly accurately; lying causes miscompiles. - Implicit zero-extension. Solidity packs small ints in the high-zero portion of a 256-bit word; assembly that loads adjacent memory can pick up bits you didn't intend.
Inline assembly should always be commented heavily, scoped narrowly, and ideally cross-checked against a high-level reference implementation. If a review PR adds 200 lines of uncommented Yul, push back.
Quick reference
Storage slot math cheat sheet
- Fixed variable at declaration order, packed if < 32 bytes and adjacent.
- Mapping value at
keccak256(abi.encode(key, slot)). - Nested mapping:
keccak256(abi.encode(k2, keccak256(abi.encode(k1, slot)))). - Dynamic array length at slot; element i at
keccak256(slot) + i. - EIP-1967 implementation slot:
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbf=keccak256("eip1967.proxy.implementation") - 1.
Gas opcode costs to know cold
SSTOREzero→nonzero: 22100; nonzero→nonzero: 5000; clear→refund: 4800.SLOADcold: 2100; warm: 100.CALLcold address: 2600; warm: 100. Stipend rules removed post-Berlin for value transfer.CREATE2: 32000 + memory expansion + initcode word cost.KECCAK256: 30 + 6 per word.