🌿 Intermediate ⏱️ 35 min

The EVM: How Ethereum Executes Code

The Ethereum Virtual Machine (EVM) is the beating heart of Ethereum. Every smart contract you read, audit, or write runs inside it. Understanding the EVM at a mechanical level is what separates a developer who writes Solidity from a security researcher who can read raw bytecode, spot hidden logic, and reason about gas griefing attacks. This lesson builds that mental model from the ground up.

📖 What "Virtual Machine" Means

A virtual machine is a software-implemented computer that runs inside a real computer. The EVM defines a complete instruction set, memory model, and execution rules. Every Ethereum node runs an EVM implementation — in Go (go-ethereum), Rust (reth), C++ (erigon), or other languages — and they all produce identical results for identical inputs. That determinism is what makes consensus possible.

The EVM's Core Properties

Three properties define the EVM and have direct security implications:

  • Sandboxed: Contract code cannot access the host filesystem, network, or OS. The only external data it can read is blockchain state and calldata passed by the caller.
  • Deterministic: Given the same state and input, every node in the world gets the same output. No randomness, no side effects. This is why using block.timestamp or block.prevrandao for randomness is exploitable.
  • Quasi-Turing Complete: The EVM can execute arbitrary logic, but gas limits prevent infinite loops. Every computation must terminate because it must be paid for.

Stack Machine Architecture

The EVM is a stack-based machine. It does not have registers like a CPU. Instead, all operations read their inputs from the top of a stack and push their results back onto it. Understanding the stack is fundamental to reading bytecode.

📊 EVM Stack — How ADD Works
// Solidity: uint256 result = a + b; // Becomes these EVM opcodes: PUSH1 0x05 // Push value 5 onto stack. Stack: [5] PUSH1 0x03 // Push value 3 onto stack. Stack: [3, 5] ADD // Pop 3 and 5, push 8. Stack: [8] // Stack grows downward in EVM convention // Stack constraints: // - Maximum depth: 1,024 elements // - Each slot: exactly 256 bits (32 bytes) // - Exceeding 1,024 causes a STACK OVERFLOW — the call reverts // - This is exploitable: see "Stack Depth Attack" section below

The 256-bit word size was deliberately chosen to match two key cryptographic primitives: SHA-3 (Keccak-256) hashes and secp256k1 elliptic curve coordinates used in Ethereum's signature scheme. Both produce 256-bit values, so 256-bit words make crypto operations cheap and natural.

The EVM Execution Environment

When a contract executes, the EVM provides five distinct data regions. Each has a different cost profile, persistence model, and security implication:

🗄️ EVM Data Regions — ASCII Overview
┌─────────────────────────────────────────────────────────────────┐ │ EVM EXECUTION CONTEXT │ ├──────────────┬──────────────────────────────────────────────────┤ │ STACK │ 256-bit slots, max 1024 deep, volatile │ │ │ Used for: operands, return values, local vars │ │ │ Cost: ~2-3 gas to push/pop │ ├──────────────┼──────────────────────────────────────────────────┤ │ MEMORY │ Byte-addressable, expands dynamically, volatile │ │ │ Used for: abi.encode output, return data, bytes │ │ │ Cost: grows QUADRATICALLY — 3 gas/word + memory │ ├──────────────┼──────────────────────────────────────────────────┤ │ STORAGE │ 256-bit → 256-bit map, PERSISTENT across txs │ │ │ Used for: state variables (balances, settings) │ │ │ Cost: SSTORE cold = 22,100 gas. SLOAD = 2,100 │ ├──────────────┼──────────────────────────────────────────────────┤ │ CALLDATA │ Read-only input from the transaction caller │ │ │ Used for: function arguments (cheapest input) │ │ │ Cost: 4 gas/zero byte, 16 gas/non-zero byte │ ├──────────────┼──────────────────────────────────────────────────┤ │ CODE │ Immutable bytecode of the contract │ │ │ Used for: the program itself │ │ │ Read via: CODECOPY, EXTCODECOPY, EXTCODESIZE │ └──────────────┴──────────────────────────────────────────────────┘

Opcodes — The EVM Instruction Set

Every Solidity statement compiles into one or more opcodes. Each opcode has a fixed gas cost. Here are the most important ones for security researchers:

OpcodeHexGas CostWhat It DoesSecurity Note
ADD0x013Pop a, b; push a+bWraps in unchecked blocks
MUL0x025Pop a, b; push a*bOverflow risk in old Solidity
SLOAD0x542,100 (cold)Load from storage slotCache in memory to avoid repeated SLOADs
SSTORE0x5522,100 (cold)Write to storage slotMost expensive op — avoid in loops
CALL0xf1100 + gasCall another contractReentrancy entry point
DELEGATECALL0xf4100 + gasCall in caller's contextStorage collision risk
STATICCALL0xfa100 + gasRead-only callReverts if callee writes state
MSTORE0x523+Write 32 bytes to memoryMemory expansion costs can surprise
MLOAD0x513+Read 32 bytes from memorySame expansion cost applies
KECCAK2560x2030 + 6/wordHash memory regionUsed to compute mapping slots
JUMPI0x5710Conditional jumpCore of if/require/loop
REVERT0xfd0Abort, return error dataRefunds unused gas
SELFDESTRUCT0xff5,000+Kill contract, send ETHPermanent — never add lightly

From Solidity to Bytecode to Opcodes

When you compile a Solidity contract, the compiler produces bytecode — a sequence of opcode bytes. Let's trace a simple function all the way down:

🔧 Solidity → Bytecode → Opcodes
// Solidity source: function add(uint256 a, uint256 b) public pure returns (uint256) { return a + b; } // Compiled bytecode (hex): 0x60806040...771b6020526040519081900360200190f35b // Disassembled opcodes (simplified — the key arithmetic part): CALLDATALOAD // Load parameter 'a' from calldata offset 4 CALLDATALOAD // Load parameter 'b' from calldata offset 36 ADD // a + b MSTORE // Write result to memory for return RETURN // Return 32 bytes from memory // Tools to disassemble: // forge inspect MyContract opcodes // cast disassemble <bytecode> // https://www.evm.codes/playground

The Call Stack and Context Variables

Each time a contract calls another contract, a new execution frame is pushed onto the call stack. Each frame has its own msg.sender, msg.value, and execution context. This is critical for understanding cross-contract interactions:

📞 Call Context Changes Across Frames
// EOA (Alice) calls ContractA, which calls ContractB // Frame 1 — ContractA execution: // msg.sender = Alice's address // msg.value = ETH sent by Alice // address(this) = ContractA // Frame 2 — ContractB execution (called by ContractA): // msg.sender = ContractA's address ← NOT Alice! // msg.value = ETH forwarded by ContractA (often 0) // address(this) = ContractB // tx.origin is ALWAYS Alice (the original EOA) // Using tx.origin for auth is dangerous — ContractA can exploit it contract VulnerableAuth { address public owner = msg.sender; function withdraw() external { // BUG: tx.origin check can be bypassed via phishing contract require(tx.origin == owner, "not owner"); payable(owner).transfer(address(this).balance); } }

Gas: The Cost of Computation

Every opcode costs gas. Gas prevents denial-of-service attacks and makes computation economically rational. The most important gas costs for security auditors are storage operations, because they dominate transaction costs and create griefing vectors:

⛽ Gas Cost Breakdown — Why Storage Is Expensive
// Gas costs that matter most (EIP-2929 / post-Berlin): // SSTORE (cold, new value non-zero): 22,100 gas // SSTORE (cold, clearing to zero): 5,000 gas (+ 4800 refund) // SSTORE (warm, dirty): 100 gas // SLOAD (cold): 2,100 gas // SLOAD (warm): 100 gas // ADD / SUB / MUL: 3-5 gas // CALL (empty account): 25,000 gas // Gas griefing example — unbounded array loop: contract GasGriefing { address[] public users; // Anyone can push to users array — DoS vector! function register() external { users.push(msg.sender); } // After 1000 users this may hit block gas limit function distributeRewards() external { for (uint i = 0; i < users.length; i++) { // 2,100 gas SLOAD + 22,100 gas SSTORE per iteration! rewards[users[i]] += 100; } } }

EVM Precompiles

Precompiles are special contracts at addresses 0x01 through 0x0a that implement cryptographic operations in native code rather than EVM opcodes. They are dramatically cheaper and faster than equivalent EVM implementations:

AddressNamePurposeSecurity Use
0x01ecRecoverRecover signer from ECDSA signatureSignature verification — malleability risk
0x02SHA2-256SHA-256 hashBitcoin compatibility
0x03RIPEMD-160RIPEMD hashBitcoin address derivation
0x04identityCopy bytes (memcpy)Cheap memory copy
0x05modexpModular exponentiationRSA, Diffie-Hellman
0x06-0x08BN256 opsElliptic curve pairingZK proof verification
0x09BLAKE2FBLAKE2 compressionZcash compatibility
⚠️ ecRecover Signature Malleability

The ecRecover precompile has a known quirk: for every valid signature (r, s), the signature (r, n-s) is also valid for the same message. This means an attacker can replay a transaction with a different signature that still verifies. Always use OpenZeppelin's ECDSA.recover() which normalizes s to the lower half.

Reading Bytecode: A Practical Auditor Skill

Sometimes contracts are deployed without verified source code. A security researcher must be able to extract meaning from raw bytecode. Here is the essential toolkit:

✏️ Try It — Disassemble Bytecode with Foundry
# Get bytecode of a deployed contract cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url mainnet # Disassemble with cast cast disassemble $(cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url mainnet) # Look for function selectors in bytecode # First 4 bytes after 0x: the selector routing table # Pattern: PUSH4 <selector>, EQ, PUSH2 <dest>, JUMPI # Decode a selector to function name: cast 4byte 0x70a08231 # Output: balanceOf(address) # Interactive EVM playground for learning: # https://www.evm.codes/playground

Security Implications of EVM Architecture

🚨 Stack Depth Limit Attack (Call Depth Attack)

The EVM enforces a maximum call depth of 1,024. Before EIP-150 (2016), an attacker could pre-fill the call stack to 1,023 frames and then call a victim contract — causing any sub-calls within it to fail silently. Modern contracts are protected by EIP-150's 63/64 gas rule, but re-entrancy and call depth awareness still matter for complex call chains.

⚠️ Hidden Logic in Unverified Bytecode

Malicious contracts can hide functionality that doesn't appear in "verified" source code if the deployed bytecode doesn't match what was submitted to Etherscan. Always verify that the on-chain bytecode hash matches the compiled artifact hash. Check for mismatches using forge verify-check or by comparing cast code <addr> against forge inspect MyContract bytecode.

💡 Audit Tip: Look at Opcodes for Hidden SELFDESTRUCT

Searching the raw bytecode for opcode 0xff (SELFDESTRUCT) and 0xf4 (DELEGATECALL) is a quick sanity check when reviewing suspicious contracts. Even if the Solidity source looks safe, the compiler might generate unexpected bytecode from optimizer or via assembly blocks.

Common EVM Security Issues

🚫 Gas Griefing via Memory Expansion
// Memory expansion cost is quadratic — cheap early, expensive late // Cost formula: 3 * words + words^2 / 512 // // First 724 bytes: ~3 gas/word (cheap) // At 1 MB: ~1500 gas/word (expensive) // At 100 MB: gas limit exceeded function vulnerableConcat(bytes calldata userInput) external { // If userInput is very large, memory expansion can exhaust gas bytes memory result = abi.encodePacked(prefix, userInput, suffix); // Attacker passes 10MB calldata → memory expands → OOG revert } // Fix: cap input length require(userInput.length <= 1024, "input too large");

Key Takeaways

  • The EVM is a 256-bit stack machine with a 1,024-element stack limit, five data regions (stack, memory, storage, calldata, code), and a gas metering system for every opcode.
  • Storage operations (SSTORE/SLOAD) are 100-7,000x more expensive than arithmetic — avoid them in loops and cache storage values in memory variables.
  • Each cross-contract call creates a new execution frame with a new msg.sender. Never confuse msg.sender with tx.origin.
  • Precompiles at 0x01-0x0a provide cheap native crypto operations — ecRecover has signature malleability, always use a wrapper.
  • Auditors can read raw bytecode using cast disassemble and cast 4byte to identify hidden selectors or dangerous opcodes even without source code.
  • Memory expansion is quadratic — user-controlled input sizes must be capped to prevent gas griefing.