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.
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.timestamporblock.prevrandaofor 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.
// 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 belowThe 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 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:
| Opcode | Hex | Gas Cost | What It Does | Security Note |
|---|---|---|---|---|
| ADD | 0x01 | 3 | Pop a, b; push a+b | Wraps in unchecked blocks |
| MUL | 0x02 | 5 | Pop a, b; push a*b | Overflow risk in old Solidity |
| SLOAD | 0x54 | 2,100 (cold) | Load from storage slot | Cache in memory to avoid repeated SLOADs |
| SSTORE | 0x55 | 22,100 (cold) | Write to storage slot | Most expensive op — avoid in loops |
| CALL | 0xf1 | 100 + gas | Call another contract | Reentrancy entry point |
| DELEGATECALL | 0xf4 | 100 + gas | Call in caller's context | Storage collision risk |
| STATICCALL | 0xfa | 100 + gas | Read-only call | Reverts if callee writes state |
| MSTORE | 0x52 | 3+ | Write 32 bytes to memory | Memory expansion costs can surprise |
| MLOAD | 0x51 | 3+ | Read 32 bytes from memory | Same expansion cost applies |
| KECCAK256 | 0x20 | 30 + 6/word | Hash memory region | Used to compute mapping slots |
| JUMPI | 0x57 | 10 | Conditional jump | Core of if/require/loop |
| REVERT | 0xfd | 0 | Abort, return error data | Refunds unused gas |
| SELFDESTRUCT | 0xff | 5,000+ | Kill contract, send ETH | Permanent — 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 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/playgroundThe 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:
// 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 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:
| Address | Name | Purpose | Security Use |
|---|---|---|---|
| 0x01 | ecRecover | Recover signer from ECDSA signature | Signature verification — malleability risk |
| 0x02 | SHA2-256 | SHA-256 hash | Bitcoin compatibility |
| 0x03 | RIPEMD-160 | RIPEMD hash | Bitcoin address derivation |
| 0x04 | identity | Copy bytes (memcpy) | Cheap memory copy |
| 0x05 | modexp | Modular exponentiation | RSA, Diffie-Hellman |
| 0x06-0x08 | BN256 ops | Elliptic curve pairing | ZK proof verification |
| 0x09 | BLAKE2F | BLAKE2 compression | Zcash compatibility |
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:
# 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/playgroundSecurity Implications of EVM Architecture
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.
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.
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
// 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 confusemsg.senderwithtx.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 disassembleandcast 4byteto 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.