Section B · Technical Core

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). Except delegatecall, 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.
Why this matters in security review

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.

Delegatecall hazards
  • 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.sender is 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 Initializable is also a fixed offset. Forgetting _disableInitializers() on the implementation contract is a real-world bug (Wormhole, et al).
forge inspect MyContract storage-layout --pretty

Memory, 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 public if you forget. State variables default to internal. A missing internal on what should be a helper turns it into public; 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.decode reverts 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 function selector 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_ir increases 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:

  1. 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.
  2. Scratch space at 0x00 - 0x40 is short-term. Don't rely on its persistence across a function call.
  3. Selector loading — many gas-optimized contracts load the function selector with assembly. Make sure the comparison is exact and signed/unsigned correct.
  4. Calldata bounds. calldataload(offset) beyond calldatasize() returns 0 silently. Decoding past the end is a real vuln class.
  5. Memory-safe assembly. assembly ("memory-safe") { ... } tells the Yul optimizer it can rely on standard memory discipline. Mark assembly accurately; lying causes miscompiles.
  6. 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.
A flag for review

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
  • SSTORE zero→nonzero: 22100; nonzero→nonzero: 5000; clear→refund: 4800.
  • SLOAD cold: 2100; warm: 100.
  • CALL cold address: 2600; warm: 100. Stipend rules removed post-Berlin for value transfer.
  • CREATE2: 32000 + memory expansion + initcode word cost.
  • KECCAK256: 30 + 6 per word.